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
114 changes: 114 additions & 0 deletions packages/devframe/scripts/check-client-dist.ts
Original file line number Diff line number Diff line change
@@ -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<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/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`)
}
224 changes: 132 additions & 92 deletions packages/devframe/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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({
Comment on lines +89 to +92
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',
Comment on lines +122 to +126
'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',
},
},
})
])
Original file line number Diff line number Diff line change
@@ -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<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>;
}
// #endregion

// #region Functions
export declare function createWsRpcChannel(_: WsRpcChannelOptions): ChannelOptions;
// #endregion
Loading