From a9875fe2cc5d7500cf28dda8786f75f05acfdf03 Mon Sep 17 00:00:00 2001 From: Patricia Jacob Date: Fri, 24 Apr 2026 18:28:35 +0100 Subject: [PATCH 01/11] Refine RSC plan into vertical milestones --- docs/rsc-plan.md | 369 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 docs/rsc-plan.md diff --git a/docs/rsc-plan.md b/docs/rsc-plan.md new file mode 100644 index 0000000..ba6dcda --- /dev/null +++ b/docs/rsc-plan.md @@ -0,0 +1,369 @@ +# Real RSC Implementation Plan + +This document outlines how to add real React Server Components (RSC) to MatchaStack using the React Flight protocol, server references, and a proper server/client module split. + +It is intentionally not a "temporary simplification" plan. The goal is to build the actual architecture needed for React-compatible RSC rather than a props-over-JSON approximation. + +## Goals + +- Use the React Flight protocol for server component payloads. +- Support a true server/client module split. +- Generate and consume the manifests needed by React for client references. +- Add server references and server actions later, on top of a working RSC document and navigation pipeline. +- Preserve the existing custom Vite + Express architecture where possible. +- Evolve the current SSR pipeline into an RSC + HTML shell pipeline instead of layering a fake RSC model on top of `__INITIAL_PROPS__`. + +## Current Starting Point + +The current architecture already gives us a few useful building blocks: + +- [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) renders the app on the server. +- [src/entry-client.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-client.tsx) hydrates the client. +- [src/router.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/router.tsx) handles client navigation and route data fetching. +- [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) already owns a meaningful part of the build pipeline. +- [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) and [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) already control request handling. + +What is missing is the core RSC contract: + +- no server/client file boundary model +- no client reference manifest +- no Flight response endpoint +- no client-side Flight stream consumption +- no separation between the SSR shell and the RSC tree +- no server reference manifest or action transport for later phases + +## Architectural Target + +The end state should look like this: + +1. The app tree is split into: + - server components + - client components marked with `'use client'` + - server functions marked with `'use server'` +2. The build produces: + - a server bundle capable of evaluating RSC modules + - a client bundle containing only browser code + - a client reference manifest for React Flight + - later, a server reference manifest for actions / server functions +3. The server responds to: + - document requests with an HTML shell plus an RSC stream + - client navigations with an RSC payload + - later, action submissions with the standard server reference invocation flow +4. The browser: + - hydrates the shell + - consumes the Flight stream + - resolves client references using the manifest + - re-renders on navigation using fresh RSC payloads + +## Core Design Decisions + +### 1. Use directive-based boundaries + +Match React conventions directly: + +- `'use client'` at the file level marks a client component module. +- `'use server'` marks server function exports. + +Do not introduce custom file suffixes as the primary boundary mechanism. Supporting `.client.tsx` and `.server.tsx` as optional conveniences is fine, but the protocol-facing system should treat directives as the source of truth. + +### 2. Split SSR shell rendering from RSC rendering + +Today `renderToString()` in [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) performs the entire render. + +With RSC, there are two related but distinct server tasks: + +- render the server component tree to a Flight payload +- render the HTML shell that bootstraps the client app and consumes that payload + +Those should become separate entry points with separate responsibilities. + +### 3. Route modules become server-first + +The current route model is props-centric: + +- route component +- `getStaticProps` +- `getServerSideProps` + +For real RSC, the primary abstraction should shift to route modules that can render server components directly and fetch data inline on the server side. Over time, route loaders should become optional compatibility APIs rather than the main model. + +### 4. Manifests are first-class build artifacts + +This is the central build problem. + +We need to generate, persist, and load: + +- client reference manifest: maps client module exports to browser chunks and React client reference metadata +- later, server reference manifest: maps callable server exports to invocation metadata used by actions / server functions + +The server runtime should never guess about module IDs. It should always resolve through the manifests. + +## Vertical Milestones + +The milestones below are intentionally vertical. Each one should leave the repo in a usable, demonstrable state before the next one begins. + +## Milestone 1: Single-Route RSC Document Render + +### End state + +One route can render through real Flight on a full document request, with a real client reference manifest and at least one `'use client'` island working end to end. + +### What changes + +1. Split the current server runtime in [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) into: + - request context creation + - RSC tree rendering + - HTML shell rendering +2. Extend [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) to: + - detect `'use client'` + - classify modules into server vs client graphs + - emit a client reference manifest +3. Replace the `window.__INITIAL_PROPS__` bootstrap for that route with: + - an HTML shell + - an initial Flight payload + - client-side Flight consumption in [src/entry-client.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-client.tsx) +4. Keep the rest of the app on the old SSR path while the first route proves the architecture. + +### Why this is vertical + +It exercises the real RSC protocol on a real page load without forcing the whole router, all routes, or server actions to land at once. + +### Exit criteria + +- one route renders via React Flight on initial document load +- one `'use client'` component resolves through the generated manifest +- no `__INITIAL_PROPS__` is required for that route +- dev and production builds can both render that route + +## Milestone 2: Full-Document RSC for All Routes + +### End state + +All document requests render through the new RSC document pipeline, even if client-side navigation still uses the legacy path. + +### What changes + +1. Generalize the Milestone 1 runtime so every route can render as: + - server-first route module + - HTML shell plus Flight payload +2. Update [src/routes.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/routes.ts) and related route modules so route defaults can be server components. +3. Keep `getStaticProps` and `getServerSideProps` only as a migration layer where needed. +4. Expand module boundary checks so invalid server/client imports fail loudly across the app. + +### Why this is vertical + +After this step, a browser refresh on any route uses the real RSC render path, so the framework is already useful even before client navigation is migrated. + +### Exit criteria + +- all full document requests use Flight-backed rendering +- all routes can include `'use client'` islands +- the old props bootstrap path is no longer needed for initial document loads + +## Milestone 3: RSC Client Navigation + +### End state + +Client-side navigation fetches Flight payloads instead of route props, so the app works as a genuine RSC app during both initial load and in-app navigation. + +### What changes + +1. Replace the `_props.json` and `__matcha_props` navigation model in [src/router.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/router.tsx). +2. Add a Flight endpoint for subrequests in: + - [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) + - [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) +3. Teach the client runtime to: + - request the next route as Flight + - apply the returned tree + - preserve history and back/forward behavior +4. Keep route compatibility shims only where still needed to bridge old route modules during migration. + +### Why this is vertical + +This is the milestone where the app becomes workable as an RSC app in day-to-day usage, not just on refresh. + +### Exit criteria + +- client navigation uses Flight payloads +- back/forward works against the RSC router path +- `_props.json` and `__matcha_props` are no longer part of the primary navigation flow + +## Milestone 4: Route Model Cleanup and Layouts + +### End state + +The route model is server-component-first rather than loader-first, and shared layouts can participate naturally in the RSC tree. + +### What changes + +1. Move route data access from `getStaticProps` / `getServerSideProps` into server components or adjacent server utilities where practical. +2. Introduce layout boundaries if the framework wants nested layouts. +3. Remove remaining compatibility code that exists only for the old props-centric model. +4. Simplify the public mental model around: + - server components by default + - client components via `'use client'` + - no props JSON bootstrap path + +### Why this is vertical + +This is a product-quality milestone rather than just an internal refactor: the public framework surface becomes coherent and teachable. + +### Exit criteria + +- new routes can be authored as server-first modules +- loader APIs are optional or deprecated rather than foundational +- layouts compose through the RSC tree + +## Milestone 5: Server References and Actions + +### End state + +`'use server'` exports can be referenced from the client and invoked through the proper React server reference transport. + +### What changes + +1. Extend [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) to discover `'use server'` exports and emit a server reference manifest. +2. Add runtime lookup and invocation support for server references. +3. Add action endpoints in: + - [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) + - [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) +4. Return action results in the format React expects, including updated Flight payloads when needed. + +### Why this is vertical + +Actions become an additive capability on top of a working RSC app instead of a prerequisite for getting the main rendering model live. + +### Exit criteria + +- server reference manifest is generated and loaded correctly +- `'use server'` functions can be invoked from client components +- action requests can trigger the expected RSC updates + +## Milestone 6: Hardening, Dev UX, and Production Cleanup + +### End state + +The RSC architecture is stable in dev and production, with tests covering the protocol boundaries and without legacy props infrastructure hanging around. + +### What changes + +1. Harden [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) for: + - HMR across server and client graphs + - manifest invalidation + - useful boundary diagnostics +2. Finalize [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) around explicit manifest loading and RSC request handling. +3. Remove remaining `_props.json`, `__matcha_props`, and `__INITIAL_PROPS__` infrastructure if any compatibility remnants still exist. +4. Add tests for: + - module classification + - client reference manifest generation + - document render + - Flight navigation + - server reference invocation once actions exist + +### Why this is vertical + +This turns the implementation from "feature-complete in principle" into something maintainable enough to iterate on. + +### Exit criteria + +- dev mode and production mode follow the same RSC architecture +- protocol regressions are covered by tests +- legacy props transport is gone + +## Concrete Repo Changes + +These are the main files and modules likely to change first. + +### Runtime + +- [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) +- [src/entry-client.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-client.tsx) +- [src/router.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/router.tsx) +- [src/app.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/app.tsx) +- [src/routes.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/routes.ts) + +### Build / Framework internals + +- [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) +- [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) +- [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) + +### New modules likely needed + +- `lib/rsc/module-classifier.ts` +- `lib/rsc/client-manifest.ts` +- `lib/rsc/server-manifest.ts` +- `lib/rsc/request-context.ts` +- `lib/rsc/render-flight.ts` +- `lib/rsc/render-document.ts` +- `lib/rsc/server-references.ts` +- `lib/rsc/action-handler.ts` + +The exact filenames can change, but these concerns should stay separated. + +## Recommended Build Order Within Each Milestone + +Inside each milestone, keep the work in this order: + +1. establish the runtime contract for that slice +2. teach the build pipeline and manifests what the runtime needs +3. wire dev and production request handling +4. migrate one route or flow first +5. expand to the rest of the surface covered by that milestone + +This keeps each milestone demonstrable early, instead of spending several phases building internal plumbing before anything user-visible works. + +## Risks and Hard Parts + +### Build graph correctness + +The hardest technical problem is not rendering itself. It is producing correct module graphs and manifests while preserving a good dev experience. + +### React runtime contract + +Flight and server references are unforgiving about identifiers, manifests, and module resolution. The runtime must treat React's contract as the source of truth. + +### Router rewrite + +The current router is designed around fetching route props. A real RSC router path is materially different and should be treated as a first-class rewrite, not an incremental tweak. + +### Action semantics + +Once `'use server'` exists, request context, serialization, redirects, and error handling all become part of the public framework contract. That is why actions are intentionally moved later in this plan, after document render and navigation are already stable. + +## Non-Goals + +These items should not block the first end-to-end RSC implementation: + +- advanced caching and revalidation +- partial prerendering +- sophisticated nested layout persistence optimizations +- custom data cache semantics beyond what React requires for correctness + +Those are valuable follow-on features, but they should sit on top of a correct RSC core. + +## Definition of Done + +RSC should be considered implemented when all of the following are true: + +- route trees can render real server components +- client components are referenced through the client manifest +- initial document requests render an HTML shell plus Flight payload +- client navigations fetch and apply Flight responses +- production and dev modes both use the same conceptual RSC architecture +- the legacy route-props bootstrap path is no longer required + +If server actions are in scope for the release, also require: + +- `'use server'` exports are callable through server references + +## Immediate Next Step + +Start with Milestone 1: + +- refactor the server runtime contract away from `{ html, props }` +- add directive-aware client/server module classification in the Vite plugin +- generate the first client reference manifest +- land one route on the full document RSC path + +That gives the project a real, working Flight slice immediately, while still building toward the full framework architecture. From bacc56d79f0377a9de6ab0de253c76f5cab3de78 Mon Sep 17 00:00:00 2001 From: Patricia Jacob Date: Fri, 8 May 2026 18:25:16 +0100 Subject: [PATCH 02/11] wip --- lib/commands/dev.ts | 51 +- lib/commands/serve.ts | 12 + lib/plugin.ts | 312 ++++++++-- lib/rsc/client-manifest.ts | 97 +++ lib/rsc/module-classifier.ts | 193 ++++++ lib/types/react-server-dom-webpack.d.ts | 44 ++ package-lock.json | 752 +++++++++++++++++++++++- package.json | 1 + src/entry-client.tsx | 64 +- src/entry-rsc-document.tsx | 72 +++ src/entry-rsc-server.tsx | 37 ++ src/rsc/HomeCounter.client-reference.ts | 6 + src/rsc/HomeCounter.client.tsx | 13 + src/rsc/HomePage.tsx | 17 + src/rsc/client-reference-runtime.ts | 71 +++ src/rsc/createClientReference.ts | 11 + 16 files changed, 1686 insertions(+), 67 deletions(-) create mode 100644 lib/rsc/client-manifest.ts create mode 100644 lib/rsc/module-classifier.ts create mode 100644 lib/types/react-server-dom-webpack.d.ts create mode 100644 src/entry-rsc-document.tsx create mode 100644 src/entry-rsc-server.tsx create mode 100644 src/rsc/HomeCounter.client-reference.ts create mode 100644 src/rsc/HomeCounter.client.tsx create mode 100644 src/rsc/HomePage.tsx create mode 100644 src/rsc/client-reference-runtime.ts create mode 100644 src/rsc/createClientReference.ts diff --git a/lib/commands/dev.ts b/lib/commands/dev.ts index fad7808..4e36829 100644 --- a/lib/commands/dev.ts +++ b/lib/commands/dev.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import express from 'express'; import { createServer as createViteServer } from 'vite'; +import { buildDevClientManifest, collectClientModules } from '../rsc/client-manifest.js'; export const description = 'Start development server with HMR and SSR'; @@ -13,6 +14,21 @@ export async function run() { server: { middlewareMode: true }, appType: 'custom', }); + const rscVite = await createViteServer({ + server: { middlewareMode: true }, + appType: 'custom', + resolve: { + alias: [ + { find: /^react$/, replacement: path.resolve(root, 'node_modules/react/react.react-server.js') }, + { find: /^react\/jsx-runtime$/, replacement: path.resolve(root, 'node_modules/react/jsx-runtime.react-server.js') }, + { find: /^react\/jsx-dev-runtime$/, replacement: path.resolve(root, 'node_modules/react/jsx-dev-runtime.react-server.js') }, + ], + conditions: ['react-server', 'node', 'import', 'module', 'default'], + }, + ssr: { + noExternal: ['react', 'react-dom', 'react-server-dom-webpack'], + }, + }); app.get('/__matcha_props', async (req, res) => { const rawPath = req.query.path; @@ -50,19 +66,40 @@ export async function run() { const url = req.originalUrl; try { - // 1. Read index.html let template = fs.readFileSync(path.resolve(root, 'index.html'), 'utf-8'); - - // 2. Apply Vite HTML transforms (injects HMR client, etc.) template = await vite.transformIndexHtml(url, template); - // 3. Load server entry via Vite (enables HMR for SSR) - const { render, routes } = await vite.ssrLoadModule('/src/entry-server.tsx'); + if ((req.path === '/' || req.path === '') && req.method === 'GET') { + const [rscEntry, rscDocumentEntry, clientModules] = await Promise.all([ + rscVite.ssrLoadModule('/src/entry-rsc-server.tsx'), + vite.ssrLoadModule('/src/entry-rsc-document.tsx'), + collectClientModules(root), + ]); + const manifest = buildDevClientManifest(root, clientModules); + const payload = await (rscEntry as { + renderHomePayload: ( + manifest: ReturnType, + ) => Promise; + }).renderHomePayload(manifest); + const html = await (rscDocumentEntry as { + renderHomeDocument: ( + template: string, + manifest: ReturnType, + payload: string, + ) => Promise; + }).renderHomeDocument( + template, + manifest, + payload, + ); + + res.status(200).set({ 'Content-Type': 'text/html' }).end(html); + return; + } - // 4. Render the app + const { render, routes } = await vite.ssrLoadModule('/src/entry-server.tsx'); const { html: appHtml, props } = await render(url); - // 5. Inject rendered HTML const propsScript = ``; const ssrRoutes = (routes as Array<{ path: string; getServerSideProps?: unknown }>) .filter((route) => Boolean(route.getServerSideProps)) diff --git a/lib/commands/serve.ts b/lib/commands/serve.ts index e4439c6..a88124d 100644 --- a/lib/commands/serve.ts +++ b/lib/commands/serve.ts @@ -6,6 +6,8 @@ import { pathToFileURL } from 'node:url'; interface SsrFunctionModule { isSsrRoute: (path: string) => boolean; + isRscRoute: (path: string) => boolean; + renderRscPage: (path: string) => Promise; renderSsrPage: (path: string) => Promise; renderRouteProps: (path: string) => Promise>; } @@ -63,6 +65,16 @@ export async function run() { const requestUrl = req.originalUrl; const urlPath = requestUrl.split('?')[0] ?? ''; + if (ssrFunction && ssrFunction.isRscRoute(requestUrl)) { + try { + const html = await ssrFunction.renderRscPage(requestUrl); + return res.status(200).set({ 'Content-Type': 'text/html' }).end(html); + } catch (e) { + console.error(e); + return res.status(500).end((e as Error).message); + } + } + const indexPath = path.resolve(distPath, urlPath.slice(1), 'index.html'); if (fs.existsSync(indexPath)) { const html = await readFile(indexPath, 'utf-8'); diff --git a/lib/plugin.ts b/lib/plugin.ts index 7f80b73..5938984 100644 --- a/lib/plugin.ts +++ b/lib/plugin.ts @@ -2,6 +2,9 @@ import { Plugin, build } from 'vite'; import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import path from 'node:path'; +import { analyzeModule, stripServerCode, toModuleId } from './rsc/module-classifier.js'; +import { collectClientModules } from './rsc/client-manifest.js'; interface Route { path: string; @@ -13,34 +16,82 @@ interface RenderResult { props: Record; } -/** - * Strip server-only code from client builds: - * - getStaticProps/getServerSideProps exports - * - Node.js built-in imports - */ -function stripServerCode(code: string): string { - code = code.replace( - /^export\s+const\s+getStaticProps\s*=[\s\S]*?^\};?\n/gm, - '' - ); - code = code.replace( - /^export\s+(async\s+)?function\s+getStaticProps[\s\S]*?^\}\n/gm, - '' - ); - code = code.replace( - /^export\s+const\s+getServerSideProps\s*=[\s\S]*?^\};?\n/gm, - '' - ); - code = code.replace( - /^export\s+(async\s+)?function\s+getServerSideProps[\s\S]*?^\}\n/gm, - '' - ); - - code = code.replace(/,?\s*getStaticProps:\s*[^,}]+/g, ''); - code = code.replace(/,?\s*getServerSideProps:\s*[^,}]+/g, ', hasServerSideProps: true'); - code = code.replace(/^import\s+.*\s+from\s+['"]node:.*['"];?\n/gm, ''); - - return code; +interface ClientReferenceManifest { + moduleMap: Record>; + serverModuleMap: Record; + chunkMap: Record; + ssrChunkMap: Record; +} + +interface ClientModuleRecord { + exports: string[]; +} + +function toSsrClientEntryName(root: string, filePath: string): string { + return toModuleId(root, filePath).replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +function isSourceModule(id: string): boolean { + return /\.[cm]?[jt]sx?$/.test(id); +} + +function createClientReferenceModuleCode(moduleId: string, exportNames: string[]): string { + const lines = [ + `import { registerClientReference } from 'react-server-dom-webpack/server.node';`, + '', + `function createClientReference(exportName) {`, + ` return registerClientReference(`, + ` function clientReferenceProxy() {`, + ` throw new Error(\`Cannot call the client export "\${exportName}" from "${moduleId}" on the server.\`);`, + ` },`, + ` ${JSON.stringify(moduleId)},`, + ` exportName,`, + ` );`, + `}`, + '', + ]; + + if (exportNames.includes('default')) { + lines.push(`const __matcha_default__ = createClientReference('default');`); + lines.push(`export default __matcha_default__;`); + } + + for (const exportName of exportNames) { + if (exportName === 'default') { + continue; + } + + lines.push(`export const ${exportName} = createClientReference(${JSON.stringify(exportName)});`); + } + + if (exportNames.length === 0) { + lines.push(`export default createClientReference('default');`); + } + + return `${lines.join('\n')}\n`; +} + +function createRscBuildPlugin(root: string): Plugin { + return { + name: 'matcha-rsc-server-build', + + transform(code, id) { + const cleanId = id.split('?', 1)[0] ?? id; + if (!cleanId.includes('/src/') || !isSourceModule(cleanId)) { + return; + } + + const analysis = analyzeModule(code, cleanId); + if (!analysis.useClient) { + return; + } + + return { + code: createClientReferenceModuleCode(toModuleId(root, cleanId), analysis.exports), + map: null, + }; + }, + }; } export default function matcha(): Plugin { @@ -48,6 +99,8 @@ export default function matcha(): Plugin { let outDir: string; let isSsr: boolean; let command: 'build' | 'serve'; + const clientModules = new Map(); + const emittedClientEntries = new Set(); return { name: 'matcha', @@ -59,19 +112,103 @@ export default function matcha(): Plugin { command = config.command; }, + async buildStart() { + if (command !== 'build' || isSsr) { + return; + } + + const modules = await collectClientModules(root); + for (const moduleInfo of modules) { + clientModules.set(moduleInfo.filePath, { + exports: moduleInfo.exports, + }); + + if (!emittedClientEntries.has(moduleInfo.filePath)) { + emittedClientEntries.add(moduleInfo.filePath); + this.emitFile({ + type: 'chunk', + id: moduleInfo.filePath, + name: path.basename(moduleInfo.filePath, path.extname(moduleInfo.filePath)), + preserveSignature: 'strict', + }); + } + } + }, + transform(code, id) { - if (command !== 'build') return; - if (isSsr) return; - if (!id.includes('/src/')) return; - if (!id.match(/\.(tsx?|jsx?)$/)) return; + const cleanId = id.split('?', 1)[0] ?? id; + if (!cleanId.includes('/src/') || !isSourceModule(cleanId)) { + return; + } + + const analysis = analyzeModule(code, cleanId); + + if (analysis.useClient && isSsr) { + return { + code: createClientReferenceModuleCode(toModuleId(root, cleanId), analysis.exports), + map: null, + }; + } + + if (command !== 'build' || isSsr) { + return; + } - const stripped = stripServerCode(code); + const stripped = stripServerCode(code, cleanId); if (stripped !== code) { return { code: stripped, map: null }; } }, + generateBundle(_, bundle) { + if (command !== 'build' || isSsr) { + return; + } + + const manifest: ClientReferenceManifest = { + moduleMap: {}, + serverModuleMap: {}, + chunkMap: {}, + ssrChunkMap: {}, + }; + + for (const [filePath, record] of clientModules) { + const moduleId = toModuleId(root, filePath); + const chunk = Object.values(bundle).find((entry) => { + return entry.type === 'chunk' && entry.facadeModuleId === filePath; + }); + + if (!chunk || chunk.type !== 'chunk') { + continue; + } + + manifest.chunkMap[moduleId] = `/${chunk.fileName}`; + manifest.moduleMap[moduleId] = {}; + + for (const exportName of record.exports) { + const clientReference = { + id: moduleId, + chunks: [moduleId, chunk.fileName], + name: exportName, + }; + + manifest.moduleMap[moduleId][exportName] = clientReference; + manifest.serverModuleMap[`${moduleId}#${exportName}`] = clientReference; + } + } + + this.emitFile({ + type: 'asset', + fileName: 'rsc-client-manifest.json', + source: JSON.stringify(manifest, null, 2), + }); + }, + async closeBundle() { + if (command !== 'build' || isSsr) { + return; + } + const distDir = resolve(root, outDir); const serverOutDir = resolve(root, 'dist/server'); @@ -91,6 +228,73 @@ export default function matcha(): Plugin { }, }); + await build({ + configFile: false, + root, + build: { + ssr: resolve(root, 'src/entry-rsc-document.tsx'), + outDir: serverOutDir, + emptyOutDir: false, + rollupOptions: { + output: { + entryFileNames: 'entry-rsc-document.js', + format: 'esm', + }, + }, + }, + }); + + if (clientModules.size > 0) { + const clientSsrInputs = Object.fromEntries( + [...clientModules.keys()].map((filePath) => [toSsrClientEntryName(root, filePath), filePath]), + ); + + await build({ + configFile: false, + root, + build: { + ssr: true, + outDir: serverOutDir, + emptyOutDir: false, + rollupOptions: { + input: clientSsrInputs, + output: { + entryFileNames: 'rsc-client/[name].js', + format: 'esm', + }, + }, + }, + }); + } + + await build({ + configFile: false, + root, + plugins: [createRscBuildPlugin(root)], + resolve: { + alias: [ + { find: /^react$/, replacement: resolve(root, 'node_modules/react/react.react-server.js') }, + { find: /^react\/jsx-runtime$/, replacement: resolve(root, 'node_modules/react/jsx-runtime.react-server.js') }, + { find: /^react\/jsx-dev-runtime$/, replacement: resolve(root, 'node_modules/react/jsx-dev-runtime.react-server.js') }, + ], + conditions: ['react-server', 'node', 'import', 'module', 'default'], + }, + ssr: { + noExternal: ['react', 'react-dom', 'react-server-dom-webpack'], + }, + build: { + ssr: resolve(root, 'src/entry-rsc-server.tsx'), + outDir: serverOutDir, + emptyOutDir: false, + rollupOptions: { + output: { + entryFileNames: 'entry-rsc-server.js', + format: 'esm', + }, + }, + }, + }); + const serverEntryPath = resolve(serverOutDir, 'entry-server.js'); const serverEntryUrl = pathToFileURL(serverEntryPath).href; const { render, loadStaticProps, routes } = await import(serverEntryUrl) as { @@ -114,10 +318,13 @@ export default function matcha(): Plugin { import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadServerSideProps, renderWithProps } from './entry-server.js'; +import { renderHomePayload } from './entry-rsc-server.js'; +import { renderHomeDocument } from './entry-rsc-document.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const templatePath = path.resolve(__dirname, './ssr-template.html'); const publicRoot = path.resolve(__dirname, '../public'); +const manifestPath = path.resolve(publicRoot, 'rsc-client-manifest.json'); const ssrRoutes = ${JSON.stringify(ssrRoutes)}; function normalizePath(routePath) { @@ -137,6 +344,10 @@ function isSsrRoute(routeTarget) { return ssrRoutes.includes(toRouteTarget(routeTarget).pathname); } +function isRscRoute(routeTarget) { + return toRouteTarget(routeTarget).pathname === '/'; +} + function staticPropsFilePath(routePath) { if (routePath === '/') return path.resolve(publicRoot, '_props.json'); return path.resolve(publicRoot, routePath.slice(1), '_props.json'); @@ -151,6 +362,21 @@ async function loadCachedStaticProps(routePath) { } } +async function loadClientManifest() { + const file = await readFile(manifestPath, 'utf-8'); + return JSON.parse(file); +} + +export async function renderRscPage() { + const [template, manifest] = await Promise.all([ + readFile(templatePath, 'utf-8'), + loadClientManifest(), + ]); + const payload = await renderHomePayload(manifest); + + return renderHomeDocument(template, manifest, payload); +} + export async function renderSsrPage(routeTarget) { const { pathname, target } = toRouteTarget(routeTarget); const [template, staticProps] = await Promise.all([ @@ -175,9 +401,20 @@ export async function renderRouteProps(routeTarget) { return { ...staticProps, ...serverProps }; } -export { isSsrRoute, ssrRoutes };`; +export { isSsrRoute, ssrRoutes, isRscRoute };`; await writeFile(ssrFunctionPath, ssrFunctionCode); + const manifestPath = resolve(distDir, 'rsc-client-manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as ClientReferenceManifest; + manifest.ssrChunkMap = Object.fromEntries( + [...clientModules.keys()].map((filePath) => { + const moduleId = toModuleId(root, filePath); + const entryName = toSsrClientEntryName(root, filePath); + return [moduleId, resolve(serverOutDir, 'rsc-client', `${entryName}.js`)]; + }), + ); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + let renderedCount = 0; for (const route of routes) { const staticProps = await loadStaticProps(route.path); @@ -189,6 +426,11 @@ export { isSsrRoute, ssrRoutes };`; const propsPath = resolve(routeDir, '_props.json'); await writeFile(propsPath, JSON.stringify(staticProps)); + if (route.path === '/') { + console.log('[matcha] / -> RSC document runtime'); + continue; + } + if (ssrRoutes.includes(route.path)) { console.log(`[matcha] ${route.path} -> SSR runtime`); continue; @@ -207,7 +449,7 @@ export { isSsrRoute, ssrRoutes };`; console.log(`[matcha] ${route.path} -> ${htmlPath.replace(root + '/', '')}`); } - console.log(`[matcha] Static pages: ${renderedCount}, SSR pages: ${ssrRoutes.length}`); + console.log(`[matcha] Static pages: ${renderedCount}, SSR pages: ${ssrRoutes.length}, RSC pages: 1`); console.log(`[matcha] SSR function: ${ssrFunctionPath.replace(root + '/', '')}`); }, }; diff --git a/lib/rsc/client-manifest.ts b/lib/rsc/client-manifest.ts new file mode 100644 index 0000000..8d2fd1d --- /dev/null +++ b/lib/rsc/client-manifest.ts @@ -0,0 +1,97 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { analyzeModule, toModuleId } from './module-classifier.js'; + +export interface ClientReferenceRecord { + id: string; + chunks: string[]; + name: string; + async?: boolean; +} + +export interface ClientManifest { + moduleMap: Record>; + serverModuleMap: Record; + chunkMap: Record; + ssrChunkMap?: Record; +} + +export interface ClientModuleInfo { + filePath: string; + moduleId: string; + exports: string[]; +} + +async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const resolved = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...await walk(resolved)); + continue; + } + + files.push(resolved); + } + + return files; +} + +function isSourceModule(filePath: string): boolean { + return /\.(?:[cm]?[jt]sx?)$/.test(filePath); +} + +export async function collectClientModules(root: string): Promise { + const srcDir = path.resolve(root, 'src'); + const files = await walk(srcDir); + const clientModules: ClientModuleInfo[] = []; + + for (const filePath of files) { + if (!isSourceModule(filePath)) { + continue; + } + + const code = await readFile(filePath, 'utf8'); + const analysis = analyzeModule(code, filePath); + if (!analysis.useClient) { + continue; + } + + clientModules.push({ + filePath, + moduleId: toModuleId(root, filePath), + exports: analysis.exports.length > 0 ? analysis.exports : ['default'], + }); + } + + return clientModules; +} + +export function buildDevClientManifest(root: string, modules: ClientModuleInfo[]): ClientManifest { + const moduleMap: ClientManifest['moduleMap'] = {}; + const serverModuleMap: ClientManifest['serverModuleMap'] = {}; + const chunkMap: ClientManifest['chunkMap'] = {}; + const ssrChunkMap: ClientManifest['ssrChunkMap'] = {}; + + for (const moduleInfo of modules) { + chunkMap[moduleInfo.moduleId] = `/${moduleInfo.moduleId}`; + ssrChunkMap[moduleInfo.moduleId] = moduleInfo.filePath; + moduleMap[moduleInfo.moduleId] = {}; + + for (const exportName of moduleInfo.exports) { + const record = { + id: moduleInfo.moduleId, + chunks: [moduleInfo.moduleId, moduleInfo.moduleId], + name: exportName, + }; + + moduleMap[moduleInfo.moduleId][exportName] = record; + serverModuleMap[`${moduleInfo.moduleId}#${exportName}`] = record; + } + } + + return { moduleMap, serverModuleMap, chunkMap, ssrChunkMap }; +} diff --git a/lib/rsc/module-classifier.ts b/lib/rsc/module-classifier.ts new file mode 100644 index 0000000..08da03b --- /dev/null +++ b/lib/rsc/module-classifier.ts @@ -0,0 +1,193 @@ +import path from 'node:path'; +import ts from 'typescript'; + +export interface ModuleDirectiveInfo { + useClient: boolean; + useServer: boolean; + exports: string[]; +} + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + return Boolean(modifiers?.some((modifier) => modifier.kind === kind)); +} + +function getDeclarationExportNames(statement: ts.Statement): string[] { + if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { + if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) { + return ['default']; + } + + return statement.name ? [statement.name.text] : []; + } + + if (ts.isVariableStatement(statement)) { + const names: string[] = []; + + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + names.push(declaration.name.text); + } + } + + return names; + } + + if (ts.isExportAssignment(statement)) { + return ['default']; + } + + if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) { + return statement.exportClause.elements.map((element) => element.name.text); + } + + return []; +} + +export function analyzeModule(code: string, fileName: string): ModuleDirectiveInfo { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + let useClient = false; + let useServer = false; + const exports = new Set(); + + for (const statement of sourceFile.statements) { + if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) { + if (statement.expression.text === 'use client') { + useClient = true; + continue; + } + + if (statement.expression.text === 'use server') { + useServer = true; + continue; + } + } + + break; + } + + for (const statement of sourceFile.statements) { + if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword) && !ts.isExportAssignment(statement) && !ts.isExportDeclaration(statement)) { + continue; + } + + for (const name of getDeclarationExportNames(statement)) { + exports.add(name); + } + } + + return { + useClient, + useServer, + exports: [...exports], + }; +} + +function isServerLoaderStatement(statement: ts.Statement): boolean { + const isTargetName = (name: string) => name === 'getStaticProps' || name === 'getServerSideProps'; + + if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { + return hasModifier(statement, ts.SyntaxKind.ExportKeyword) && Boolean(statement.name && isTargetName(statement.name.text)); + } + + if (ts.isVariableStatement(statement) && hasModifier(statement, ts.SyntaxKind.ExportKeyword)) { + return statement.declarationList.declarations.some((declaration) => { + return ts.isIdentifier(declaration.name) && isTargetName(declaration.name.text); + }); + } + + return false; +} + +function getPropertyName(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + + return null; +} + +export function stripServerCode(code: string, fileName: string): string { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + const transformer: ts.TransformerFactory = (context) => { + const visit: ts.Visitor = (node) => { + if (ts.isObjectLiteralExpression(node)) { + const nextProperties: ts.ObjectLiteralElementLike[] = []; + let removedServerSideProps = false; + let hasServerSidePropsMarker = false; + + for (const property of node.properties) { + const propertyName = + (ts.isPropertyAssignment(property) || + ts.isShorthandPropertyAssignment(property) || + ts.isMethodDeclaration(property)) && + property.name + ? getPropertyName(property.name) + : null; + + if (propertyName === 'getStaticProps') { + continue; + } + + if (propertyName === 'getServerSideProps') { + removedServerSideProps = true; + continue; + } + + if (propertyName === 'hasServerSideProps') { + hasServerSidePropsMarker = true; + } + + nextProperties.push(ts.visitNode(property, visit) as ts.ObjectLiteralElementLike); + } + + if (removedServerSideProps && !hasServerSidePropsMarker) { + nextProperties.push( + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('hasServerSideProps'), + ts.factory.createTrue(), + ), + ); + } + + return ts.factory.updateObjectLiteralExpression(node, nextProperties); + } + + return ts.visitEachChild(node, visit, context); + }; + + return (file) => { + const statements: ts.Statement[] = []; + + for (const statement of file.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith('node:') + ) { + continue; + } + + if (isServerLoaderStatement(statement)) { + continue; + } + + statements.push(ts.visitNode(statement, visit) as ts.Statement); + } + + return ts.factory.updateSourceFile(file, statements); + }; + }; + + const transformed = ts.transform(sourceFile, [transformer]); + const printer = ts.createPrinter(); + const output = printer.printFile(transformed.transformed[0]); + transformed.dispose(); + return output; +} + +export function toModuleId(root: string, filePath: string): string { + return path.relative(root, filePath).split(path.sep).join('/'); +} diff --git a/lib/types/react-server-dom-webpack.d.ts b/lib/types/react-server-dom-webpack.d.ts new file mode 100644 index 0000000..ef39204 --- /dev/null +++ b/lib/types/react-server-dom-webpack.d.ts @@ -0,0 +1,44 @@ +declare module 'react-server-dom-webpack/server.node' { + import type * as React from 'react'; + import type { Writable } from 'node:stream'; + + export interface ClientReferenceRecord { + id: string; + chunks: string[]; + name: string; + async?: boolean; + } + + export interface PipeableStream { + pipe(destination: Writable): void; + } + + export function registerClientReference(proxy: T, moduleId: string, exportName: string): T; + + export function renderToPipeableStream( + model: React.ReactNode, + moduleMap: Record, + ): PipeableStream; +} + +declare module 'react-server-dom-webpack/client.edge' { + import type * as React from 'react'; + + export interface ClientReferenceRecord { + id: string; + chunks: string[]; + name: string; + async?: boolean; + } + + export function createFromReadableStream( + stream: ReadableStream, + options: { + serverConsumerManifest: { + moduleMap: Record>; + serverModuleMap?: Record; + moduleLoading?: unknown; + }; + }, + ): Promise; +} diff --git a/package-lock.json b/package-lock.json index 5457c9f..61124d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "esbuild": "^0.27.2", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-server-dom-webpack": "^19.2.0", "typescript": "^5.9.3", "vite": "^7.3.1" }, @@ -728,7 +729,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -750,24 +750,32 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1172,6 +1180,28 @@ "@types/node": "*" } }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@types/estree/-/estree-1.0.8.tgz", @@ -1210,11 +1240,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1296,6 +1332,181 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1310,11 +1521,95 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.13", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1352,7 +1647,6 @@ "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1382,6 +1676,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "peer": true + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1427,7 +1728,6 @@ "version": "1.0.30001784", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1444,6 +1744,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1556,7 +1873,6 @@ "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, "license": "ISC" }, "node_modules/encodeurl": { @@ -1569,6 +1885,20 @@ "node": ">= 0.8" } }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1589,6 +1919,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT", + "peer": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1647,7 +1984,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1660,6 +1996,53 @@ "dev": true, "license": "MIT" }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1670,6 +2053,16 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1714,6 +2107,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/fdir/-/fdir-6.5.0.tgz", @@ -1846,6 +2263,13 @@ "node": ">= 0.4" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1859,6 +2283,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "peer": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1947,6 +2388,21 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1967,6 +2423,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1980,6 +2443,20 @@ "node": ">=6" } }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2023,11 +2500,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2085,11 +2568,16 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true, "license": "MIT" }, "node_modules/object-inspect": { @@ -2252,24 +2740,24 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.5" } }, "node_modules/react-refresh": { @@ -2282,6 +2770,35 @@ "node": ">=0.10.0" } }, + "node_modules/react-server-dom-webpack": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-server-dom-webpack/-/react-server-dom-webpack-19.2.5.tgz", + "integrity": "sha512-bYhdd2cZJhXHqyJBoloYaJrn8MrL9Egf3ZZVn0OrIODCCORm2goFD7C+xszf6xgfsSJi0rtgB/ichcuHfkJ4yQ==", + "license": "MIT", + "dependencies": { + "acorn-loose": "^8.3.0", + "neo-async": "^2.6.1", + "webpack-sources": "^3.2.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "webpack": "^5.59.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.55.1", "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/rollup/-/rollup-4.55.1.tgz", @@ -2356,6 +2873,26 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2496,6 +3033,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2505,6 +3052,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2515,6 +3073,89 @@ "node": ">= 0.8" } }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", + "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2573,7 +3214,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -2590,7 +3230,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2701,6 +3340,77 @@ } } }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", + "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 08b538a..43bf394 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "esbuild": "^0.27.2", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-server-dom-webpack": "^19.2.0", "typescript": "^5.9.3", "vite": "^7.3.1" }, diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 962837c..9e486a4 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,16 +1,72 @@ +import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; import App from './app.js'; +import { ClientManifest, installWebpackClientReferenceRuntime } from './rsc/client-reference-runtime.js'; declare global { interface Window { __INITIAL_PROPS__?: Record; __MATCHA_SSR_ROUTES__?: string[]; + __MATCHA_RSC_ENABLED__?: boolean; + __MATCHA_RSC_MANIFEST__?: ClientManifest; + __MATCHA_RSC_PAYLOAD__?: string; } } const initialProps = window.__INITIAL_PROPS__ ?? {}; +const appRoot = document.getElementById('app')!; -ReactDOM.hydrateRoot( - document.getElementById('app')!, - -); +function createStreamFromBase64(base64: string): ReadableStream { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +async function bootstrapRsc() { + installWebpackClientReferenceRuntime(window, async (chunkId) => { + const chunkUrl = window.__MATCHA_RSC_MANIFEST__.chunkMap[chunkId]; + if (!chunkUrl) { + throw new Error(`Unknown client chunk "${chunkId}"`); + } + + return await import(/* @vite-ignore */ chunkUrl); + }); + const { createFromReadableStream } = await import('react-server-dom-webpack/client.edge'); + const response = createFromReadableStream( + createStreamFromBase64(window.__MATCHA_RSC_PAYLOAD__), + { + serverConsumerManifest: { + moduleMap: window.__MATCHA_RSC_MANIFEST__.moduleMap, + serverModuleMap: window.__MATCHA_RSC_MANIFEST__.serverModuleMap ?? {}, + moduleLoading: null, + }, + }, + ) as Promise; + const resolvedNode = await response; + ReactDOM.createRoot(appRoot).render( + +
+ {resolvedNode} +
+
, + ); +} + +if (window.__MATCHA_RSC_ENABLED__ && window.__MATCHA_RSC_MANIFEST__ && window.__MATCHA_RSC_PAYLOAD__) { + void bootstrapRsc(); +} else { + ReactDOM.hydrateRoot( + appRoot, + + ); +} diff --git a/src/entry-rsc-document.tsx b/src/entry-rsc-document.tsx new file mode 100644 index 0000000..dfc929a --- /dev/null +++ b/src/entry-rsc-document.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { PassThrough, Readable } from 'node:stream'; +import { pathToFileURL } from 'node:url'; +import { renderToPipeableStream } from 'react-dom/server'; +import { createFromNodeStream } from 'react-server-dom-webpack/client.node'; +import { ClientManifest, installWebpackClientReferenceRuntime } from './rsc/client-reference-runtime.js'; + +declare global { + // eslint-disable-next-line no-var + var __webpack_chunk_load__: ((chunkId: string) => Promise) | undefined; + // eslint-disable-next-line no-var + var __webpack_require__: (((moduleId: string) => unknown) & { m?: Record }) | undefined; +} + +async function renderHtmlToString(node: React.ReactNode): Promise { + const stream = renderToPipeableStream(node); + + return await new Promise((resolve, reject) => { + const sink = new PassThrough(); + const chunks: Buffer[] = []; + + sink.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + sink.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + sink.on('error', reject); + + stream.pipe(sink); + }); +} + +function createRscBootstrapScript(manifest: ClientManifest, payload: string): string { + const base64Payload = Buffer.from(payload, 'utf8').toString('base64'); + return ``; +} + +async function renderHomeHtml(payload: string, manifest: ClientManifest): Promise { + installWebpackClientReferenceRuntime(globalThis, async (chunkId) => { + const chunkPath = manifest.ssrChunkMap?.[chunkId]; + if (!chunkPath) { + throw new Error(`Unknown SSR client chunk "${chunkId}"`); + } + + return await import(pathToFileURL(chunkPath).href); + }); + + const response = createFromNodeStream(Readable.from([payload]), { + moduleMap: manifest.moduleMap, + serverModuleMap: manifest.serverModuleMap, + moduleLoading: null, + }) as Promise; + const resolvedNode = await response; + + return await renderHtmlToString( +
+ {resolvedNode} +
, + ); +} + +export async function renderHomeDocument( + template: string, + manifest: ClientManifest, + payload: string, +): Promise { + const html = await renderHomeHtml(payload, manifest); + const bootstrapScript = createRscBootstrapScript(manifest, payload); + + return template + .replace('', html) + .replace('', `${bootstrapScript}`); +} diff --git a/src/entry-rsc-server.tsx b/src/entry-rsc-server.tsx new file mode 100644 index 0000000..4e1e097 --- /dev/null +++ b/src/entry-rsc-server.tsx @@ -0,0 +1,37 @@ +import type * as React from 'react'; +import { PassThrough } from 'node:stream'; +import { renderToPipeableStream } from 'react-server-dom-webpack/server.node'; +import HomePage from './rsc/HomePage.js'; +import { ClientManifest } from './rsc/client-reference-runtime.js'; + +export function renderHomeRoute() { + return ; +} + +async function renderFlightPayloadToString( + model: React.ReactNode, + moduleMap: ClientManifest['moduleMap'], +): Promise { + const stream = renderToPipeableStream(model, moduleMap); + + return await new Promise((resolve, reject) => { + const sink = new PassThrough(); + const chunks: Buffer[] = []; + + sink.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + sink.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + sink.on('error', reject); + + try { + stream.pipe(sink); + } catch (error) { + reject(error); + } + }); +} + +export async function renderHomePayload(manifest: ClientManifest): Promise { + return await renderFlightPayloadToString(renderHomeRoute(), manifest.serverModuleMap); +} diff --git a/src/rsc/HomeCounter.client-reference.ts b/src/rsc/HomeCounter.client-reference.ts new file mode 100644 index 0000000..94bdaad --- /dev/null +++ b/src/rsc/HomeCounter.client-reference.ts @@ -0,0 +1,6 @@ +import type HomeCounterComponent from './HomeCounter.client.js'; +import { createClientReference } from './createClientReference.js'; + +const HOME_COUNTER_MODULE_ID = 'src/rsc/HomeCounter.client.tsx'; + +export default createClientReference(HOME_COUNTER_MODULE_ID, 'default'); diff --git a/src/rsc/HomeCounter.client.tsx b/src/rsc/HomeCounter.client.tsx new file mode 100644 index 0000000..6accde2 --- /dev/null +++ b/src/rsc/HomeCounter.client.tsx @@ -0,0 +1,13 @@ +'use client'; + +import * as React from 'react'; + +export default function HomeCounter() { + const [count, setCount] = React.useState(0); + + return ( + + ); +} diff --git a/src/rsc/HomePage.tsx b/src/rsc/HomePage.tsx new file mode 100644 index 0000000..2054040 --- /dev/null +++ b/src/rsc/HomePage.tsx @@ -0,0 +1,17 @@ +import HomeCounter from './HomeCounter.client-reference.js'; + +export default function HomePage() { + return ( +
+

MatchaStack

+

Home now renders through a real Flight payload on full document requests.

+

The counter below is a client component resolved from the RSC client manifest.

+ + +
+ ); +} diff --git a/src/rsc/client-reference-runtime.ts b/src/rsc/client-reference-runtime.ts new file mode 100644 index 0000000..dc2a732 --- /dev/null +++ b/src/rsc/client-reference-runtime.ts @@ -0,0 +1,71 @@ +export interface ClientReferenceRecord { + id: string; + chunks: string[]; + name: string; + async?: boolean; +} + +export interface ClientManifest { + moduleMap: Record>; + serverModuleMap: Record; + chunkMap: Record; + ssrChunkMap?: Record; +} + +declare global { + interface Window { + __webpack_chunk_load__?: (chunkId: string) => Promise; + __webpack_require__?: ((moduleId: string) => unknown) & { + m?: Record; + }; + } +} + +interface WebpackRuntimeTarget { + __webpack_chunk_load__?: (chunkId: string) => Promise; + __webpack_require__?: ((moduleId: string) => unknown) & { + m?: Record; + }; +} + +export function installWebpackClientReferenceRuntime( + target: WebpackRuntimeTarget, + loadChunk: (chunkId: string) => Promise, +) { + const loadedModules = new Map(); + const loadingModules = new Map>(); + + target.__webpack_chunk_load__ = async (chunkId: string) => { + if (loadedModules.has(chunkId)) { + return loadedModules.get(chunkId); + } + + const existing = loadingModules.get(chunkId); + if (existing) { + return existing; + } + + const loadPromise = loadChunk(chunkId).then((moduleNamespace) => { + loadedModules.set(chunkId, moduleNamespace); + loadingModules.delete(chunkId); + return moduleNamespace; + }).catch((error: unknown) => { + loadingModules.delete(chunkId); + throw error; + }); + + loadingModules.set(chunkId, loadPromise); + return loadPromise; + }; + + target.__webpack_require__ = Object.assign( + (moduleId: string) => { + if (!loadedModules.has(moduleId)) { + throw new Error(`Client module "${moduleId}" was required before it finished loading.`); + } + + return loadedModules.get(moduleId); + }, + { m: Object.create(null) as Record }, + ); +} diff --git a/src/rsc/createClientReference.ts b/src/rsc/createClientReference.ts new file mode 100644 index 0000000..bae8533 --- /dev/null +++ b/src/rsc/createClientReference.ts @@ -0,0 +1,11 @@ +import { registerClientReference } from 'react-server-dom-webpack/server.node'; + +export function createClientReference(moduleId: string, exportName: string): T { + return registerClientReference( + function clientReferenceProxy() { + throw new Error(`Cannot call the client export "${exportName}" from "${moduleId}" on the server.`); + }, + moduleId, + exportName, + ) as T; +} From 2ea9ac8238e3ae4159bcc4e2980a3894d8f0e929 Mon Sep 17 00:00:00 2001 From: Krzysztof Bielikowicz <62392609+ibniss@users.noreply.github.com> Date: Fri, 8 May 2026 18:53:43 +0100 Subject: [PATCH 03/11] Fix stuff --- .fledglingignore | 0 lib/commands/dev.ts | 10 +- lib/plugin.ts | 26 ++- lib/rsc/module-classifier.ts | 26 +++ .../react-server-runtime/jsx-dev-runtime.js | 25 +++ lib/rsc/react-server-runtime/jsx-runtime.js | 26 +++ lib/rsc/react-server-runtime/react.js | 31 +++ .../react-server-runtime/rsc-server-node.js | 27 +++ lib/types/react-server-dom-webpack.d.ts | 59 +++--- package-lock.json | 193 ++++++++++-------- package.json | 2 +- src/entry-client.tsx | 14 +- src/entry-rsc-document.tsx | 20 +- src/entry-rsc-server.tsx | 2 +- src/rsc/HomePage.tsx | 2 +- src/rsc/client-reference-runtime.ts | 22 +- 16 files changed, 337 insertions(+), 148 deletions(-) create mode 100644 .fledglingignore create mode 100644 lib/rsc/react-server-runtime/jsx-dev-runtime.js create mode 100644 lib/rsc/react-server-runtime/jsx-runtime.js create mode 100644 lib/rsc/react-server-runtime/react.js create mode 100644 lib/rsc/react-server-runtime/rsc-server-node.js diff --git a/.fledglingignore b/.fledglingignore new file mode 100644 index 0000000..e69de29 diff --git a/lib/commands/dev.ts b/lib/commands/dev.ts index 4e36829..bc18ad9 100644 --- a/lib/commands/dev.ts +++ b/lib/commands/dev.ts @@ -19,15 +19,13 @@ export async function run() { appType: 'custom', resolve: { alias: [ - { find: /^react$/, replacement: path.resolve(root, 'node_modules/react/react.react-server.js') }, - { find: /^react\/jsx-runtime$/, replacement: path.resolve(root, 'node_modules/react/jsx-runtime.react-server.js') }, - { find: /^react\/jsx-dev-runtime$/, replacement: path.resolve(root, 'node_modules/react/jsx-dev-runtime.react-server.js') }, + { find: /^react$/, replacement: path.resolve(root, 'lib/rsc/react-server-runtime/react.js') }, + { find: /^react\/jsx-runtime$/, replacement: path.resolve(root, 'lib/rsc/react-server-runtime/jsx-runtime.js') }, + { find: /^react\/jsx-dev-runtime$/, replacement: path.resolve(root, 'lib/rsc/react-server-runtime/jsx-dev-runtime.js') }, + { find: /^react-server-dom-webpack\/server\.node$/, replacement: path.resolve(root, 'lib/rsc/react-server-runtime/rsc-server-node.js') }, ], conditions: ['react-server', 'node', 'import', 'module', 'default'], }, - ssr: { - noExternal: ['react', 'react-dom', 'react-server-dom-webpack'], - }, }); app.get('/__matcha_props', async (req, res) => { diff --git a/lib/plugin.ts b/lib/plugin.ts index 5938984..aec86fa 100644 --- a/lib/plugin.ts +++ b/lib/plugin.ts @@ -3,7 +3,7 @@ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import path from 'node:path'; -import { analyzeModule, stripServerCode, toModuleId } from './rsc/module-classifier.js'; +import { analyzeModule, stripModuleDirectives, stripServerCode, toModuleId } from './rsc/module-classifier.js'; import { collectClientModules } from './rsc/client-manifest.js'; interface Route { @@ -94,6 +94,29 @@ function createRscBuildPlugin(root: string): Plugin { }; } +function createClientSsrBuildPlugin(root: string): Plugin { + return { + name: 'matcha-rsc-client-ssr-build', + + transform(code, id) { + const cleanId = id.split('?', 1)[0] ?? id; + if (!cleanId.includes('/src/') || !isSourceModule(cleanId)) { + return; + } + + const analysis = analyzeModule(code, cleanId); + if (!analysis.useClient) { + return; + } + + return { + code: stripModuleDirectives(code, cleanId, ['use client']), + map: null, + }; + }, + }; +} + export default function matcha(): Plugin { let root: string; let outDir: string; @@ -252,6 +275,7 @@ export default function matcha(): Plugin { await build({ configFile: false, root, + plugins: [createClientSsrBuildPlugin(root)], build: { ssr: true, outDir: serverOutDir, diff --git a/lib/rsc/module-classifier.ts b/lib/rsc/module-classifier.ts index 08da03b..72807e6 100644 --- a/lib/rsc/module-classifier.ts +++ b/lib/rsc/module-classifier.ts @@ -188,6 +188,32 @@ export function stripServerCode(code: string, fileName: string): string { return output; } +export function stripModuleDirectives( + code: string, + fileName: string, + directives: readonly string[], +): string { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const removals: Array<{ start: number; end: number }> = []; + const directiveSet = new Set(directives); + + for (const statement of sourceFile.statements) { + if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) { + break; + } + + if (directiveSet.has(statement.expression.text)) { + removals.push({ start: statement.getStart(sourceFile), end: statement.end }); + } + } + + return removals + .reverse() + .reduce((nextCode, removal) => { + return `${nextCode.slice(0, removal.start)}${nextCode.slice(removal.end)}`; + }, code); +} + export function toModuleId(root: string, filePath: string): string { return path.relative(root, filePath).split(path.sep).join('/'); } diff --git a/lib/rsc/react-server-runtime/jsx-dev-runtime.js b/lib/rsc/react-server-runtime/jsx-dev-runtime.js new file mode 100644 index 0000000..fe48dde --- /dev/null +++ b/lib/rsc/react-server-runtime/jsx-dev-runtime.js @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const Module = require('node:module'); +const reactServer = require(resolve(process.cwd(), 'node_modules/react/cjs/react.react-server.development.js')); + +const originalLoad = Module._load; +Module._load = function loadWithReactServerCondition(request, parent, isMain) { + if (request === 'react') { + return reactServer; + } + + return originalLoad.call(this, request, parent, isMain); +}; + +let jsxDevRuntime; +try { + jsxDevRuntime = require(resolve(process.cwd(), 'node_modules/react/cjs/react-jsx-dev-runtime.react-server.development.js')); +} finally { + Module._load = originalLoad; +} + +export const Fragment = jsxDevRuntime.Fragment; +export const jsxDEV = jsxDevRuntime.jsxDEV; diff --git a/lib/rsc/react-server-runtime/jsx-runtime.js b/lib/rsc/react-server-runtime/jsx-runtime.js new file mode 100644 index 0000000..104adcb --- /dev/null +++ b/lib/rsc/react-server-runtime/jsx-runtime.js @@ -0,0 +1,26 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const Module = require('node:module'); +const reactServer = require(resolve(process.cwd(), 'node_modules/react/cjs/react.react-server.development.js')); + +const originalLoad = Module._load; +Module._load = function loadWithReactServerCondition(request, parent, isMain) { + if (request === 'react') { + return reactServer; + } + + return originalLoad.call(this, request, parent, isMain); +}; + +let jsxRuntime; +try { + jsxRuntime = require(resolve(process.cwd(), 'node_modules/react/cjs/react-jsx-runtime.react-server.development.js')); +} finally { + Module._load = originalLoad; +} + +export const Fragment = jsxRuntime.Fragment; +export const jsx = jsxRuntime.jsx; +export const jsxs = jsxRuntime.jsxs; diff --git a/lib/rsc/react-server-runtime/react.js b/lib/rsc/react-server-runtime/react.js new file mode 100644 index 0000000..a7cc90c --- /dev/null +++ b/lib/rsc/react-server-runtime/react.js @@ -0,0 +1,31 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const React = require(resolve(process.cwd(), 'node_modules/react/cjs/react.react-server.development.js')); + +export const Children = React.Children; +export const Fragment = React.Fragment; +export const Profiler = React.Profiler; +export const StrictMode = React.StrictMode; +export const Suspense = React.Suspense; +export const __SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = + React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; +export const cache = React.cache; +export const cacheSignal = React.cacheSignal; +export const captureOwnerStack = React.captureOwnerStack; +export const cloneElement = React.cloneElement; +export const createElement = React.createElement; +export const createRef = React.createRef; +export const forwardRef = React.forwardRef; +export const isValidElement = React.isValidElement; +export const lazy = React.lazy; +export const memo = React.memo; +export const use = React.use; +export const useCallback = React.useCallback; +export const useDebugValue = React.useDebugValue; +export const useId = React.useId; +export const useMemo = React.useMemo; +export const version = React.version; + +export default React; diff --git a/lib/rsc/react-server-runtime/rsc-server-node.js b/lib/rsc/react-server-runtime/rsc-server-node.js new file mode 100644 index 0000000..64e52a3 --- /dev/null +++ b/lib/rsc/react-server-runtime/rsc-server-node.js @@ -0,0 +1,27 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const Module = require('node:module'); +const reactServer = require(resolve(process.cwd(), 'node_modules/react/cjs/react.react-server.development.js')); + +const originalLoad = Module._load; +Module._load = function loadWithReactServerCondition(request, parent, isMain) { + if (request === 'react') { + return reactServer; + } + + return originalLoad.call(this, request, parent, isMain); +}; + +let serverDom; +try { + serverDom = require(resolve(process.cwd(), 'node_modules/react-server-dom-webpack/server.node.js')); +} finally { + Module._load = originalLoad; +} + +export const renderToPipeableStream = serverDom.renderToPipeableStream; +export const renderToReadableStream = serverDom.renderToReadableStream; +export const registerClientReference = serverDom.registerClientReference; +export const registerServerReference = serverDom.registerServerReference; diff --git a/lib/types/react-server-dom-webpack.d.ts b/lib/types/react-server-dom-webpack.d.ts index ef39204..3ca178e 100644 --- a/lib/types/react-server-dom-webpack.d.ts +++ b/lib/types/react-server-dom-webpack.d.ts @@ -1,44 +1,51 @@ declare module 'react-server-dom-webpack/server.node' { - import type * as React from 'react'; + import type { ReactNode } from 'react'; import type { Writable } from 'node:stream'; - export interface ClientReferenceRecord { - id: string; - chunks: string[]; - name: string; - async?: boolean; - } - export interface PipeableStream { pipe(destination: Writable): void; } - export function registerClientReference(proxy: T, moduleId: string, exportName: string): T; - export function renderToPipeableStream( - model: React.ReactNode, - moduleMap: Record, + model: ReactNode, + webpackMap: unknown, + options?: { + onError?: (error: unknown) => void; + }, ): PipeableStream; + + export function registerClientReference( + proxyImplementation: T, + id: string, + exportName: string, + ): T; } declare module 'react-server-dom-webpack/client.edge' { - import type * as React from 'react'; - - export interface ClientReferenceRecord { - id: string; - chunks: string[]; - name: string; - async?: boolean; - } + import type { ReactNode } from 'react'; export function createFromReadableStream( stream: ReadableStream, - options: { - serverConsumerManifest: { - moduleMap: Record>; - serverModuleMap?: Record; - moduleLoading?: unknown; + options?: { + serverConsumerManifest?: { + moduleMap: unknown; + serverModuleMap: unknown; + moduleLoading: unknown; }; }, - ): Promise; + ): Promise; +} + +declare module 'react-server-dom-webpack/client.node' { + import type { ReactNode } from 'react'; + import type { Readable } from 'node:stream'; + + export function createFromNodeStream( + stream: Readable, + options?: { + moduleMap: unknown; + serverModuleMap: unknown; + moduleLoading: unknown; + }, + ): Promise; } diff --git a/package-lock.json b/package-lock.json index 61124d6..5a3a489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "esbuild": "^0.27.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-server-dom-webpack": "^19.2.0", + "react-server-dom-webpack": "^19.2.6", "typescript": "^5.9.3", "vite": "^7.3.1" }, @@ -757,7 +757,7 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "peer": true, @@ -1182,7 +1182,7 @@ }, "node_modules/@types/eslint": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", "peer": true, @@ -1193,7 +1193,7 @@ }, "node_modules/@types/eslint-scope": { "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", "peer": true, @@ -1242,7 +1242,7 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT", "peer": true @@ -1334,7 +1334,7 @@ }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", "peer": true, @@ -1345,28 +1345,28 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", "peer": true, @@ -1378,14 +1378,14 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", "peer": true, @@ -1398,7 +1398,7 @@ }, "node_modules/@webassemblyjs/ieee754": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", "peer": true, @@ -1408,7 +1408,7 @@ }, "node_modules/@webassemblyjs/leb128": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", "peer": true, @@ -1418,14 +1418,14 @@ }, "node_modules/@webassemblyjs/utf8": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", "peer": true, @@ -1442,7 +1442,7 @@ }, "node_modules/@webassemblyjs/wasm-gen": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", "peer": true, @@ -1456,7 +1456,7 @@ }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", "peer": true, @@ -1469,7 +1469,7 @@ }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", "peer": true, @@ -1484,7 +1484,7 @@ }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", "peer": true, @@ -1495,14 +1495,14 @@ }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "license": "BSD-3-Clause", "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0", "peer": true @@ -1523,7 +1523,7 @@ }, "node_modules/acorn": { "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { @@ -1535,7 +1535,7 @@ }, "node_modules/acorn-import-phases": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", "peer": true, @@ -1548,7 +1548,7 @@ }, "node_modules/acorn-loose": { "version": "8.5.2", - "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/acorn-loose/-/acorn-loose-8.5.2.tgz", "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", "license": "MIT", "dependencies": { @@ -1560,7 +1560,7 @@ }, "node_modules/ajv": { "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "peer": true, @@ -1577,7 +1577,7 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "peer": true, @@ -1595,7 +1595,7 @@ }, "node_modules/ajv-keywords": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", "peer": true, @@ -1678,7 +1678,7 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT", "peer": true @@ -1746,7 +1746,7 @@ }, "node_modules/chrome-trace-event": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", "peer": true, @@ -1756,7 +1756,7 @@ }, "node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", "peer": true @@ -1886,9 +1886,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.2", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", "license": "MIT", "peer": true, "dependencies": { @@ -1920,9 +1920,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "license": "MIT", "peer": true }, @@ -1998,7 +1998,7 @@ }, "node_modules/eslint-scope": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", "peer": true, @@ -2012,7 +2012,7 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", "peer": true, @@ -2025,7 +2025,7 @@ }, "node_modules/esrecurse/node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "license": "BSD-2-Clause", "peer": true, @@ -2035,7 +2035,7 @@ }, "node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", "peer": true, @@ -2055,7 +2055,7 @@ }, "node_modules/events": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", "peer": true, @@ -2109,15 +2109,15 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT", "peer": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -2265,7 +2265,7 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause", "peer": true @@ -2285,14 +2285,14 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC", "peer": true }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "peer": true, @@ -2390,7 +2390,7 @@ }, "node_modules/jest-worker": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", "peer": true, @@ -2425,7 +2425,7 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT", "peer": true @@ -2445,7 +2445,7 @@ }, "node_modules/loader-runner": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/loader-runner/-/loader-runner-4.3.2.tgz", "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "license": "MIT", "peer": true, @@ -2502,7 +2502,7 @@ }, "node_modules/merge-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT", "peer": true @@ -2570,7 +2570,7 @@ }, "node_modules/neo-async": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, @@ -2740,24 +2740,24 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/react-refresh": { @@ -2771,9 +2771,9 @@ } }, "node_modules/react-server-dom-webpack": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-server-dom-webpack/-/react-server-dom-webpack-19.2.5.tgz", - "integrity": "sha512-bYhdd2cZJhXHqyJBoloYaJrn8MrL9Egf3ZZVn0OrIODCCORm2goFD7C+xszf6xgfsSJi0rtgB/ichcuHfkJ4yQ==", + "version": "19.2.6", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/react-server-dom-webpack/-/react-server-dom-webpack-19.2.6.tgz", + "integrity": "sha512-762YXjBPc6hw2V16k4x1sdnw0mxghcSPzoiOhefGYjiTguJLCImGBUz6EjznPIfqKhWgLtpaQMlBhp66N8dKrw==", "license": "MIT", "dependencies": { "acorn-loose": "^8.3.0", @@ -2784,14 +2784,14 @@ "node": ">=0.10.0" }, "peerDependencies": { - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", "webpack": "^5.59.0" } }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "peer": true, @@ -2875,7 +2875,7 @@ }, "node_modules/schema-utils": { "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "peer": true, @@ -3035,7 +3035,7 @@ }, "node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "peer": true, @@ -3054,7 +3054,7 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "peer": true, @@ -3075,7 +3075,7 @@ }, "node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", "peer": true, @@ -3091,7 +3091,7 @@ }, "node_modules/tapable": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "peer": true, @@ -3104,9 +3104,9 @@ } }, "node_modules/terser": { - "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", + "version": "5.47.1", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -3123,9 +3123,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", + "version": "5.6.0", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", "license": "MIT", "peer": true, "dependencies": { @@ -3145,12 +3145,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -3342,7 +3369,7 @@ }, "node_modules/watchpack": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "peer": true, @@ -3356,7 +3383,7 @@ }, "node_modules/webpack": { "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/webpack/-/webpack-5.106.2.tgz", "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "license": "MIT", "peer": true, @@ -3403,9 +3430,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", - "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", + "version": "3.4.1", + "resolved": "https://npm-artifact-registry-auth.services-gcp.causalens.com/causalens-internal/npm-internal/webpack-sources/-/webpack-sources-3.4.1.tgz", + "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", "license": "MIT", "engines": { "node": ">=10.13.0" diff --git a/package.json b/package.json index 43bf394..633953a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "esbuild": "^0.27.2", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-server-dom-webpack": "^19.2.0", + "react-server-dom-webpack": "^19.2.6", "typescript": "^5.9.3", "vite": "^7.3.1" }, diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 9e486a4..42bf113 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; import App from './app.js'; -import { ClientManifest, installWebpackClientReferenceRuntime } from './rsc/client-reference-runtime.js'; +import { ClientManifest, installFlightClientReferenceRuntime } from './rsc/client-reference-runtime.js'; declare global { interface Window { @@ -33,10 +33,10 @@ function createStreamFromBase64(base64: string): ReadableStream { } async function bootstrapRsc() { - installWebpackClientReferenceRuntime(window, async (chunkId) => { - const chunkUrl = window.__MATCHA_RSC_MANIFEST__.chunkMap[chunkId]; + installFlightClientReferenceRuntime(window, async (moduleId) => { + const chunkUrl = window.__MATCHA_RSC_MANIFEST__.chunkMap[moduleId]; if (!chunkUrl) { - throw new Error(`Unknown client chunk "${chunkId}"`); + throw new Error(`Unknown client module "${moduleId}"`); } return await import(/* @vite-ignore */ chunkUrl); @@ -47,13 +47,15 @@ async function bootstrapRsc() { { serverConsumerManifest: { moduleMap: window.__MATCHA_RSC_MANIFEST__.moduleMap, - serverModuleMap: window.__MATCHA_RSC_MANIFEST__.serverModuleMap ?? {}, + serverModuleMap: window.__MATCHA_RSC_MANIFEST__.serverModuleMap, moduleLoading: null, }, }, ) as Promise; const resolvedNode = await response; - ReactDOM.createRoot(appRoot).render( + + ReactDOM.hydrateRoot( + appRoot,
{resolvedNode} diff --git a/src/entry-rsc-document.tsx b/src/entry-rsc-document.tsx index dfc929a..93f9240 100644 --- a/src/entry-rsc-document.tsx +++ b/src/entry-rsc-document.tsx @@ -3,14 +3,7 @@ import { PassThrough, Readable } from 'node:stream'; import { pathToFileURL } from 'node:url'; import { renderToPipeableStream } from 'react-dom/server'; import { createFromNodeStream } from 'react-server-dom-webpack/client.node'; -import { ClientManifest, installWebpackClientReferenceRuntime } from './rsc/client-reference-runtime.js'; - -declare global { - // eslint-disable-next-line no-var - var __webpack_chunk_load__: ((chunkId: string) => Promise) | undefined; - // eslint-disable-next-line no-var - var __webpack_require__: (((moduleId: string) => unknown) & { m?: Record }) | undefined; -} +import { ClientManifest, installFlightClientReferenceRuntime } from './rsc/client-reference-runtime.js'; async function renderHtmlToString(node: React.ReactNode): Promise { const stream = renderToPipeableStream(node); @@ -35,14 +28,17 @@ function createRscBootstrapScript(manifest: ClientManifest, payload: string): st } async function renderHomeHtml(payload: string, manifest: ClientManifest): Promise { - installWebpackClientReferenceRuntime(globalThis, async (chunkId) => { - const chunkPath = manifest.ssrChunkMap?.[chunkId]; + installFlightClientReferenceRuntime(globalThis, async (moduleId) => { + const chunkPath = manifest.ssrChunkMap?.[moduleId]; if (!chunkPath) { - throw new Error(`Unknown SSR client chunk "${chunkId}"`); + throw new Error(`Unknown SSR client module "${moduleId}"`); } - return await import(pathToFileURL(chunkPath).href); + return await import(/* @vite-ignore */ pathToFileURL(chunkPath).href); }); + await Promise.all( + Object.keys(manifest.moduleMap).map((moduleId) => globalThis.__webpack_chunk_load__?.(moduleId)), + ); const response = createFromNodeStream(Readable.from([payload]), { moduleMap: manifest.moduleMap, diff --git a/src/entry-rsc-server.tsx b/src/entry-rsc-server.tsx index 4e1e097..1650b84 100644 --- a/src/entry-rsc-server.tsx +++ b/src/entry-rsc-server.tsx @@ -10,7 +10,7 @@ export function renderHomeRoute() { async function renderFlightPayloadToString( model: React.ReactNode, - moduleMap: ClientManifest['moduleMap'], + moduleMap: ClientManifest['serverModuleMap'], ): Promise { const stream = renderToPipeableStream(model, moduleMap); diff --git a/src/rsc/HomePage.tsx b/src/rsc/HomePage.tsx index 2054040..5f7d649 100644 --- a/src/rsc/HomePage.tsx +++ b/src/rsc/HomePage.tsx @@ -4,7 +4,7 @@ export default function HomePage() { return (

MatchaStack

-

Home now renders through a real Flight payload on full document requests.

+

Home now renders through an RSC payload on full document requests.

The counter below is a client component resolved from the RSC client manifest.

Status -

Milestone 1 is complete for /: a full document request renders through Flight, a generated manifest resolves a client island, and the browser hydrates the island.

+

Milestone 2 is complete for the current routes: document requests render through Flight, generated manifests resolve client islands, and the browser hydrates from the embedded RSC payload.

Scope -

/ uses RSC. /about still uses static props. /user-profile still uses request-time SSR props.

+

/, /about, and /user-profile are all real RSC routes. The old loader files and props transport are gone.

-

Full Document Request For /

+

Full Document Request

1. Browser

HTTP request -

GET / asks for a document, not a props JSON file.

+

GET /about asks for a document; any current route enters the same RSC path.

2. Route Gate

Express handler -

isRscRoute("/") selects the RSC document pipeline.

+

matchRscRoute(url) selects the route entry or returns the RSC 404.

3. RSC Render

entry-rsc-server -

renderToPipeableStream(<HomePage />, serverModuleMap) creates Flight rows.

+

renderRoutePayload(url, manifest) renders the selected server component tree to Flight rows.

4. HTML Shell

@@ -351,7 +351,7 @@

Build Artifacts

Client bundle -

Contains browser code, including HomeCounter.client.tsx as a chunk.

+

Contains browser code, including HomeCounter.client.tsx as a chunk for the client island.

RSC server bundle @@ -368,7 +368,7 @@

Build Artifacts

-

The dev server now uses one Vite server. RSC loads enter the same server through ?matcha-rsc, which creates an RSC-scoped module graph inside the existing Vite process.

+

The dev server uses one Vite server. RSC loads enter the same server through ?matcha-rsc, which creates an RSC-scoped module graph inside the existing Vite process.

@@ -451,6 +451,36 @@

Data Formats At Each Boundary

} +
+
+ RSC Route Table + src/entry-rsc-server.tsx +
+
[
+  { path: "/", render: () => <HomePage /> },
+  { path: "/about", render: () => <AboutPage /> },
+  { path: "/user-profile", render: () => <UserProfilePage /> }
+]
+
+ +
+
+ Server Component Data + Data is read before Flight serialization +
+
// /about
+const blog = readFileSync("static/blog.md", "utf8");
+return <pre>{blog}</pre>;
+
+// /user-profile
+const user = {
+  id: "user_123",
+  name: "Ada Lovelace",
+  email: "ada@example.com",
+  plan: "pro"
+};
+
+
Flight Payload Rows @@ -530,34 +560,34 @@

