From 781467ab9d5e4755dd4b9eb5309578e2f674c91b Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Thu, 7 May 2026 12:29:19 +0100 Subject: [PATCH] wip: poc rendering server components in browser run with `FRED_BROWSER_SSR=true npm run start` then visit e.g. http://localhost:3000/en-US/?path=docs/Web/API/Navigation_API --- build/server-worker.js | 30 +++++++--- .../server/async-local-storage-client.js | 26 +++++++++ entry.ssr-browser.js | 57 +++++++++++++++++++ rspack.config.js | 50 ++++++++++++++++ 4 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 components/server/async-local-storage-client.js create mode 100644 entry.ssr-browser.js diff --git a/build/server-worker.js b/build/server-worker.js index 8d54f08dc..2186382e4 100644 --- a/build/server-worker.js +++ b/build/server-worker.js @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parentPort, workerData } from "node:worker_threads"; @@ -26,15 +27,26 @@ if (!indexModulePath) { } try { - /** @type {import("../entry.ssr.js")} */ - const indexModule = await import(pathToFileURL(indexModulePath).href); - const html = process.env.FRED_SIMPLE_HTML - ? `${await indexModule?.renderSimplified(reqPath, context)}` - : await indexModule?.render(reqPath, context, { - client: compilationStats.find((x) => x.name === "client") || {}, - legacy: compilationStats.find((x) => x.name === "legacy") || {}, - }); - parentPort?.postMessage({ html }); + if (process.env.FRED_BROWSER_SSR) { + const ssrBrowserStats = compilationStats.find( + (x) => x.name === "ssr-browser", + ); + const html = await fs.readFile( + path.join(ssrBrowserStats.outputPath, "index.html"), + "utf8", + ); + parentPort?.postMessage({ html }); + } else { + /** @type {import("../entry.ssr.js")} */ + const indexModule = await import(pathToFileURL(indexModulePath).href); + const html = process.env.FRED_SIMPLE_HTML + ? `${await indexModule?.renderSimplified(reqPath, context)}` + : await indexModule?.render(reqPath, context, { + client: compilationStats.find((x) => x.name === "client") || {}, + legacy: compilationStats.find((x) => x.name === "legacy") || {}, + }); + parentPort?.postMessage({ html }); + } } catch (error) { parentPort?.postMessage({ error }); } diff --git a/components/server/async-local-storage-client.js b/components/server/async-local-storage-client.js new file mode 100644 index 000000000..35b603549 --- /dev/null +++ b/components/server/async-local-storage-client.js @@ -0,0 +1,26 @@ +export class AsyncLocalStorage { + #store = undefined; + + run(store, callback) { + this.#store = store; + return callback(); + } + + getStore() { + return this.#store; + } +} + +/** + * Store for internal context passed around components. + * + * Generally only used within the `ServerComponent` class itself, + * or very special server components (such as the `OuterLayout`). + * + * e.g. We add the rspack compilation stats for use in `OuterLayout`. + * + * Populated in `entry.ssr.js`. + * + * @type {AsyncLocalStorage} + */ +export const asyncLocalStorage = new AsyncLocalStorage(); diff --git a/entry.ssr-browser.js b/entry.ssr-browser.js new file mode 100644 index 000000000..5b336e406 --- /dev/null +++ b/entry.ssr-browser.js @@ -0,0 +1,57 @@ +import { render as litRender } from "lit"; + +import { Doc } from "./components/doc/server.js"; +import { NotFound } from "./components/not-found/server.js"; +import { asyncLocalStorage } from "./components/server/async-local-storage.js"; +import { addFluent } from "./l10n/context.js"; +import { runWithContext } from "./symmetric-context/server.js"; + +const cssContext = require.context( + "./components", + true, + /\/(server|global)\.css$/, +); +cssContext.keys().forEach(cssContext); + +const params = new URLSearchParams(globalThis.location.search); +const path = params.get("path"); + +const response = await fetch(`${path}/index.json`); +const json = await response.json(); + +await render(path, json, null); + +/** + * @param {string} path + * @param {import("@fred").PartialContext} partialContext + * @param {import("@fred").CompilationStats} compilationStats + */ +export async function render(path, partialContext, compilationStats) { + const locale = "en-US"; + + const context = { + path, + ...(await addFluent(locale)), + ...partialContext, + }; + /** @type {import("./components/server/types.js").FredLocalContents} */ + const storageContents = { + componentsUsed: new Set(), + componentsWithStylesInHead: new Set(), + compilationStats, + }; + return asyncLocalStorage.run(storageContents, () => + runWithContext({ locale }, async () => { + const component = await (async () => { + switch (context.renderer) { + case "Doc": + return Doc.render(context); + case "SpaNotFound": + default: + return NotFound.render(context); + } + })(); + return litRender(component, document.body); + }), + ); +} diff --git a/rspack.config.js b/rspack.config.js index f54e1d082..1bb04c50f 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -410,6 +410,55 @@ const clientConfig = merge( }, ); +const ssrinBrowserConfig = merge(clientConfig, { + name: "ssr-browser", + target: ["web", "browserslist"], + async entry() { + return { + index: [ + // load custom elements + ...(await crawl(path.join(__dirname, "components"), (filePath) => + filePath.endsWith("/element.js"), + )), + "./entry.ssr-browser.js", + ], + }; + }, + plugins: [ + new rspack.NormalModuleReplacementPlugin( + /^node:async_hooks$/, + path.resolve( + __dirname, + "components/server/async-local-storage-client.js", + ), + ), + new rspack.HtmlRspackPlugin({ + inject: true, + chunks: ["index"], + filename: "index.html", + scriptLoading: "module", + // template: "node_modules/@mdn/yari/client/public/index.html", + }), + ], + resolve: { + alias: { + "@lit-labs/ssr": "lit", + }, + }, + output: { + path: path.resolve(FRED_BUILD_ROOT, "static", "ssr-browser"), + filename: "[name].js", + // use proper file names in sourcemaps: + devtoolModuleFilenameTemplate: (info) => + path.resolve(info.absoluteResourcePath), + clean: { + keep: "index.d.ts", + }, + publicPath: "/static/ssr-browser/", + library: { type: "module" }, + }, +}); + const legacyConfig = merge( common, notServiceWorkerCommon, @@ -639,6 +688,7 @@ const serviceWorkerConfig = merge(common, { /** @type {import("@rspack/core").MultiRspackOptions} */ export default [ ssrConfig, + ssrinBrowserConfig, clientConfig, ...(buildLegacy ? [legacyConfig, serviceWorkerConfig] : []), ];