diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 9ea6ba6dfe..c7e1ed5c68 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -14,7 +14,10 @@ export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, } from '@deepseek-ai/dsh-host-apiproxy/api' -export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' +// transportError moved down to the apiproxy api layer (it belongs beside +// RpcResult, its subject); re-exported here so connection consumers keep one +// contract entry point. +export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api' export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' @@ -31,16 +34,3 @@ import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' export function resultOf(response: RpcResponse): RpcResult { return response.result } - -/** - * Fold a transport exception into the RpcResult error branch (unified error - * surface; 'internal' as the catch-all code). - * @param error - the thrown value from the carrier. - * @returns the error branch of an RpcResult. - */ -export function transportError(error: unknown): RpcResult { - return { - ok: false, - error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }, - } -} diff --git a/packages/client/i18n/package.json b/packages/client/i18n/package.json index 5b5d4e718c..3e617174bf 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/i18n/package.json @@ -27,10 +27,6 @@ "platform": "web", "immediately": true }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^" diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a207fdc0c2..b65935a97d 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -3,7 +3,9 @@ // List data never enters zustand; React connects via subscribe/getListSnapshot. import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' -import { transportError } from '@deepseek-ai/dsh-client-connection/client' +// Value import from the inline-safe wire layer (not the connection plugin): +// plugin-to-plugin value imports are a bundle purity error. +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import { Notifier } from './notifier.ts' diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 93958900a6..b9116971d0 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -168,6 +168,18 @@ export class SessionsService { return this.resolve(id)?.ctx } + /** + * Read the session scope tag off a context. Service-method seam: fetch + * bundles must reach scope resolution through ctx.sessions — a cross-bundle + * value import of the standalone helper would inline a second module + * instance whose private tag Symbol never matches. + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined { + return scopeOf(ctx) + } + /** * Resolve the stable session binding (scope-addressed assembly feed). Pure * resolution — no staging, no window side effects. diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 6251391df0..c394141d85 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -9,7 +9,9 @@ import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' -import { transportError } from '@deepseek-ai/dsh-client-connection/client' +// Value import from the inline-safe wire layer (not the connection plugin): +// plugin-to-plugin value imports are a bundle purity error. +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 94d050765d..56bdd29320 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -1,6 +1,6 @@ /** * Shared tsdown preset for UI plugin client bundles. Emits a closure-factory - * artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, factory}) + * artifact: the bundle calls window.__ModuleLoader__.load({id, factory}) * and resolves externals through the injected require (loader module table — * cordis DI entities, no globals, no import map). CSS Modules are compiled by * lightningcss inside the bundle: importing `x.module.css` yields the @@ -11,6 +11,7 @@ import { readFile } from 'node:fs/promises' import { basename, dirname, resolve as resolvePath } from 'node:path' import type { UserConfig } from 'tsdown' import { transform } from 'lightningcss' +import { PLATFORM_MODULES } from './web/src/platform.ts' /** * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline @@ -28,22 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' */ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ -/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */ -export const CLIENT_EXTERNALS = [ - 'react', - 'react-dom', - 'react/jsx-runtime', - 'cordis', - '@deepseek-ai/dsh-client-ui-slots', - '@deepseek-ai/dsh-client-web-react', - '@deepseek-ai/dsh-client-ui-primitives', - '@deepseek-ai/dsh-client-connection/client', - '@deepseek-ai/dsh-client-runtime/client', - '@deepseek-ai/dsh-client-ui-layout/client', - '@deepseek-ai/dsh-client-ui-conversation/client', - '@deepseek-ai/dsh-client-ui-theme/client', - '@deepseek-ai/dsh-client-i18n/client', -] +/** + * Documented TEMPORARY exemption, not a platform module (hence not in + * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ + * shallowEqual) lives in runtime pending its promotion-time rehoming, and + * five importers (i18n, ui-layout, ui-conversation ×3) ride this single + * exemption. At runtime the lazy CJS table answers the require natively: + * runtime is an immediately-tier row, its factory is registered before any + * dependent bundle materializes. TODO(webload/store-rehome): remove with the + * store-engine relocation follow-up. + */ +const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client' + +/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */ +export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION] /** * Build the tsdown config for one UI plugin package: the node-half lib build @@ -51,8 +50,8 @@ export const CLIENT_EXTERNALS = [ * the root workspace shape, so the lib half must be restated here — dropping * it leaves the package without lib/index.js and the host Loader cannot * import its node half. - * @param id - plugin id (package name), stamped into the loadPlugin handoff - * and onto the injected style tags. + * @param id - plugin id (package name), stamped into the __ModuleLoader__.load + * handoff and onto the injected style tags. * @param libEntry - node-half entries, spelled at the call site so the * package-invariants gate can see `lib/types/invariant.js` in each package's * own tsdown.config.ts (a preset-side glob hides it from the mechanical check). @@ -79,7 +78,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing. dts: false, clean: false, - external: CLIENT_EXTERNALS, + external: [...CLIENT_EXTERNALS], // Browser bundles inline node-idiom deps (zustand/immer read // process.env.NODE_ENV; zustand's esm build also probes // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags @@ -102,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi // opinion for table entries (external above wins), bundle everything else. noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true), plugins: [{ - // Bundle purity gate: a bare-name import of a module-table package would - // slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a - // second copy of that package — duplicate runtime identity (a second - // scope Symbol was tonight's white-screen root cause). Resolve-time is - // the earliest, most precise interception: rewrite bare table names to - // their /client form (the loader registers both specifiers), and reject - // any other @deepseek-ai/* leak that is not an inline-safe wire layer. + // Bundle purity gate (build-time mirror of the module-edge rules): + // platform seed entries stay external, inline-safe wire layers inline, + // and every other @deepseek-ai value import is a build error — a + // cross-plugin value import either inlines a duplicate runtime instance + // or requires a specifier the frozen module table cannot answer. + // Cross-plugin collaboration goes through cordis services instead. name: 'dsh-client-bundle-purity', resolveId(source: string) { if (!source.startsWith('@deepseek-ai/')) return null - if (CLIENT_EXTERNALS.includes(source)) return null // external wins - if (CLIENT_EXTERNALS.includes(`${source}/client`)) { - return { id: `${source}/client`, external: true } - } + if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point throw new Error( - `client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — ` - + 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance', + `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — ` + + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)', ) }, }, { @@ -158,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi }], outputOptions: { entryFileNames: 'client.js', - banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`, + banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`, footer: `return module.exports; } });`, intro: 'var module = { exports: {} }; var exports = module.exports;', }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 41256612d9..ed43e3988d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -24,6 +24,8 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-i18n", + "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" ], "platform": "web" @@ -34,21 +36,27 @@ }, "license": "BSD-3-Clause", "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-i18n": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-i18n": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "clsx": "^2.0.0", - "react": "^18.2.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 94ebd59628..0c6b9632bb 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -15,14 +15,10 @@ */ import { Service } from 'cordis' import type { Context } from 'cordis' -// Value import MUST use the /client subpath: only that specifier is in the -// bundle externals (CLIENT_EXTERNALS), so it resolves to the shared runtime -// module at load time. A bare-specifier value import gets INLINED as a second -// module instance whose private scope-tag Symbol never matches the one -// SessionsService tags contexts with — scopeOf then always returns undefined -// in the browser while unit tests (single-instance path resolution) stay green. -import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only imports: a plugin-to-plugin value import is a bundle purity +// error, so scope resolution goes through the sessions service (scopeOf +// method) instead of the standalone helper. +import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service { @@ -83,11 +79,17 @@ export class ConversationService extends Service { /** Resolve the caller scope's Session or throw on root contexts. */ private scopedSession(op: string): Session { - const id = scopeOf(this.ctx) + const id = this.scopeId(op) + return this.requireSessions().manager.get(id) + } + + /** Read the caller's session scope tag via the sessions service; root contexts fail loud. */ + private scopeId(op: string): SessionId { + const id = this.requireSessions().scopeOf(this.ctx) if (id === undefined) { throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`) } - return this.requireSessions().manager.get(id) + return id } private requireSessions(): SessionsService { diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 109646dd9b..3043c71ed5 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -76,6 +76,7 @@ async function bench() { manager: { get: () => sessionFake }, scope: (id: SessionId) => mint(id), cell: () => undefined, + scopeOf, create: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), } diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index afd7c08807..2d3be81a4d 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -67,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) { create: createMock, open: openMock, scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)), + scopeOf, } as unknown as SessionsService if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake) // Class-plugin mount — the same form apply.ts uses in production. diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3448e62f5c..fd3e691375 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -33,19 +33,20 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index b188841b43..5f7b4d0f23 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" ], "platform": "web" @@ -34,21 +35,25 @@ }, "license": "BSD-3-Clause", "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "clsx": "^2.0.0", - "react": "^18.2.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index fbad2194c3..4e601914e3 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -28,10 +28,6 @@ "platform": "web", "immediately": true }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index d6f93e8156..28f6620f2c 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -33,19 +33,19 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "react": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index b0f3f39eb3..4b317d9d84 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** * Real tsdown artifact shape: lib/client.js hands off through - * window.DSHClientProxy.loadPlugin, resolves externals through the injected + * window.__ModuleLoader__.load, resolves externals through the injected * require, returns the export surface (apply + inject), and a mounted apply * registers both view tabs into a real SlotsService ring. Skips when dist/ is * not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`). @@ -15,7 +15,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory' interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record } -type Win = { DSHClientProxy?: { loadPlugin(h: Handoff): void } } +type Win = { __ModuleLoader__?: { load(h: Handoff): void } } function readBundle(): string | undefined { try { @@ -28,7 +28,7 @@ function readBundle(): string | undefined { } afterEach(() => { - delete (window as Win).DSHClientProxy + delete (window as Win).__ModuleLoader__ for (const el of document.querySelectorAll('style')) el.remove() }) @@ -37,7 +37,7 @@ describe('tsdown client artifact', () => { async function loadArtifact() { let handoff: Handoff | undefined - ;(window as Win).DSHClientProxy = { loadPlugin: (h) => { handoff = h } } + ;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } } // Same execution form the loader uses (inline script eval, window scope) — // the implied-eval ban targets accidental string execution, not this // deliberate bundle-execution fixture. diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c51c785a7c..c2fbb0d189 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -39,7 +39,7 @@ export type { } from './rpc.ts' // ---- Errors and ids ---- -export { RpcId } from './rpc.ts' +export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' // ---- Method registry and derived generics ---- diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 4503d1fb6d..53b2fc43e8 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -50,6 +50,20 @@ export type RpcError = { /** Business success/failure result: the result slot of a unary response; methods never throw business errors. */ export type RpcResult = { ok: true; value: T } | { ok: false; error: RpcError } +/** + * Fold a transport exception into the RpcResult error branch (unified error + * surface; 'internal' as the catch-all code). Lives with RpcResult so every + * carrier consumer folds the same way. + * @param error - the thrown value from the carrier. + * @returns the error branch of an RpcResult. + */ +export function transportError(error: unknown): RpcResult { + return { + ok: false, + error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }, + } +} + /** * Signature-layer narrow form, request side (domain-interface view, shared by * both directions): rpcId is explicit in the signature, never mixed into the diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 8f04e2f044..379346134a 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -1,9 +1,10 @@ /** - * Pins the client-bundle purity gate (tsdown preset resolveId classifier): - * a bare-name import of a module-table package must rewrite to its /client - * external form (inlining it duplicates runtime identity — the P0 -/* leak that is not an - * inline-safe wire layer must fail the build loudly. + * Pins the client-bundle purity gate (tsdown preset resolveId classifier), + * the build-time mirror of the module-edge rules: platform module-table + * entries stay external, inline-safe wire layers inline, and every other + * @deepseek-ai value import — including a bare plugin-package name and a + * cross-plugin /client subpath — must fail the build loudly (cross-plugin + * collaboration goes through cordis services, never module imports). */ import { describe, expect, it } from 'vitest' import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts' @@ -23,22 +24,16 @@ function purityResolveId(): ResolveId { describe('client bundle purity gate', () => { const resolveId = purityResolveId() - it('leaves table entries and non-scoped specifiers alone', () => { + it('leaves platform table entries and non-scoped specifiers alone', () => { expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull() - expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-client-web-react')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull() expect(resolveId('react')).toBeNull() expect(resolveId('zod')).toBeNull() }) - it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => { - expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({ - id: '@deepseek-ai/dsh-client-connection/client', - external: true, - }) - expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({ - id: '@deepseek-ai/dsh-client-ui-layout/client', - external: true, - }) + it('rejects retired table entries (web-react/store left the 8-entry seed)', () => { + expect(() => resolveId('@deepseek-ai/dsh-client-web-react/store')).toThrow(/purity/) }) it('lets inline-safe wire layers inline', () => { @@ -52,9 +47,16 @@ describe('client bundle purity gate', () => { expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/) }) - it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => { - for (const entry of CLIENT_EXTERNALS) { - if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length)) - } + it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => { + expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/) + }) + + it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => { + expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() + const dshClientChannels = CLIENT_EXTERNALS.filter( + entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client')) + expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client']) }) })