From 588f7c7129e21b8a15c4bb46c0f28d3c69fa39d9 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 6 Jun 2026 15:25:01 -0400 Subject: [PATCH 1/3] feat: api route refactoring wip --- .../renderer/plugin-renderer-default.js | 1 + .../src/plugins/resource/plugin-api-routes.js | 7 +- .../src/api-route-worker.js | 62 +++++++++ packages/plugin-renderer-lit/src/index.js | 1 + .../greenwood.config.js | 5 + .../loaders-develop.import-attributes.spec.js | 118 ++++++++++++++++++ .../package.json | 7 ++ .../src/components/card/card.css | 19 +++ .../src/components/card/card.js | 40 ++++++ .../src/pages/api/fragment.js | 41 ++++++ .../greenwood.config.js | 5 + .../loaders-serve.import-attributes.spec.js | 118 ++++++++++++++++++ .../package.json | 7 ++ .../src/components/card/card.css | 19 +++ .../src/components/card/card.js | 40 ++++++ .../src/pages/api/fragment.js | 41 ++++++ 16 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-renderer-lit/src/api-route-worker.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js create mode 100644 packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js diff --git a/packages/cli/src/plugins/renderer/plugin-renderer-default.js b/packages/cli/src/plugins/renderer/plugin-renderer-default.js index ff806f543..b7d2c7cdc 100644 --- a/packages/cli/src/plugins/renderer/plugin-renderer-default.js +++ b/packages/cli/src/plugins/renderer/plugin-renderer-default.js @@ -4,6 +4,7 @@ const greenwoodPluginRendererDefault = { provider: () => { return { executeModuleUrl: new URL("../../lib/execute-route-module.js", import.meta.url), + apiRouteWorkerUrl: new URL("../../lib/api-route-worker.js", import.meta.url), }; }, }; diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js index 1d2414cef..dbcdff57f 100644 --- a/packages/cli/src/plugins/resource/plugin-api-routes.js +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -44,11 +44,14 @@ class ApiRoutesResource { : undefined; if (process.env.__GWD_COMMAND__ === "develop") { - const workerUrl = new URL("../../lib/api-route-worker.js", import.meta.url); + const apiRouterWorkerUrl = this.compilation.config.plugins + .find((plugin) => plugin.type === "renderer") + .provider().apiRouteWorkerUrl; + // const workerUrl = new URL("../../lib/api-route-worker.js", import.meta.url); const req = await requestAsObject(request); const response = await new Promise((resolve, reject) => { - const worker = new Worker(workerUrl); + const worker = new Worker(apiRouterWorkerUrl); worker.on("message", (result) => { resolve(result); diff --git a/packages/plugin-renderer-lit/src/api-route-worker.js b/packages/plugin-renderer-lit/src/api-route-worker.js new file mode 100644 index 000000000..d49b9efa0 --- /dev/null +++ b/packages/plugin-renderer-lit/src/api-route-worker.js @@ -0,0 +1,62 @@ +// https://github.com/nodejs/modules/issues/307#issuecomment-858729422 +import { parentPort } from "node:worker_threads"; +import { transformKoaRequestIntoStandardRequest } from "@greenwood/cli/src/lib/resource-utils.js"; +import "@lit-labs/ssr-dom-shim/register-css-hook.js"; + +// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript +// TODO: make these a shared utility +async function responseAsObject(response) { + if (!(response instanceof Response)) { + throw Object.assign(new Error(), { + name: "TypeError", + message: "Argument must be a Response object", + }); + } + response = response.clone(); + + function stringifiableObject(obj) { + const filtered = {}; + for (const key in obj) { + if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) { + filtered[key] = obj[key]; + } + } + return filtered; + } + + return { + ...stringifiableObject(response), + headers: Object.fromEntries(response.headers), + body: await response.text(), + }; +} + +async function executeRouteModule({ href, request, params }) { + const { body, headers = {}, method, url } = request; + const contentType = headers["content-type"] || ""; + // @ts-expect-error see https://github.com/microsoft/TypeScript/issues/42866 + const { handler } = await import(new URL(href)); + const format = contentType.startsWith("application/json") ? JSON.parse(body) : body; + + // handling of serialized FormData across Worker threads + if (contentType.startsWith("x-greenwood/www-form-urlencoded")) { + headers["content-type"] = "application/x-www-form-urlencoded"; + } + + const response = await handler( + transformKoaRequestIntoStandardRequest(new URL(url), { + method, + header: headers, + body: format, + }), + { + params, + }, + ); + + parentPort.postMessage(await responseAsObject(response)); +} + +parentPort.on("message", async (task) => { + await executeRouteModule(task); +}); diff --git a/packages/plugin-renderer-lit/src/index.js b/packages/plugin-renderer-lit/src/index.js index 594e70bbc..bb586af7c 100755 --- a/packages/plugin-renderer-lit/src/index.js +++ b/packages/plugin-renderer-lit/src/index.js @@ -76,6 +76,7 @@ const greenwoodPluginRendererLit = () => { provider: () => { return { executeModuleUrl: new URL("./execute-route-module.js", import.meta.url), + apiRouteWorkerUrl: new URL("./api-route-worker.js", import.meta.url), }; }, }, diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js new file mode 100644 index 000000000..0eaca34bd --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js @@ -0,0 +1,5 @@ +import { greenwoodPluginRendererLit } from "../../../src/index.js"; + +export default { + plugins: [greenwoodPluginRendererLit()], +}; diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js new file mode 100644 index 000000000..b0e17ceab --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js @@ -0,0 +1,118 @@ +/* + * Use Case + * Run Greenwood build command with a static site and only prerendering the content (no JS!) and using import attributes + * + * User Result + * Should generate a bare bones Greenwood build with correctly templated out HTML from a LitElement. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginRendererLit } from '@greenwood/plugin-renderer-lit'; + * + * { + * prerender: true + * plugins: [ + * greenwoodPluginRendererLit() + * ] + * } + * + * User Workspace + * src/ + * components/ + * card/ + * card.js + * card.css + * pages/ + * api/ + * fragment.js + */ +import { expect } from "chai"; +import { JSDOM } from "jsdom"; +import path from "node:path"; +import { getOutputTeardownFiles } from "../../../../../test/utils.js"; +import { Runner } from "gallinago"; +import { fileURLToPath } from "node:url"; + +describe("Develop Greenwood With Custom Lit Renderer for SSR: ", function () { + const LABEL = "For SSR with Import Attributes"; + const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js"); + const outputPath = fileURLToPath(new URL(".", import.meta.url)); + const hostname = "http://127.0.0.1:1984"; + let runner; + + before(function () { + this.context = { + publicDir: path.join(outputPath, "public"), + }; + runner = new Runner(true, true); + }); + + describe(LABEL, function () { + before(async function () { + await runner.setup(outputPath); + + await new Promise((resolve, reject) => { + runner + .runCommand(cliPath, "develop", { + onStdOut: (message) => { + if (message.includes(`Started local development server at http://localhost:1984`)) { + resolve(); + } + }, + }) + .catch(reject); + }); + }); + + describe("Serve command with API route server rendering LitElement components as an HTML response with import attributes", function () { + let resp; + let html; + let dom; + + before(async function () { + resp = await fetch(`${hostname}/api/fragment`); + html = await resp.text(); + dom = new JSDOM(html); + }); + + it("should have a response status of 200", function (done) { + expect(resp.status).to.equal(200); + + done(); + }); + + it("should have a Content-Type header of text/html", function (done) { + const type = resp.headers.get("Content-Type"); + + expect(type).to.equal("text/html"); + + done(); + }); + + it("should have the expected number of components for a single search result", function (done) { + const cards = dom.window.document.querySelectorAll( + 'app-card template[shadowrootmode="open"]', + ); + const cardDom = new JSDOM(cards[0].innerHTML); + + expect(cards.length).to.equal(1); + // TODO this should be using real data from attributes (see issue with static in card.js) + expect(cardDom.window.document.querySelectorAll("h3")[0].textContent).to.equal( + "1) Product 1", + ); + expect(cardDom.window.document.querySelectorAll("img")[0].getAttribute("src")).to.equal( + "product1.png", + ); + + done(); + }); + }); + }); + + after(async function () { + await runner.teardown(getOutputTeardownFiles(outputPath)); + await runner.stopCommand(); + }); +}); diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json new file mode 100644 index 000000000..8c42594ed --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json @@ -0,0 +1,7 @@ +{ + "name": "plugin-prerender-lit-develop-import-attributes", + "type": "module", + "dependencies": { + "lit": "^3.1.0" + } +} diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css new file mode 100644 index 000000000..1293dd047 --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css @@ -0,0 +1,19 @@ +h3 { + font-size: 1.85rem; +} + +button { + background: var(--color-accent); + color: var(--color-white); + padding: 1rem 2rem; + border: 0; + font-size: 1rem; + border-radius: 5px; + cursor: pointer; +} + +img { + max-width: 500px; + min-width: 500px; + width: 100%; +} diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js new file mode 100644 index 000000000..9075f5dd5 --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js @@ -0,0 +1,40 @@ +import { LitElement, html } from "lit"; +import sheet from "./card.css" with { type: "css" }; + +export default class Card extends LitElement { + static properties = { + title: { type: String }, + thumbnail: { type: String }, + }; + + static styles = [sheet]; + + constructor() { + super(); + + this.title; + this.thumbnail; + } + + selectItem() { + alert(`selected item is => ${this.title}!`); + } + + render() { + const { title = "Foo", thumbnail = "bar.png" } = this; + + if (!title && !thumbnail) { + return; + } + + return html` +
+

${title}

+ ${title} + +
+ `; + } +} + +customElements.define("app-card", Card); diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js new file mode 100644 index 000000000..3dc99721d --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js @@ -0,0 +1,41 @@ +import "@lit-labs/ssr-dom-shim/register-css-hook.js"; +import { render } from "@lit-labs/ssr"; +import { collectResult } from "@lit-labs/ssr/lib/render-result.js"; +import { html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import "../../components/card/card.js"; + +export const isolation = true; + +export async function handler() { + const products = [ + { + title: "Product 1", + thumbnail: "product1.png", + }, + ]; + const body = await collectResult( + render(html` + ${unsafeHTML( + products + .map((item, idx) => { + const { title, thumbnail } = item; + + return ` + + `; + }) + .join(""), + )} + `), + ); + + return new Response(body, { + headers: new Headers({ + "Content-Type": "text/html", + }), + }); +} diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js new file mode 100644 index 000000000..0eaca34bd --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js @@ -0,0 +1,5 @@ +import { greenwoodPluginRendererLit } from "../../../src/index.js"; + +export default { + plugins: [greenwoodPluginRendererLit()], +}; diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js new file mode 100644 index 000000000..05e005b99 --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js @@ -0,0 +1,118 @@ +/* + * Use Case + * Run Greenwood build command with a static site and only prerendering the content (no JS!) and using import attributes + * + * User Result + * Should generate a bare bones Greenwood build with correctly templated out HTML from a LitElement. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginRendererLit } from '@greenwood/plugin-renderer-lit'; + * + * { + * plugins: [ + * greenwoodPluginRendererLit() + * ] + * } + * + * User Workspace + * src/ + * components/ + * card/ + * card.js + * card.css + * pages/ + * api/ + * fragment.js + */ +import { expect } from "chai"; +import { JSDOM } from "jsdom"; +import path from "node:path"; +import { getOutputTeardownFiles } from "../../../../../test/utils.js"; +import { Runner } from "gallinago"; +import { fileURLToPath } from "node:url"; + +describe("Build Greenwood With Custom Lit Renderer for SSR: ", function () { + const LABEL = "For SSR with Import Attributes"; + const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js"); + const outputPath = fileURLToPath(new URL(".", import.meta.url)); + const hostname = "http://localhost:8080"; + let runner; + + before(function () { + this.context = { + publicDir: path.join(outputPath, "public"), + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function () { + before(async function () { + await runner.setup(outputPath); + await runner.runCommand(cliPath, "build"); + + await new Promise((resolve, reject) => { + runner + .runCommand(cliPath, "serve", { + onStdOut: (message) => { + if (message.includes("Started server at http://localhost:8080")) { + resolve(); + } + }, + }) + .catch(reject); + }); + }); + + describe("Serve command with API route server rendering LitElement components as an HTML response with import attributes", function () { + let resp; + let html; + let dom; + + before(async function () { + resp = await fetch(`${hostname}/api/fragment`); + html = await resp.text(); + dom = new JSDOM(html); + }); + + it("should have a response status of 200", function (done) { + expect(resp.status).to.equal(200); + + done(); + }); + + it("should have a Content-Type header of text/html", function (done) { + const type = resp.headers.get("Content-Type"); + + expect(type).to.equal("text/html"); + + done(); + }); + + it("should have the expected number of components for a single search result", function (done) { + const cards = dom.window.document.querySelectorAll( + 'app-card template[shadowrootmode="open"]', + ); + const cardDom = new JSDOM(cards[0].innerHTML); + + expect(cards.length).to.equal(1); + // TODO this should be using real data from attributes (see issue with static in card.js) + expect(cardDom.window.document.querySelectorAll("h3")[0].textContent).to.equal( + "1) Product 1", + ); + expect(cardDom.window.document.querySelectorAll("img")[0].getAttribute("src")).to.equal( + "product1.png", + ); + + done(); + }); + }); + }); + + after(async function () { + await runner.teardown(getOutputTeardownFiles(outputPath)); + await runner.stopCommand(); + }); +}); diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json new file mode 100644 index 000000000..d0f01837e --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json @@ -0,0 +1,7 @@ +{ + "name": "plugin-prerender-lit-serve-import-attributes", + "type": "module", + "dependencies": { + "lit": "^3.1.0" + } +} diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css new file mode 100644 index 000000000..1293dd047 --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css @@ -0,0 +1,19 @@ +h3 { + font-size: 1.85rem; +} + +button { + background: var(--color-accent); + color: var(--color-white); + padding: 1rem 2rem; + border: 0; + font-size: 1rem; + border-radius: 5px; + cursor: pointer; +} + +img { + max-width: 500px; + min-width: 500px; + width: 100%; +} diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js new file mode 100644 index 000000000..9075f5dd5 --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js @@ -0,0 +1,40 @@ +import { LitElement, html } from "lit"; +import sheet from "./card.css" with { type: "css" }; + +export default class Card extends LitElement { + static properties = { + title: { type: String }, + thumbnail: { type: String }, + }; + + static styles = [sheet]; + + constructor() { + super(); + + this.title; + this.thumbnail; + } + + selectItem() { + alert(`selected item is => ${this.title}!`); + } + + render() { + const { title = "Foo", thumbnail = "bar.png" } = this; + + if (!title && !thumbnail) { + return; + } + + return html` +
+

${title}

+ ${title} + +
+ `; + } +} + +customElements.define("app-card", Card); diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js new file mode 100644 index 000000000..3dc99721d --- /dev/null +++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js @@ -0,0 +1,41 @@ +import "@lit-labs/ssr-dom-shim/register-css-hook.js"; +import { render } from "@lit-labs/ssr"; +import { collectResult } from "@lit-labs/ssr/lib/render-result.js"; +import { html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import "../../components/card/card.js"; + +export const isolation = true; + +export async function handler() { + const products = [ + { + title: "Product 1", + thumbnail: "product1.png", + }, + ]; + const body = await collectResult( + render(html` + ${unsafeHTML( + products + .map((item, idx) => { + const { title, thumbnail } = item; + + return ` + + `; + }) + .join(""), + )} + `), + ); + + return new Response(body, { + headers: new Headers({ + "Content-Type": "text/html", + }), + }); +} From d563493de1f6850701d2f0ee46b2ee5172a98925 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 19 Jun 2026 19:46:41 -0400 Subject: [PATCH 2/3] feature(cli): #1694 shared response serialization util --- packages/cli/src/lib/api-route-worker.js | 29 +------------- packages/cli/src/lib/resource-utils.js | 38 ++++++++++++++----- .../src/api-route-worker.js | 33 ++-------------- .../loaders-develop.import-attributes.spec.js | 2 +- 4 files changed, 34 insertions(+), 68 deletions(-) diff --git a/packages/cli/src/lib/api-route-worker.js b/packages/cli/src/lib/api-route-worker.js index 44a289b87..2388d057e 100644 --- a/packages/cli/src/lib/api-route-worker.js +++ b/packages/cli/src/lib/api-route-worker.js @@ -1,33 +1,6 @@ // https://github.com/nodejs/modules/issues/307#issuecomment-858729422 import { parentPort } from "node:worker_threads"; -import { transformKoaRequestIntoStandardRequest } from "./resource-utils.js"; - -// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript -async function responseAsObject(response) { - if (!(response instanceof Response)) { - throw Object.assign(new Error(), { - name: "TypeError", - message: "Argument must be a Response object", - }); - } - response = response.clone(); - - function stringifiableObject(obj) { - const filtered = {}; - for (const key in obj) { - if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) { - filtered[key] = obj[key]; - } - } - return filtered; - } - - return { - ...stringifiableObject(response), - headers: Object.fromEntries(response.headers), - body: await response.text(), - }; -} +import { transformKoaRequestIntoStandardRequest, responseAsObject } from "./resource-utils.js"; async function executeRouteModule({ href, request, params }) { const { body, headers = {}, method, url } = request; diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js index dee4dc1f7..950f7a5e7 100644 --- a/packages/cli/src/lib/resource-utils.js +++ b/packages/cli/src/lib/resource-utils.js @@ -254,6 +254,16 @@ function transformKoaRequestIntoStandardRequest(url, request) { }); } +function stringifiableObject(obj) { + const filtered = {}; + for (const key in obj) { + if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) { + filtered[key] = obj[key]; + } + } + return filtered; +} + // https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript async function requestAsObject(_request) { if (!(_request instanceof Request)) { @@ -268,16 +278,6 @@ async function requestAsObject(_request) { let headers = Object.fromEntries(request.headers); let format; - function stringifiableObject(obj) { - const filtered = {}; - for (const key in obj) { - if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) { - filtered[key] = obj[key]; - } - } - return filtered; - } - if (contentType.includes("application/x-www-form-urlencoded")) { const formData = await request.formData(); const params = {}; @@ -305,12 +305,30 @@ async function requestAsObject(_request) { }; } +// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript +async function responseAsObject(response) { + if (!(response instanceof Response)) { + throw Object.assign(new Error(), { + name: "TypeError", + message: "Argument must be a Response object", + }); + } + response = response.clone(); + + return { + ...stringifiableObject(response), + headers: Object.fromEntries(response.headers), + body: await response.text(), + }; +} + export { checkResourceExists, mergeResponse, modelResource, normalizePathnameForWindows, requestAsObject, + responseAsObject, resolveForRelativeUrl, trackResourcesForRoute, transformKoaRequestIntoStandardRequest, diff --git a/packages/plugin-renderer-lit/src/api-route-worker.js b/packages/plugin-renderer-lit/src/api-route-worker.js index d49b9efa0..c42e41666 100644 --- a/packages/plugin-renderer-lit/src/api-route-worker.js +++ b/packages/plugin-renderer-lit/src/api-route-worker.js @@ -1,36 +1,11 @@ // https://github.com/nodejs/modules/issues/307#issuecomment-858729422 import { parentPort } from "node:worker_threads"; -import { transformKoaRequestIntoStandardRequest } from "@greenwood/cli/src/lib/resource-utils.js"; +import { + transformKoaRequestIntoStandardRequest, + responseAsObject, +} from "@greenwood/cli/src/lib/resource-utils.js"; import "@lit-labs/ssr-dom-shim/register-css-hook.js"; -// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript -// TODO: make these a shared utility -async function responseAsObject(response) { - if (!(response instanceof Response)) { - throw Object.assign(new Error(), { - name: "TypeError", - message: "Argument must be a Response object", - }); - } - response = response.clone(); - - function stringifiableObject(obj) { - const filtered = {}; - for (const key in obj) { - if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) { - filtered[key] = obj[key]; - } - } - return filtered; - } - - return { - ...stringifiableObject(response), - headers: Object.fromEntries(response.headers), - body: await response.text(), - }; -} - async function executeRouteModule({ href, request, params }) { const { body, headers = {}, method, url } = request; const contentType = headers["content-type"] || ""; diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js index b0e17ceab..34215a54b 100644 --- a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js +++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js @@ -46,7 +46,7 @@ describe("Develop Greenwood With Custom Lit Renderer for SSR: ", function () { this.context = { publicDir: path.join(outputPath, "public"), }; - runner = new Runner(true, true); + runner = new Runner(false, true); }); describe(LABEL, function () { From 4911d2984dbebb704c552d67dd123b5762914bec Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 19 Jun 2026 20:04:25 -0400 Subject: [PATCH 3/3] docs(plugins): #1694 document css module scripts caveat with api routes --- .../src/plugins/resource/plugin-api-routes.js | 1 - packages/plugin-renderer-lit/README.md | 42 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js index dbcdff57f..4af6a5164 100644 --- a/packages/cli/src/plugins/resource/plugin-api-routes.js +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -47,7 +47,6 @@ class ApiRoutesResource { const apiRouterWorkerUrl = this.compilation.config.plugins .find((plugin) => plugin.type === "renderer") .provider().apiRouteWorkerUrl; - // const workerUrl = new URL("../../lib/api-route-worker.js", import.meta.url); const req = await requestAsObject(request); const response = await new Promise((resolve, reject) => { diff --git a/packages/plugin-renderer-lit/README.md b/packages/plugin-renderer-lit/README.md index d4c1b8ba6..651e7da6c 100644 --- a/packages/plugin-renderer-lit/README.md +++ b/packages/plugin-renderer-lit/README.md @@ -4,6 +4,8 @@ A Greenwood plugin for using [**Lit**'s SSR capabilities](https://github.com/lit/lit/tree/main/packages/labs/ssr) as a custom server-side renderer instead of Greenwood's default renderer (WCC). This plugin also gives the ability to statically [(pre) render entire pages and layouts](https://greenwoodjs.dev/docs/reference/rendering-strategies/#prerendering) to output completely static sites. For more information and complete docs on Greenwood, please visit [our website](https://www.greenwoodjs.dev). +You can see [this repo](https://github.com/thescientist13/greenwood-lit-ssr) for a full demo of an isomorphic Lit SSR project with SSR pages and API routes, deployed to Vercel. + > This package assumes you already have `@greenwood/cli` installed. ## Prerequisite @@ -70,6 +72,44 @@ Types should automatically be inferred through this package's exports map, but c import type { LitRendererPlugin } from '@greenwood/plugin-renderer-lit'; ``` +## API Routes + +If you're using CSS Module Scripts and [API Routes](https://greenwoodjs.dev/docs/pages/api-routes/), you will need to make sure to import [Lit's CSS Register Hook](https://github.com/lit/lit/tree/main/packages/labs/ssr-dom-shim#css-nodejs-customization-hook) at the top of your function handler. + +```js +// make sure to import this first! +import "@lit-labs/ssr-dom-shim/register-css-hook.js"; +import { render } from '@lit-labs/ssr'; +import { collectResult } from '@lit-labs/ssr/lib/render-result.js' +import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { getProducts } from '../../services/products.ts'; +import '../../components/card/card.ts'; + +export async function handler() { + const products = (await getProducts()); + const body = await collectResult(render(html` + ${ + unsafeHTML(products.map((item, idx) => { + const { title, thumbnail } = item; + + return ` + + `; + }).join('')) + } + `)); + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} +``` ## Caveats @@ -82,8 +122,6 @@ import type { LitRendererPlugin } from '@greenwood/plugin-renderer-lit'; _**Note**: As `LitElement` [only renders into Shadow Roots](https://github.com/lit/lit/issues/3080#issuecomment-1165158794), for pages and layouts this plugin will extract the HTML contents of the SSR'd `