diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 9734685e70..cb5c070067 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -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 } } diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index ff7daaca09..56a1c8ba00 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -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) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 888519a890..574696c9fb 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -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 `/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 | 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//client.js.map. The // browser resolves its local sources back into URLs that mirror the diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 51b80ad117..0f49341a19 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -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 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 19d3f32ecf..fc6aebaa52 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## 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. diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 2c34b5df8a..c558d14460 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在存储或消费生成 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 反向投影。 diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index a5f4e98079..de224aadef 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -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" ], diff --git a/packages/typert/registry/tsdown.config.ts b/packages/typert/registry/tsdown.config.ts index 71d5bd77f8..c39ddc7e93 100644 --- a/packages/typert/registry/tsdown.config.ts +++ b/packages/typert/registry/tsdown.config.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 }) diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 434efaca09..a6ec80f95d 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -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'] diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e4a038cf1b..26cb8ae8ce 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -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 => diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index 4d5e62c8e1..3a3f54a6b0 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -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', () => { diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts index 57f12ef13b..2cf28aa8d4 100644 --- a/scripts/client-build-environment.ts +++ b/scripts/client-build-environment.ts @@ -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') diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 4ffd130b0a..409047ff5f 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -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 }) }) diff --git a/scripts/publint-all.spec.ts b/scripts/publint-all.spec.ts index 6eee225f3b..aea7be74bb 100644 --- a/scripts/publint-all.spec.ts +++ b/scripts/publint-all.spec.ts @@ -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() diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 17a7380fb8..8b52f1f8c1 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -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 { try { const files = publicationFiles(target) diff --git a/scripts/publint-browser-format.ts b/scripts/publint-browser-format.ts new file mode 100644 index 0000000000..0a785b8aaa --- /dev/null +++ b/scripts/publint-browser-format.ts @@ -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) +} diff --git a/tsconfig.node.base.json b/tsconfig.node.base.json new file mode 100644 index 0000000000..08b58789e7 --- /dev/null +++ b/tsconfig.node.base.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "ESNext.Disposable"], + "types": ["node"] + } +}