Skip to content

Commit 3085997

Browse files
authored
fix(core): split client/server tsdown builds to keep ws/node out of browser (#347)
1 parent 36d865e commit 3085997

2 files changed

Lines changed: 212 additions & 63 deletions

File tree

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

packages/core/tsdown.config.ts

Lines changed: 104 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,120 @@
1+
import { dirname, resolve } from 'node:path'
2+
import { fileURLToPath } from 'node:url'
13
import { defineConfig } from 'tsdown'
24
import Vue from 'unplugin-vue/rolldown'
35

6+
const here = dirname(fileURLToPath(import.meta.url))
7+
const distDir = resolve(here, 'dist')
8+
49
const define = {
510
'import.meta.env.VITE_DEVTOOLS_LOCAL_DEV': 'false',
611
'process.env.VITE_DEVTOOLS_LOCAL_DEV': 'false',
712
}
813

9-
export default defineConfig({
10-
exports: true,
11-
plugins: [
12-
Vue({
13-
isProduction: true,
14-
}),
14+
const deps = {
15+
neverBundle: [
16+
'vite',
17+
'@vitejs/devtools/client/webcomponents',
18+
/^node:/,
1519
],
16-
deps: {
17-
neverBundle: [
18-
'vite',
19-
'@vitejs/devtools/client/webcomponents',
20-
/^node:/,
21-
],
22-
// @keep-sorted
23-
onlyBundle: [
24-
'@clack/core',
25-
'@clack/prompts',
26-
'@json-render/core',
27-
'@json-render/vue',
28-
'@vue/reactivity',
29-
'@vue/runtime-core',
30-
'@vue/runtime-dom',
31-
'@vue/shared',
32-
'@vueuse/core',
33-
'@vueuse/shared',
34-
'@xterm/addon-fit',
35-
'@xterm/xterm',
36-
'csstype',
37-
'dompurify',
38-
'fast-string-truncated-width',
39-
'fast-string-width',
40-
'fast-wrap-ansi',
41-
'fuse.js',
42-
'get-port-please',
43-
'human-id',
44-
'sisteransi',
45-
'vue',
46-
'zod',
47-
],
20+
// @keep-sorted
21+
onlyBundle: [
22+
'@clack/core',
23+
'@clack/prompts',
24+
'@json-render/core',
25+
'@json-render/vue',
26+
'@vue/reactivity',
27+
'@vue/runtime-core',
28+
'@vue/runtime-dom',
29+
'@vue/shared',
30+
'@vueuse/core',
31+
'@vueuse/shared',
32+
'@xterm/addon-fit',
33+
'@xterm/xterm',
34+
'csstype',
35+
'dompurify',
36+
'fast-string-truncated-width',
37+
'fast-string-width',
38+
'fast-wrap-ansi',
39+
'fuse.js',
40+
'get-port-please',
41+
'human-id',
42+
'sisteransi',
43+
'vue',
44+
'zod',
45+
],
46+
}
47+
48+
const inputOptions = {
49+
resolve: {
50+
mainFields: ['module', 'main'],
4851
},
49-
clean: true,
50-
platform: 'neutral',
51-
tsconfig: '../../tsconfig.base.json',
52-
entry: {
53-
'index': 'src/index.ts',
54-
'integration': 'src/integration.ts',
55-
'internal': 'src/internal.ts',
56-
'dirs': 'src/dirs.ts',
57-
'cli': 'src/node/cli.ts',
58-
'cli-commands': 'src/node/cli-commands.ts',
59-
'config': 'src/node/config.ts',
60-
'client/inject': 'src/client/inject/index.ts',
61-
'client/webcomponents': 'src/client/webcomponents/index.ts',
52+
experimental: {
53+
resolveNewUrlToAsset: false,
6254
},
63-
dts: true,
64-
inputOptions: {
65-
resolve: {
66-
mainFields: ['module', 'main'],
55+
}
56+
57+
const tsconfig = '../../tsconfig.base.json'
58+
59+
// Split into two configs so the client and server entries live in independent
60+
// rolldown chunk graphs. A single combined build lets rolldown hoist shared
61+
// helpers (e.g. `__exportAll`) into chunks that mix server-only imports like
62+
// `devframe/rpc/transports/ws-server`, which then leak into the browser bundle.
63+
export default defineConfig([
64+
// Client build — runs first; `clean: true` clears dist/ before the server
65+
// build appends to it. Keep this first in the array.
66+
{
67+
clean: true,
68+
platform: 'browser',
69+
tsconfig,
70+
plugins: [
71+
Vue({
72+
isProduction: true,
73+
}),
74+
],
75+
deps,
76+
entry: {
77+
'client/inject': 'src/client/inject/index.ts',
78+
'client/webcomponents': 'src/client/webcomponents/index.ts',
6779
},
68-
experimental: {
69-
resolveNewUrlToAsset: false,
80+
dts: true,
81+
inputOptions,
82+
define,
83+
hooks: {
84+
'build:before': async function () {
85+
const { buildCSS } = await import('./src/client/webcomponents/scripts/build-css')
86+
await buildCSS()
87+
},
88+
'build:done': async function () {
89+
const { checkClientDist } = await import('./scripts/check-client-dist')
90+
await checkClientDist({
91+
entries: [
92+
resolve(distDir, 'client/inject.js'),
93+
resolve(distDir, 'client/webcomponents.js'),
94+
],
95+
cwd: here,
96+
})
97+
},
7098
},
7199
},
72-
define,
73-
hooks: {
74-
'build:before': async function () {
75-
const { buildCSS } = await import('./src/client/webcomponents/scripts/build-css')
76-
await buildCSS()
100+
// Server build — `clean: false` so it appends to the client output. No Vue
101+
// plugin (server entries don't import .vue) and no CSS hook.
102+
{
103+
clean: false,
104+
platform: 'neutral',
105+
tsconfig,
106+
deps,
107+
entry: {
108+
'index': 'src/index.ts',
109+
'integration': 'src/integration.ts',
110+
'internal': 'src/internal.ts',
111+
'dirs': 'src/dirs.ts',
112+
'cli': 'src/node/cli.ts',
113+
'cli-commands': 'src/node/cli-commands.ts',
114+
'config': 'src/node/config.ts',
77115
},
116+
dts: true,
117+
inputOptions,
118+
define,
78119
},
79-
})
120+
])

0 commit comments

Comments
 (0)