From 209e2ffc92e4269980a3ca33fb111246b3d449f3 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 14 May 2026 21:55:17 +0900 Subject: [PATCH 1/2] fix(core): split client and server tsdown builds to keep ws/node out of the browser bundle Rolldown's automatic chunking was hoisting the `__exportAll` runtime helper into a chunk that also reached `devframe/rpc/transports/ws-server`, so client `DockStandalone-*.js` statically imported `ws` and threw `SyntaxError: ... does not provide an export named 'WebSocketServer'` in the browser. Split `packages/core/tsdown.config.ts` into two configs (client + server) so the chunk graphs are independent, and add a `build:check` guard that BFS-walks `dist/client/*.js` and fails on any forbidden static import (`ws`, `h3`, `node:*`, `devframe/rpc/transports/*`, `devframe/node*`). --- packages/core/package.json | 3 +- packages/core/scripts/check-client-dist.ts | 109 +++++++++++++++ packages/core/tsdown.config.ts | 152 ++++++++++++--------- 3 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 packages/core/scripts/check-client-dist.ts diff --git a/packages/core/package.json b/packages/core/package.json index 500021d0..e8faa657 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,9 +40,10 @@ "dist" ], "scripts": { - "build": "pnpm build:js && pnpm build:standalone", + "build": "pnpm build:js && pnpm build:standalone && pnpm build:check", "build:js": "tsdown --config-loader=tsx", "build:standalone": "cd src/client/standalone && vite build", + "build:check": "tsx scripts/check-client-dist.ts", "watch": "tsdown --watch --config-loader=tsx", "dev:standalone": "cd src/client/standalone && vite dev", "prepack": "pnpm build", diff --git a/packages/core/scripts/check-client-dist.ts b/packages/core/scripts/check-client-dist.ts new file mode 100644 index 00000000..0f623dfa --- /dev/null +++ b/packages/core/scripts/check-client-dist.ts @@ -0,0 +1,109 @@ +import { readFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { findDynamicImports, findExports, findStaticImports } from 'mlly' + +const ROOT = resolve(fileURLToPath(import.meta.url), '../..') +const DIST = resolve(ROOT, 'dist') + +const ENTRIES = [ + resolve(DIST, 'client/inject.js'), + resolve(DIST, 'client/webcomponents.js'), +] + +interface ForbiddenRule { + name: string + match: (specifier: string) => boolean +} + +const FORBIDDEN: ForbiddenRule[] = [ + { name: 'ws', match: id => id === 'ws' || id.startsWith('ws/') }, + { name: 'h3', match: id => id === 'h3' || id.startsWith('h3/') }, + { name: 'node:* builtin', match: id => id.startsWith('node:') }, + { name: 'devframe/rpc/transports/*', match: id => id.startsWith('devframe/rpc/transports/') }, + { name: 'devframe/node*', match: id => id === 'devframe/node' || id.startsWith('devframe/node/') }, +] + +interface Violation { + file: string + specifier: string + rule: string +} + +interface ScannedSpecifiers { + static: string[] + dynamic: string[] +} + +const visited = new Set() +const violations: Violation[] = [] + +async function scanSpecifiers(file: string): Promise { + const code = await readFile(file, 'utf8') + const staticIds = new Set() + for (const i of findStaticImports(code)) + staticIds.add(i.specifier) + for (const e of findExports(code)) { + if (e.specifier) + staticIds.add(e.specifier) + } + const dynamicIds = new Set() + for (const d of findDynamicImports(code)) { + // Only consider plain string expressions; ignore variable/template imports. + const match = d.expression.match(/^\s*['"]([^'"]+)['"]\s*$/) + if (match?.[1]) + dynamicIds.add(match[1]) + } + return { static: [...staticIds], dynamic: [...dynamicIds] } +} + +async function visit(file: string): Promise { + if (visited.has(file)) + return + visited.add(file) + + let scanned: ScannedSpecifiers + try { + scanned = await scanSpecifiers(file) + } + catch (err) { + throw new Error(`Failed to read ${relative(ROOT, file)}: ${(err as Error).message}`) + } + + // Static imports load eagerly when the file is evaluated — they're the leak + // vector this guard exists to catch. Flag any forbidden specifier. + for (const id of scanned.static) { + const hit = FORBIDDEN.find(r => r.match(id)) + if (hit) + violations.push({ file, specifier: id, rule: hit.name }) + } + + // Follow both static and dynamic relative imports to discover every chunk + // the browser can end up loading. Dynamic specifiers themselves aren't + // checked against FORBIDDEN — the chunk they target is, on visit. + for (const id of [...scanned.static, ...scanned.dynamic]) { + if (id.startsWith('./') || id.startsWith('../')) { + const next = resolve(dirname(file), id) + await visit(next) + } + } +} + +for (const entry of ENTRIES) + await visit(entry) + +if (violations.length > 0) { + console.error('[check-client-dist] Forbidden server-only imports found in client dist:') + console.error() + for (const v of violations) { + console.error(` ${relative(ROOT, v.file)}`) + console.error(` imports ${JSON.stringify(v.specifier)} (matches forbidden rule: ${v.rule})`) + } + console.error() + console.error(`Scanned ${visited.size} chunks reachable by static import from ${ENTRIES.length} client entries.`) + console.error('Client chunks must not statically import server-only modules — see packages/core/tsdown.config.ts.') + process.exit(1) +} + +console.log(`[check-client-dist] OK — scanned ${visited.size} chunks reachable from ${ENTRIES.length} client entries`) diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index e785a19d..e0fbdd02 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -6,74 +6,100 @@ const define = { 'process.env.VITE_DEVTOOLS_LOCAL_DEV': 'false', } -export default defineConfig({ - exports: true, - plugins: [ - Vue({ - isProduction: true, - }), +const deps = { + neverBundle: [ + 'vite', + '@vitejs/devtools/client/webcomponents', + /^node:/, ], - deps: { - neverBundle: [ - 'vite', - '@vitejs/devtools/client/webcomponents', - /^node:/, - ], - // @keep-sorted - onlyBundle: [ - '@clack/core', - '@clack/prompts', - '@json-render/core', - '@json-render/vue', - '@vue/reactivity', - '@vue/runtime-core', - '@vue/runtime-dom', - '@vue/shared', - '@vueuse/core', - '@vueuse/shared', - '@xterm/addon-fit', - '@xterm/xterm', - 'csstype', - 'dompurify', - 'fast-string-truncated-width', - 'fast-string-width', - 'fast-wrap-ansi', - 'fuse.js', - 'get-port-please', - 'human-id', - 'sisteransi', - 'vue', - 'zod', - ], + // @keep-sorted + onlyBundle: [ + '@clack/core', + '@clack/prompts', + '@json-render/core', + '@json-render/vue', + '@vue/reactivity', + '@vue/runtime-core', + '@vue/runtime-dom', + '@vue/shared', + '@vueuse/core', + '@vueuse/shared', + '@xterm/addon-fit', + '@xterm/xterm', + 'csstype', + 'dompurify', + 'fast-string-truncated-width', + 'fast-string-width', + 'fast-wrap-ansi', + 'fuse.js', + 'get-port-please', + 'human-id', + 'sisteransi', + 'vue', + 'zod', + ], +} + +const inputOptions = { + resolve: { + mainFields: ['module', 'main'], }, - clean: true, - platform: 'neutral', - tsconfig: '../../tsconfig.base.json', - entry: { - 'index': 'src/index.ts', - 'integration': 'src/integration.ts', - 'internal': 'src/internal.ts', - 'dirs': 'src/dirs.ts', - 'cli': 'src/node/cli.ts', - 'cli-commands': 'src/node/cli-commands.ts', - 'config': 'src/node/config.ts', - 'client/inject': 'src/client/inject/index.ts', - 'client/webcomponents': 'src/client/webcomponents/index.ts', + experimental: { + resolveNewUrlToAsset: false, }, - dts: true, - inputOptions: { - resolve: { - mainFields: ['module', 'main'], +} + +const tsconfig = '../../tsconfig.base.json' + +// Split into two configs so the client and server entries live in independent +// rolldown chunk graphs. A single combined build lets rolldown hoist shared +// helpers (e.g. `__exportAll`) into chunks that mix server-only imports like +// `devframe/rpc/transports/ws-server`, which then leak into the browser bundle. +export default defineConfig([ + // Client build — runs first; `clean: true` clears dist/ before the server + // build appends to it. Keep this first in the array. + { + clean: true, + platform: 'browser', + tsconfig, + plugins: [ + Vue({ + isProduction: true, + }), + ], + deps, + entry: { + 'client/inject': 'src/client/inject/index.ts', + 'client/webcomponents': 'src/client/webcomponents/index.ts', }, - experimental: { - resolveNewUrlToAsset: false, + dts: true, + inputOptions, + define, + hooks: { + 'build:before': async function () { + const { buildCSS } = await import('./src/client/webcomponents/scripts/build-css') + await buildCSS() + }, }, }, - define, - hooks: { - 'build:before': async function () { - const { buildCSS } = await import('./src/client/webcomponents/scripts/build-css') - await buildCSS() + // Server build — `clean: false` so it appends to the client output. No Vue + // plugin (server entries don't import .vue) and no CSS hook. + { + clean: false, + platform: 'neutral', + tsconfig, + deps, + entry: { + 'index': 'src/index.ts', + 'integration': 'src/integration.ts', + 'internal': 'src/internal.ts', + 'dirs': 'src/dirs.ts', + 'cli': 'src/node/cli.ts', + 'cli-commands': 'src/node/cli-commands.ts', + 'config': 'src/node/config.ts', }, + dts: true, + inputOptions, + define, }, -}) +]) From e3159d2c8938a9deab0f6f7baf46c54b487b3292 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 15 May 2026 09:55:01 +0900 Subject: [PATCH 2/2] chore(core): invoke client-dist check from tsdown build:done hook Move the forbidden-import check out of the `build:check` npm script and into the client config's `build:done` hook, so it runs automatically as part of the tsdown build (including `watch` mode) without needing a separate pipeline step. The script becomes an exported `checkClientDist` function that throws on violation; `package.json` drops the `build:check` entry. --- packages/core/package.json | 3 +- packages/core/scripts/check-client-dist.ts | 115 ++++++++++----------- packages/core/tsdown.config.ts | 15 +++ 3 files changed, 73 insertions(+), 60 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index e8faa657..500021d0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,10 +40,9 @@ "dist" ], "scripts": { - "build": "pnpm build:js && pnpm build:standalone && pnpm build:check", + "build": "pnpm build:js && pnpm build:standalone", "build:js": "tsdown --config-loader=tsx", "build:standalone": "cd src/client/standalone && vite build", - "build:check": "tsx scripts/check-client-dist.ts", "watch": "tsdown --watch --config-loader=tsx", "dev:standalone": "cd src/client/standalone && vite dev", "prepack": "pnpm build", diff --git a/packages/core/scripts/check-client-dist.ts b/packages/core/scripts/check-client-dist.ts index 0f623dfa..772b6848 100644 --- a/packages/core/scripts/check-client-dist.ts +++ b/packages/core/scripts/check-client-dist.ts @@ -1,17 +1,7 @@ import { readFile } from 'node:fs/promises' import { dirname, relative, resolve } from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' import { findDynamicImports, findExports, findStaticImports } from 'mlly' -const ROOT = resolve(fileURLToPath(import.meta.url), '../..') -const DIST = resolve(ROOT, 'dist') - -const ENTRIES = [ - resolve(DIST, 'client/inject.js'), - resolve(DIST, 'client/webcomponents.js'), -] - interface ForbiddenRule { name: string match: (specifier: string) => boolean @@ -25,19 +15,16 @@ const FORBIDDEN: ForbiddenRule[] = [ { name: 'devframe/node*', match: id => id === 'devframe/node' || id.startsWith('devframe/node/') }, ] -interface Violation { - file: string - specifier: string - rule: string -} - interface ScannedSpecifiers { static: string[] dynamic: string[] } -const visited = new Set() -const violations: Violation[] = [] +interface Violation { + file: string + specifier: string + rule: string +} async function scanSpecifiers(file: string): Promise { const code = await readFile(file, 'utf8') @@ -58,52 +45,64 @@ async function scanSpecifiers(file: string): Promise { return { static: [...staticIds], dynamic: [...dynamicIds] } } -async function visit(file: string): Promise { - if (visited.has(file)) - return - visited.add(file) +export interface CheckClientDistOptions { + /** Absolute paths to the client entry chunks to walk from. */ + entries: string[] + /** Used to build relative paths in error messages. */ + cwd: string +} - let scanned: ScannedSpecifiers - try { - scanned = await scanSpecifiers(file) - } - catch (err) { - throw new Error(`Failed to read ${relative(ROOT, file)}: ${(err as Error).message}`) - } +export async function checkClientDist(options: CheckClientDistOptions): Promise { + const { entries, cwd } = options + const visited = new Set() + const violations: Violation[] = [] - // Static imports load eagerly when the file is evaluated — they're the leak - // vector this guard exists to catch. Flag any forbidden specifier. - for (const id of scanned.static) { - const hit = FORBIDDEN.find(r => r.match(id)) - if (hit) - violations.push({ file, specifier: id, rule: hit.name }) - } + async function visit(file: string): Promise { + if (visited.has(file)) + return + visited.add(file) + + let scanned: ScannedSpecifiers + try { + scanned = await scanSpecifiers(file) + } + catch (err) { + throw new Error(`[check-client-dist] Failed to read ${relative(cwd, file)}: ${(err as Error).message}`) + } - // Follow both static and dynamic relative imports to discover every chunk - // the browser can end up loading. Dynamic specifiers themselves aren't - // checked against FORBIDDEN — the chunk they target is, on visit. - for (const id of [...scanned.static, ...scanned.dynamic]) { - if (id.startsWith('./') || id.startsWith('../')) { - const next = resolve(dirname(file), id) - await visit(next) + // Static imports load eagerly when the file is evaluated — they're the leak + // vector this guard exists to catch. Flag any forbidden specifier. + for (const id of scanned.static) { + const hit = FORBIDDEN.find(r => r.match(id)) + if (hit) + violations.push({ file, specifier: id, rule: hit.name }) + } + + // Follow both static and dynamic relative imports to discover every chunk + // the browser can end up loading. Dynamic specifiers themselves aren't + // checked against FORBIDDEN — the chunk they target is, on visit. + for (const id of [...scanned.static, ...scanned.dynamic]) { + if (id.startsWith('./') || id.startsWith('../')) { + const next = resolve(dirname(file), id) + await visit(next) + } } } -} -for (const entry of ENTRIES) - await visit(entry) + for (const entry of entries) + await visit(entry) -if (violations.length > 0) { - console.error('[check-client-dist] Forbidden server-only imports found in client dist:') - console.error() - for (const v of violations) { - console.error(` ${relative(ROOT, v.file)}`) - console.error(` imports ${JSON.stringify(v.specifier)} (matches forbidden rule: ${v.rule})`) + if (violations.length > 0) { + const lines: string[] = ['[check-client-dist] Forbidden server-only imports found in client dist:', ''] + for (const v of violations) { + lines.push(` ${relative(cwd, v.file)}`) + lines.push(` imports ${JSON.stringify(v.specifier)} (matches forbidden rule: ${v.rule})`) + } + lines.push('') + lines.push(`Scanned ${visited.size} chunks reachable from ${entries.length} client entries.`) + lines.push('Client chunks must not statically import server-only modules — see packages/core/tsdown.config.ts.') + throw new Error(lines.join('\n')) } - console.error() - console.error(`Scanned ${visited.size} chunks reachable by static import from ${ENTRIES.length} client entries.`) - console.error('Client chunks must not statically import server-only modules — see packages/core/tsdown.config.ts.') - process.exit(1) -} -console.log(`[check-client-dist] OK — scanned ${visited.size} chunks reachable from ${ENTRIES.length} client entries`) + console.log(`[check-client-dist] OK — scanned ${visited.size} chunks reachable from ${entries.length} client entries`) +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index e0fbdd02..833fe36c 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,6 +1,11 @@ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { defineConfig } from 'tsdown' import Vue from 'unplugin-vue/rolldown' +const here = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(here, 'dist') + const define = { 'import.meta.env.VITE_DEVTOOLS_LOCAL_DEV': 'false', 'process.env.VITE_DEVTOOLS_LOCAL_DEV': 'false', @@ -80,6 +85,16 @@ export default defineConfig([ const { buildCSS } = await import('./src/client/webcomponents/scripts/build-css') await buildCSS() }, + 'build:done': async function () { + const { checkClientDist } = await import('./scripts/check-client-dist') + await checkClientDist({ + entries: [ + resolve(distDir, 'client/inject.js'), + resolve(distDir, 'client/webcomponents.js'), + ], + cwd: here, + }) + }, }, }, // Server build — `clean: false` so it appends to the client output. No Vue