Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 1 addition & 28 deletions packages/cli/src/lib/api-route-worker.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
38 changes: 28 additions & 10 deletions packages/cli/src/lib/resource-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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 = {};
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
},
};
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/plugins/resource/plugin-api-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ 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 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);
Expand Down
42 changes: 40 additions & 2 deletions packages/plugin-renderer-lit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `
<app-card
title="${idx + 1}) ${title}"
thumbnail="${thumbnail}"
></app-card>
`;
}).join(''))
}
`));

return new Response(body, {
headers: new Headers({
'Content-Type': 'text/html'
})
});
}
```

## Caveats

Expand All @@ -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 `<template>` tag and output those content into the **Light DOM** of your page._

> See [this repo](https://github.com/thescientist13/greenwood-lit-ssr) for a full demo of isomorphic Lit SSR with SSR pages and API routes deployed to Vercel serverless functions.

## Usage

Add this plugin to your _greenwood.config.js_:
Expand Down
37 changes: 37 additions & 0 deletions packages/plugin-renderer-lit/src/api-route-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
import { parentPort } from "node:worker_threads";
import {
transformKoaRequestIntoStandardRequest,
responseAsObject,
} from "@greenwood/cli/src/lib/resource-utils.js";
import "@lit-labs/ssr-dom-shim/register-css-hook.js";

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);
});
1 change: 1 addition & 0 deletions packages/plugin-renderer-lit/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { greenwoodPluginRendererLit } from "../../../src/index.js";

export default {
plugins: [greenwoodPluginRendererLit()],
};
Original file line number Diff line number Diff line change
@@ -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(false, 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 <app-card> 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 <app-card> 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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "plugin-prerender-lit-develop-import-attributes",
"type": "module",
"dependencies": {
"lit": "^3.1.0"
}
}
Original file line number Diff line number Diff line change
@@ -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%;
}
Loading
Loading