Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/core/scripts/check-client-dist.ts
Original file line number Diff line number Diff line change
@@ -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<ScannedSpecifiers> {
const code = await readFile(file, 'utf8')
const staticIds = new Set<string>()
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<string>()
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<void> {
const { entries, cwd } = options
const visited = new Set<string>()
const violations: Violation[] = []

async function visit(file: string): Promise<void> {
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`)
}
167 changes: 104 additions & 63 deletions packages/core/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
])
Loading