diff --git a/packages/devframe/scripts/check-client-dist.ts b/packages/devframe/scripts/check-client-dist.ts new file mode 100644 index 0000000..61f7bbe --- /dev/null +++ b/packages/devframe/scripts/check-client-dist.ts @@ -0,0 +1,114 @@ +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/') }, + { name: 'devframe/adapters/*', match: id => id.startsWith('devframe/adapters/') }, + { name: 'devframe/helpers/*', match: id => id.startsWith('devframe/helpers/') }, + { name: 'devframe/recipes/*', match: id => id.startsWith('devframe/recipes/') }, + { name: 'devframe/utils/launch-editor', match: id => id === 'devframe/utils/launch-editor' }, + { name: 'devframe/utils/open', match: id => id === 'devframe/utils/open' }, + { name: 'devframe/utils/serve-static', match: id => id === 'devframe/utils/serve-static' }, +] + +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/devframe/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/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 9c68fd5..d9d2e31 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -1,102 +1,142 @@ +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { resolveSync } from 'mlly' import { defineConfig } from 'tsdown' -// Resolve `ohash/crypto` without the `node` condition so the pure-JS digest -// is bundled. The default resolution honours `node`, which inlines -// `node:crypto.createHash` into outputs that are later shipped to the -// browser via the `client` entry. -const ohashCryptoAgnostic = fileURLToPath( - resolveSync('ohash/crypto', { url: import.meta.url, conditions: ['import'] }), -) +const here = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(here, 'dist') -export default defineConfig({ - alias: { - 'ohash/crypto': ohashCryptoAgnostic, - }, - entry: { - 'index': 'src/index.ts', - 'rpc/index': 'src/rpc/index.ts', - 'rpc/client': 'src/rpc/client.ts', - 'rpc/server': 'src/rpc/server.ts', - 'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts', - 'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts', - 'types/index': 'src/types/index.ts', - 'node/index': 'src/node/index.ts', - 'node/auth': 'src/node/auth/index.ts', - 'node/internal': 'src/node/internal/index.ts', - 'constants': 'src/constants.ts', - 'utils/colors': 'src/utils/colors.ts', - 'utils/events': 'src/utils/events.ts', - 'utils/hash': 'src/utils/hash.ts', - 'utils/human-id': 'src/utils/human-id.ts', - 'utils/launch-editor': 'src/utils/launch-editor.ts', - 'utils/nanoid': 'src/utils/nanoid.ts', - 'utils/open': 'src/utils/open.ts', - 'utils/promise': 'src/utils/promise.ts', - 'utils/serve-static': 'src/utils/serve-static.ts', - 'utils/shared-state': 'src/utils/shared-state.ts', - 'utils/streaming-channel': 'src/utils/streaming-channel.ts', - 'utils/structured-clone': 'src/utils/structured-clone.ts', - 'utils/when': 'src/utils/when.ts', - 'adapters/cli': 'src/adapters/cli.ts', - 'adapters/dev': 'src/adapters/dev.ts', - 'adapters/build': 'src/adapters/build.ts', - 'helpers/vite': 'src/helpers/vite.ts', - 'adapters/embedded': 'src/adapters/embedded.ts', - 'adapters/mcp': 'src/adapters/mcp/index.ts', - 'client/index': 'src/client/index.ts', - 'recipes/open-helpers': 'src/recipes/open-helpers.ts', - }, - tsconfig: '../../tsconfig.base.json', - clean: true, - dts: true, - exports: true, +const tsconfig = '../../tsconfig.base.json' + +const deps = { // Keep transitive external type graphs out of dts bundling. // `vite`/`esbuild`/`postcss` are pulled in via the kit client's // `declare module 'vite'` augmentation and contain // rolldown-incompatible re-exports that would otherwise fail dts // generation with dozens of MISSING_EXPORT errors. - deps: { - neverBundle: [ - 'vite', - 'esbuild', - 'postcss', - 'rolldown', - /^@rolldown\//, - /^@oxc-project\//, - 'terser', - '@jridgewell/trace-mapping', - ], - onlyBundle: [ - 'acorn', - 'bundle-name', - 'default-browser', - 'default-browser-id', - 'define-lazy-prop', - 'get-port-please', - 'immer', - 'is-docker', - 'is-in-ssh', - 'is-inside-container', - 'is-wsl', - 'launch-editor', - 'mlly', - 'obug', - 'ohash', - 'open', - 'p-limit', - 'perfect-debounce', - 'picocolors', - 'powershell-utils', - 'run-applescript', - 'shell-quote', - 'structured-clone-es', - 'tinyexec', - 'ua-parser-modern', - 'whenexpr', - 'wsl-utils', - 'yocto-queue', - ], + neverBundle: [ + 'vite', + 'esbuild', + 'postcss', + 'rolldown', + /^@rolldown\//, + /^@oxc-project\//, + 'terser', + '@jridgewell/trace-mapping', + ], + onlyBundle: [ + 'acorn', + 'bundle-name', + 'default-browser', + 'default-browser-id', + 'define-lazy-prop', + 'get-port-please', + 'immer', + 'is-docker', + 'is-in-ssh', + 'is-inside-container', + 'is-wsl', + 'launch-editor', + 'mlly', + 'obug', + 'ohash', + 'open', + 'p-limit', + 'perfect-debounce', + 'picocolors', + 'powershell-utils', + 'run-applescript', + 'shell-quote', + 'structured-clone-es', + 'tinyexec', + 'ua-parser-modern', + 'whenexpr', + 'wsl-utils', + 'yocto-queue', + ], +} + +// Split into two configs so client/agnostic and server entries live in +// independent rolldown chunk graphs. A single combined build lets rolldown +// hoist shared helpers into chunks that mix server-only imports like +// `devframe/rpc/transports/ws-server` or `node:crypto`, which then leak into +// browser-loaded outputs (e.g. `client/index.mjs`, `utils/hash.mjs`). +export default defineConfig([ + // Client / agnostic 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, + deps, + dts: true, + // Force `.mjs` / `.d.mts` extensions to match the server config and the + // `packages/devframe/package.json` `exports` map. `platform: 'browser'` + // defaults to `.js`, which would break those entry paths. + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + entry: { + 'client/index': 'src/client/index.ts', + 'utils/colors': 'src/utils/colors.ts', + 'utils/events': 'src/utils/events.ts', + 'utils/hash': 'src/utils/hash.ts', + 'utils/human-id': 'src/utils/human-id.ts', + 'utils/nanoid': 'src/utils/nanoid.ts', + 'utils/promise': 'src/utils/promise.ts', + 'utils/shared-state': 'src/utils/shared-state.ts', + 'utils/streaming-channel': 'src/utils/streaming-channel.ts', + 'utils/structured-clone': 'src/utils/structured-clone.ts', + 'utils/when': 'src/utils/when.ts', + }, + hooks: { + 'build:done': async () => { + const { checkClientDist } = await import('./scripts/check-client-dist.ts') + await checkClientDist({ + entries: [ + resolve(distDir, 'client/index.mjs'), + resolve(distDir, 'utils/colors.mjs'), + resolve(distDir, 'utils/events.mjs'), + resolve(distDir, 'utils/hash.mjs'), + resolve(distDir, 'utils/human-id.mjs'), + resolve(distDir, 'utils/nanoid.mjs'), + resolve(distDir, 'utils/promise.mjs'), + resolve(distDir, 'utils/shared-state.mjs'), + resolve(distDir, 'utils/streaming-channel.mjs'), + resolve(distDir, 'utils/structured-clone.mjs'), + resolve(distDir, 'utils/when.mjs'), + ], + cwd: here, + }) + }, + }, + }, + // Server / node build — `clean: false` so it appends to the client output. + { + clean: false, + platform: 'node', + tsconfig, + deps, + dts: true, + entry: { + 'index': 'src/index.ts', + 'constants': 'src/constants.ts', + 'types/index': 'src/types/index.ts', + 'rpc/index': 'src/rpc/index.ts', + 'rpc/client': 'src/rpc/client.ts', + 'rpc/server': 'src/rpc/server.ts', + 'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts', + 'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts', + 'node/index': 'src/node/index.ts', + 'node/auth': 'src/node/auth/index.ts', + 'node/internal': 'src/node/internal/index.ts', + 'utils/launch-editor': 'src/utils/launch-editor.ts', + 'utils/open': 'src/utils/open.ts', + 'utils/serve-static': 'src/utils/serve-static.ts', + 'adapters/cli': 'src/adapters/cli.ts', + 'adapters/dev': 'src/adapters/dev.ts', + 'adapters/build': 'src/adapters/build.ts', + 'adapters/embedded': 'src/adapters/embedded.ts', + 'adapters/mcp': 'src/adapters/mcp/index.ts', + 'helpers/vite': 'src/helpers/vite.ts', + 'recipes/open-helpers': 'src/recipes/open-helpers.ts', + }, }, -}) +]) diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts index 22cde9f..656516d 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-client.snapshot.d.ts @@ -1,7 +1,17 @@ /** * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-client` */ -// #region Other -export { createWsRpcChannel } -export { WsRpcChannelOptions } +// #region Interfaces +export interface WsRpcChannelOptions { + url: string; + onConnected?: (_: Event) => void; + onError?: (_: Error) => void; + onDisconnected?: (_: CloseEvent) => void; + authToken?: string; + definitions?: ReadonlyMap>; +} +// #endregion + +// #region Functions +export declare function createWsRpcChannel(_: WsRpcChannelOptions): ChannelOptions; // #endregion \ No newline at end of file