feat: implement Web Components SSR framework with Koa 3, DOM-Renderer, Parcel 2, and CLI#1
feat: implement Web Components SSR framework with Koa 3, DOM-Renderer, Parcel 2, and CLI#1
Conversation
…and Parcel 2 plugin Agent-Logs-Url: https://github.com/EasyWebApp/WebSSR/sessions/2eff332b-4410-47ef-9e45-8763652062b4 Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com>
TechQuery
left a comment
There was a problem hiding this comment.
既然是参考 Next.js 的服务端渲染框架,那就得对外包装一个 CLI 让用户一键运行,CLI 入口架构用 http://github.com/TechQuery/CommanderJSX 。
app/page.tsx
Outdated
There was a problem hiding this comment.
导入 CSS modules、<SubComponent /> 的能力也要实现,要满足日常开发的核心需求。
There was a problem hiding this comment.
Implemented in commit b41a4bc:
- SubComponent:
test/components/NavBar.tsxis a reusable<NavBar links={...} />component imported and used directly intest/app/page.tsx - CSS modules:
test/styles/home.module.cssis imported asimport styles from '../styles/home.module.css'; a new Node.js ESM loader hook (source/css-loader.ts, registered insource/polyfill.ts) returns an identity proxy on the server (styles.hero → 'hero'), andsource/css-modules.d.tsprovides the ambient TypeScript type
The rendered <main class="hero"> confirms both features working end-to-end on the server.
package-lock.json
Outdated
There was a problem hiding this comment.
Done in commit b41a4bc – deleted package-lock.json, added "packageManager": "pnpm@10.33.0" to package.json, and committed pnpm-lock.yaml + pnpm-workspace.yaml.
source/renderer.ts
Outdated
| const POLYFILL_SCRIPT = `<script type="module"> | ||
| import 'declarative-shadow-dom-polyfill'; | ||
| </script>`; |
There was a problem hiding this comment.
| const POLYFILL_SCRIPT = `<script type="module"> | |
| import 'declarative-shadow-dom-polyfill'; | |
| </script>`; | |
| const POLYFILL_SCRIPT = `<script src="https://unpkg.com/declarative-shadow-dom-polyfill"></script>`; |
There was a problem hiding this comment.
Applied in commit b41a4bc – POLYFILL_SCRIPT is now:
<script src="https://unpkg.com/declarative-shadow-dom-polyfill"></script>
source/renderer.ts
Outdated
| return new ReadableStream<string>({ | ||
| async start(controller) { | ||
| const enqueue = (chunk: string) => controller.enqueue(chunk); | ||
|
|
||
| enqueue(`<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| ${POLYFILL_SCRIPT} | ||
| ${clientScripts.map(src => `<script type="module" src="${src}"></script>`).join('\n')} | ||
| </head> | ||
| <body> | ||
| `); | ||
|
|
||
| const vnode = await Page(props); | ||
| const stream = renderer.renderToReadableStream(vnode); | ||
| const reader = stream.getReader(); | ||
|
|
||
| try { | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| enqueue(value); | ||
| } | ||
| } finally { | ||
| reader.releaseLock(); | ||
| } | ||
|
|
||
| enqueue('\n</body>\n</html>'); | ||
| controller.close(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Rewritten in commit b41a4bc. renderPageToStream is now an async function* generator that yields HTML chunks:
export async function* renderPageToStream(Page, props, clientScripts) {
yield `<!DOCTYPE html>...<body>\n`;
const vnode = await Page(props);
yield* renderer.renderToReadableStream(vnode);
yield '\n</body>\n</html>';
}The router converts it to a Node.js Readable with Readable.from(renderPageToStream(...)).
| router.get(koaPath, async ctx => { | ||
| const mod = await import(fileUrl); | ||
| const Page: PageComponent = mod.default; | ||
|
|
||
| if (typeof Page !== 'function') { | ||
| ctx.status = 404; | ||
| ctx.body = 'Page component not found'; | ||
| return; | ||
| } | ||
|
|
||
| const params = ctx.params as Record<string, string>; | ||
| const searchParams = Object.fromEntries( | ||
| new URLSearchParams(ctx.querystring) | ||
| ); | ||
|
|
||
| const stream = renderPageToStream(Page, { params, searchParams }); | ||
|
|
||
| ctx.type = 'text/html; charset=utf-8'; | ||
| // Pipe the web ReadableStream into the Koa (Node.js) response | ||
| ctx.body = Readable.fromWeb( | ||
| stream as import('stream/web').ReadableStream | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Added in commit b41a4bc via the CLI (source/cli.ts):
- Dev mode (
webssr dev): starts Parcel in watch mode via@parcel/corealongside the Koa SSR server. Client entry points are discovered as*.client.tsxfiles; hot reload served onport+1. Bundled assets from.webssr/distare served bykoa-static. - Prod build (
webssr build): runs@parcel/corein production mode, outputs to.webssr/dist. - Prod server (
webssr start): serves SSR + static assets from.webssr/dist.
| "exports": { | ||
| ".": "./source/index.ts", | ||
| "./parcel-plugin": "./source/parcel-plugin/index.ts" | ||
| }, |
There was a problem hiding this comment.
TypeScript 文件直接导出去没法用吧?至少编译成 ESM 啊!
There was a problem hiding this comment.
Fixed in commit b41a4bc – package.json exports now point to compiled output:
"exports": {
".": "./dist/index.js",
"./parcel-plugin": "./dist/parcel-plugin/index.js"
}The bin field also points to ./dist/cli.js. Run npm run build (→ tsc) to compile source/ to dist/ before publishing.
package.json
Outdated
| "start": "node --import tsx/esm source/server.ts", | ||
| "dev": "node --watch --import tsx/esm source/server.ts", | ||
| "build": "tsc", | ||
| "test": "node --import tsx/esm --test source/**/*.test.ts" |
There was a problem hiding this comment.
你单元测试都没写啊!把 app/ 目录移到 test/ 里作为测试用示例代码。
| "test": "node --import tsx/esm --test source/**/*.test.ts" | |
| "test": "node --import tsx/esm --test test/**/*.test.ts" |
There was a problem hiding this comment.
Done in commit b41a4bc:
- Moved
app/→test/app/as example/fixture code - Added
test/renderer.test.tsx(5 tests forrenderPage+renderPageToStream) andtest/router.test.ts(2 tests for route discovery) - All 7 tests pass via
npm test(node --import tsx/esm --import ./source/polyfill.ts --test 'test/**/*.test.ts' 'test/**/*.test.tsx')
package.json
Outdated
| "@koa/router": "^13.1.0", | ||
| "dom-renderer": "^2.6.4", | ||
| "happy-dom": "^20.8.9", | ||
| "koa": "^2.15.4" |
There was a problem hiding this comment.
Upgraded in commit b41a4bc – koa@^3.2.0, @koa/router@^15.4.0, @types/koa@^3.0.2. Removed @types/koa__router (no longer needed; @koa/router@15 ships its own types at ./dist/index.d.ts).
package.json
Outdated
| "happy-dom": "^20.8.9" | ||
| }, | ||
| "devDependencies": { | ||
| "@parcel/plugin": "^2.12.0", |
There was a problem hiding this comment.
Upgraded to @parcel/plugin@^2.16.4 and @parcel/core@^2.16.4 in commit b41a4bc.
…, CSS modules, Koa 3.x Agent-Logs-Url: https://github.com/EasyWebApp/WebSSR/sessions/a8094aaf-888b-452f-abeb-1b63b4b1ac60 Co-authored-by: TechQuery <19969570+TechQuery@users.noreply.github.com>
Added in commit b41a4bc.
The Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
Bootstraps the WebSSR framework: a Node.js 22 + Koa 3 server that renders Web Components pages via JSX → Declarative Shadow DOM HTML, with file-based routing (Next.js App Router convention), a Parcel 2 plugin for client component boundaries, a
webssrCLI, CSS module support, and unit tests.Core modules
source/polyfill.ts– ESM-native happy-dom bootstrap. Registers a Node.js ESM loader hook for CSS module stubs and injects DOM globals intoglobalThis. Pre-loaded via--import ./source/polyfill.tsso it runs before any module graph is constructed.source/css-loader.ts– Node.js ESM loader hook that resolves.css/.module.cssimports to an identity proxy (styles.hero → 'hero') for SSR. Parcel handles scoped class names on the client.source/renderer.ts– Awaits the async page component, then usesDOMRenderer.renderToReadableStreamfor streaming HTML via anasync function*generator. Wraps output in a full document shell injecting thedeclarative-shadow-dom-polyfillCDN script (https://unpkg.com/declarative-shadow-dom-polyfill).source/router.ts– Recursively scansapp/**/page.tsx, converts[param]segments to Koa:param, and streams the response usingReadable.from(asyncGenerator).source/server.ts– Entry point: router scan → Koa listen → optional static asset serving from.webssr/dist.source/cli.ts–webssrCLI built with CommanderJSX (Commandconstructor API). Three commands:webssr dev [-p port] [-a appDir]– starts the Koa SSR server and Parcel in watch mode (HMR onport+1)webssr start [-p port] [-a appDir]– starts the production Koa serverwebssr build [-a appDir]– runs a production Parcel build into.webssr/distParcel 2 client component plugin
source/parcel-plugin/index.tsis a ParcelTransformerthat intercepts import attributes at the client boundary:env.context === 'node'orPARCEL_BUILD_TARGET=server): replaces the import with a stub{ __clientComponent: true, specifier, name }so SSR runs without browser-only code.CSS modules and sub-components
Page components can import CSS modules and reusable sub-components using standard syntax:
The SSR CSS loader returns class names as-is (identity proxy); Parcel handles scoped names in the browser bundle.
Test fixtures and unit tests
Example pages have been moved from
app/totest/app/as test fixtures. Unit tests cover:test/renderer.test.tsx– 5 tests forrenderPageandrenderPageToStream(document structure, polyfill injection,searchParamsinterpolation, client script injection, async generator streaming)test/router.test.ts– 2 tests for route discovery and[param]→:paramconversionRun with:
node --import tsx/esm --import ./source/polyfill.ts --test 'test/**/*.test.ts' 'test/**/*.test.tsx'Package changes
"packageManager": "pnpm@10.33.0");package-lock.jsonremoved."."→./dist/index.js,"./parcel-plugin"→./dist/parcel-plugin/index.js(compiled ESM, not.tssource).bin:webssr→./dist/cli.js.koa@^3.2.0,@koa/router@^15.4.0,@types/koa@^3.0.2; removed@types/koa__router(bundled in v15).@parcel/plugin@^2.16.4,@parcel/core@^2.16.4.commander-jsx,koa-static,@parcel/core.Dependency notes
happy-dompinned to^20.8.9viaoverrides— earlier v14 range had critical CVEs (VM context escape, script tag RCE).--import tsx/esm --import ./source/polyfill.tsto pre-load the DOM polyfill before any module graph is constructed. This fixes an ESM evaluation-order race wheredom-renderer/jsx-runtime(implicitly added to.tsxfiles by tsx) would loaddeclarative-shadow-dom-polyfillbeforeXMLSerializerwas available inglobalThis.Original prompt