Data Formats At Each Boundary

-

Current Route Split

+

Current Route Model

/

RSC document -

Flight payload plus HTML shell. No __INITIAL_PROPS__.

+

Server component route with a hydrated 'use client' counter island.

-
+

/about

- Static props -

Build-time props are written to _props.json and embedded in static HTML.

+ RSC document +

Server component reads static/blog.md directly during the RSC render.

-
+

/user-profile

- SSR props -

Request-time props still flow through /__matcha_props for client navigation.

+ RSC document +

Server component creates request-time profile data inside the route tree.

-
-

Next milestone

- All document routes -

Generalize the RSC document pipeline beyond /.

+
+

404

+ RSC document +

Unknown paths still return an HTML shell and Flight payload, with a 404 status.

-
-

Later

+
+

Next milestone

Flight navigation -

Replace props JSON navigation with route Flight subrequests.

+

Client-side route changes should request Flight and apply the returned tree.

-
+

Later

Server actions

Add 'use server' references and action response payloads.

diff --git a/docs/rsc-plan.md b/docs/rsc-plan.md index 4f1b1ff..d1920b0 100644 --- a/docs/rsc-plan.md +++ b/docs/rsc-plan.md @@ -13,23 +13,19 @@ It is intentionally not a "temporary simplification" plan. The goal is to build - Preserve the existing custom Vite + Express architecture where possible. - Evolve the current SSR pipeline into an RSC + HTML shell pipeline instead of layering a fake RSC model on top of `__INITIAL_PROPS__`. -## Current Starting Point +## Current Implementation -The current architecture already gives us a few useful building blocks: +The current architecture now has the RSC document pipeline in place for every route in the toy app: -- [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) renders the app on the server. -- [src/entry-client.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-client.tsx) hydrates the client. -- [src/router.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/router.tsx) handles client navigation and route data fetching. -- [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) already owns a meaningful part of the build pipeline. -- [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) and [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) already control request handling. +- [src/entry-rsc-server.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-rsc-server.tsx) owns the RSC route table and renders route trees to Flight payloads. +- [src/entry-rsc-document.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-rsc-document.tsx) renders the HTML shell from the same Flight payload used by the browser. +- [src/entry-client.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-client.tsx) decodes the embedded Flight stream and hydrates the resulting React tree. +- [lib/plugin.ts](/Users/kbiel/code/personal/MatchaStack/lib/plugin.ts) builds the client graph, RSC server graph, SSR-safe client chunks, and client reference manifests. +- [lib/commands/dev.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/dev.ts) and [lib/commands/serve.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/serve.ts) route document requests through the same RSC model in development and production. What is missing is the core RSC contract: -- no server/client file boundary model -- no client reference manifest -- no Flight response endpoint -- no client-side Flight stream consumption -- no separation between the SSR shell and the RSC tree +- no Flight endpoint for client-side navigation yet - no server reference manifest or action transport for later phases ## Architectural Target @@ -68,7 +64,7 @@ Do not introduce custom file suffixes as the primary boundary mechanism. Support ### 2. Split SSR shell rendering from RSC rendering -Today `renderToString()` in [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) performs the entire render. +Originally, `renderToString()` in the deleted `src/entry-server.tsx` performed the entire render. With RSC, there are two related but distinct server tasks: @@ -77,15 +73,15 @@ With RSC, there are two related but distinct server tasks: Those should become separate entry points with separate responsibilities. -### 3. Route modules become server-first +### 3. Route modules are server-first -The current route model is props-centric: +The old route model was props-centric: - route component - `getStaticProps` - `getServerSideProps` -For real RSC, the primary abstraction should shift to route modules that can render server components directly and fetch data inline on the server side. Over time, route loaders should become optional compatibility APIs rather than the main model. +For real RSC, the primary abstraction is now route modules that render server components directly and fetch data inline on the server side. The loader compatibility layer was intentionally removed instead of preserved. ### 4. Manifests are first-class build artifacts @@ -104,7 +100,7 @@ The milestones below are intentionally vertical. Each one should leave the repo ## Milestone 1: Single-Route RSC Document Render -Status: Complete for `/`. +Status: Complete. ### End state @@ -112,18 +108,18 @@ One route can render through real Flight on a full document request, with a real ### What changes -1. Split the current server runtime in [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) into: +1. Split the original server runtime into: - request context creation - RSC tree rendering - HTML shell rendering -2. Extend [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) to: +2. Extend [lib/plugin.ts](/Users/kbiel/code/personal/MatchaStack/lib/plugin.ts) to: - detect `'use client'` - classify modules into server vs client graphs - emit a client reference manifest 3. Replace the `window.__INITIAL_PROPS__` bootstrap for that route with: - an HTML shell - an initial Flight payload - - client-side Flight consumption in [src/entry-client.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-client.tsx) + - client-side Flight consumption in [src/entry-client.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-client.tsx) 4. Keep the rest of the app on the old SSR path while the first route proves the architecture. ### Why this is vertical @@ -139,17 +135,19 @@ It exercises the real RSC protocol on a real page load without forcing the whole ## Milestone 2: Full-Document RSC for All Routes +Status: Complete for the current routes. The old loader path was removed rather than kept as a migration layer. + ### End state -All document requests render through the new RSC document pipeline, even if client-side navigation still uses the legacy path. +All document requests render through the new RSC document pipeline. ### What changes 1. Generalize the Milestone 1 runtime so every route can render as: - server-first route module - HTML shell plus Flight payload -2. Update [src/routes.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/routes.ts) and related route modules so route defaults can be server components. -3. Keep `getStaticProps` and `getServerSideProps` only as a migration layer where needed. +2. Replace the old props route table with RSC route entries in [src/entry-rsc-server.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-rsc-server.tsx). +3. Move `/about` and `/user-profile` data reads into server components. 4. Expand module boundary checks so invalid server/client imports fail loudly across the app. ### Why this is vertical @@ -161,6 +159,7 @@ After this step, a browser refresh on any route uses the real RSC render path, s - all full document requests use Flight-backed rendering - all routes can include `'use client'` islands - the old props bootstrap path is no longer needed for initial document loads +- `_props.json`, `__matcha_props`, `__INITIAL_PROPS__`, and the old route loader files are gone from the runtime ## Milestone 3: RSC Client Navigation @@ -170,15 +169,15 @@ Client-side navigation fetches Flight payloads instead of route props, so the ap ### What changes -1. Replace the `_props.json` and `__matcha_props` navigation model in [src/router.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/router.tsx). +1. Add an RSC navigation request path instead of the removed props navigation model. 2. Add a Flight endpoint for subrequests in: - - [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) - - [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) + - [lib/commands/dev.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/dev.ts) + - [lib/commands/serve.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/serve.ts) 3. Teach the client runtime to: - request the next route as Flight - apply the returned tree - preserve history and back/forward behavior -4. Keep route compatibility shims only where still needed to bridge old route modules during migration. +4. Keep route state in the Flight tree rather than rebuilding the old loader contract. ### Why this is vertical @@ -188,19 +187,19 @@ This is the milestone where the app becomes workable as an RSC app in day-to-day - client navigation uses Flight payloads - back/forward works against the RSC router path -- `_props.json` and `__matcha_props` are no longer part of the primary navigation flow +- navigation requests fetch and apply Flight payloads -## Milestone 4: Route Model Cleanup and Layouts +## Milestone 4: Layouts and Route Model Hardening ### End state -The route model is server-component-first rather than loader-first, and shared layouts can participate naturally in the RSC tree. +The route model is hardened around server components, and shared layouts can participate naturally in the RSC tree. ### What changes -1. Move route data access from `getStaticProps` / `getServerSideProps` into server components or adjacent server utilities where practical. +1. Introduce a clearer route module authoring surface for server components. 2. Introduce layout boundaries if the framework wants nested layouts. -3. Remove remaining compatibility code that exists only for the old props-centric model. +3. Add focused diagnostics and tests around invalid server/client imports. 4. Simplify the public mental model around: - server components by default - client components via `'use client'` @@ -213,7 +212,7 @@ This is a product-quality milestone rather than just an internal refactor: the p ### Exit criteria - new routes can be authored as server-first modules -- loader APIs are optional or deprecated rather than foundational +- no loader APIs are needed for the current route model - layouts compose through the RSC tree ## Milestone 5: Server References and Actions @@ -224,11 +223,11 @@ This is a product-quality milestone rather than just an internal refactor: the p ### What changes -1. Extend [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) to discover `'use server'` exports and emit a server reference manifest. +1. Extend [lib/plugin.ts](/Users/kbiel/code/personal/MatchaStack/lib/plugin.ts) to discover `'use server'` exports and emit a server reference manifest. 2. Add runtime lookup and invocation support for server references. 3. Add action endpoints in: - - [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) - - [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) + - [lib/commands/dev.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/dev.ts) + - [lib/commands/serve.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/serve.ts) 4. Return action results in the format React expects, including updated Flight payloads when needed. ### Why this is vertical @@ -249,12 +248,12 @@ The RSC architecture is stable in dev and production, with tests covering the pr ### What changes -1. Harden [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) for: +1. Harden [lib/commands/dev.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/dev.ts) for: - HMR across server and client graphs - manifest invalidation - useful boundary diagnostics -2. Finalize [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) around explicit manifest loading and RSC request handling. -3. Remove remaining `_props.json`, `__matcha_props`, and `__INITIAL_PROPS__` infrastructure if any compatibility remnants still exist. +2. Finalize [lib/commands/serve.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/serve.ts) around explicit manifest loading and RSC request handling. +3. Keep the removed props infrastructure from reappearing by testing the document and navigation contracts. 4. Add tests for: - module classification - client reference manifest generation @@ -278,17 +277,15 @@ These are the main files and modules likely to change first. ### Runtime -- [src/entry-server.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-server.tsx) -- [src/entry-client.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/entry-client.tsx) -- [src/router.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/router.tsx) -- [src/app.tsx](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/app.tsx) -- [src/routes.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/src/routes.ts) +- [src/entry-rsc-server.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-rsc-server.tsx) +- [src/entry-rsc-document.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-rsc-document.tsx) +- [src/entry-client.tsx](/Users/kbiel/code/personal/MatchaStack/src/entry-client.tsx) ### Build / Framework internals -- [lib/plugin.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/plugin.ts) -- [lib/commands/dev.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/dev.ts) -- [lib/commands/serve.ts](/Users/patriciajacob/.codex/worktrees/de21/MatchaStack/lib/commands/serve.ts) +- [lib/plugin.ts](/Users/kbiel/code/personal/MatchaStack/lib/plugin.ts) +- [lib/commands/dev.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/dev.ts) +- [lib/commands/serve.ts](/Users/kbiel/code/personal/MatchaStack/lib/commands/serve.ts) ### New modules likely needed @@ -361,5 +358,5 @@ If server actions are in scope for the release, also require: ## Immediate Next Step -- visualize current milestone (fixed) -- implement and and visualize/play with how RSC works on navigation/reloads, suspense behaviour, streaming etc +- implement and visualize Flight-backed navigation +- explore reloads, Suspense behavior, streaming, and server action transport diff --git a/lib/commands/dev.ts b/lib/commands/dev.ts index 10b4493..54918f2 100644 --- a/lib/commands/dev.ts +++ b/lib/commands/dev.ts @@ -1,11 +1,11 @@ -import path from 'node:path'; import fs from 'node:fs'; +import path from 'node:path'; import express from 'express'; import { createServer as createViteServer } from 'vite'; import { buildDevClientManifest, collectClientModules } from '../rsc/client-manifest.js'; import { createRscDevPlugin } from '../plugin.js'; -export const description = 'Start development server with HMR and SSR'; +export const description = 'Start development server with HMR and RSC rendering'; export async function run() { const app = express(); @@ -17,36 +17,6 @@ export async function run() { plugins: [createRscDevPlugin(root)], }); - app.get('/__matcha_props', async (req, res) => { - const rawPath = req.query.path; - const routePath = typeof rawPath === 'string' ? rawPath : '/'; - - if (!routePath.startsWith('/')) { - res.status(400).json({ error: 'Invalid path' }); - return; - } - - try { - const { loadStaticProps, loadServerSideProps } = await vite.ssrLoadModule('/src/entry-server.tsx'); - const props = { - ...(await loadStaticProps(routePath)), - ...(await loadServerSideProps(routePath)), - }; - - res - .status(200) - .set({ - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }) - .end(JSON.stringify(props)); - } catch (e) { - vite.ssrFixStacktrace(e as Error); - console.error(e); - res.status(500).json({ error: (e as Error).message }); - } - }); - app.use(vite.middlewares); app.use('*all', async (req, res) => { @@ -56,47 +26,30 @@ export async function run() { let template = fs.readFileSync(path.resolve(root, 'index.html'), 'utf-8'); template = await vite.transformIndexHtml(url, template); - if ((req.path === '/' || req.path === '') && req.method === 'GET') { - const [rscEntry, rscDocumentEntry, clientModules] = await Promise.all([ - vite.ssrLoadModule('/src/entry-rsc-server.tsx?matcha-rsc'), - vite.ssrLoadModule('/src/entry-rsc-document.tsx'), - collectClientModules(root), - ]); - const manifest = buildDevClientManifest(root, clientModules); - const payload = await (rscEntry as { - renderHomePayload: ( - manifest: ReturnType, - ) => Promise; - }).renderHomePayload(manifest); - const html = await (rscDocumentEntry as { - renderHomeDocument: ( - template: string, - manifest: ReturnType, - payload: string, - ) => Promise; - }).renderHomeDocument( - template, - manifest, - payload, - ); - - res.status(200).set({ 'Content-Type': 'text/html' }).end(html); - return; - } - - const { render, routes } = await vite.ssrLoadModule('/src/entry-server.tsx'); - const { html: appHtml, props } = await render(url); - - const propsScript = ``; - const ssrRoutes = (routes as Array<{ path: string; getServerSideProps?: unknown }>) - .filter((route) => Boolean(route.getServerSideProps)) - .map((route) => route.path); - const ssrRoutesScript = ``; - const html = template - .replace('', appHtml) - .replace('', `${propsScript}${ssrRoutesScript}`); - - res.status(200).set({ 'Content-Type': 'text/html' }).end(html); + const [rscEntry, rscDocumentEntry, clientModules] = await Promise.all([ + vite.ssrLoadModule('/src/entry-rsc-server.tsx?matcha-rsc'), + vite.ssrLoadModule('/src/entry-rsc-document.tsx'), + collectClientModules(root), + ]); + const route = (rscEntry as { + matchRscRoute: (routeTarget: string) => unknown; + }).matchRscRoute(url); + const manifest = buildDevClientManifest(root, clientModules); + const payload = await (rscEntry as { + renderRoutePayload: ( + routeTarget: string, + manifest: ReturnType, + ) => Promise; + }).renderRoutePayload(url, manifest); + const html = await (rscDocumentEntry as { + renderRscDocument: ( + template: string, + manifest: ReturnType, + payload: string, + ) => Promise; + }).renderRscDocument(template, manifest, payload); + + res.status(route ? 200 : 404).set({ 'Content-Type': 'text/html' }).end(html); } catch (e) { vite.ssrFixStacktrace(e as Error); console.error(e); diff --git a/lib/commands/serve.ts b/lib/commands/serve.ts index a88124d..2db70b9 100644 --- a/lib/commands/serve.ts +++ b/lib/commands/serve.ts @@ -4,87 +4,34 @@ import { readFile } from 'node:fs/promises'; import express from 'express'; import { pathToFileURL } from 'node:url'; -interface SsrFunctionModule { - isSsrRoute: (path: string) => boolean; +interface RscFunctionModule { isRscRoute: (path: string) => boolean; renderRscPage: (path: string) => Promise; - renderSsrPage: (path: string) => Promise; - renderRouteProps: (path: string) => Promise>; } -export const description = 'Serve the production build with static + SSR routes'; +export const description = 'Serve the production build with RSC routes'; export async function run() { const app = express(); const root = process.cwd(); const distPath = path.resolve(root, 'dist/public'); - const ssrFunctionPath = path.resolve(root, 'dist/server/ssr-function.js'); + const rscFunctionPath = path.resolve(root, 'dist/server/ssr-function.js'); - let ssrFunction: SsrFunctionModule | null = null; - if (fs.existsSync(ssrFunctionPath)) { - ssrFunction = await import(pathToFileURL(ssrFunctionPath).href) as SsrFunctionModule; + let rscFunction: RscFunctionModule | null = null; + if (fs.existsSync(rscFunctionPath)) { + rscFunction = await import(pathToFileURL(rscFunctionPath).href) as RscFunctionModule; } - app.get('/__matcha_props', async (req, res) => { - if (!ssrFunction) { - res.status(404).json({ error: 'SSR runtime not available' }); - return; - } - - const rawPath = req.query.path; - const routePath = typeof rawPath === 'string' ? rawPath : '/'; - if (!routePath.startsWith('/')) { - res.status(400).json({ error: 'Invalid path' }); - return; - } - - if (!ssrFunction.isSsrRoute(routePath)) { - res.status(404).json({ error: 'Route is not SSR' }); - return; - } - - try { - const props = await ssrFunction.renderRouteProps(routePath); - res - .status(200) - .set({ - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }) - .end(JSON.stringify(props)); - } catch (e) { - console.error(e); - res.status(500).json({ error: (e as Error).message }); - } - }); - app.use(express.static(distPath, { index: false, redirect: false })); - // Handle clean URLs: /about -> /about/index.html app.use('*all', async (req, res) => { const requestUrl = req.originalUrl; - const urlPath = requestUrl.split('?')[0] ?? ''; - - if (ssrFunction && ssrFunction.isRscRoute(requestUrl)) { - try { - const html = await ssrFunction.renderRscPage(requestUrl); - return res.status(200).set({ 'Content-Type': 'text/html' }).end(html); - } catch (e) { - console.error(e); - return res.status(500).end((e as Error).message); - } - } - - const indexPath = path.resolve(distPath, urlPath.slice(1), 'index.html'); - if (fs.existsSync(indexPath)) { - const html = await readFile(indexPath, 'utf-8'); - return res.status(200).set({ 'Content-Type': 'text/html' }).end(html); - } - if (ssrFunction && ssrFunction.isSsrRoute(requestUrl)) { + if (rscFunction) { try { - const html = await ssrFunction.renderSsrPage(requestUrl); - return res.status(200).set({ 'Content-Type': 'text/html' }).end(html); + const html = await rscFunction.renderRscPage(requestUrl); + const status = rscFunction.isRscRoute(requestUrl) ? 200 : 404; + return res.status(status).set({ 'Content-Type': 'text/html' }).end(html); } catch (e) { console.error(e); return res.status(500).end((e as Error).message); diff --git a/lib/plugin.ts b/lib/plugin.ts index 110fec3..965d522 100644 --- a/lib/plugin.ts +++ b/lib/plugin.ts @@ -1,20 +1,14 @@ import { Plugin, build } from 'vite'; -import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; +import { readFile, writeFile, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import path from 'node:path'; import ts from 'typescript'; -import { analyzeModule, stripModuleDirectives, stripServerCode, toModuleId } from './rsc/module-classifier.js'; +import { analyzeModule, stripModuleDirectives, toModuleId } from './rsc/module-classifier.js'; import { collectClientModules } from './rsc/client-manifest.js'; -interface Route { +interface RscRoute { path: string; - getServerSideProps?: unknown; -} - -interface RenderResult { - html: string; - props: Record; } interface ClientReferenceManifest { @@ -310,10 +304,7 @@ export default function matcha(): Plugin { return; } - const stripped = stripServerCode(code, cleanId); - if (stripped !== code) { - return { code: stripped, map: null }; - } + return; }, generateBundle(_, bundle) { @@ -369,20 +360,6 @@ export default function matcha(): Plugin { await rm(serverOutDir, { recursive: true, force: true }); - await build({ - configFile: false, - root, - build: { - ssr: resolve(root, 'src/entry-server.tsx'), - outDir: serverOutDir, - rollupOptions: { - output: { - format: 'esm', - }, - }, - }, - }); - await build({ configFile: false, root, @@ -451,19 +428,13 @@ export default function matcha(): Plugin { }, }); - const serverEntryPath = resolve(serverOutDir, 'entry-server.js'); - const serverEntryUrl = pathToFileURL(serverEntryPath).href; - const { render, loadStaticProps, routes } = await import(serverEntryUrl) as { - render: (url: string) => Promise; - loadStaticProps: (url: string) => Promise>; - routes: Route[]; + const rscEntryPath = resolve(serverOutDir, 'entry-rsc-server.js'); + const rscEntryUrl = pathToFileURL(rscEntryPath).href; + const { rscRoutes } = await import(rscEntryUrl) as { + rscRoutes: RscRoute[]; }; - const ssrRoutes = routes - .filter((route) => Boolean(route.getServerSideProps)) - .map((route) => route.path); - - const ssrRoutesScript = ``; + const rscRoutePaths = rscRoutes.map((route) => route.path); const templatePath = resolve(distDir, 'index.html'); const template = await readFile(templatePath, 'utf-8'); const ssrTemplatePath = resolve(serverOutDir, 'ssr-template.html'); @@ -473,15 +444,13 @@ export default function matcha(): Plugin { const ssrFunctionCode = `import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadServerSideProps, renderWithProps } from './entry-server.js'; -import { renderHomePayload } from './entry-rsc-server.js'; -import { renderHomeDocument } from './entry-rsc-document.js'; +import { renderRoutePayload } from './entry-rsc-server.js'; +import { renderRscDocument } from './entry-rsc-document.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const templatePath = path.resolve(__dirname, './ssr-template.html'); -const publicRoot = path.resolve(__dirname, '../public'); const manifestPath = path.resolve(__dirname, './rsc-client-manifest.json'); -const ssrRoutes = ${JSON.stringify(ssrRoutes)}; +const rscRoutes = ${JSON.stringify(rscRoutePaths)}; function normalizePath(routePath) { return routePath === '/' ? routePath : routePath.replace(/\\/$/, ''); @@ -496,26 +465,8 @@ function toRouteTarget(routeTarget) { }; } -function isSsrRoute(routeTarget) { - return ssrRoutes.includes(toRouteTarget(routeTarget).pathname); -} - function isRscRoute(routeTarget) { - return toRouteTarget(routeTarget).pathname === '/'; -} - -function staticPropsFilePath(routePath) { - if (routePath === '/') return path.resolve(publicRoot, '_props.json'); - return path.resolve(publicRoot, routePath.slice(1), '_props.json'); -} - -async function loadCachedStaticProps(routePath) { - try { - const file = await readFile(staticPropsFilePath(routePath), 'utf-8'); - return JSON.parse(file); - } catch { - return {}; - } + return rscRoutes.includes(toRouteTarget(routeTarget).pathname); } async function loadClientManifest() { @@ -523,41 +474,18 @@ async function loadClientManifest() { return JSON.parse(file); } -export async function renderRscPage() { +export async function renderRscPage(routeTarget) { + const { target } = toRouteTarget(routeTarget); const [template, manifest] = await Promise.all([ readFile(templatePath, 'utf-8'), loadClientManifest(), ]); - const payload = await renderHomePayload(manifest); - - return renderHomeDocument(template, manifest, payload); -} - -export async function renderSsrPage(routeTarget) { - const { pathname, target } = toRouteTarget(routeTarget); - const [template, staticProps] = await Promise.all([ - readFile(templatePath, 'utf-8'), - loadCachedStaticProps(pathname), - ]); - const serverProps = await loadServerSideProps(target); - const props = { ...staticProps, ...serverProps }; - const { html: appHtml } = renderWithProps(target, props); - const propsScript = \`\`; - const routesScript = ${JSON.stringify(ssrRoutesScript)}; - - return template - .replace('', appHtml) - .replace('', \`\${propsScript}\${routesScript}\`); -} + const payload = await renderRoutePayload(target, manifest); -export async function renderRouteProps(routeTarget) { - const { pathname, target } = toRouteTarget(routeTarget); - const staticProps = await loadCachedStaticProps(pathname); - const serverProps = await loadServerSideProps(target); - return { ...staticProps, ...serverProps }; + return renderRscDocument(template, manifest, payload); } -export { isSsrRoute, ssrRoutes, isRscRoute };`; +export { rscRoutes, isRscRoute };`; await writeFile(ssrFunctionPath, ssrFunctionCode); const publicManifestPath = resolve(distDir, 'rsc-client-manifest.json'); @@ -575,41 +503,11 @@ export { isSsrRoute, ssrRoutes, isRscRoute };`; }; await writeFile(serverManifestPath, JSON.stringify(serverManifest, null, 2)); - let renderedCount = 0; - for (const route of routes) { - const staticProps = await loadStaticProps(route.path); - const routeDir = route.path === '/' - ? distDir - : resolve(distDir, route.path.slice(1)); - - await mkdir(routeDir, { recursive: true }); - const propsPath = resolve(routeDir, '_props.json'); - await writeFile(propsPath, JSON.stringify(staticProps)); - - if (route.path === '/') { - console.log('[matcha] / -> RSC document runtime'); - continue; - } - - if (ssrRoutes.includes(route.path)) { - console.log(`[matcha] ${route.path} -> SSR runtime`); - continue; - } - - const { html: appHtml, props } = await render(route.path); - const propsScript = ``; - const finalHtml = template - .replace('', appHtml) - .replace('', `${propsScript}${ssrRoutesScript}`); - - const htmlPath = resolve(routeDir, 'index.html'); - await writeFile(htmlPath, finalHtml); - - renderedCount += 1; - console.log(`[matcha] ${route.path} -> ${htmlPath.replace(root + '/', '')}`); + for (const routePath of rscRoutePaths) { + console.log(`[matcha] ${routePath} -> RSC document runtime`); } - console.log(`[matcha] Static pages: ${renderedCount}, SSR pages: ${ssrRoutes.length}, RSC pages: 1`); + console.log(`[matcha] RSC pages: ${rscRoutePaths.length}`); console.log(`[matcha] SSR function: ${ssrFunctionPath.replace(root + '/', '')}`); }, }; diff --git a/lib/rsc/module-classifier.ts b/lib/rsc/module-classifier.ts index 72807e6..e310a61 100644 --- a/lib/rsc/module-classifier.ts +++ b/lib/rsc/module-classifier.ts @@ -84,110 +84,6 @@ export function analyzeModule(code: string, fileName: string): ModuleDirectiveIn }; } -function isServerLoaderStatement(statement: ts.Statement): boolean { - const isTargetName = (name: string) => name === 'getStaticProps' || name === 'getServerSideProps'; - - if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { - return hasModifier(statement, ts.SyntaxKind.ExportKeyword) && Boolean(statement.name && isTargetName(statement.name.text)); - } - - if (ts.isVariableStatement(statement) && hasModifier(statement, ts.SyntaxKind.ExportKeyword)) { - return statement.declarationList.declarations.some((declaration) => { - return ts.isIdentifier(declaration.name) && isTargetName(declaration.name.text); - }); - } - - return false; -} - -function getPropertyName(name: ts.PropertyName): string | null { - if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { - return name.text; - } - - return null; -} - -export function stripServerCode(code: string, fileName: string): string { - const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - - const transformer: ts.TransformerFactory = (context) => { - const visit: ts.Visitor = (node) => { - if (ts.isObjectLiteralExpression(node)) { - const nextProperties: ts.ObjectLiteralElementLike[] = []; - let removedServerSideProps = false; - let hasServerSidePropsMarker = false; - - for (const property of node.properties) { - const propertyName = - (ts.isPropertyAssignment(property) || - ts.isShorthandPropertyAssignment(property) || - ts.isMethodDeclaration(property)) && - property.name - ? getPropertyName(property.name) - : null; - - if (propertyName === 'getStaticProps') { - continue; - } - - if (propertyName === 'getServerSideProps') { - removedServerSideProps = true; - continue; - } - - if (propertyName === 'hasServerSideProps') { - hasServerSidePropsMarker = true; - } - - nextProperties.push(ts.visitNode(property, visit) as ts.ObjectLiteralElementLike); - } - - if (removedServerSideProps && !hasServerSidePropsMarker) { - nextProperties.push( - ts.factory.createPropertyAssignment( - ts.factory.createIdentifier('hasServerSideProps'), - ts.factory.createTrue(), - ), - ); - } - - return ts.factory.updateObjectLiteralExpression(node, nextProperties); - } - - return ts.visitEachChild(node, visit, context); - }; - - return (file) => { - const statements: ts.Statement[] = []; - - for (const statement of file.statements) { - if ( - ts.isImportDeclaration(statement) && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.moduleSpecifier.text.startsWith('node:') - ) { - continue; - } - - if (isServerLoaderStatement(statement)) { - continue; - } - - statements.push(ts.visitNode(statement, visit) as ts.Statement); - } - - return ts.factory.updateSourceFile(file, statements); - }; - }; - - const transformed = ts.transform(sourceFile, [transformer]); - const printer = ts.createPrinter(); - const output = printer.printFile(transformed.transformed[0]); - transformed.dispose(); - return output; -} - export function stripModuleDirectives( code: string, fileName: string, diff --git a/src/app.tsx b/src/app.tsx deleted file mode 100644 index 08a5f25..0000000 --- a/src/app.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import { RouteProps, Router } from './router.js'; -import { routes } from './routes.js'; - -interface AppProps { - path: string; - props: RouteProps; -} - -export default function App({ path, props }: AppProps) { - return ( - - - - ); -} diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 42bf113..dcea102 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,19 +1,15 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; -import App from './app.js'; import { ClientManifest, installFlightClientReferenceRuntime } from './rsc/client-reference-runtime.js'; declare global { interface Window { - __INITIAL_PROPS__?: Record; - __MATCHA_SSR_ROUTES__?: string[]; __MATCHA_RSC_ENABLED__?: boolean; __MATCHA_RSC_MANIFEST__?: ClientManifest; __MATCHA_RSC_PAYLOAD__?: string; } } -const initialProps = window.__INITIAL_PROPS__ ?? {}; const appRoot = document.getElementById('app')!; function createStreamFromBase64(base64: string): ReadableStream { @@ -64,11 +60,8 @@ async function bootstrapRsc() { ); } -if (window.__MATCHA_RSC_ENABLED__ && window.__MATCHA_RSC_MANIFEST__ && window.__MATCHA_RSC_PAYLOAD__) { - void bootstrapRsc(); -} else { - ReactDOM.hydrateRoot( - appRoot, - - ); +if (!window.__MATCHA_RSC_ENABLED__ || !window.__MATCHA_RSC_MANIFEST__ || !window.__MATCHA_RSC_PAYLOAD__) { + throw new Error('MatchaStack expected an RSC document bootstrap.'); } + +void bootstrapRsc(); diff --git a/src/entry-rsc-document.tsx b/src/entry-rsc-document.tsx index 22cb1c2..60b667c 100644 --- a/src/entry-rsc-document.tsx +++ b/src/entry-rsc-document.tsx @@ -32,7 +32,7 @@ function createRscBootstrapScript(manifest: ClientManifest, payload: string): st return ``; } -async function renderHomeHtml(payload: string, manifest: ClientManifest): Promise { +async function renderRscHtml(payload: string, manifest: ClientManifest): Promise { installFlightClientReferenceRuntime(globalThis, async (moduleId) => { const chunkPath = manifest.ssrChunkMap?.[moduleId]; if (!chunkPath) { @@ -59,12 +59,12 @@ async function renderHomeHtml(payload: string, manifest: ClientManifest): Promis ); } -export async function renderHomeDocument( +export async function renderRscDocument( template: string, manifest: ClientManifest, payload: string, ): Promise { - const html = await renderHomeHtml(payload, manifest); + const html = await renderRscHtml(payload, manifest); const bootstrapScript = createRscBootstrapScript(manifest, payload); return template diff --git a/src/entry-rsc-server.tsx b/src/entry-rsc-server.tsx index 1650b84..eb3d728 100644 --- a/src/entry-rsc-server.tsx +++ b/src/entry-rsc-server.tsx @@ -2,10 +2,46 @@ import type * as React from 'react'; import { PassThrough } from 'node:stream'; import { renderToPipeableStream } from 'react-server-dom-webpack/server.node'; import HomePage from './rsc/HomePage.js'; +import AboutPage from './rsc/AboutPage.js'; +import UserProfilePage from './rsc/UserProfilePage.js'; import { ClientManifest } from './rsc/client-reference-runtime.js'; -export function renderHomeRoute() { - return ; +interface RscRoute { + path: string; + render: () => React.ReactNode; +} + +export const rscRoutes: RscRoute[] = [ + { path: '/', render: () => }, + { path: '/about', render: () => }, + { path: '/user-profile', render: () => }, +]; + +function normalizePath(routePath: string): string { + return routePath === '/' ? routePath : routePath.replace(/\/$/, ''); +} + +function toPathname(routeTarget: string): string { + return normalizePath(new URL(routeTarget, 'http://localhost').pathname); +} + +export function matchRscRoute(routeTarget: string): RscRoute | undefined { + const pathname = toPathname(routeTarget); + return rscRoutes.find((route) => route.path === pathname); +} + +export function renderRoute(routeTarget: string) { + const route = matchRscRoute(routeTarget); + if (!route) { + return ( +
+

