Skip to content

Commit ae78f05

Browse files
committed
fix(devframe): split tsdown into client + server configs to keep node:* out of browser
A single combined build lets rolldown hoist shared helpers into chunks reachable from both server-only entries (e.g. ws-server, h3, node:crypto) and the agnostic/client outputs, leaking node-only imports into browser-loadable files like utils/hash.mjs and client/index.mjs. The previous `ohash/crypto` alias only addressed one symptom; the underlying chunk-graph mixing remained. Split packages/devframe/tsdown.config.ts into defineConfig([client, server]) so each platform owns its chunk graph. Client config uses platform: 'browser' (drops the ohash/crypto alias), forces .mjs / .d.mts extensions to preserve the package.json exports map, and gates the build via a build:done hook calling scripts/check-client-dist.ts. The check BFS-walks chunks reachable from each agnostic entry and fails on forbidden static imports (ws, h3, node:*, devframe/{rpc/transports,node,adapters,helpers,recipes}/*, and the server-only utils launch-editor/open/serve-static). Snapshot regenerated for rpc/transports/ws-client where the smaller per-config DTS chunk graph now inlines WsRpcChannelOptions + createWsRpcChannel locally instead of re-exporting from a shared chunk (same public API). Mirrors vitejs/devtools#347.
1 parent 1ce81d8 commit ae78f05

3 files changed

Lines changed: 259 additions & 95 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { dirname, relative, resolve } from 'node:path'
3+
import { findDynamicImports, findExports, findStaticImports } from 'mlly'
4+
5+
interface ForbiddenRule {
6+
name: string
7+
match: (specifier: string) => boolean
8+
}
9+
10+
const FORBIDDEN: ForbiddenRule[] = [
11+
{ name: 'ws', match: id => id === 'ws' || id.startsWith('ws/') },
12+
{ name: 'h3', match: id => id === 'h3' || id.startsWith('h3/') },
13+
{ name: 'node:* builtin', match: id => id.startsWith('node:') },
14+
{ name: 'devframe/rpc/transports/*', match: id => id.startsWith('devframe/rpc/transports/') },
15+
{ name: 'devframe/node*', match: id => id === 'devframe/node' || id.startsWith('devframe/node/') },
16+
{ name: 'devframe/adapters/*', match: id => id.startsWith('devframe/adapters/') },
17+
{ name: 'devframe/helpers/*', match: id => id.startsWith('devframe/helpers/') },
18+
{ name: 'devframe/recipes/*', match: id => id.startsWith('devframe/recipes/') },
19+
{ name: 'devframe/utils/launch-editor', match: id => id === 'devframe/utils/launch-editor' },
20+
{ name: 'devframe/utils/open', match: id => id === 'devframe/utils/open' },
21+
{ name: 'devframe/utils/serve-static', match: id => id === 'devframe/utils/serve-static' },
22+
]
23+
24+
interface ScannedSpecifiers {
25+
static: string[]
26+
dynamic: string[]
27+
}
28+
29+
interface Violation {
30+
file: string
31+
specifier: string
32+
rule: string
33+
}
34+
35+
async function scanSpecifiers(file: string): Promise<ScannedSpecifiers> {
36+
const code = await readFile(file, 'utf8')
37+
const staticIds = new Set<string>()
38+
for (const i of findStaticImports(code))
39+
staticIds.add(i.specifier)
40+
for (const e of findExports(code)) {
41+
if (e.specifier)
42+
staticIds.add(e.specifier)
43+
}
44+
const dynamicIds = new Set<string>()
45+
for (const d of findDynamicImports(code)) {
46+
// Only consider plain string expressions; ignore variable/template imports.
47+
const match = d.expression.match(/^\s*['"]([^'"]+)['"]\s*$/)
48+
if (match?.[1])
49+
dynamicIds.add(match[1])
50+
}
51+
return { static: [...staticIds], dynamic: [...dynamicIds] }
52+
}
53+
54+
export interface CheckClientDistOptions {
55+
/** Absolute paths to the client entry chunks to walk from. */
56+
entries: string[]
57+
/** Used to build relative paths in error messages. */
58+
cwd: string
59+
}
60+
61+
export async function checkClientDist(options: CheckClientDistOptions): Promise<void> {
62+
const { entries, cwd } = options
63+
const visited = new Set<string>()
64+
const violations: Violation[] = []
65+
66+
async function visit(file: string): Promise<void> {
67+
if (visited.has(file))
68+
return
69+
visited.add(file)
70+
71+
let scanned: ScannedSpecifiers
72+
try {
73+
scanned = await scanSpecifiers(file)
74+
}
75+
catch (err) {
76+
throw new Error(`[check-client-dist] Failed to read ${relative(cwd, file)}: ${(err as Error).message}`)
77+
}
78+
79+
// Static imports load eagerly when the file is evaluated — they're the leak
80+
// vector this guard exists to catch. Flag any forbidden specifier.
81+
for (const id of scanned.static) {
82+
const hit = FORBIDDEN.find(r => r.match(id))
83+
if (hit)
84+
violations.push({ file, specifier: id, rule: hit.name })
85+
}
86+
87+
// Follow both static and dynamic relative imports to discover every chunk
88+
// the browser can end up loading. Dynamic specifiers themselves aren't
89+
// checked against FORBIDDEN — the chunk they target is, on visit.
90+
for (const id of [...scanned.static, ...scanned.dynamic]) {
91+
if (id.startsWith('./') || id.startsWith('../')) {
92+
const next = resolve(dirname(file), id)
93+
await visit(next)
94+
}
95+
}
96+
}
97+
98+
for (const entry of entries)
99+
await visit(entry)
100+
101+
if (violations.length > 0) {
102+
const lines: string[] = ['[check-client-dist] Forbidden server-only imports found in client dist:', '']
103+
for (const v of violations) {
104+
lines.push(` ${relative(cwd, v.file)}`)
105+
lines.push(` imports ${JSON.stringify(v.specifier)} (matches forbidden rule: ${v.rule})`)
106+
}
107+
lines.push('')
108+
lines.push(`Scanned ${visited.size} chunks reachable from ${entries.length} client entries.`)
109+
lines.push('Client chunks must not statically import server-only modules — see packages/devframe/tsdown.config.ts.')
110+
throw new Error(lines.join('\n'))
111+
}
112+
113+
console.log(`[check-client-dist] OK — scanned ${visited.size} chunks reachable from ${entries.length} client entries`)
114+
}

