Skip to content

feat: implement Web Components SSR framework with Koa 3, DOM-Renderer, Parcel 2, and CLI#1

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/implement-web-components-ssr-framework
Draft

feat: implement Web Components SSR framework with Koa 3, DOM-Renderer, Parcel 2, and CLI#1
Copilot wants to merge 3 commits intomainfrom
copilot/implement-web-components-ssr-framework

Conversation

Copy link
Copy Markdown

Copilot AI commented Apr 4, 2026

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 webssr CLI, 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 into globalThis. Pre-loaded via --import ./source/polyfill.ts so it runs before any module graph is constructed.
  • source/css-loader.ts – Node.js ESM loader hook that resolves .css / .module.css imports 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 uses DOMRenderer.renderToReadableStream for streaming HTML via an async function* generator. Wraps output in a full document shell injecting the declarative-shadow-dom-polyfill CDN script (https://unpkg.com/declarative-shadow-dom-polyfill).
  • source/router.ts – Recursively scans app/**/page.tsx, converts [param] segments to Koa :param, and streams the response using Readable.from(asyncGenerator).
  • source/server.ts – Entry point: router scan → Koa listen → optional static asset serving from .webssr/dist.
  • source/cli.tswebssr CLI built with CommanderJSX (Command constructor API). Three commands:
    • webssr dev [-p port] [-a appDir] – starts the Koa SSR server and Parcel in watch mode (HMR on port+1)
    • webssr start [-p port] [-a appDir] – starts the production Koa server
    • webssr build [-a appDir] – runs a production Parcel build into .webssr/dist

Parcel 2 client component plugin

source/parcel-plugin/index.ts is a Parcel Transformer that intercepts import attributes at the client boundary:

// Page component (server-side)
import { MyButton } from './MyButton' with { runtime: 'client' };
  • Server build (env.context === 'node' or PARCEL_BUILD_TARGET=server): replaces the import with a stub { __clientComponent: true, specifier, name } so SSR runs without browser-only code.
  • Client build: import passes through unchanged for normal Parcel bundling.

CSS modules and sub-components

Page components can import CSS modules and reusable sub-components using standard syntax:

import styles from './styles/home.module.css';
import { NavBar } from '../components/NavBar';

export default async function Page({ searchParams }: PageProps) {
    return <main class={styles.hero}><NavBar links={[...]} /></main>;
}

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/ to test/app/ as test fixtures. Unit tests cover:

  • test/renderer.test.tsx – 5 tests for renderPage and renderPageToStream (document structure, polyfill injection, searchParams interpolation, client script injection, async generator streaming)
  • test/router.test.ts – 2 tests for route discovery and [param]:param conversion

Run with: node --import tsx/esm --import ./source/polyfill.ts --test 'test/**/*.test.ts' 'test/**/*.test.tsx'

Package changes

  • Package manager: switched to pnpm ("packageManager": "pnpm@10.33.0"); package-lock.json removed.
  • Exports: "."./dist/index.js, "./parcel-plugin"./dist/parcel-plugin/index.js (compiled ESM, not .ts source).
  • bin: webssr./dist/cli.js.
  • Koa ecosystem: upgraded to koa@^3.2.0, @koa/router@^15.4.0, @types/koa@^3.0.2; removed @types/koa__router (bundled in v15).
  • Parcel: @parcel/plugin@^2.16.4, @parcel/core@^2.16.4.
  • New deps: commander-jsx, koa-static, @parcel/core.

Dependency notes

  • happy-dom pinned to ^20.8.9 via overrides — earlier v14 range had critical CVEs (VM context escape, script tag RCE).
  • All node scripts use --import tsx/esm --import ./source/polyfill.ts to pre-load the DOM polyfill before any module graph is constructed. This fixes an ESM evaluation-order race where dom-renderer/jsx-runtime (implicitly added to .tsx files by tsx) would load declarative-shadow-dom-polyfill before XMLSerializer was available in globalThis.
Original prompt

实现一个 Web components 服务端渲染框架,遵循以下设计:

  1. HTML 序列化、DOM 水合基于声明式影子 DOM 标准,polyfill 补丁为 https://github.com/EasyWebApp/declarative-shadow-dom-polyfill
  2. 页面组件用异步函数返回 JSX,兼容 React 服务端组件写法,但不使用其私有协议
  3. https://github.com/EasyWebApp/DOM-Renderer 将 JSX 流式生成 HTML 字符串(已处理好自定义元素的标签)
  4. 页面路由采用文件目录式,兼容 Next.js app router,但初版只实现 page.tsx,后期再做 layout.tsx 等高级能力
  5. 通过 Parcel 2 插件做客户端组件依赖收集,并以 import { ClientComponent } from './client-component' with { runtime: 'client' } 作为边界,不使用 "use client" 声明
  6. HTTP 服务器采用 Node.js ^22 + Koa

…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>
Copilot AI changed the title [WIP] Implement Web components server-side rendering framework feat: implement Web Components SSR framework with Koa, DOM-Renderer, and Parcel 2 plugin Apr 4, 2026
Copilot AI requested a review from TechQuery April 4, 2026 15:19
@TechQuery TechQuery added enhancement New feature or request feature labels Apr 4, 2026
@github-project-automation github-project-automation bot moved this to In progress in Upgrade & Refactor Apr 4, 2026
Copy link
Copy Markdown
Member

@TechQuery TechQuery left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

既然是参考 Next.js 的服务端渲染框架,那就得对外包装一个 CLI 让用户一键运行,CLI 入口架构用 http://github.com/TechQuery/CommanderJSX

app/page.tsx Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

导入 CSS modules、<SubComponent /> 的能力也要实现,要满足日常开发的核心需求。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit b41a4bc:

  • SubComponent: test/components/NavBar.tsx is a reusable <NavBar links={...} /> component imported and used directly in test/app/page.tsx
  • CSS modules: test/styles/home.module.css is imported as import styles from '../styles/home.module.css'; a new Node.js ESM loader hook (source/css-loader.ts, registered in source/polyfill.ts) returns an identity proxy on the server (styles.hero → 'hero'), and source/css-modules.d.ts provides the ambient TypeScript type

The rendered <main class="hero"> confirms both features working end-to-end on the server.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

用 PNPM,此文件删掉。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +17 to +19
const POLYFILL_SCRIPT = `<script type="module">
import 'declarative-shadow-dom-polyfill';
</script>`;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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>`;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in commit b41a4bcPOLYFILL_SCRIPT is now:

<script src="https://unpkg.com/declarative-shadow-dom-polyfill"></script>

Comment on lines +65 to +97
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();
}
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不要手写流,用异步生成器。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...)).

Comment on lines +74 to +96
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
);
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parcel 的资源打包在哪里?开发模式热更新在哪里?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in commit b41a4bc via the CLI (source/cli.ts):

  • Dev mode (webssr dev): starts Parcel in watch mode via @parcel/core alongside the Koa SSR server. Client entry points are discovered as *.client.tsx files; hot reload served on port+1. Bundled assets from .webssr/dist are served by koa-static.
  • Prod build (webssr build): runs @parcel/core in production mode, outputs to .webssr/dist.
  • Prod server (webssr start): serves SSR + static assets from .webssr/dist.

Comment on lines +18 to +21
"exports": {
".": "./source/index.ts",
"./parcel-plugin": "./source/parcel-plugin/index.ts"
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript 文件直接导出去没法用吧?至少编译成 ESM 啊!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit b41a4bcpackage.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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

你单元测试都没写啊!把 app/ 目录移到 test/ 里作为测试用示例代码。

Suggested change
"test": "node --import tsx/esm --test source/**/*.test.ts"
"test": "node --import tsx/esm --test test/**/*.test.ts"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit b41a4bc:

  • Moved app/test/app/ as example/fixture code
  • Added test/renderer.test.tsx (5 tests for renderPage + renderPageToStream) and test/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
Comment on lines +29 to +32
"@koa/router": "^13.1.0",
"dom-renderer": "^2.6.4",
"happy-dom": "^20.8.9",
"koa": "^2.15.4"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

升级到 Koa 3.x 及其生态库最新版!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upgraded in commit b41a4bckoa@^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",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parcel 升到 2.16.4 最新版

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown
Author

Copilot AI commented Apr 4, 2026

既然是参考 Next.js 的服务端渲染框架,那就得对外包装一个 CLI 让用户一键运行,CLI 入口架构用 http://github.com/TechQuery/CommanderJSX

Added in commit b41a4bc. source/cli.ts implements a webssr CLI using the CommanderJSX Command constructor API with three commands:

  • webssr dev [-p port] [-a appDir] – starts the Koa SSR server and Parcel in watch mode (HMR on port+1)
  • webssr start [-p port] [-a appDir] – starts the production Koa server
  • webssr build [-a appDir] – runs a production Parcel build into .webssr/dist

The bin field in package.json maps webssr → dist/cli.js.

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:

  • cdn.jsdelivr.net
    • Triggering command: /usr/bin/curl curl -s REDACTED (dns block)
  • https://api.github.com/repos/TechQuery/CommanderJSX/contents
    • Triggering command: /usr/bin/curl curl -s REDACTED (http block)
  • https://api.github.com/repos/TechQuery/CommanderJSX/contents/src
    • Triggering command: /usr/bin/curl curl -s REDACTED (http block)
  • https://api.github.com/repos/TechQuery/CommanderJSX/readme
    • Triggering command: /usr/bin/curl curl -s REDACTED -H Accept: application/vnd.github.v3.raw (http block)
  • unpkg.com
    • Triggering command: /usr/bin/curl curl -s REDACTED (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI changed the title feat: implement Web Components SSR framework with Koa, DOM-Renderer, and Parcel 2 plugin feat: implement Web Components SSR framework with Koa 3, DOM-Renderer, Parcel 2, and CLI Apr 4, 2026
Copilot AI requested a review from TechQuery April 4, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request feature

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants