mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Build shared Client bundles independently for Node and browser
This commit is contained in:
1 parent
b2ea1d1b71
commit
e137ebaaa1
17 files changed
+208
-42
No files matched your search
@@ -1090,7 +1090,9 @@ export class ClientModuleRegistry extends Service {
|
||||
const requested = requestUrl.pathname.slice(prefix.length)
|
||||
const sourceMap = requested.endsWith('.map')
|
||||
const fileName = sourceMap ? requested.slice(0, -'.map'.length) : requested
|
||||
if (!CLIENT_CHUNK.test(fileName)) return undefined
|
||||
// The Node Client entry and its split chunks share this directory but
|
||||
// must never be served as browser factory scripts.
|
||||
if (!CLIENT_CHUNK.test(fileName) || fileName === 'client.node.js' || fileName.startsWith('client.node.')) return undefined
|
||||
if (resourceUrl !== chunkUrl(record.entry.id, fileName, record.entry.rev, sourceMap)) return undefined
|
||||
return { record, fileName, sourceMap, resourceUrl }
|
||||
}
|
||||
|
||||
@@ -915,6 +915,36 @@ describe('client bundle activation', () => {
|
||||
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('loads a renamed browser export at the logical URL without exposing Node bundles as chunks', async () => {
|
||||
const packageName = '@fixture/dual-client'
|
||||
const legacyPath = writePackage(packageName, {
|
||||
dsh: { client: { platform: 'web' } },
|
||||
exports: {
|
||||
'./client': {
|
||||
types: './lib/types/client/index.d.ts',
|
||||
node: './lib/client.node.js',
|
||||
browser: './lib/client.browser.js',
|
||||
default: './lib/client.browser.js',
|
||||
},
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
})
|
||||
mkdirSync(dirname(legacyPath), { recursive: true })
|
||||
writeFileSync(join(dirname(legacyPath), 'client.browser.js'), 'module.exports = { browser: true }\n')
|
||||
for (const name of ['client.node.js', 'client.node.helper.js']) {
|
||||
writeFileSync(join(dirname(legacyPath), name), 'throw new Error("Node code must not reach a browser")\n')
|
||||
}
|
||||
const { service, route } = constructWithRoute([packageName])
|
||||
const row = service.graph().entries[0]!
|
||||
expect(row.url).toContain(`${packageName}/client.js`)
|
||||
expect((await routeRequest(route, row.url)).body.toString('utf8')).toContain('browser: true')
|
||||
for (const name of ['client.node.js', 'client.node.helper.js']) {
|
||||
for (const suffix of ['', '.map']) {
|
||||
expect((await routeRequest(route, chunkReference(packageName, name + suffix, row.rev))).status).toBe(404)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes a new chunk revision when a completed build rewrites only the entry timestamp', async () => {
|
||||
const packageName = '@fixture/chunk-only-rebuild'
|
||||
const clientPath = writePackage(packageName)
|
||||
|
||||
@@ -104,7 +104,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
* `import.meta.dirname`. Its `package.json` name is the plugin id stamped into
|
||||
* the __ModuleLoader__.load handoff and onto the injected style tags.
|
||||
* @param libEntry - node-half entries.
|
||||
* @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner.
|
||||
* @param options - phase placement, lib overrides, optional independently bundled Node Client, companion configs, and per-file Client banner.
|
||||
* @returns ENV-selected tsdown config for the current build face.
|
||||
* @throws {Error} when `<packageDir>/package.json` is unreadable or declares no name.
|
||||
*/
|
||||
@@ -118,13 +118,16 @@ export function clientBundle(
|
||||
return ({ env }) => {
|
||||
const face = buildFace(env?.DSH_BUILD_FACE)
|
||||
const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
|
||||
const client = clientConfig(manifest, clientEntry, options.clientBanner)
|
||||
const client = clientConfig(manifest, clientEntry, options.clientBanner, options.dualClient === true)
|
||||
const node = [lib, ...(options.companions ?? [])]
|
||||
const clientFaces = options.dualClient === true
|
||||
? [nodeClientConfig(manifest, clientEntry), client]
|
||||
: [client]
|
||||
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
|
||||
if (face === 'client') {
|
||||
return options.hostPhase === true ? [client] : [...node, client]
|
||||
return options.hostPhase === true ? clientFaces : [...node, ...clientFaces]
|
||||
}
|
||||
return [...node, client]
|
||||
return [...node, ...clientFaces]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +211,8 @@ export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
|
||||
interface ClientBundleOptions {
|
||||
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
|
||||
readonly hostPhase?: boolean
|
||||
/** Emit independent ESM `client.node.js` and browser-factory `client.browser.js` from the same Client entry. */
|
||||
readonly dualClient?: boolean
|
||||
/** Additional Node-side configs emitted alongside the package library. */
|
||||
readonly companions?: readonly UserConfig[]
|
||||
/** Overrides for the package's primary Node-side library config. */
|
||||
@@ -467,7 +472,30 @@ function asyncChunkRequirePlugin(): TsdownPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig {
|
||||
/** Bundle the Client entry for plain Node independently of the Host entry and browser factory. */
|
||||
function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig {
|
||||
const isolation = clientInputIsolation(manifest.name)
|
||||
return clientLibraryConfig(manifest, [entry], {
|
||||
name: `${manifest.name}/client-node`,
|
||||
entry: { 'client.node': entry },
|
||||
sourcemap: true,
|
||||
inputOptions: { resolve: { conditionNames: ['node', 'import', 'module', 'default'] } },
|
||||
plugins: [tscSourceMapPlugin(), isolation.plugin],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.node.js',
|
||||
chunkFileNames: 'client.node.[name].js',
|
||||
sourcemapExcludeSources: false,
|
||||
sourcemapPathTransform: isolation.sourcePath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function clientConfig(
|
||||
manifest: PackageManifest,
|
||||
entry: string,
|
||||
clientBanner?: (fileName: string) => string | undefined,
|
||||
dualClient = false,
|
||||
): UserConfig {
|
||||
const id = manifest.name
|
||||
let requested: ReadonlySet<string> | undefined
|
||||
const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier)
|
||||
@@ -475,9 +503,8 @@ function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (
|
||||
return {
|
||||
name: `${id}/client`,
|
||||
entry: { client: entry },
|
||||
// Browser bundle lands next to the node half (single lib/ artifact dir;
|
||||
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
|
||||
// off — a default clean would wipe the node-half output emitted above.
|
||||
// Browser bundle lands beside the Node artifacts. clean must stay off:
|
||||
// cleaning this invocation would wipe the independently emitted Node entry.
|
||||
outDir: 'lib',
|
||||
format: 'cjs',
|
||||
platform: 'browser',
|
||||
@@ -601,10 +628,10 @@ function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (
|
||||
},
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.js',
|
||||
entryFileNames: dualClient ? 'client.browser.js' : 'client.js',
|
||||
// The imported source basename becomes the published chunk name; package
|
||||
// files lists and artifact tests pin every intentional chunk.
|
||||
chunkFileNames: 'client.[name].js',
|
||||
chunkFileNames: dualClient ? 'client.browser.[name].js' : 'client.[name].js',
|
||||
sourcemapExcludeSources: false,
|
||||
// The map is served from /plugins/<scoped-package>/client.js.map. The
|
||||
// browser resolves its local sources back into URLs that mirror the
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-typert-registry/use-this-package:
|
||||
en: d885ccd8f13315f1
|
||||
zh: cea7c258f5f57e2d
|
||||
en: eeeaecf7800bcb6b
|
||||
zh: 2562380c15cb2428
|
||||
/deepseek-ai-dsh-typert-registry/use-this-package/minimal-setup:
|
||||
en: 1ec4b304a024bb3c
|
||||
zh: f17e796c26faabab
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 254d265a3e5b1638
|
||||
zh: bfa325ec79afbc72
|
||||
/deepseek-ai-dsh-typert-registry/use-this-package/registering-a-contribution:
|
||||
en: 2b2f750a1b145d85
|
||||
zh: d15c55c01af1755a
|
||||
en: 9ca425515904d5c9
|
||||
zh: b251226ecfa32002
|
||||
/deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers:
|
||||
en: 937dfd14d9e18a5c
|
||||
zh: 6cf6aa352c8825c6
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration.
|
||||
Mount the registry in any Host or Client composition that stores or consumes generated Typert artifacts; it provides `ctx.typert`. There is no configuration. Node resolves `/client` to the standalone ESM `lib/client.node.js` bundle for programmatic Cordis composition; browsers use the `lib/client.browser.js` loader factory through the logical `/plugins/.../client.js` URL. Both bundles use the same registry implementation, separate from the Host package root.
|
||||
|
||||
### Minimal setup
|
||||
|
||||
@@ -45,6 +45,8 @@ Invocation information is available through existing `ctx.typert.local.get/list`
|
||||
|
||||
Generated artifacts register through the [loader](../loader/README.md) in Loader compositions; any other owner calls `ctx.typert.register(contribution)` directly and receives the exact disposer that withdraws it. Duplicate package-face identities, schema keys, invocation ids, or endpoints reject the whole batch before anything is committed.
|
||||
|
||||
Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution with `ctx.typert.remotes.register(contribution)` in their profile Context. `$mount(contribution)` already performs that registration, so registering and mounting the same contribution in the same registry duplicates it and fails. Keep offline metadata ownership separate from the live connection-and-mount Context. Independent offline consumers may instead read `TYPERT_REMOTE.methods` without any Registry. Registry state belongs to its Context and process, is not shared across processes, and neither registration nor querying opens a connection.
|
||||
|
||||
### Lookup and Context providers
|
||||
|
||||
Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context.
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。
|
||||
在存储或消费生成 Typert 产物的任何 Host 或 Client 组合中挂载本注册表;它提供 `ctx.typert`。没有配置。Node 将 `/client` 解析为供程序化 Cordis 组合使用的独立 ESM bundle `lib/client.node.js`;浏览器通过逻辑 URL `/plugins/.../client.js` 使用 `lib/client.browser.js` loader factory。两个 bundle 使用相同的注册表实现,且与 Host 包根入口分离。
|
||||
|
||||
### 最小设置
|
||||
|
||||
@@ -45,6 +45,8 @@ kind: "package-reference"
|
||||
|
||||
生成产物在 Loader 组合中通过 [loader](../loader/README.zh.md) 注册;其他所有者直接调用 `ctx.typert.register(contribution)`,并获得撤销它的同一资源释放函数。重复的包与 face 组合键、schema 键、调用 id 或端点会在提交任何内容之前拒绝整个批次。
|
||||
|
||||
Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.register(contribution)` 注册普通 `TYPERT_REMOTE` 贡献。`$mount(contribution)` 已执行该注册,因此在同一个注册表中先注册再挂载同一贡献会因重复而失败。离线元数据所有权应与建立实际连接及挂载的 Context 分离。独立离线消费方也可以直接读取 `TYPERT_REMOTE.methods`,无需任何 Registry。注册表状态归所属 Context 与进程所有,不跨进程共享;注册和查询都不会打开连接。
|
||||
|
||||
### Lookup 与 Context 提供方
|
||||
|
||||
Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
"node": "./lib/client.node.js",
|
||||
"browser": "./lib/client.browser.js",
|
||||
"default": "./lib/client.browser.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
@@ -44,7 +46,8 @@
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/client.browser.js",
|
||||
"lib/client.node.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { dualClient: true })
|
||||
@@ -442,6 +442,17 @@ it('requires the Inspector Worker, Client chunks, and mirrored DevTools resource
|
||||
}
|
||||
})
|
||||
|
||||
it('requires both physical Client entries only for opted-in packages', () => {
|
||||
const dir = 'packages/typert/registry'
|
||||
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
|
||||
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
|
||||
for (const omitted of ['lib/client.node.js', 'lib/client.browser.js']) {
|
||||
const files = manifest.files!.filter(file => file !== omitted)
|
||||
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files } }))
|
||||
.toEqual([expect.stringContaining('package.json files must be')])
|
||||
}
|
||||
})
|
||||
|
||||
it('requires the standalone shortcut protocol and rejects unrelated runtime files', () => {
|
||||
const dir = 'packages/client/shortcuts'
|
||||
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
|
||||
|
||||
@@ -249,6 +249,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
|
||||
const target = exportDefault(manifest, resource)
|
||||
if (target?.startsWith('./') && target.endsWith('.json')) localeFiles.add(target.slice(2))
|
||||
}
|
||||
const browserClient = exportDefault(manifest, './client')
|
||||
const bundle = manifest.dsh?.bundle
|
||||
const bundleFiles = bundle === undefined ? [] : bundlePatchFiles(bundle).map(file => file.replace(/^\.\//, ''))
|
||||
const extras = [
|
||||
@@ -272,11 +273,11 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
|
||||
// Keyed on the artifact path, like ./client below.
|
||||
...exportDefault(manifest, './worker') === './lib/worker.cjs' ? ['lib/worker.cjs'] : [],
|
||||
...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [],
|
||||
// UI plugin packages ship their browser bundle beside the node lib
|
||||
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
|
||||
// Keyed on the artifact path, not the subpath name: a package's ./client is
|
||||
// a browser-safe source channel, not a bundle.
|
||||
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
|
||||
// Only physical browser bundles need a publication entry; a ./client
|
||||
// subpath may instead expose a browser-safe emitted source tree.
|
||||
...browserClient === './lib/client.js' ? ['lib/client.js'] : [],
|
||||
...browserClient === './lib/client.browser.js' ? ['lib/client.browser.js'] : [],
|
||||
...exportCondition(manifest, './client', 'node') === './lib/client.node.js' ? ['lib/client.node.js'] : [],
|
||||
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
|
||||
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
// A store subpath ships its own bundle (single-entry builds; no shared chunk).
|
||||
@@ -331,6 +332,12 @@ function exportDefault(manifest: PackageManifest, subpath: string): string | und
|
||||
return typeof target === 'string' ? target : undefined
|
||||
}
|
||||
|
||||
/** Exact named condition target of an export entry. */
|
||||
function exportCondition(manifest: PackageManifest, subpath: string, condition: string): string | undefined {
|
||||
const target = exportFields(manifest.exports?.[subpath])?.[condition]
|
||||
return typeof target === 'string' ? target : undefined
|
||||
}
|
||||
|
||||
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
|
||||
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
||||
return Object.keys(manifest.exports ?? {}).some(subpath =>
|
||||
|
||||
@@ -278,6 +278,13 @@ describe('client build environment', () => {
|
||||
const chunked = buildFixture(officialEnvironment)
|
||||
write(join(chunked, 'packages/client/example/lib/client.pdf.js'), 'module.exports = {}\n')
|
||||
expect(() => { readClientBuildRecord(chunked) }).toThrow(/artifacts differ/)
|
||||
|
||||
const dual = buildFixture(officialEnvironment)
|
||||
write(join(dual, 'packages/client/example/lib/client.node.js'), 'export const node = true\n')
|
||||
write(join(dual, 'packages/client/example/lib/client.node.helper.js'), 'export const helper = true\n')
|
||||
expect(() => { readClientBuildRecord(dual) }).not.toThrow()
|
||||
write(join(dual, 'packages/client/example/lib/client.browser.js'), 'module.exports = { browser: true }\n')
|
||||
expect(() => { readClientBuildRecord(dual) }).toThrow(/artifacts differ/)
|
||||
})
|
||||
|
||||
it('keeps public client values out of workflow-wide environments', () => {
|
||||
|
||||
@@ -331,7 +331,7 @@ export function readClientBuildRecord(
|
||||
function clientArtifactDigest(root: string): ClientArtifactDigest {
|
||||
const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root })
|
||||
.map(path => path.replaceAll('\\', '/'))
|
||||
.filter(path => statSync(resolve(root, path)).isFile())
|
||||
.filter(path => !/\/lib\/client\.node(?:\.|$)/.test(path) && statSync(resolve(root, path)).isFile())
|
||||
.sort()
|
||||
if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts')
|
||||
|
||||
|
||||
@@ -75,6 +75,29 @@ describe('client bundle build faces', () => {
|
||||
expect(() => staticLinked(root, ['lib/types/index.js'])).toThrow(/declares no string package name/)
|
||||
})
|
||||
|
||||
it('opts shared Clients into independent Node ESM and browser factory entries', () => {
|
||||
for (const dir of ['client/connection', 'api/gateway', 'typert/registry']) {
|
||||
const configs = clientBundle(packageDir(dir), ['lib/types/index.js'], { dualClient: true })(
|
||||
{ env: { DSH_BUILD_FACE: 'client' } },
|
||||
)
|
||||
const node = configs.find(config => config.name?.endsWith('/client-node'))
|
||||
const browser = configs.find(config => config.name?.endsWith('/client'))
|
||||
expect(node?.entry).toEqual({ 'client.node': 'lib/types/client/index.js' })
|
||||
expect(node).toMatchObject({ platform: 'node', format: ['esm'], sourcemap: true })
|
||||
expect(node?.inputOptions).toMatchObject({ resolve: { conditionNames: ['node', 'import', 'module', 'default'] } })
|
||||
expect(node?.outputOptions).toMatchObject({ entryFileNames: 'client.node.js' })
|
||||
expect(browser?.platform).toBe('browser')
|
||||
expect(browser?.outputOptions).toMatchObject({ entryFileNames: 'client.browser.js' })
|
||||
expect(configs.find(config => config.platform === 'node' && config.name?.endsWith('/client'))).toBeUndefined()
|
||||
}
|
||||
const ordinary = clientBundle(REQUESTING_PACKAGE_DIR, ['lib/types/index.js'])(
|
||||
{ env: { DSH_BUILD_FACE: 'client' } },
|
||||
)
|
||||
expect(ordinary.some(config => config.name?.endsWith('/client-node'))).toBe(false)
|
||||
expect(ordinary.find(config => config.platform === 'browser')?.outputOptions)
|
||||
.toMatchObject({ entryFileNames: 'client.js' })
|
||||
})
|
||||
|
||||
it('watches source in development and consumes emitted JavaScript in the Client build', () => {
|
||||
const bundle = clientBundle(REQUESTING_PACKAGE_DIR, ['lib/types/index.js'])
|
||||
const development = bundle({ env: {} }).find(config => config.platform === 'browser')
|
||||
@@ -87,6 +110,34 @@ describe('client bundle build faces', () => {
|
||||
})
|
||||
|
||||
describe('client bundle dynamic imports', () => {
|
||||
it('keeps a dual Client browser chunk disjoint from its Node output', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-dual-client-chunk-'))
|
||||
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
const entry = join(root, 'lib/types/client/index.js')
|
||||
mkdirSync(dirname(entry), { recursive: true })
|
||||
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: REQUESTING_PACKAGE, type: 'module' }))
|
||||
writeFileSync(entry, 'export const load = () => import("./terminal.js")\n')
|
||||
writeFileSync(join(dirname(entry), 'terminal.js'), 'export const marker = "terminal"\n')
|
||||
const configs = clientBundle(root, ['lib/types/index.js'], { dualClient: true })(
|
||||
{ env: { DSH_BUILD_FACE: 'client' } },
|
||||
)
|
||||
for (const platform of ['node', 'browser'] as const) {
|
||||
const config = configs.find(config => config.name?.endsWith(platform === 'node' ? '/client-node' : '/client'))
|
||||
if (config === undefined) throw new Error(`${platform} config missing`)
|
||||
const builds = await build({
|
||||
...config, cwd: root, config: false, tsconfig: false,
|
||||
write: false, clean: false, exports: false, report: false, logLevel: 'silent',
|
||||
})
|
||||
try {
|
||||
const names = builds.flatMap(bundle => bundle.chunks).map(chunk => chunk.fileName)
|
||||
expect(names).toContain(platform === 'node' ? 'client.node.js' : 'client.browser.js')
|
||||
expect(names).toContain(platform === 'node' ? 'client.node.terminal.js' : 'client.browser.terminal.js')
|
||||
} finally {
|
||||
for (const bundle of builds) await bundle[Symbol.asyncDispose]()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('compiles import() to the module loader asynchronous operation', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-client-dynamic-import-'))
|
||||
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import { once } from 'node:events'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Message } from 'publint'
|
||||
import { isBrowserBundleFormatFalsePositive } from './publint-browser-format.ts'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
|
||||
@@ -91,6 +93,20 @@ async function run(root: string, signal: AbortSignal) {
|
||||
return completed
|
||||
}
|
||||
|
||||
describe('publint browser format filtering', () => {
|
||||
it('exempts browser factories but not the sibling Node ESM artifact', () => {
|
||||
const format = (file: string): Message => ({
|
||||
code: 'FILE_INVALID_FORMAT',
|
||||
args: { actualFilePath: `package/lib/${file}` },
|
||||
path: ['./client'],
|
||||
}) as Message
|
||||
expect(isBrowserBundleFormatFalsePositive(format('client.browser.js'))).toBe(true)
|
||||
expect(isBrowserBundleFormatFalsePositive(format('client.js'))).toBe(true)
|
||||
expect(isBrowserBundleFormatFalsePositive(format('client.node.js'))).toBe(false)
|
||||
expect(isBrowserBundleFormatFalsePositive(format('client.node.helper.js'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publint package runner', () => {
|
||||
it('reports deadline cancellation after every owned child closes', async ({ signal }) => {
|
||||
const deadline = new AbortController()
|
||||
|
||||
+1
-16
@@ -12,6 +12,7 @@ import { publint, type Message, type PackFile } from 'publint'
|
||||
import { formatMessage } from 'publint/utils'
|
||||
import ts from 'typescript'
|
||||
import { defaultParallelism } from './parallelism.ts'
|
||||
import { isBrowserBundleFormatFalsePositive } from './publint-browser-format.ts'
|
||||
|
||||
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
@@ -177,22 +178,6 @@ function relativeImports(file: string, sourceText: string): RelativeImport[] {
|
||||
return imports
|
||||
}
|
||||
|
||||
/**
|
||||
* publint reads the CommonJS interop preamble inside the prebuilt browser
|
||||
* bundles and reports CJS-written-as-ESM. Node never resolves those files:
|
||||
* `lib/client.js` is evaluated by the page module system as a classic script,
|
||||
* and `lib/worker.js` by `new Worker(url, { type: 'module' })` — both outside
|
||||
* the Node resolution publint models. Exactly that verdict on exactly those
|
||||
* files is suppressed; every other publint error stays fatal.
|
||||
*/
|
||||
function isBrowserBundleFormatFalsePositive(message: Message): boolean {
|
||||
if (message.code !== 'FILE_INVALID_FORMAT') return false
|
||||
const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
|
||||
const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
|
||||
return /(^|\/)lib\/(client|worker)\.js$/.test(filePath)
|
||||
|| /(^|\/)\.\/(client|worker)$/.test(exportKey)
|
||||
}
|
||||
|
||||
async function runPublint(target: PackageTarget): Promise<PublintResult> {
|
||||
try {
|
||||
const files = publicationFiles(target)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Message } from 'publint'
|
||||
|
||||
/**
|
||||
* Browser Client and Worker artifacts run outside Node's ESM loader, so their
|
||||
* CommonJS-format verdict is false; a Node Client entry must never be exempt.
|
||||
* Other publint diagnostics remain fatal.
|
||||
* @param message - one publint diagnostic for a published file.
|
||||
* @returns true only for the known browser script format verdict.
|
||||
*/
|
||||
export function isBrowserBundleFormatFalsePositive(message: Message): boolean {
|
||||
if (message.code !== 'FILE_INVALID_FORMAT') return false
|
||||
const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
|
||||
const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
|
||||
return /(^|\/)lib\/(?:client(?:\.browser)?|worker)\.js$/.test(filePath)
|
||||
|| filePath === '' && /(^|\/)\.\/worker$/.test(exportKey)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2024", "ESNext.Disposable"],
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user