404 - Not Found

+

No RSC route matched {toPathname(routeTarget)}.

+
+ ); + } + + return route.render(); } async function renderFlightPayloadToString( @@ -32,6 +68,9 @@ async function renderFlightPayloadToString( }); } -export async function renderHomePayload(manifest: ClientManifest): Promise { - return await renderFlightPayloadToString(renderHomeRoute(), manifest.serverModuleMap); +export async function renderRoutePayload( + routeTarget: string, + manifest: ClientManifest, +): Promise { + return await renderFlightPayloadToString(renderRoute(routeTarget), manifest.serverModuleMap); } diff --git a/src/entry-server.tsx b/src/entry-server.tsx deleted file mode 100644 index 02c3de1..0000000 --- a/src/entry-server.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { renderToString } from 'react-dom/server'; -import App from './app.js'; -import { matchRoute, QueryParams, RouteProps, RoutePropsResult, ServerSidePropsContext } from './router.js'; -import { routes } from './routes.js'; - -// Re-export routes so plugin can access them -export { routes }; - -function normalizeLoaderResult(result: unknown): RouteProps { - if (!result || typeof result !== 'object') return {}; - return 'props' in (result as { props?: RouteProps }) - ? ((result as { props?: RouteProps }).props ?? {}) - : (result as RoutePropsResult); -} - -function toQueryParams(url: URL): QueryParams { - const query: QueryParams = {}; - - for (const [key, value] of url.searchParams.entries()) { - const current = query[key]; - if (current === undefined) { - query[key] = value; - } else if (Array.isArray(current)) { - current.push(value); - } else { - query[key] = [current, value]; - } - } - - return query; -} - -function createServerSidePropsContext(target: string): ServerSidePropsContext { - const url = new URL(target, 'http://localhost'); - const path = url.pathname === '/' ? url.pathname : url.pathname.replace(/\/$/, ''); - - return { - url: `${path}${url.search}`, - path, - query: toQueryParams(url), - }; -} - -export async function loadStaticProps(target: string): Promise { - const context = createServerSidePropsContext(target); - const route = matchRoute(routes, context.path); - - if (!route?.getStaticProps) { - return {}; - } - - return normalizeLoaderResult(await route.getStaticProps()); -} - -export async function loadServerSideProps(target: string): Promise { - const context = createServerSidePropsContext(target); - const route = matchRoute(routes, context.path); - - if (!route?.getServerSideProps) { - return {}; - } - - return normalizeLoaderResult(await route.getServerSideProps(context)); -} - -export async function render(target: string) { - const staticProps = await loadStaticProps(target); - const serverProps = await loadServerSideProps(target); - const props = { ...staticProps, ...serverProps }; - return renderWithProps(target, props); -} - -export function renderWithProps(target: string, props: RouteProps) { - const context = createServerSidePropsContext(target); - const html = renderToString(); - return { html, props }; -} diff --git a/src/pages/About.tsx b/src/pages/About.tsx deleted file mode 100644 index 1cebb17..0000000 --- a/src/pages/About.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import fs from 'node:fs'; -import { Link, RouteProps } from '../router.js'; - -export const getStaticProps = () => { - return { - blog: fs.readFileSync('static/blog.md', 'utf8'), - }; -}; - -interface AboutProps extends RouteProps { - blog: string; -} - -export default function About(props: AboutProps) { - return ( -
-

About

-

A minimal SSG framework.

-
- Blog: -
{props.blog}
-
- -
- ); -} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx deleted file mode 100644 index 724d2cc..0000000 --- a/src/pages/Home.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; -import { Link } from '../router.js'; - -function Counter() { - const [count, setCount] = React.useState(0); - return ( - - ); -} - -export default function Home() { - const [content, setContent] = React.useState('initial'); - - return ( -
-

MatchaStack

- setContent(e.target.value)} - className="bg-blue-500 text-white px-4 py-2 rounded-md" - /> -

{content}

- - -
- ); -} diff --git a/src/router.tsx b/src/router.tsx deleted file mode 100644 index af6b9a5..0000000 --- a/src/router.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import * as React from 'react'; - -// --- Types --- - -export type RouteProps = Record; -export type RoutePropsResult = RouteProps | { props: RouteProps }; -export type QueryValue = string | string[]; -export type QueryParams = Record; - -export interface ServerSidePropsContext { - url: string; - path: string; - query: QueryParams; -} - -export interface Route { - path: string; - component: React.ComponentType; - getStaticProps?: () => RoutePropsResult | Promise; - getServerSideProps?: (context: ServerSidePropsContext) => RoutePropsResult | Promise; - hasServerSideProps?: boolean; -} - -export interface RouterContextValue { - path: string; - navigate: (to: string) => Promise; -} - -// --- Context --- - -const RouterContext = React.createContext(null); - -export function useRouter(): RouterContextValue { - const ctx = React.useContext(RouterContext); - if (!ctx) throw new Error('useRouter must be used within Router'); - return ctx; -} - -// --- Router --- - -interface RouterProps { - routes: Route[]; - initialPath: string; - initialProps?: RouteProps; -} - -function getSsrRoutes(): string[] { - if (typeof window === 'undefined') return []; - const value = (window as Window & { __MATCHA_SSR_ROUTES__?: unknown }).__MATCHA_SSR_ROUTES__; - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; -} - -function parseTarget(target: string): { path: string; url: string } { - const parsed = new URL(target, window.location.origin); - const path = normalizePath(parsed.pathname); - - return { - path, - url: `${path}${parsed.search}`, - }; -} - -async function fetchRouteProps(route: Route | undefined, target: string): Promise { - const { path, url } = parseTarget(target); - const shouldUseRuntimeProps = - import.meta.env.DEV || - getSsrRoutes().includes(path) || - Boolean(route?.hasServerSideProps || route?.getServerSideProps); - - if (shouldUseRuntimeProps) { - try { - const runtimePropsUrl = `/__matcha_props?path=${encodeURIComponent(url)}`; - const res = await fetch(runtimePropsUrl, { cache: 'no-store' }); - if (res.ok) { - return await res.json() as RouteProps; - } - } catch { - // Runtime props endpoint failed, fall through to static props. - } - } - - const propsUrl = path === '/' ? '/_props.json' : `${path}/_props.json`; - try { - const res = await fetch(propsUrl); - if (res.ok) { - return await res.json() as RouteProps; - } - } catch { - // Props fetch failed, continue without props - } - return {}; -} - -export function Router({ routes, initialPath, initialProps }: RouterProps) { - const [path, setPath] = React.useState(initialPath); - const [props, setProps] = React.useState(initialProps ?? {}); - const [isLoading, setIsLoading] = React.useState(false); - - const navigate = React.useCallback(async (to: string) => { - const { path: nextPath, url } = parseTarget(to); - const route = matchRoute(routes, nextPath); - - setIsLoading(true); - const newProps = await fetchRouteProps(route, url); - - window.history.pushState({ props: newProps }, '', to); - setPath(nextPath); - setProps(newProps); - setIsLoading(false); - }, [routes]); - - // Handle browser back/forward - React.useEffect(() => { - const onPopState = async (e: PopStateEvent) => { - const { path: nextPath, url } = parseTarget(window.location.href); - const route = matchRoute(routes, nextPath); - setPath(nextPath); - - // Use cached props from history state, or fetch - if (e.state?.props) { - setProps(e.state.props as RouteProps); - } else { - setIsLoading(true); - const newProps = await fetchRouteProps(route, url); - setProps(newProps); - setIsLoading(false); - } - }; - window.addEventListener('popstate', onPopState); - return () => window.removeEventListener('popstate', onPopState); - }, [routes]); - - const route = matchRoute(routes, path); - if (!route) { - return
404 - Not Found
; - } - - const Component = route.component; - - return ( - - {isLoading ?
Loading...
: } -
- ); -} - -// --- Link --- - -interface LinkProps extends React.AnchorHTMLAttributes { - to: string; - children: React.ReactNode; -} - -export function Link({ to, children, ...rest }: LinkProps) { - const { navigate } = useRouter(); - - const onClick = (e: React.MouseEvent) => { - e.preventDefault(); - navigate(to); - }; - - return ( - - {children} - - ); -} - -// --- Utils --- - -function normalizePath(path: string): string { - // Remove trailing slash (except for root) - return path === '/' ? path : path.replace(/\/$/, ''); -} - -export function matchRoute(routes: Route[], path: string): Route | undefined { - const normalized = normalizePath(path); - return routes.find((r) => r.path === normalized); -} diff --git a/src/routes.ts b/src/routes.ts deleted file mode 100644 index d41781b..0000000 --- a/src/routes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Route } from './router.js'; -import Home from './pages/Home.js'; -import * as AboutPage from './pages/About.js'; -import * as UserProfilePage from './pages/UserProfile.js'; - -export const routes: Route[] = [ - { path: '/', component: Home }, - { path: '/about', component: AboutPage.default, getStaticProps: AboutPage.getStaticProps }, - { - path: '/user-profile', - component: UserProfilePage.default, - getStaticProps: UserProfilePage.getStaticProps, - getServerSideProps: UserProfilePage.getServerSideProps, - }, -]; diff --git a/src/rsc/AboutPage.tsx b/src/rsc/AboutPage.tsx new file mode 100644 index 0000000..8dff878 --- /dev/null +++ b/src/rsc/AboutPage.tsx @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; + +export default function AboutPage() { + const blog = readFileSync('static/blog.md', 'utf8'); + + return ( +
+