packages/devframe/tsdown.config.ts

Lines changed: 132 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,142 @@
1+
import { dirname, resolve } from 'node:path'
12
import { fileURLToPath } from 'node:url'
2-
import { resolveSync } from 'mlly'
33
import { defineConfig } from 'tsdown'
44

5-
// Resolve `ohash/crypto` without the `node` condition so the pure-JS digest
6-
// is bundled. The default resolution honours `node`, which inlines
7-
// `node:crypto.createHash` into outputs that are later shipped to the
8-
// browser via the `client` entry.
9-
const ohashCryptoAgnostic = fileURLToPath(
10-
resolveSync('ohash/crypto', { url: import.meta.url, conditions: ['import'] }),
11-
)
5+
const here = dirname(fileURLToPath(import.meta.url))
6+
const distDir = resolve(here, 'dist')
127

13-
export default defineConfig({
14-
alias: {
15-
'ohash/crypto': ohashCryptoAgnostic,
16-
},
17-
entry: {
18-
'index': 'src/index.ts',
19-
'rpc/index': 'src/rpc/index.ts',
20-
'rpc/client': 'src/rpc/client.ts',
21-
'rpc/server': 'src/rpc/server.ts',
22-
'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts',
23-
'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts',
24-
'types/index': 'src/types/index.ts',
25-
'node/index': 'src/node/index.ts',
26-
'node/auth': 'src/node/auth/index.ts',
27-
'node/internal': 'src/node/internal/index.ts',
28-
'constants': 'src/constants.ts',
29-
'utils/colors': 'src/utils/colors.ts',
30-
'utils/events': 'src/utils/events.ts',
31-
'utils/hash': 'src/utils/hash.ts',
32-
'utils/human-id': 'src/utils/human-id.ts',
33-
'utils/launch-editor': 'src/utils/launch-editor.ts',
34-
'utils/nanoid': 'src/utils/nanoid.ts',
35-
'utils/open': 'src/utils/open.ts',
36-
'utils/promise': 'src/utils/promise.ts',
37-
'utils/serve-static': 'src/utils/serve-static.ts',
38-
'utils/shared-state': 'src/utils/shared-state.ts',
39-
'utils/streaming-channel': 'src/utils/streaming-channel.ts',
40-
'utils/structured-clone': 'src/utils/structured-clone.ts',
41-
'utils/when': 'src/utils/when.ts',
42-
'adapters/cli': 'src/adapters/cli.ts',
43-
'adapters/dev': 'src/adapters/dev.ts',
44-
'adapters/build': 'src/adapters/build.ts',
45-
'helpers/vite': 'src/helpers/vite.ts',
46-
'adapters/embedded': 'src/adapters/embedded.ts',
47-
'adapters/mcp': 'src/adapters/mcp/index.ts',
48-
'client/index': 'src/client/index.ts',
49-
'recipes/open-helpers': 'src/recipes/open-helpers.ts',
50-
},
51-
tsconfig: '../../tsconfig.base.json',
52-
clean: true,
53-
dts: true,
54-
exports: true,
8+
const tsconfig = '../../tsconfig.base.json'
9+
10+
const deps = {
5511
// Keep transitive external type graphs out of dts bundling.
5612
// `vite`/`esbuild`/`postcss` are pulled in via the kit client's
5713
// `declare module 'vite'` augmentation and contain
5814
// rolldown-incompatible re-exports that would otherwise fail dts
5915
// generation with dozens of MISSING_EXPORT errors.
60-
deps: {
61-
neverBundle: [
62-
'vite',
63-
'esbuild',
64-
'postcss',
65-
'rolldown',
66-
/^@rolldown\//,
67-
/^@oxc-project\//,
68-
'terser',
69-
'@jridgewell/trace-mapping',
70-
],
71-
onlyBundle: [
72-
'acorn',
73-
'bundle-name',
74-
'default-browser',
75-
'default-browser-id',
76-
'define-lazy-prop',
77-
'get-port-please',
78-
'immer',
79-
'is-docker',
80-
'is-in-ssh',
81-
'is-inside-container',
82-
'is-wsl',
83-
'launch-editor',
84-
'mlly',
85-
'obug',
86-
'ohash',
87-
'open',
88-
'p-limit',
89-
'perfect-debounce',
90-
'picocolors',
91-
'powershell-utils',
92-
'run-applescript',
93-
'shell-quote',
94-
'structured-clone-es',
95-
'tinyexec',
96-
'ua-parser-modern',
97-
'whenexpr',
98-
'wsl-utils',
99-
'yocto-queue',
100-
],
16+
neverBundle: [
17+
'vite',
18+
'esbuild',
19+
'postcss',
20+
'rolldown',
21+
/^@rolldown\//,
22+
/^@oxc-project\//,
23+
'terser',
24+
'@jridgewell/trace-mapping',
25+
],
26+
onlyBundle: [
27+
'acorn',
28+
'bundle-name',
29+
'default-browser',
30+
'default-browser-id',
31+
'define-lazy-prop',
32+
'get-port-please',
33+
'immer',
34+
'is-docker',
35+
'is-in-ssh',
36+
'is-inside-container',
37+
'is-wsl',
38+
'launch-editor',
39+
'mlly',
40+
'obug',
41+
'ohash',
42+
'open',
43+
'p-limit',
44+
'perfect-debounce',
45+
'picocolors',
46+
'powershell-utils',
47+
'run-applescript',
48+
'shell-quote',
49+
'structured-clone-es',
50+
'tinyexec',
51+
'ua-parser-modern',
52+
'whenexpr',
53+
'wsl-utils',
54+
'yocto-queue',
55+
],
56+
}
57+
58+
// Split into two configs so client/agnostic and server entries live in
59+
// independent rolldown chunk graphs. A single combined build lets rolldown
60+
// hoist shared helpers into chunks that mix server-only imports like
61+
// `devframe/rpc/transports/ws-server` or `node:crypto`, which then leak into
62+
// browser-loaded outputs (e.g. `client/index.mjs`, `utils/hash.mjs`).
63+
export default defineConfig([
64+
// Client / agnostic build — runs first; `clean: true` clears dist/ before
65+
// the server build appends to it. Keep this first in the array.
66+
{
67+
clean: true,
68+
platform: 'browser',
69+
tsconfig,
70+
deps,
71+
dts: true,
72+
// Force `.mjs` / `.d.mts` extensions to match the server config and the
73+
// `packages/devframe/package.json` `exports` map. `platform: 'browser'`
74+
// defaults to `.js`, which would break those entry paths.
75+
outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }),
76+
entry: {
77+
'client/index': 'src/client/index.ts',
78+
'utils/colors': 'src/utils/colors.ts',
79+
'utils/events': 'src/utils/events.ts',
80+
'utils/hash': 'src/utils/hash.ts',
81+
'utils/human-id': 'src/utils/human-id.ts',
82+
'utils/nanoid': 'src/utils/nanoid.ts',
83+
'utils/promise': 'src/utils/promise.ts',
84+
'utils/shared-state': 'src/utils/shared-state.ts',
85+
'utils/streaming-channel': 'src/utils/streaming-channel.ts',
86+
'utils/structured-clone': 'src/utils/structured-clone.ts',
87+
'utils/when': 'src/utils/when.ts',
88+
},
89+
hooks: {
90+
'build:done': async () => {
91+
const { checkClientDist } = await import('./scripts/check-client-dist.ts')
92+
await checkClientDist({
93+
entries: [
94+
resolve(distDir, 'client/index.mjs'),
95+
resolve(distDir, 'utils/colors.mjs'),
96+
resolve(distDir, 'utils/events.mjs'),
97+
resolve(distDir, 'utils/hash.mjs'),
98+
resolve(distDir, 'utils/human-id.mjs'),
99+
resolve(distDir, 'utils/nanoid.mjs'),
100+
resolve(distDir, 'utils/promise.mjs'),
101+
resolve(distDir, 'utils/shared-state.mjs'),
102+
resolve(distDir, 'utils/streaming-channel.mjs'),
103+
resolve(distDir, 'utils/structured-clone.mjs'),
104+
resolve(distDir, 'utils/when.mjs'),
105+
],
106+
cwd: here,
107+
})
108+
},
109+
},
110+
},
111+
// Server / node build — `clean: false` so it appends to the client output.
112+
{
113+
clean: false,
114+
platform: 'node',
115+
tsconfig,
116+
deps,
117+
dts: true,
118+
entry: {
119+
'index': 'src/index.ts',
120+
'constants': 'src/constants.ts',
121+
'types/index': 'src/types/index.ts',
122+
'rpc/index': 'src/rpc/index.ts',
123+
'rpc/client': 'src/rpc/client.ts',
124+
'rpc/server': 'src/rpc/server.ts',
125+
'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts',
126+
'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts',
127+
'node/index': 'src/node/index.ts',
128+
'node/auth': 'src/node/auth/index.ts',
129+
'node/internal': 'src/node/internal/index.ts',
130+
'utils/launch-editor': 'src/utils/launch-editor.ts',
131+
'utils/open': 'src/utils/open.ts',
132+
'utils/serve-static': 'src/utils/serve-static.ts',
133+
'adapters/cli': 'src/adapters/cli.ts',
134+
'adapters/dev': 'src/adapters/dev.ts',
135+
'adapters/build': 'src/adapters/build.ts',
136+
'adapters/embedded': 'src/adapters/embedded.ts',
137+
'adapters/mcp': 'src/adapters/mcp/index.ts',
138+
'helpers/vite': 'src/helpers/vite.ts',
139+
'recipes/open-helpers': 'src/recipes/open-helpers.ts',
140+
},
101141
},
102-
})
142+
])
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-client`
33
*/
4-
// #region Other
5-
export { createWsRpcChannel }
6-
export { WsRpcChannelOptions }
4+
// #region Interfaces
5+
export interface WsRpcChannelOptions {
6+
url: string;
7+
onConnected?: (_: Event) => void;
8+
onError?: (_: Error) => void;
9+
onDisconnected?: (_: CloseEvent) => void;
10+
authToken?: string;
11+
definitions?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>;
12+
}
13+
// #endregion
14+
15+
// #region Functions
16+
export declare function createWsRpcChannel(_: WsRpcChannelOptions): ChannelOptions;
717
// #endregion

0 commit comments

Comments
 (0)