diff --git a/packages/core/scripts/check-client-dist.ts b/packages/core/scripts/check-client-dist.ts new file mode 100644 index 00000000..772b6848 --- /dev/null +++ b/packages/core/scripts/check-client-dist.ts @@ -0,0 +1,108 @@ +import { readFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { findDynamicImports, findExports, findStaticImports } from 'mlly' + +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 ScannedSpecifiers { + static: string[] + dynamic: string[] +} + +interface Violation { + file: string + specifier: string + rule: string +} + +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] } +} + +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 +} + +export async function checkClientDist(options: CheckClientDistOptions): Promise { + const { entries, cwd } = options + const visited = new Set() + const violations: Violation[] = [] + + 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}`) + } + + // 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) { + 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.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..833fe36c 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,79 +1,120 @@ +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', } -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() + }, + '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, + }) + }, }, }, - 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, }, -}) +])