About

+

A minimal SSG framework.

+
+ Blog: +
{blog}
+
+ +
+ ); +} diff --git a/src/pages/UserProfile.tsx b/src/rsc/UserProfilePage.tsx similarity index 50% rename from src/pages/UserProfile.tsx rename to src/rsc/UserProfilePage.tsx index 831b9c7..8b75666 100644 --- a/src/pages/UserProfile.tsx +++ b/src/rsc/UserProfilePage.tsx @@ -1,5 +1,3 @@ -import { Link, type RouteProps } from '../router.js'; - interface User { id: string; name: string; @@ -8,34 +6,24 @@ interface User { lastLoginAt: string; } -interface UserProfileProps extends RouteProps { - user: User; - generatedAt: string; - builtAt: string; -} - -export async function getStaticProps() { +function loadUser(): User { return { - builtAt: new Date().toISOString(), - }; -} - -export async function getServerSideProps() { - const user: User = { id: 'user_123', name: 'Ada Lovelace', email: 'ada@example.com', plan: 'pro', lastLoginAt: '2026-02-08T16:30:00.000Z', }; +} - return { - user, - generatedAt: new Date().toISOString(), - }; +function formatDate(value: string): string { + return new Date(value).toLocaleString(); } -export default function UserProfile({ user, generatedAt, builtAt }: UserProfileProps) { +export default function UserProfilePage() { + const user = loadUser(); + const generatedAt = new Date().toISOString(); + return (

User Profile (Sample)

@@ -51,15 +39,13 @@ export default function UserProfile({ user, generatedAt, builtAt }: UserProfileP
Plan
{user.plan}
Last Login
-
{new Date(user.lastLoginAt).toLocaleString()}
+
{formatDate(user.lastLoginAt)}
-

Generated at: {new Date(generatedAt).toLocaleString()}

- -

Built at: {new Date(builtAt).toLocaleString()}

+

Generated at: {formatDate(generatedAt)}

); From 3a52ee8edd96c35d60e66596e7b6ebd505f42e30 Mon Sep 17 00:00:00 2001 From: Krzysztof Bielikowicz <62392609+ibniss@users.noreply.github.com> Date: Fri, 22 May 2026 18:38:12 +0100 Subject: [PATCH 07/11] feat: add RSC navigation --- README.md | 3 +- docs/rsc-current-state.html | 78 ++++++++++++++-- docs/rsc-plan.md | 3 +- lib/commands/dev.ts | 85 +++++++++++++----- lib/commands/serve.ts | 26 ++++++ lib/plugin.ts | 6 ++ src/entry-client.tsx | 175 +++++++++++++++++++++++++++++++++--- 7 files changed, 332 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 8b15de6..9b021d2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ A learning project: building a React metaframework from scratch. - Milestone 1 complete: `/` renders through a Flight-backed document pipeline with a generated client reference manifest and a hydrated `'use client'` counter island. - Milestone 2 complete for the current routes: `/`, `/about`, and `/user-profile` all render as server components through the RSC document pipeline. Route data now lives inside server components instead of old loader APIs. -- Remaining RSC work: add Flight-backed client navigation, then add server references/actions. +- Milestone 3 complete: same-origin client navigation fetches Flight payloads from `/__matcha_rsc`, applies the returned tree, and handles back/forward through the same RSC path. +- Remaining RSC work: add server references/actions and harden streaming, Suspense, and route diagnostics. ## Usage diff --git a/docs/rsc-current-state.html b/docs/rsc-current-state.html index b4dc587..bb9caed 100644 --- a/docs/rsc-current-state.html +++ b/docs/rsc-current-state.html @@ -300,11 +300,11 @@

MatchaStack RSC Pipeline

Status -

Milestone 2 is complete for the current routes: document requests render through Flight, generated manifests resolve client islands, and the browser hydrates from the embedded RSC payload.

+

Milestone 3 is complete for the current routes: document requests and same-origin client navigations both render through Flight.

Scope -

/, /about, and /user-profile are all real RSC routes. The old loader files and props transport are gone.

+

/, /about, and /user-profile are real RSC routes. The browser navigates between them with /__matcha_rsc payloads instead of document reloads.

@@ -345,6 +345,42 @@

6. Hydration

+
+

Client Navigation Request

+
+
+

1. Click

+ entry-client +

A same-origin <a> click is intercepted unless it is modified, external, targeted, or a download.

+
+
+

2. Fetch

+ Flight endpoint +

GET /__matcha_rsc?path=/user-profile requests raw Flight rows with Accept: text/x-component.

+
+
+

3. Render

+ entry-rsc-server +

The endpoint calls renderRoutePayload(path, manifest) and returns text/x-component.

+
+
+

4. Decode

+ Flight client +

createFromReadableStream(response.body) resolves the next React tree using the browser manifest.

+
+
+

5. Commit

+ React root +

history.pushState updates the URL and root.render applies the decoded RSC tree.

+
+
+

6. Restore

+ popstate +

Back and forward fetch the current URL through the same Flight endpoint.

+
+
+
+

Build Artifacts

@@ -526,7 +562,7 @@

Data Formats At Each Boundary

- Client Decode + Initial Client Decode src/entry-client.tsx
const stream = createStreamFromBase64(
@@ -544,6 +580,36 @@ 

Data Formats At Each Boundary

hydrateRoot(appRoot, node);
+
+
+ Navigation Flight Fetch + Client transition payload +
+
GET /__matcha_rsc?path=/about
+Accept: text/x-component
+
+HTTP/1.1 200 OK
+Content-Type: text/x-component; charset=utf-8
+
+:N1779470661501.049
+1:["$","div",null,{...}]
+
+ +
+
+ Navigation Commit + src/entry-client.tsx +
+
const node = await createFromReadableStream(response.body, {
+  serverConsumerManifest
+});
+
+history.pushState({ __matchaRsc: true }, "", url);
+startTransition(() => {
+  root.render(<RscRoot>{node}</RscRoot>);
+});
+
+
Client Module Loading @@ -582,10 +648,10 @@

404

RSC document

Unknown paths still return an HTML shell and Flight payload, with a 404 status.

-
-

Next milestone

+
+

Navigation

Flight navigation -

Client-side route changes should request Flight and apply the returned tree.

+

Same-origin link clicks and back/forward fetch /__matcha_rsc and render the returned tree.

Later

diff --git a/docs/rsc-plan.md b/docs/rsc-plan.md index d1920b0..9bfe2f6 100644 --- a/docs/rsc-plan.md +++ b/docs/rsc-plan.md @@ -163,6 +163,8 @@ After this step, a browser refresh on any route uses the real RSC render path, s ## Milestone 3: RSC Client Navigation +Status: Complete for same-origin anchors and browser back/forward. + ### End state Client-side navigation fetches Flight payloads instead of route props, so the app works as a genuine RSC app during both initial load and in-app navigation. @@ -358,5 +360,4 @@ If server actions are in scope for the release, also require: ## Immediate Next Step -- implement and visualize Flight-backed navigation - explore reloads, Suspense behavior, streaming, and server action transport diff --git a/lib/commands/dev.ts b/lib/commands/dev.ts index 54918f2..d6b9aae 100644 --- a/lib/commands/dev.ts +++ b/lib/commands/dev.ts @@ -7,6 +7,24 @@ import { createRscDevPlugin } from '../plugin.js'; export const description = 'Start development server with HMR and RSC rendering'; +type DevClientManifest = ReturnType; + +interface RscServerEntry { + matchRscRoute: (routeTarget: string) => unknown; + renderRoutePayload: ( + routeTarget: string, + manifest: DevClientManifest, + ) => Promise; +} + +interface RscDocumentEntry { + renderRscDocument: ( + template: string, + manifest: DevClientManifest, + payload: string, + ) => Promise; +} + export async function run() { const app = express(); const root = process.cwd(); @@ -17,6 +35,47 @@ export async function run() { plugins: [createRscDevPlugin(root)], }); + async function loadRscRuntime(): Promise<{ + rscEntry: RscServerEntry; + rscDocumentEntry: RscDocumentEntry; + manifest: DevClientManifest; + }> { + const [rscEntry, rscDocumentEntry, clientModules] = await Promise.all([ + vite.ssrLoadModule('/src/entry-rsc-server.tsx?matcha-rsc'), + vite.ssrLoadModule('/src/entry-rsc-document.tsx'), + collectClientModules(root), + ]); + + return { + rscEntry: rscEntry as RscServerEntry, + rscDocumentEntry: rscDocumentEntry as RscDocumentEntry, + manifest: buildDevClientManifest(root, clientModules), + }; + } + + app.get('/__matcha_rsc', async (req, res) => { + const rawPath = req.query.path; + const routeTarget = typeof rawPath === 'string' && rawPath.startsWith('/') ? rawPath : '/'; + + try { + const { rscEntry, manifest } = await loadRscRuntime(); + const route = rscEntry.matchRscRoute(routeTarget); + const payload = await rscEntry.renderRoutePayload(routeTarget, manifest); + + res + .status(route ? 200 : 404) + .set({ + 'Content-Type': 'text/x-component; charset=utf-8', + 'Cache-Control': 'no-store', + }) + .end(payload); + } catch (e) { + vite.ssrFixStacktrace(e as Error); + console.error(e); + res.status(500).end((e as Error).message); + } + }); + app.use(vite.middlewares); app.use('*all', async (req, res) => { @@ -26,28 +85,10 @@ export async function run() { let template = fs.readFileSync(path.resolve(root, 'index.html'), 'utf-8'); template = await vite.transformIndexHtml(url, template); - const [rscEntry, rscDocumentEntry, clientModules] = await Promise.all([ - vite.ssrLoadModule('/src/entry-rsc-server.tsx?matcha-rsc'), - vite.ssrLoadModule('/src/entry-rsc-document.tsx'), - collectClientModules(root), - ]); - const route = (rscEntry as { - matchRscRoute: (routeTarget: string) => unknown; - }).matchRscRoute(url); - const manifest = buildDevClientManifest(root, clientModules); - const payload = await (rscEntry as { - renderRoutePayload: ( - routeTarget: string, - manifest: ReturnType, - ) => Promise; - }).renderRoutePayload(url, manifest); - const html = await (rscDocumentEntry as { - renderRscDocument: ( - template: string, - manifest: ReturnType, - payload: string, - ) => Promise; - }).renderRscDocument(template, manifest, payload); + const { rscEntry, rscDocumentEntry, manifest } = await loadRscRuntime(); + const route = rscEntry.matchRscRoute(url); + const payload = await rscEntry.renderRoutePayload(url, manifest); + const html = await rscDocumentEntry.renderRscDocument(template, manifest, payload); res.status(route ? 200 : 404).set({ 'Content-Type': 'text/html' }).end(html); } catch (e) { diff --git a/lib/commands/serve.ts b/lib/commands/serve.ts index 2db70b9..119d681 100644 --- a/lib/commands/serve.ts +++ b/lib/commands/serve.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'; interface RscFunctionModule { isRscRoute: (path: string) => boolean; renderRscPage: (path: string) => Promise; + renderRscPayload: (path: string) => Promise; } export const description = 'Serve the production build with RSC routes'; @@ -24,6 +25,31 @@ export async function run() { app.use(express.static(distPath, { index: false, redirect: false })); + app.get('/__matcha_rsc', async (req, res) => { + if (!rscFunction) { + res.status(404).end('RSC runtime not available'); + return; + } + + const rawPath = req.query.path; + const routeTarget = typeof rawPath === 'string' && rawPath.startsWith('/') ? rawPath : '/'; + + try { + const payload = await rscFunction.renderRscPayload(routeTarget); + const status = rscFunction.isRscRoute(routeTarget) ? 200 : 404; + res + .status(status) + .set({ + 'Content-Type': 'text/x-component; charset=utf-8', + 'Cache-Control': 'no-store', + }) + .end(payload); + } catch (e) { + console.error(e); + res.status(500).end((e as Error).message); + } + }); + app.use('*all', async (req, res) => { const requestUrl = req.originalUrl; diff --git a/lib/plugin.ts b/lib/plugin.ts index 965d522..445ed10 100644 --- a/lib/plugin.ts +++ b/lib/plugin.ts @@ -474,6 +474,12 @@ async function loadClientManifest() { return JSON.parse(file); } +export async function renderRscPayload(routeTarget) { + const { target } = toRouteTarget(routeTarget); + const manifest = await loadClientManifest(); + return renderRoutePayload(target, manifest); +} + export async function renderRscPage(routeTarget) { const { target } = toRouteTarget(routeTarget); const [template, manifest] = await Promise.all([ diff --git a/src/entry-client.tsx b/src/entry-client.tsx index dcea102..1bf37f0 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -11,6 +11,27 @@ declare global { } const appRoot = document.getElementById('app')!; +let root: ReactDOM.Root | undefined; +let navigationVersion = 0; + +type CreateFromReadableStream = typeof import('react-server-dom-webpack/client.edge').createFromReadableStream; +let createFromReadableStreamPromise: Promise | undefined; + +function getManifest(): ClientManifest { + const manifest = window.__MATCHA_RSC_MANIFEST__; + if (!manifest) { + throw new Error('MatchaStack expected an RSC client manifest.'); + } + + return manifest; +} + +async function getCreateFromReadableStream(): Promise { + createFromReadableStreamPromise ??= import('react-server-dom-webpack/client.edge') + .then((module) => module.createFromReadableStream); + + return await createFromReadableStreamPromise; +} function createStreamFromBase64(base64: string): ReadableStream { const binary = window.atob(base64); @@ -28,29 +49,154 @@ function createStreamFromBase64(base64: string): ReadableStream { }); } -async function bootstrapRsc() { - installFlightClientReferenceRuntime(window, async (moduleId) => { - const chunkUrl = window.__MATCHA_RSC_MANIFEST__.chunkMap[moduleId]; - if (!chunkUrl) { - throw new Error(`Unknown client module "${moduleId}"`); - } +function renderRscNode(node: React.ReactNode) { + if (!root) { + throw new Error('MatchaStack attempted to render before hydration.'); + } - return await import(/* @vite-ignore */ chunkUrl); - }); - const { createFromReadableStream } = await import('react-server-dom-webpack/client.edge'); + root.render( + +
+ {node} +
+
, + ); +} + +async function decodeRscStream(stream: ReadableStream): Promise { + const manifest = getManifest(); + const createFromReadableStream = await getCreateFromReadableStream(); const response = createFromReadableStream( - createStreamFromBase64(window.__MATCHA_RSC_PAYLOAD__), + stream, { serverConsumerManifest: { - moduleMap: window.__MATCHA_RSC_MANIFEST__.moduleMap, - serverModuleMap: window.__MATCHA_RSC_MANIFEST__.serverModuleMap, + moduleMap: manifest.moduleMap, + serverModuleMap: manifest.serverModuleMap, moduleLoading: null, }, }, ) as Promise; - const resolvedNode = await response; - ReactDOM.hydrateRoot( + return await response; +} + +async function decodeInitialRscPayload(): Promise { + const payload = window.__MATCHA_RSC_PAYLOAD__; + if (!payload) { + throw new Error('MatchaStack expected an RSC payload.'); + } + + return await decodeRscStream(createStreamFromBase64(payload)); +} + +async function fetchRscNode(routeTarget: string): Promise { + const endpoint = new URL('/__matcha_rsc', window.location.origin); + endpoint.searchParams.set('path', routeTarget); + + const response = await fetch(endpoint, { + headers: { + Accept: 'text/x-component', + }, + }); + const contentType = response.headers.get('Content-Type') ?? ''; + if (!response.body || !contentType.includes('text/x-component')) { + throw new Error(`Expected an RSC response for "${routeTarget}".`); + } + + return await decodeRscStream(response.body); +} + +function routeTargetFromUrl(url: URL): string { + return `${url.pathname}${url.search}`; +} + +function shouldHandleLinkClick(event: MouseEvent): URL | null { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) { + return null; + } + + const target = event.target; + if (!(target instanceof Element)) { + return null; + } + + const anchor = target.closest('a'); + if (!anchor || anchor.target || anchor.hasAttribute('download')) { + return null; + } + + const url = new URL(anchor.href, window.location.href); + if (url.origin !== window.location.origin) { + return null; + } + + if (url.pathname === window.location.pathname && url.search === window.location.search && url.hash) { + return null; + } + + return url; +} + +async function navigateTo(url: URL, mode: 'push' | 'replace' | 'restore') { + const version = navigationVersion + 1; + navigationVersion = version; + + try { + const node = await fetchRscNode(routeTargetFromUrl(url)); + if (version !== navigationVersion) { + return; + } + + if (mode === 'push') { + window.history.pushState({ __matchaRsc: true }, '', url); + window.scrollTo({ left: 0, top: 0 }); + } else if (mode === 'replace') { + window.history.replaceState({ __matchaRsc: true }, '', url); + } + + React.startTransition(() => { + renderRscNode(node); + }); + } catch (error) { + if (mode === 'restore') { + window.location.reload(); + return; + } + + window.location.href = url.href; + } +} + +function installRscNavigation() { + window.history.replaceState({ __matchaRsc: true }, '', window.location.href); + + window.addEventListener('click', (event) => { + const url = shouldHandleLinkClick(event); + if (!url) { + return; + } + + event.preventDefault(); + void navigateTo(url, 'push'); + }); + + window.addEventListener('popstate', () => { + void navigateTo(new URL(window.location.href), 'restore'); + }); +} + +async function bootstrapRsc() { + installFlightClientReferenceRuntime(window, async (moduleId) => { + const chunkUrl = getManifest().chunkMap[moduleId]; + if (!chunkUrl) { + throw new Error(`Unknown client module "${moduleId}"`); + } + + return await import(/* @vite-ignore */ chunkUrl); + }); + const resolvedNode = await decodeInitialRscPayload(); + + root = ReactDOM.hydrateRoot( appRoot,
@@ -58,6 +204,7 @@ async function bootstrapRsc() {
, ); + installRscNavigation(); } if (!window.__MATCHA_RSC_ENABLED__ || !window.__MATCHA_RSC_MANIFEST__ || !window.__MATCHA_RSC_PAYLOAD__) { From 17cbdb651461c7c7736933b9279d3573876aa30e Mon Sep 17 00:00:00 2001 From: Krzysztof Bielikowicz <62392609+ibniss@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:36:50 +0100 Subject: [PATCH 08/11] refactor: add RSC router shell --- docs/rsc-current-state.html | 4 +-- src/entry-client.tsx | 65 +++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/docs/rsc-current-state.html b/docs/rsc-current-state.html index bb9caed..f19ee73 100644 --- a/docs/rsc-current-state.html +++ b/docs/rsc-current-state.html @@ -371,7 +371,7 @@

4. Decode

5. Commit

React root -

history.pushState updates the URL and root.render applies the decoded RSC tree.

+

history.pushState updates the URL and MatchaRoot applies the decoded RSC tree through state.

6. Restore

@@ -606,7 +606,7 @@

Data Formats At Each Boundary

history.pushState({ __matchaRsc: true }, "", url); startTransition(() => { - root.render(<RscRoot>{node}</RscRoot>); + setTree(node); }); diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 1bf37f0..27a4833 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -11,10 +11,10 @@ declare global { } const appRoot = document.getElementById('app')!; -let root: ReactDOM.Root | undefined; let navigationVersion = 0; type CreateFromReadableStream = typeof import('react-server-dom-webpack/client.edge').createFromReadableStream; +type SetRscTree = React.Dispatch>; let createFromReadableStreamPromise: Promise | undefined; function getManifest(): ClientManifest { @@ -49,20 +49,6 @@ function createStreamFromBase64(base64: string): ReadableStream { }); } -function renderRscNode(node: React.ReactNode) { - if (!root) { - throw new Error('MatchaStack attempted to render before hydration.'); - } - - root.render( - -
- {node} -
-
, - ); -} - async function decodeRscStream(stream: ReadableStream): Promise { const manifest = getManifest(); const createFromReadableStream = await getCreateFromReadableStream(); @@ -137,7 +123,7 @@ function shouldHandleLinkClick(event: MouseEvent): URL | null { return url; } -async function navigateTo(url: URL, mode: 'push' | 'replace' | 'restore') { +async function navigateTo(url: URL, mode: 'push' | 'restore', setTree: SetRscTree) { const version = navigationVersion + 1; navigationVersion = version; @@ -150,12 +136,10 @@ async function navigateTo(url: URL, mode: 'push' | 'replace' | 'restore') { if (mode === 'push') { window.history.pushState({ __matchaRsc: true }, '', url); window.scrollTo({ left: 0, top: 0 }); - } else if (mode === 'replace') { - window.history.replaceState({ __matchaRsc: true }, '', url); } React.startTransition(() => { - renderRscNode(node); + setTree(node); }); } catch (error) { if (mode === 'restore') { @@ -167,22 +151,44 @@ async function navigateTo(url: URL, mode: 'push' | 'replace' | 'restore') { } } -function installRscNavigation() { +function installRscNavigation(setTree: SetRscTree): () => void { window.history.replaceState({ __matchaRsc: true }, '', window.location.href); - window.addEventListener('click', (event) => { + function handleClick(event: MouseEvent) { const url = shouldHandleLinkClick(event); if (!url) { return; } event.preventDefault(); - void navigateTo(url, 'push'); - }); + void navigateTo(url, 'push', setTree); + } - window.addEventListener('popstate', () => { - void navigateTo(new URL(window.location.href), 'restore'); - }); + function handlePopState() { + void navigateTo(new URL(window.location.href), 'restore', setTree); + } + + window.addEventListener('click', handleClick); + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('click', handleClick); + window.removeEventListener('popstate', handlePopState); + }; +} + +function MatchaRoot({ initialTree }: { initialTree: React.ReactNode }) { + const [tree, setTree] = React.useState(initialTree); + + React.useEffect(() => { + return installRscNavigation(setTree); + }, []); + + return ( +
+ {tree} +
+ ); } async function bootstrapRsc() { @@ -196,15 +202,12 @@ async function bootstrapRsc() { }); const resolvedNode = await decodeInitialRscPayload(); - root = ReactDOM.hydrateRoot( + ReactDOM.hydrateRoot( appRoot, -
- {resolvedNode} -
+
, ); - installRscNavigation(); } if (!window.__MATCHA_RSC_ENABLED__ || !window.__MATCHA_RSC_MANIFEST__ || !window.__MATCHA_RSC_PAYLOAD__) { From 18f2ab1e601fdd5c65f173096cb5e70be74c51a2 Mon Sep 17 00:00:00 2001 From: Krzysztof Bielikowicz <62392609+ibniss@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:47:06 +0100 Subject: [PATCH 09/11] feat: add Suspense demo route --- README.md | 2 +- docs/rsc-current-state.html | 10 ++++++-- src/entry-rsc-server.tsx | 2 ++ src/rsc/AboutPage.tsx | 2 ++ src/rsc/HomePage.tsx | 2 ++ src/rsc/SuspenseDemoPage.tsx | 48 ++++++++++++++++++++++++++++++++++++ src/rsc/UserProfilePage.tsx | 2 ++ 7 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 src/rsc/SuspenseDemoPage.tsx diff --git a/README.md b/README.md index 9b021d2..d29a3fc 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A learning project: building a React metaframework from scratch. ## RSC Progress - Milestone 1 complete: `/` renders through a Flight-backed document pipeline with a generated client reference manifest and a hydrated `'use client'` counter island. -- Milestone 2 complete for the current routes: `/`, `/about`, and `/user-profile` all render as server components through the RSC document pipeline. Route data now lives inside server components instead of old loader APIs. +- Milestone 2 complete for the current routes: `/`, `/about`, `/user-profile`, and `/suspense-demo` all render as server components through the RSC document pipeline. Route data now lives inside server components instead of old loader APIs. - Milestone 3 complete: same-origin client navigation fetches Flight payloads from `/__matcha_rsc`, applies the returned tree, and handles back/forward through the same RSC path. - Remaining RSC work: add server references/actions and harden streaming, Suspense, and route diagnostics. diff --git a/docs/rsc-current-state.html b/docs/rsc-current-state.html index f19ee73..e2e6c70 100644 --- a/docs/rsc-current-state.html +++ b/docs/rsc-current-state.html @@ -304,7 +304,7 @@

MatchaStack RSC Pipeline

Scope -

/, /about, and /user-profile are real RSC routes. The browser navigates between them with /__matcha_rsc payloads instead of document reloads.

+

/, /about, /user-profile, and /suspense-demo are real RSC routes. The browser navigates between them with /__matcha_rsc payloads instead of document reloads.

@@ -495,7 +495,8 @@

Data Formats At Each Boundary

[
   { path: "/", render: () => <HomePage /> },
   { path: "/about", render: () => <AboutPage /> },
-  { path: "/user-profile", render: () => <UserProfilePage /> }
+  { path: "/user-profile", render: () => <UserProfilePage /> },
+  { path: "/suspense-demo", render: () => <SuspenseDemoPage /> }
 ]
@@ -643,6 +644,11 @@

/user-profile

RSC document

Server component creates request-time profile data inside the route tree.

+
+

/suspense-demo

+ Buffered Suspense demo +

Async server component waits for 2 seconds inside Suspense; today the route appears after the whole Flight payload is ready.

+

404

RSC document diff --git a/src/entry-rsc-server.tsx b/src/entry-rsc-server.tsx index eb3d728..72b9625 100644 --- a/src/entry-rsc-server.tsx +++ b/src/entry-rsc-server.tsx @@ -4,6 +4,7 @@ import { renderToPipeableStream } from 'react-server-dom-webpack/server.node'; import HomePage from './rsc/HomePage.js'; import AboutPage from './rsc/AboutPage.js'; import UserProfilePage from './rsc/UserProfilePage.js'; +import SuspenseDemoPage from './rsc/SuspenseDemoPage.js'; import { ClientManifest } from './rsc/client-reference-runtime.js'; interface RscRoute { @@ -15,6 +16,7 @@ export const rscRoutes: RscRoute[] = [ { path: '/', render: () => }, { path: '/about', render: () => }, { path: '/user-profile', render: () => }, + { path: '/suspense-demo', render: () => }, ]; function normalizePath(routePath: string): string { diff --git a/src/rsc/AboutPage.tsx b/src/rsc/AboutPage.tsx index 8dff878..e875f43 100644 --- a/src/rsc/AboutPage.tsx +++ b/src/rsc/AboutPage.tsx @@ -15,6 +15,8 @@ export default function AboutPage() { Go Home {' | '} Visit User Sample + {' | '} + Open Suspense Demo
); diff --git a/src/rsc/HomePage.tsx b/src/rsc/HomePage.tsx index 63747b4..6e55b28 100644 --- a/src/rsc/HomePage.tsx +++ b/src/rsc/HomePage.tsx @@ -11,6 +11,8 @@ export default function HomePage() { Go to About {' | '} View User Sample + {' | '} + Open Suspense Demo ); diff --git a/src/rsc/SuspenseDemoPage.tsx b/src/rsc/SuspenseDemoPage.tsx new file mode 100644 index 0000000..52081a7 --- /dev/null +++ b/src/rsc/SuspenseDemoPage.tsx @@ -0,0 +1,48 @@ +import { Suspense } from 'react'; + +function wait(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +async function SlowServerPanel() { + const startedAt = new Date().toISOString(); + await wait(2000); + const resolvedAt = new Date().toISOString(); + + return ( +
+

Slow Server Panel

+

This server component waited for 2 seconds before resolving.

+
+
Started
+
{startedAt}
+
Resolved
+
{resolvedAt}
+
+
+ ); +} + +export default function SuspenseDemoPage() { + return ( +
+

Suspense Demo

+

+ This route has an async server component inside a Suspense boundary. The current + runtime buffers the whole Flight response, so navigation waits before this page appears. +

+ + Loading slow server panel...

}> + +
+ + +
+ ); +} diff --git a/src/rsc/UserProfilePage.tsx b/src/rsc/UserProfilePage.tsx index 8b75666..b48af12 100644 --- a/src/rsc/UserProfilePage.tsx +++ b/src/rsc/UserProfilePage.tsx @@ -46,6 +46,8 @@ export default function UserProfilePage() { ); From f5f2901395430cd62d3581e683e36c62048739a1 Mon Sep 17 00:00:00 2001 From: Krzysztof Bielikowicz <62392609+ibniss@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:55:09 +0100 Subject: [PATCH 10/11] feat: stream RSC navigation --- docs/rsc-current-state.html | 12 +++++------ lib/commands/dev.ts | 28 +++++++++++++++++++++--- lib/commands/serve.ts | 24 +++++++++++++++++---- lib/plugin.ts | 6 +++--- src/entry-client.tsx | 41 ++++++++++++++++++++++++++++-------- src/entry-rsc-server.tsx | 18 +++++++++++++++- src/rsc/SuspenseDemoPage.tsx | 4 ++-- 7 files changed, 105 insertions(+), 28 deletions(-) diff --git a/docs/rsc-current-state.html b/docs/rsc-current-state.html index e2e6c70..1d0da8e 100644 --- a/docs/rsc-current-state.html +++ b/docs/rsc-current-state.html @@ -361,7 +361,7 @@

2. Fetch

3. Render

entry-rsc-server -

The endpoint calls renderRoutePayload(path, manifest) and returns text/x-component.

+

The endpoint calls renderRoutePayloadStream(path, manifest) and pipes text/x-component rows.

4. Decode

@@ -371,7 +371,7 @@

4. Decode

5. Commit

React root -

history.pushState updates the URL and MatchaRoot applies the decoded RSC tree through state.

+

history.pushState updates the URL and MatchaRoot stores the pending RSC tree in state.

6. Restore

@@ -601,13 +601,13 @@

Data Formats At Each Boundary

Navigation Commit src/entry-client.tsx -
const node = await createFromReadableStream(response.body, {
+            
const tree = createFromReadableStream(response.body, {
   serverConsumerManifest
 });
 
 history.pushState({ __matchaRsc: true }, "", url);
 startTransition(() => {
-  setTree(node);
+  setTree({ status: "pending", response: tree });
 });
@@ -646,8 +646,8 @@

/user-profile

/suspense-demo

- Buffered Suspense demo -

Async server component waits for 2 seconds inside Suspense; today the route appears after the whole Flight payload is ready.

+ Streaming Suspense demo +

Async server component waits for 2 seconds inside Suspense; navigation shows the route while the slow panel fallback is pending.

404

diff --git a/lib/commands/dev.ts b/lib/commands/dev.ts index d6b9aae..6ea54b7 100644 --- a/lib/commands/dev.ts +++ b/lib/commands/dev.ts @@ -9,12 +9,21 @@ export const description = 'Start development server with HMR and RSC rendering' type DevClientManifest = ReturnType; +interface FlightPipeableStream { + pipe: (destination: NodeJS.WritableStream) => void; + abort: () => void; +} + interface RscServerEntry { matchRscRoute: (routeTarget: string) => unknown; renderRoutePayload: ( routeTarget: string, manifest: DevClientManifest, ) => Promise; + renderRoutePayloadStream: ( + routeTarget: string, + manifest: DevClientManifest, + ) => FlightPipeableStream; } interface RscDocumentEntry { @@ -60,15 +69,28 @@ export async function run() { try { const { rscEntry, manifest } = await loadRscRuntime(); const route = rscEntry.matchRscRoute(routeTarget); - const payload = await rscEntry.renderRoutePayload(routeTarget, manifest); + const stream = rscEntry.renderRoutePayloadStream(routeTarget, manifest); res .status(route ? 200 : 404) .set({ 'Content-Type': 'text/x-component; charset=utf-8', 'Cache-Control': 'no-store', - }) - .end(payload); + }); + + res.flushHeaders(); + + let completed = false; + res.on('finish', () => { + completed = true; + }); + req.on('close', () => { + if (!completed) { + stream.abort(); + } + }); + + stream.pipe(res); } catch (e) { vite.ssrFixStacktrace(e as Error); console.error(e); diff --git a/lib/commands/serve.ts b/lib/commands/serve.ts index 119d681..7503b09 100644 --- a/lib/commands/serve.ts +++ b/lib/commands/serve.ts @@ -7,7 +7,10 @@ import { pathToFileURL } from 'node:url'; interface RscFunctionModule { isRscRoute: (path: string) => boolean; renderRscPage: (path: string) => Promise; - renderRscPayload: (path: string) => Promise; + renderRscPayloadStream: (path: string) => Promise<{ + pipe: (destination: NodeJS.WritableStream) => void; + abort: () => void; + }>; } export const description = 'Serve the production build with RSC routes'; @@ -35,15 +38,28 @@ export async function run() { const routeTarget = typeof rawPath === 'string' && rawPath.startsWith('/') ? rawPath : '/'; try { - const payload = await rscFunction.renderRscPayload(routeTarget); + const stream = await rscFunction.renderRscPayloadStream(routeTarget); const status = rscFunction.isRscRoute(routeTarget) ? 200 : 404; res .status(status) .set({ 'Content-Type': 'text/x-component; charset=utf-8', 'Cache-Control': 'no-store', - }) - .end(payload); + }); + + res.flushHeaders(); + + let completed = false; + res.on('finish', () => { + completed = true; + }); + req.on('close', () => { + if (!completed) { + stream.abort(); + } + }); + + stream.pipe(res); } catch (e) { console.error(e); res.status(500).end((e as Error).message); diff --git a/lib/plugin.ts b/lib/plugin.ts index 445ed10..c7d0564 100644 --- a/lib/plugin.ts +++ b/lib/plugin.ts @@ -444,7 +444,7 @@ export default function matcha(): Plugin { const ssrFunctionCode = `import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { renderRoutePayload } from './entry-rsc-server.js'; +import { renderRoutePayload, renderRoutePayloadStream } from './entry-rsc-server.js'; import { renderRscDocument } from './entry-rsc-document.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -474,10 +474,10 @@ async function loadClientManifest() { return JSON.parse(file); } -export async function renderRscPayload(routeTarget) { +export async function renderRscPayloadStream(routeTarget) { const { target } = toRouteTarget(routeTarget); const manifest = await loadClientManifest(); - return renderRoutePayload(target, manifest); + return renderRoutePayloadStream(target, manifest); } export async function renderRscPage(routeTarget) { diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 27a4833..503af2e 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -14,7 +14,10 @@ const appRoot = document.getElementById('app')!; let navigationVersion = 0; type CreateFromReadableStream = typeof import('react-server-dom-webpack/client.edge').createFromReadableStream; -type SetRscTree = React.Dispatch>; +type RscTree = + | { status: 'ready'; node: React.ReactNode } + | { status: 'pending'; response: Promise }; +type SetRscTree = React.Dispatch>; let createFromReadableStreamPromise: Promise | undefined; function getManifest(): ClientManifest { @@ -50,8 +53,15 @@ function createStreamFromBase64(base64: string): ReadableStream { } async function decodeRscStream(stream: ReadableStream): Promise { - const manifest = getManifest(); const createFromReadableStream = await getCreateFromReadableStream(); + return createRscResponse(stream, createFromReadableStream); +} + +function createRscResponse( + stream: ReadableStream, + createFromReadableStream: CreateFromReadableStream, +): Promise { + const manifest = getManifest(); const response = createFromReadableStream( stream, { @@ -63,7 +73,7 @@ async function decodeRscStream(stream: ReadableStream): Promise; - return await response; + return response; } async function decodeInitialRscPayload(): Promise { @@ -75,7 +85,7 @@ async function decodeInitialRscPayload(): Promise { return await decodeRscStream(createStreamFromBase64(payload)); } -async function fetchRscNode(routeTarget: string): Promise { +async function fetchRscTree(routeTarget: string): Promise<{ tree: Promise }> { const endpoint = new URL('/__matcha_rsc', window.location.origin); endpoint.searchParams.set('path', routeTarget); @@ -89,7 +99,10 @@ async function fetchRscNode(routeTarget: string): Promise { throw new Error(`Expected an RSC response for "${routeTarget}".`); } - return await decodeRscStream(response.body); + const createFromReadableStream = await getCreateFromReadableStream(); + return { + tree: createRscResponse(response.body, createFromReadableStream), + }; } function routeTargetFromUrl(url: URL): string { @@ -128,7 +141,7 @@ async function navigateTo(url: URL, mode: 'push' | 'restore', setTree: SetRscTre navigationVersion = version; try { - const node = await fetchRscNode(routeTargetFromUrl(url)); + const { tree } = await fetchRscTree(routeTargetFromUrl(url)); if (version !== navigationVersion) { return; } @@ -139,7 +152,7 @@ async function navigateTo(url: URL, mode: 'push' | 'restore', setTree: SetRscTre } React.startTransition(() => { - setTree(node); + setTree({ status: 'pending', response: tree }); }); } catch (error) { if (mode === 'restore') { @@ -177,8 +190,16 @@ function installRscNavigation(setTree: SetRscTree): () => void { }; } +function RscRouteSlot({ tree }: { tree: RscTree }) { + if (tree.status === 'ready') { + return tree.node; + } + + return React.use(tree.response); +} + function MatchaRoot({ initialTree }: { initialTree: React.ReactNode }) { - const [tree, setTree] = React.useState(initialTree); + const [tree, setTree] = React.useState({ status: 'ready', node: initialTree }); React.useEffect(() => { return installRscNavigation(setTree); @@ -186,7 +207,9 @@ function MatchaRoot({ initialTree }: { initialTree: React.ReactNode }) { return (
- {tree} + Loading route...

}> + +
); } diff --git a/src/entry-rsc-server.tsx b/src/entry-rsc-server.tsx index 72b9625..db053e6 100644 --- a/src/entry-rsc-server.tsx +++ b/src/entry-rsc-server.tsx @@ -7,6 +7,8 @@ import UserProfilePage from './rsc/UserProfilePage.js'; import SuspenseDemoPage from './rsc/SuspenseDemoPage.js'; import { ClientManifest } from './rsc/client-reference-runtime.js'; +type FlightPipeableStream = ReturnType; + interface RscRoute { path: string; render: () => React.ReactNode; @@ -50,7 +52,7 @@ async function renderFlightPayloadToString( model: React.ReactNode, moduleMap: ClientManifest['serverModuleMap'], ): Promise { - const stream = renderToPipeableStream(model, moduleMap); + const stream = renderToFlightPayloadStream(model, moduleMap); return await new Promise((resolve, reject) => { const sink = new PassThrough(); @@ -70,6 +72,20 @@ async function renderFlightPayloadToString( }); } +function renderToFlightPayloadStream( + model: React.ReactNode, + moduleMap: ClientManifest['serverModuleMap'], +): FlightPipeableStream { + return renderToPipeableStream(model, moduleMap); +} + +export function renderRoutePayloadStream( + routeTarget: string, + manifest: ClientManifest, +): FlightPipeableStream { + return renderToFlightPayloadStream(renderRoute(routeTarget), manifest.serverModuleMap); +} + export async function renderRoutePayload( routeTarget: string, manifest: ClientManifest, diff --git a/src/rsc/SuspenseDemoPage.tsx b/src/rsc/SuspenseDemoPage.tsx index 52081a7..e2132ad 100644 --- a/src/rsc/SuspenseDemoPage.tsx +++ b/src/rsc/SuspenseDemoPage.tsx @@ -30,8 +30,8 @@ export default function SuspenseDemoPage() {

Suspense Demo

- This route has an async server component inside a Suspense boundary. The current - runtime buffers the whole Flight response, so navigation waits before this page appears. + This route has an async server component inside a Suspense boundary. Navigation + streams the Flight response, so the page can appear before the slow panel resolves.

Loading slow server panel...

}> From e3724efcf04b5122efe0ece2cb4fecce4f57717f Mon Sep 17 00:00:00 2001 From: Krzysztof Bielikowicz <62392609+ibniss@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:28:35 +0100 Subject: [PATCH 11/11] feat: stream RSC documents --- docs/rsc-current-state.html | 17 +- docs/rsc-navigation-flows.html | 738 +++++++++++++++++++++++++++++++++ lib/commands/dev.ts | 25 +- lib/commands/serve.ts | 23 +- lib/plugin.ts | 10 +- src/entry-rsc-document.tsx | 156 ++++++- src/rsc/SuspenseDemoPage.tsx | 4 +- 7 files changed, 943 insertions(+), 30 deletions(-) create mode 100644 docs/rsc-navigation-flows.html diff --git a/docs/rsc-current-state.html b/docs/rsc-current-state.html index 1d0da8e..1ed9946 100644 --- a/docs/rsc-current-state.html +++ b/docs/rsc-current-state.html @@ -325,17 +325,17 @@

2. Route Gate

3. RSC Render

entry-rsc-server -

renderRoutePayload(url, manifest) renders the selected server component tree to Flight rows.

+

renderRoutePayloadStream(url, manifest) starts streaming Flight rows for the selected server component tree.

4. HTML Shell

entry-rsc-document -

The server reads the Flight payload back into a React node and renders initial HTML.

+

The document renderer reads the Flight stream back into React and pipes the HTML shell as Suspense boundaries resolve.

5. Bootstrap

inline script -

The document embeds a browser manifest and a base64 Flight payload.

+

The full Flight payload is collected while streaming and appended as bootstrap state before the client entry script runs.

6. Hydration

@@ -521,7 +521,7 @@

Data Formats At Each Boundary

Flight Payload Rows - Text stream before base64 embedding + Text stream used by HTML and bootstrap
7:I[
   "src/rsc/HomeCounter.client.tsx",
@@ -548,7 +548,7 @@ 

Data Formats At Each Boundary

HTML Bootstrap - Inline document state + Appended after streamed HTML
<script>
 window.__MATCHA_RSC_ENABLED__ = true;
@@ -558,7 +558,8 @@ 

Data Formats At Each Boundary

chunkMap }; window.__MATCHA_RSC_PAYLOAD__ = "base64(Flight rows)"; -</script>
+</script> +<script type="module" src="/src/entry-client.tsx"></script>
@@ -578,7 +579,7 @@

Data Formats At Each Boundary

} }); -hydrateRoot(appRoot, node);
+hydrateRoot(appRoot, <MatchaRoot initialTree={node} />);
@@ -647,7 +648,7 @@

/user-profile

/suspense-demo

Streaming Suspense demo -

Async server component waits for 2 seconds inside Suspense; navigation shows the route while the slow panel fallback is pending.

+

Async server component waits for 2 seconds inside Suspense; document loads and client navigation both show the fallback while the slow panel is pending.

404

diff --git a/docs/rsc-navigation-flows.html b/docs/rsc-navigation-flows.html new file mode 100644 index 0000000..97427e8 --- /dev/null +++ b/docs/rsc-navigation-flows.html @@ -0,0 +1,738 @@ + + + + + + MatchaStack RSC Navigation Flows + + + +
+
+
+

RSC Navigation Data Flows

+
+ Browser + Server + Stream boundary +
+
+
+
+ Scope +

Two navigation paths: full document navigation and intercepted client navigation.

+
+
+ Key idea +

Both paths start with Flight rows. Server navigation converts the Flight stream back into HTML plus bootstrap state; client navigation consumes the Flight stream directly in the browser.

+
+
+
+ +
+

1. Server Navigation Data Flow

+
+
+

Document Request

+ browser +

GET /suspense-demo asks for HTML. No client router is involved because this is a reload or direct URL entry.

+
+
+

Route Handler

+ lib/commands/dev.ts or serve.ts +

The catch-all route loads the HTML template, RSC runtime, and manifest, then checks matchRscRoute(url) for the response status.

+
+
+

Flight Stream

+ entry-rsc-server +

renderRoutePayloadStream(url, manifest) calls RSDW renderToPipeableStream(routeNode, serverModuleMap).

+
+
+

Document Stream

+ entry-rsc-document +

renderRscDocumentStream(...) decodes the Flight stream with createFromNodeStream and renders an HTML stream with React DOM.

+
+
+

HTML Response

+ res +

The server writes the template before the outlet, pipes React HTML chunks, then appends bootstrap state and module scripts at the end.

+
+
+

Hydration

+ entry-client +

The browser reads the appended base64 Flight payload, recreates the RSC tree, and hydrates MatchaRoot.

+
+
+ +
+
+ 1. Prepare client references +

The document renderer installs the same webpack-compatible client reference runtime used by RSC decoding. On the server, __webpack_chunk_load__(moduleId) resolves through manifest.ssrChunkMap, so client components can be represented during server HTML rendering without executing browser-only code.

+
+
+ 2. Create a readable Flight input +

renderRoutePayloadStream(...) returns a pipeable Flight producer. renderRscDocumentStream(...) creates a PassThrough named flightReadable, then calls flightStream.pipe(flightReadable). That converts the producer into the Node readable stream shape expected by createFromNodeStream.

+
+
+ 3. Start decoding Flight +

createFromNodeStream(flightReadable, manifest) does not produce HTML. It produces a promise for the React model represented by the Flight rows. As rows arrive, React can discover the root tree, client references, Suspense boundaries, and deferred placeholders such as $L8.

+
+
+ 4. Preserve the same rows for hydration +

The current implementation also calls collectPayload(flightReadable). That records the exact UTF-8 Flight rows while they pass through the document renderer. At the end of the response those rows are base64 encoded into window.__MATCHA_RSC_PAYLOAD__, letting the browser hydrate from the same RSC payload the server used for HTML.

+
+
+ 5. Render a React HTML stream +

The document renderer passes a small wrapper tree to React DOM: <RscDocumentRoot><RscDocumentResponse response={response} /></RscDocumentRoot>. RscDocumentResponse calls React.use(response). If the Flight response has enough rows for the shell but not enough for a nested async component, React DOM emits the shell plus that component's Suspense fallback.

+
+
+ 6. Pipe shell output to the HTTP response +

The returned object does not write immediately. When Express calls documentStream.pipe(res), the document renderer writes the template prefix, starts renderToPipeableStream, and waits for onShellReady. At that point React DOM output is piped through reactOutput into the HTTP response.

+
+
+ 7. Finish with bootstrap state +

When React DOM ends, the renderer waits for payloadPromise, creates the bootstrap script, appends the saved module scripts, and calls destination.end(...). The module entry runs after window.__MATCHA_RSC_PAYLOAD__ exists, avoiding the previous hydration race.

+
+
+ 8. Abort both sides together +

If the browser disconnects, Express calls documentStream.abort(). That aborts the original Flight producer, aborts the React DOM stream if it has started, and destroys the PassThrough so the decode path does not keep reading orphaned work.

+
+
+ +
+
HTTP socket
+
+
Headers flushContent-Type: text/html is sent before the slow server component resolves.
+
Template prefix<html>...<div id="app"> is written directly to res.
+
React HTML chunksFallback HTML for Suspense can reach the browser while Flight is still pending.
+
Bootstrap suffixAfter the full Flight payload is collected, bootstrap state and module scripts are appended.
+
+
Flight pipe
+
+
RSC rendererrenderToPipeableStream emits Flight rows like $Sreact.suspense and $L8.
+
PassThroughflightStream.pipe(flightReadable) gives the document renderer a Node readable stream.
+
HTML decodecreateFromNodeStream(flightReadable) produces the React node promise used by React.use.
+
Bootstrap copyThe same stream is collected into UTF-8 text so the client can hydrate from identical Flight rows.
+
+
React HTML pipe
+
+
Root shapeServer HTML uses <RscDocumentRoot>, matching the browser MatchaRoot wrapper.
+
Suspense read<RscDocumentResponse response={response} /> calls React.use(response).
+
Shell readyonShellReady pipes React DOM output through reactOutput.
+
Abort pathIf the request closes early, the Express handler aborts both the Flight stream and HTML stream.
+
+
+ +
+
+
+ Express document path + Server navigation entry point +
+
const flightStream =
+  rscEntry.renderRoutePayloadStream(url, manifest);
+
+const documentStream =
+  await rscDocumentEntry.renderRscDocumentStream(
+    template,
+    manifest,
+    flightStream
+  );
+
+res.status(route ? 200 : 404);
+res.set({ "Content-Type": "text/html; charset=utf-8" });
+res.flushHeaders();
+documentStream.pipe(res);
+
+ +
+
+ Document stream internals + Flight to HTML plus bootstrap +
+
const flightReadable = new PassThrough();
+const payloadPromise = collectPayload(flightReadable);
+const response = createFromNodeStream(flightReadable, manifest);
+
+flightStream.pipe(flightReadable);
+destination.write(beforeOutlet);
+
+renderToPipeableStream(
+  <RscDocumentRoot>
+    <RscDocumentResponse response={response} />
+  </RscDocumentRoot>,
+  {
+    onShellReady() {
+      htmlStream.pipe(reactOutput);
+    }
+  }
+);
+
+ +
+
+ Early HTML shape + Before the slow panel resolves +
+
<div id="app">
+  <div data-matcha-rsc-root="true">
+    <!--$-->
+    <div>
+      <h1>Suspense Demo</h1>
+      <!--$?-->
+      <template id="B:0"></template>
+      <p data-testid="slow-panel-fallback">
+        Loading slow server panel...
+      </p>
+      <!--/$-->
+    </div>
+    <!--/$-->
+  </div>
+
+ +
+
+ Final document suffix + Bootstrap runs before client entry +
+
<script>
+window.__MATCHA_RSC_ENABLED__ = true;
+window.__MATCHA_RSC_MANIFEST__ = { moduleMap, serverModuleMap, chunkMap };
+window.__MATCHA_RSC_PAYLOAD__ = "base64(complete Flight rows)";
+</script>
+
+<script type="module" src="/src/entry-client.tsx"></script>
+
+
+ +
+

Server navigation no longer waits to send HTML. It still waits to hydrate, because the current bootstrap format appends a complete base64 Flight payload at the end of the document.

+
+
+ +
+

2. Client Navigation Data Flow

+
+
+

Click Gate

+ entry-client +

A same-origin unmodified link click is intercepted. External links, downloads, targets, and hash-only jumps are ignored.

+
+
+

Flight Fetch

+ fetch +

The browser requests /__matcha_rsc?path=/about with Accept: text/x-component.

+
+
+

Endpoint

+ app.get("/__matcha_rsc") +

The endpoint loads the RSC runtime and manifest, sets text/x-component, flushes headers, and pipes Flight rows directly.

+
+
+

ReadableStream

+ browser response body +

createFromReadableStream(response.body) starts decoding before the full Flight payload has arrived.

+
+
+

Pending Tree

+ MatchaRoot +

The RSC response promise is stored as { status: "pending", response } instead of being awaited by navigation.

+
+
+

Reveal

+ React Suspense +

RscRouteSlot calls React.use(response). Suspense shows fallbacks until streamed rows resolve.

+
+
+ +
+
Browser event
+
+
ClickshouldHandleLinkClick(event) parses the target URL.
+
Prevent defaultThe document request is cancelled for eligible same-origin routes.
+
VersioningnavigationVersion prevents older responses from winning a later navigation.
+
HistorypushState commits the visible URL after the Flight response has started.
+
+
Flight response
+
+
Headerstext/x-component confirms the response can be decoded as Flight.
+
Stream bodyThe body stays as a browser ReadableStream<Uint8Array>.
+
DecodecreateFromReadableStream receives moduleMap, serverModuleMap, and moduleLoading: null.
+
Client refs__webpack_chunk_load__ imports client chunks from chunkMap.
+
+
React render
+
+
TransitionstartTransition updates the route tree without tearing down the root.
+
Outer fallbackLoading route... covers a route that has not produced its root row yet.
+
Inner fallbackRoute-owned Suspense boundaries, like the slow panel fallback, can render while later rows are pending.
+
Abort pathIf the client disconnects, the server aborts the Flight pipe on request close.
+
+
+ +
+
+
+ Client fetch + Preserve the RSC promise +
+
async function fetchRscTree(routeTarget) {
+  const response = await fetch(endpoint, {
+    headers: { Accept: "text/x-component" }
+  });
+
+  const createFromReadableStream =
+    await getCreateFromReadableStream();
+
+  return {
+    tree: createFromReadableStream(response.body, {
+      serverConsumerManifest
+    })
+  };
+}
+
+ +
+
+ Navigation commit + Do not await the tree +
+
const { tree } = await fetchRscTree(routeTarget);
+
+history.pushState({ __matchaRsc: true }, "", url);
+scrollTo({ left: 0, top: 0 });
+
+startTransition(() => {
+  setTree({ status: "pending", response: tree });
+});
+
+ +
+
+ Client root + Shared Suspense shell +
+
function MatchaRoot({ initialTree }) {
+  const [tree, setTree] = useState({
+    status: "ready",
+    node: initialTree
+  });
+
+  return (
+    <div data-matcha-rsc-root>
+      <Suspense fallback={<p>Loading route...</p>}>
+        <RscRouteSlot tree={tree} />
+      </Suspense>
+    </div>
+  );
+}
+
+ +
+
+ Flight endpoint + Raw RSC stream +
+
const stream =
+  rscEntry.renderRoutePayloadStream(routeTarget, manifest);
+
+res.status(route ? 200 : 404);
+res.set({
+  "Content-Type": "text/x-component; charset=utf-8",
+  "Cache-Control": "no-store"
+});
+res.flushHeaders();
+stream.pipe(res);
+
+
+
+ +
+

Timing Model

+
+
+ t = 0ms +

Request starts. Server creates a Flight pipeable stream for the route.

+
+
+ t ~= 1-30ms +

Headers and first chunks flush. Server navigation sends early HTML with Suspense fallback; client navigation sends early Flight rows.

+
+
+ t ~= 2000ms +

The slow server component resolves. RSC emits the deferred row; React DOM emits the resolved HTML patch for document navigation.

+
+
+ after stream end +

Server navigation appends the complete base64 Flight payload and client entry script. Client navigation already decoded directly from the response stream.

+
+
+
+
+ + diff --git a/lib/commands/dev.ts b/lib/commands/dev.ts index 6ea54b7..f91efb7 100644 --- a/lib/commands/dev.ts +++ b/lib/commands/dev.ts @@ -27,11 +27,11 @@ interface RscServerEntry { } interface RscDocumentEntry { - renderRscDocument: ( + renderRscDocumentStream: ( template: string, manifest: DevClientManifest, - payload: string, - ) => Promise; + flightStream: FlightPipeableStream, + ) => Promise; } export async function run() { @@ -109,10 +109,23 @@ export async function run() { const { rscEntry, rscDocumentEntry, manifest } = await loadRscRuntime(); const route = rscEntry.matchRscRoute(url); - const payload = await rscEntry.renderRoutePayload(url, manifest); - const html = await rscDocumentEntry.renderRscDocument(template, manifest, payload); + const flightStream = rscEntry.renderRoutePayloadStream(url, manifest); + const documentStream = await rscDocumentEntry.renderRscDocumentStream(template, manifest, flightStream); + + res.status(route ? 200 : 404).set({ 'Content-Type': 'text/html; charset=utf-8' }); + res.flushHeaders(); + + let completed = false; + res.on('finish', () => { + completed = true; + }); + req.on('close', () => { + if (!completed) { + documentStream.abort(); + } + }); - res.status(route ? 200 : 404).set({ 'Content-Type': 'text/html' }).end(html); + documentStream.pipe(res); } catch (e) { vite.ssrFixStacktrace(e as Error); console.error(e); diff --git a/lib/commands/serve.ts b/lib/commands/serve.ts index 7503b09..98f35c8 100644 --- a/lib/commands/serve.ts +++ b/lib/commands/serve.ts @@ -6,7 +6,10 @@ import { pathToFileURL } from 'node:url'; interface RscFunctionModule { isRscRoute: (path: string) => boolean; - renderRscPage: (path: string) => Promise; + renderRscPageStream: (path: string) => Promise<{ + pipe: (destination: NodeJS.WritableStream) => void; + abort: () => void; + }>; renderRscPayloadStream: (path: string) => Promise<{ pipe: (destination: NodeJS.WritableStream) => void; abort: () => void; @@ -71,9 +74,23 @@ export async function run() { if (rscFunction) { try { - const html = await rscFunction.renderRscPage(requestUrl); + const stream = await rscFunction.renderRscPageStream(requestUrl); const status = rscFunction.isRscRoute(requestUrl) ? 200 : 404; - return res.status(status).set({ 'Content-Type': 'text/html' }).end(html); + res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8' }); + res.flushHeaders(); + + let completed = false; + res.on('finish', () => { + completed = true; + }); + req.on('close', () => { + if (!completed) { + stream.abort(); + } + }); + + stream.pipe(res); + return; } catch (e) { console.error(e); return res.status(500).end((e as Error).message); diff --git a/lib/plugin.ts b/lib/plugin.ts index c7d0564..60b8fd0 100644 --- a/lib/plugin.ts +++ b/lib/plugin.ts @@ -444,8 +444,8 @@ export default function matcha(): Plugin { const ssrFunctionCode = `import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { renderRoutePayload, renderRoutePayloadStream } from './entry-rsc-server.js'; -import { renderRscDocument } from './entry-rsc-document.js'; +import { renderRoutePayloadStream } from './entry-rsc-server.js'; +import { renderRscDocumentStream } from './entry-rsc-document.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const templatePath = path.resolve(__dirname, './ssr-template.html'); @@ -480,15 +480,15 @@ export async function renderRscPayloadStream(routeTarget) { return renderRoutePayloadStream(target, manifest); } -export async function renderRscPage(routeTarget) { +export async function renderRscPageStream(routeTarget) { const { target } = toRouteTarget(routeTarget); const [template, manifest] = await Promise.all([ readFile(templatePath, 'utf-8'), loadClientManifest(), ]); - const payload = await renderRoutePayload(target, manifest); + const flightStream = renderRoutePayloadStream(target, manifest); - return renderRscDocument(template, manifest, payload); + return renderRscDocumentStream(template, manifest, flightStream); } export { rscRoutes, isRscRoute };`; diff --git a/src/entry-rsc-document.tsx b/src/entry-rsc-document.tsx index 60b667c..9adab4a 100644 --- a/src/entry-rsc-document.tsx +++ b/src/entry-rsc-document.tsx @@ -1,10 +1,20 @@ import * as React from 'react'; -import { PassThrough, Readable } from 'node:stream'; +import { PassThrough, Readable, Writable } from 'node:stream'; import { pathToFileURL } from 'node:url'; import { renderToPipeableStream } from 'react-dom/server'; import { createFromNodeStream } from 'react-server-dom-webpack/client.node'; import { ClientManifest, installFlightClientReferenceRuntime } from './rsc/client-reference-runtime.js'; +interface FlightPipeableStream { + pipe: (destination: NodeJS.WritableStream) => void; + abort: () => void; +} + +interface DocumentPipeableStream { + pipe: (destination: Writable) => void; + abort: () => void; +} + async function renderHtmlToString(node: React.ReactNode): Promise { const stream = renderToPipeableStream(node); @@ -32,7 +42,7 @@ function createRscBootstrapScript(manifest: ClientManifest, payload: string): st return ``; } -async function renderRscHtml(payload: string, manifest: ClientManifest): Promise { +async function prepareSsrClientReferences(manifest: ClientManifest): Promise { installFlightClientReferenceRuntime(globalThis, async (moduleId) => { const chunkPath = manifest.ssrChunkMap?.[moduleId]; if (!chunkPath) { @@ -44,18 +54,42 @@ async function renderRscHtml(payload: string, manifest: ClientManifest): Promise await Promise.all( Object.keys(manifest.moduleMap).map((moduleId) => globalThis.__webpack_chunk_load__?.(moduleId)), ); +} - const response = createFromNodeStream(Readable.from([payload]), { +function createRscResponse( + stream: NodeJS.ReadableStream, + manifest: ClientManifest, +): Promise { + const response = createFromNodeStream(stream, { moduleMap: manifest.moduleMap, serverModuleMap: manifest.serverModuleMap, moduleLoading: null, }) as Promise; - const resolvedNode = await response; - return await renderHtmlToString( + return response; +} + +function RscDocumentRoot({ children }: { children: React.ReactNode }) { + return (
+ Loading route...

}> + {children} +
+
+ ); +} + +function RscDocumentResponse({ response }: { response: Promise }) { + return React.use(response); +} + +async function renderRscHtml(payload: string, manifest: ClientManifest): Promise { + await prepareSsrClientReferences(manifest); + const resolvedNode = await createRscResponse(Readable.from([payload]), manifest); + return await renderHtmlToString( + {resolvedNode} -
, + , ); } @@ -71,3 +105,113 @@ export async function renderRscDocument( .replace('', html) .replace('', `${bootstrapScript}`); } + +function splitStreamingTemplate(template: string): { + beforeOutlet: string; + afterOutlet: string; + moduleScripts: string; +} { + const moduleScripts: string[] = []; + const templateWithoutEntryScripts = template.replace( + /]*\btype=["']module["'])[^>]*><\/script>\s*/g, + (script) => { + moduleScripts.push(script); + return ''; + }, + ); + const [beforeOutlet, afterOutlet] = templateWithoutEntryScripts.split(''); + + if (afterOutlet === undefined) { + throw new Error('MatchaStack expected an in the HTML template.'); + } + + return { + beforeOutlet, + afterOutlet, + moduleScripts: moduleScripts.join(''), + }; +} + +function appendBootstrapScripts( + afterOutlet: string, + bootstrapScript: string, + moduleScripts: string, +): string { + const scripts = `${bootstrapScript}${moduleScripts}`; + + if (afterOutlet.includes('')) { + return afterOutlet.replace('', `${scripts}`); + } + + return `${afterOutlet}${scripts}`; +} + +function collectPayload(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + stream.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }); +} + +export async function renderRscDocumentStream( + template: string, + manifest: ClientManifest, + flightStream: FlightPipeableStream, +): Promise { + await prepareSsrClientReferences(manifest); + + const flightReadable = new PassThrough(); + const payloadPromise = collectPayload(flightReadable); + const response = createRscResponse(flightReadable, manifest); + const { beforeOutlet, afterOutlet, moduleScripts } = splitStreamingTemplate(template); + let htmlStream: ReturnType | undefined; + + flightStream.pipe(flightReadable); + + return { + abort() { + flightStream.abort(); + htmlStream?.abort(); + flightReadable.destroy(); + }, + pipe(destination) { + const reactOutput = new PassThrough(); + + reactOutput.on('data', (chunk) => { + destination.write(chunk); + }); + reactOutput.on('end', async () => { + try { + const payload = await payloadPromise; + const bootstrapScript = createRscBootstrapScript(manifest, payload); + destination.end(appendBootstrapScripts(afterOutlet, bootstrapScript, moduleScripts)); + } catch (error) { + destination.destroy(error as Error); + } + }); + reactOutput.on('error', (error) => { + destination.destroy(error); + }); + + destination.write(beforeOutlet); + htmlStream = renderToPipeableStream( + + + , + { + onShellReady() { + htmlStream?.pipe(reactOutput); + }, + onError(error) { + console.error(error); + }, + }, + ); + }, + }; +} diff --git a/src/rsc/SuspenseDemoPage.tsx b/src/rsc/SuspenseDemoPage.tsx index e2132ad..f513104 100644 --- a/src/rsc/SuspenseDemoPage.tsx +++ b/src/rsc/SuspenseDemoPage.tsx @@ -30,8 +30,8 @@ export default function SuspenseDemoPage() {

Suspense Demo

- This route has an async server component inside a Suspense boundary. Navigation - streams the Flight response, so the page can appear before the slow panel resolves. + This route has an async server component inside a Suspense boundary. Document + loads and client navigation both stream, so the page can appear before the slow panel resolves.

Loading slow server panel...

}>