From 3cad7f69578d98c74a7a6eae9dd394df388758b5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:18:12 +0800 Subject: [PATCH 01/22] refactor(web): upgrade apiproxy to the api-gateway service plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createApiProxy moves from dsh-host-runtime into dsh-host-apiproxy (the dependency direction already pointed this way); the package now default-exports ApiProxyService (config {provider, model}, provides ctx.apiProxy) while staying transport-agnostic — it registers no routes. Runtime keeps bootHost/startHost for the headless path with its import re-anchored, and drops the mountWebPlugins roster mounting helper. --- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/package.json | 6 +- .../{runtime => apiproxy}/src/api-proxy.ts | 16 +-- packages/host/apiproxy/src/index.ts | 65 +++++++++- packages/host/apiproxy/src/invariant.ts | 11 +- packages/host/apiproxy/tsconfig.json | 15 +++ packages/host/runtime/README.md | 2 +- packages/host/runtime/package.json | 1 - packages/host/runtime/src/index.ts | 9 +- packages/host/runtime/src/start.ts | 3 +- packages/host/runtime/src/web-plugins.ts | 57 --------- .../host/runtime/tests/api-proxy-cold.spec.ts | 2 +- .../host/runtime/tests/api-proxy-view.spec.ts | 2 +- .../host/runtime/tests/web-plugins.spec.ts | 111 ------------------ 14 files changed, 104 insertions(+), 200 deletions(-) rename packages/host/{runtime => apiproxy}/src/api-proxy.ts (97%) delete mode 100644 packages/host/runtime/src/web-plugins.ts delete mode 100644 packages/host/runtime/tests/web-plugins.spec.ts diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1badfe65e8..c9bdd73a64 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-apiproxy -The ApiProxy front layer every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser) and the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side). Host assembly lives in `dsh-host-runtime`. +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The core spine composition lives in `dsh-host-runtime`. ## Contract layer (`/api`) @@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there. +- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index fe50c16a60..471cccc96d 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-host-apiproxy", - "description": "ApiProxy front layer: the TS contract (api/) and the fetch carrier pair (fetch/); host assembly lives in dsh-host-runtime", + "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", "version": "0.0.1", "private": true, "type": "module", @@ -40,12 +40,16 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "schemastery": "^3.18.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts similarity index 97% rename from packages/host/runtime/src/api-proxy.ts rename to packages/host/apiproxy/src/api-proxy.ts index acf77751af..a5ae45f1af 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,12 +11,14 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). +import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema' -import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +} from './api/index.ts' +import { questionResponsePayloadSchema } from './api/questions.schema.ts' +import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' +import { RpcId } from './api/rpc.ts' import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' @@ -170,7 +172,7 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade } } -/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */ +/** Host-level default agent routing (same shape as dsh-host-runtime's HostDefaults, kept structural to avoid a reverse dependency). */ export interface ApiProxyDefaults { provider: string model: string @@ -272,8 +274,8 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: class SessionNotFound extends Error {} /** - * Implement ApiProxy over the ctx composed by bootHost. - * @param ctx - the root context returned by bootHost (sessions/agents services mounted). + * Implement ApiProxy over a composed host context. + * @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services). * @param defaults - host-level default provider/model: injected as * agentOptions on create/resume, reported by describe from the same source. * @returns the ApiProxy implementation. diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 2999e48b24..9eb862962b 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -1,13 +1,68 @@ /** - * @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares: - * the ApiProxy contract (api/: types + zod schemas, browser-safe) and the - * fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient + - * platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost) - * lives in @deepseek-ai/dsh-host-runtime. + * @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares: + * the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch + * carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient + + * platform subclasses on the client side), and the host-side implementation + * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing + * `ctx.apiProxy`). Transport-agnostic by design: this package registers no + * routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. */ +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { ApiProxy } from './api/index.ts' +import { createApiProxy } from './api-proxy.ts' + export type * from './api/index.ts' export { RpcId } from './api/rpc.ts' export { toFetchHandler } from './fetch/handler.ts' export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts' export type { IApiClient } from './fetch/client.ts' +export { createApiProxy } from './api-proxy.ts' +export type { ApiProxyDefaults } from './api-proxy.ts' + +declare module 'cordis' { + interface Context { + /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */ + apiProxy: ApiProxy + } +} + +/** Gateway plugin config: the host-level default agent routing. */ +export interface Config { + /** Default provider route for created/resumed agents. */ + provider: string + /** Default model id. */ + model: string +} + +/** + * The API gateway service: implements the ApiProxy contract over the composed + * host context and provides it as `ctx.apiProxy`. The default project + * directory for new sessions is the host process working directory (not a + * config field this round). + */ +export class ApiProxyService extends Service implements ApiProxy { + static inject = ['agents', 'sessions', 'tools', 'userInteraction'] + + static Config: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + }) + + readonly sessions: ApiProxy['sessions'] + readonly host: ApiProxy['host'] + readonly events: ApiProxy['events'] + readonly respond: ApiProxy['respond'] + + constructor(ctx: Context, config: Config) { + super(ctx, 'apiProxy') + const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() }) + this.sessions = api.sessions + this.host = api.host + this.events = api.events + this.respond = api.respond + } +} + +export default ApiProxyService diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts index 068cbcaa72..a96b5d081d 100644 --- a/packages/host/apiproxy/src/invariant.ts +++ b/packages/host/apiproxy/src/invariant.ts @@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant' export const inject = ['invariants'] /** - * No runtime invariant: this package is the wire contract layer (types, - * schemas, fetch carrier glue) — it emits no cordis events and owns no - * mutable cross-plugin relation. rpcId round-trip and schema acceptance are - * enforced at the carrier boundary and exercised by the protocol-isomorphism - * suite; the live implementation relations belong to dsh-host-runtime. + * No runtime invariant: this package is the wire contract layer plus the + * host-side gateway over services owned elsewhere — it emits no cordis events + * of its own; the session/agent event streams it projects are asserted by + * their owning packages' companions. rpcId round-trip and schema acceptance + * are enforced at the carrier boundary and exercised by the + * protocol-isomorphism suite. */ const install: InvariantInstaller = () => {} diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 718c5a9042..4e22627590 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -8,18 +8,33 @@ "src" ], "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../util/brand" }, { "path": "../../llm/llm" }, + { + "path": "../../core/agent" + }, { "path": "../../core/session" }, { "path": "../../core/tools" }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../session-title/session-title" + }, { "path": "../../ui/user-approval" }, diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..7b91fdcf84 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition). Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..f8067b6482 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -39,7 +39,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", diff --git a/packages/host/runtime/src/index.ts b/packages/host/runtime/src/index.ts index af10f0be16..03780817a9 100644 --- a/packages/host/runtime/src/index.ts +++ b/packages/host/runtime/src/index.ts @@ -1,14 +1,11 @@ /** * @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine - * composition (bootHost), the ApiProxy implementation (createApiProxy), and - * the one-step shell seam (startHost). Host-level configuration (defaults, - * persistenceRoot, future user profile) lives here. + * composition (bootHost) and the one-step shell seam (startHost). The ApiProxy + * implementation lives in @deepseek-ai/dsh-host-apiproxy. Host-level + * configuration (defaults, persistenceRoot, future user profile) lives here. */ export { bootHost } from './boot.ts' export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts' -export { createApiProxy } from './api-proxy.ts' -export type { ApiProxyDefaults } from './api-proxy.ts' export { startHost } from './start.ts' export type { StartHostOptions, RunningHost } from './start.ts' -export { mountWebPlugins } from './web-plugins.ts' diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts index 94e5f22da1..dc5d26d859 100644 --- a/packages/host/runtime/src/start.ts +++ b/packages/host/runtime/src/start.ts @@ -8,10 +8,9 @@ import type { Context } from 'cordis' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' -import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { bootHost } from './boot.ts' import type { BootHostOptions, HostDefaults } from './boot.ts' -import { createApiProxy } from './api-proxy.ts' /** Options for startHost. */ export interface StartHostOptions { diff --git a/packages/host/runtime/src/web-plugins.ts b/packages/host/runtime/src/web-plugins.ts deleted file mode 100644 index 5866d46492..0000000000 --- a/packages/host/runtime/src/web-plugins.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory - * entry tree over the caller-supplied client plugin roster. The roster is a - * composition decision and lives in the composing app (apps/cli); this module - * only owns the mount/settle/fail-loud mechanics. The web plugin registry - * discovers fetch-arrival entries among the mounted packages by their - * package.json dshClient declarations; node halves are empty applies, so - * mounting them here costs nothing beyond Loader governance. - */ -import { createRequire } from 'node:module' -import type { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' - -/** What the shell hands the web plugin registry (loader view + module resolution seam). */ -export interface MountedWebPlugins { - /** Entry enumeration surface of the mounted Loader (registry scan source). */ - loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> } - /** Resolve a plugin package's package.json absolute path. */ - resolvePkgJson: (name: string) => string -} - -/** - * Mount the Loader (when absent) and create one in-memory entry per client - * plugin package, then wait for the tree to settle. A plugin whose import - * fails leaves its entry fiber-less — surfaced here as a loud throw listing - * the failures (misconfiguration must not silently drop a client plugin). - * @param ctx - host root context (bootHost product). - * @param plugins - client plugin package names to mount (the composition layer's roster). - * @param anchor - module URL anchoring bare-specifier resolution (the composing - * app's import.meta.url; the roster packages must be dependencies of that app). - * @returns the loader view and package.json resolver the registry consumes. - */ -export async function mountWebPlugins( - ctx: Context, plugins: readonly string[], anchor: string, -): Promise { - // The Loader resolves bare specifiers against ctx.baseUrl; without one the - // import silently fails and every entry stays fiber-less. The composing app - // declares the roster packages as dependencies, so its URL is the right anchor. - ctx.baseUrl ??= anchor - if (ctx.get('loader') === undefined) await ctx.plugin(Loader) - const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name)) - for (const name of plugins) { - if (!existing.has(name)) await ctx.loader.create({ name }) - } - await ctx.loader.await() - const dead = [...ctx.loader.entries()] - .filter(entry => plugins.includes(entry.options.name)) - .filter(entry => entry.fiber === undefined && !entry.disabled) - if (dead.length > 0) { - throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`) - } - const require = createRequire(anchor) - return { - loader: ctx.loader, - resolvePkgJson: name => require.resolve(`${name}/package.json`), - } -} diff --git a/packages/host/runtime/tests/api-proxy-cold.spec.ts b/packages/host/runtime/tests/api-proxy-cold.spec.ts index 3d4ba8e15a..a3e2bf4e7a 100644 --- a/packages/host/runtime/tests/api-proxy-cold.spec.ts +++ b/packages/host/runtime/tests/api-proxy-cold.spec.ts @@ -16,7 +16,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId diff --git a/packages/host/runtime/tests/api-proxy-view.spec.ts b/packages/host/runtime/tests/api-proxy-view.spec.ts index a7dcdc73c5..596bf25ac8 100644 --- a/packages/host/runtime/tests/api-proxy-view.spec.ts +++ b/packages/host/runtime/tests/api-proxy-view.spec.ts @@ -21,7 +21,7 @@ import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' const reply = (text: string): Promise => Promise.resolve([{ type: 'text', text }]) diff --git a/packages/host/runtime/tests/web-plugins.spec.ts b/packages/host/runtime/tests/web-plugins.spec.ts deleted file mode 100644 index b558c253c2..0000000000 --- a/packages/host/runtime/tests/web-plugins.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * mountWebPlugins unit coverage (keyless). The Loader-facing behavior — - * baseUrl anchoring, entry creation with idempotent reuse, the fiber-less - * fail-loud sweep, and the resolver seam — is exercised against a stubbed - * loader service so it runs without built lib/ artifacts. The roster is - * caller-supplied now (composition moved to apps/cli), so these tests pass - * their own lists. - */ -import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' -import { mountWebPlugins } from '../src/web-plugins.ts' - -const ROSTER = [ - '@deepseek-ai/dsh-plugin-a', - '@deepseek-ai/dsh-plugin-b', - '@deepseek-ai/dsh-plugin-c', -] as const - -interface FakeEntry { - options: { name: string } - fiber?: unknown - disabled: boolean -} - -/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */ -class FakeLoader { - readonly created: string[] = [] - awaited = 0 - constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {} - entries(): Iterable { - return this.entriesList - } - async create(options: { name: string }): Promise { - this.created.push(options.name) - this.onCreate?.(options.name) - } - async await(): Promise { - this.awaited += 1 - } -} - -let root: Context | undefined - -afterEach(async () => { - await root?.fiber.dispose() - root = undefined -}) - -function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } { - root = new Context() - const loader = new FakeLoader(entriesList, onCreate) - root.reflect.provide('loader', loader) - return { ctx: root, loader } -} - -describe('mountWebPlugins (stubbed loader)', () => { - it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => { - const entriesList: FakeEntry[] = [] - const { ctx, loader } = withLoader(entriesList, (name) => { - entriesList.push({ options: { name }, fiber: {}, disabled: false }) - }) - const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url) - expect(loader.created).toEqual([...ROSTER]) - expect(loader.awaited).toBe(1) - expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER]) - // The resolver resolves a real package manifest through real module resolution, anchored at this test file. - expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/) - expect(ctx.baseUrl).toBeDefined() - }) - - it('reuses existing entries (idempotent mount creates no duplicates)', async () => { - const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false })) - const { ctx, loader } = withLoader(preexisting) - await mountWebPlugins(ctx, ROSTER, import.meta.url) - expect(loader.created).toEqual([]) - }) - - it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => { - const entriesList: FakeEntry[] = [] - const { ctx } = withLoader(entriesList, (name) => { - // First one loads; the rest stay fiber-less (import failed silently). - entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false }) - }) - await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)) - .rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/) - }) - - it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => { - const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true })) - const { ctx } = withLoader(entriesList) - await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined() - }) - - it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => { - root = new Context() - // An empty roster keeps this keyless and artifact-free: the branch under - // test is only the Loader auto-mount. - await mountWebPlugins(root, [], import.meta.url) - expect(root.get('loader') !== undefined).toBe(true) - }, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default - - it('keeps a caller-set baseUrl (anchors only when absent)', async () => { - const entriesList: FakeEntry[] = [] - const { ctx } = withLoader(entriesList, (name) => { - entriesList.push({ options: { name }, fiber: {}, disabled: false }) - }) - ctx.baseUrl = 'file:///caller/anchor/' - await mountWebPlugins(ctx, ROSTER, import.meta.url) - expect(ctx.baseUrl).toBe('file:///caller/anchor/') - }) -}) From f499872cecd3720df2e9df079cf563f5cf6b5f9a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:18:29 +0800 Subject: [PATCH 02/22] refactor(web): rewrite webserver as a plain route-registration plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpServerService provides ctx.httpServer: register(route) -> disposer (duplicate patterns throw), tapIndex transforms in registration order, and the bound port; matching is exact > longest prefix > static dist fallback (403/405/SPA semantics preserved). The server listens on activation, answers per-request failures with 400 + a log line instead of exiting the process, and knows no harness concepts — the boot graph, bundle routes, SSE channel, and /api prefix all moved to their owning plugins. --- packages/host/webserver/README.md | 14 +- packages/host/webserver/package.json | 5 +- packages/host/webserver/src/index.ts | 370 +++++++--------- packages/host/webserver/src/invariant.ts | 38 +- packages/host/webserver/src/plugin-events.ts | 56 --- packages/host/webserver/src/web-plugins.ts | 360 ---------------- .../host/webserver/tests/invariant.spec.ts | 50 --- .../host/webserver/tests/web-plugins.spec.ts | 347 --------------- .../host/webserver/tests/webserver.spec.ts | 400 ------------------ packages/host/webserver/tsconfig.json | 3 + 10 files changed, 194 insertions(+), 1449 deletions(-) delete mode 100644 packages/host/webserver/src/plugin-events.ts delete mode 100644 packages/host/webserver/src/web-plugins.ts delete mode 100644 packages/host/webserver/tests/invariant.spec.ts delete mode 100644 packages/host/webserver/tests/web-plugins.spec.ts delete mode 100644 packages/host/webserver/tests/webserver.spec.ts diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index baa57dbfe1..f00984ea90 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -1,18 +1,16 @@ # @deepseek-ai/dsh-host-webserver -Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. +Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. -The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own. - -A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection. +A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. ## Model Experience -None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request. +None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request. #### KV Cache effect @@ -20,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. +- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. - **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. -- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. +- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 01d8a22e9d..0dab038f41 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-host-webserver", - "description": "Web-shape HTTP carrier: static file serving plus the /api/* bridge to an injected fetch-shaped handler (SSE streamed through)", + "description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts", "version": "0.0.1", "private": true, "type": "module", @@ -30,6 +30,9 @@ "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "^0.0.1" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 60ad92d18c..936dd4f5a1 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,232 +1,184 @@ /** - * @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server - * routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG - * bridge with SSE streamed out chunk by chunk) and everything else to static - * file serving. Web (browser) shape only — Electron loads dist over file:// - * and carries fetch over an IPC bridge, not this server. This package never - * prints: the URL line belongs to the shell. + * @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a + * node:http server plus the `httpServer` service (named-route registry + index + * transform taps + static dist fallback). Knows no harness concepts — every + * feature surface (API bridge, plugin bundles, SSE) is a route some other + * plugin registers. Web (browser) shape only — Electron loads dist over + * file:// and carries fetch over an IPC bridge, not this server. This package + * never prints: the URL line belongs to the shell. */ import { createServer } from 'node:http' -import type { IncomingMessage, ServerResponse } from 'node:http' +import type { IncomingMessage, ServerResponse, Server } from 'node:http' import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' import { dirname } from 'node:path' +import { Context, Service } from 'cordis' +import z from 'schemastery' import { serveStatic } from './static.ts' -import { createPluginEventChannel } from './plugin-events.ts' -import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts' -export { createHostWebPluginRegistry } from './web-plugins.ts' -export type { - HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps, -} from './web-plugins.ts' -export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts' - -/** Options for startWebServer. */ -export interface WebServerOptions { - /** Address or hostname to listen on. */ - host: string - /** Port to listen on; zero requests an OS-assigned port. */ - port: number - /** - * Absolute path of index.html inside the static root — the caller resolves - * it (dist location is workspace knowledge of the shell, not this package's). - */ - distIndex: string - /** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */ - apiHandler: { fetch: typeof fetch } - /** - * Web plugin table. When present, every index.html response carries the - * `window.__DSH_BOOT__` entry graph script, `/plugins//client.js` serves - * each fetch entry's client bundle, and `GET /plugins/events` streams graph/ - * rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch - * notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only - * use). - */ - webPlugins?: Pick +declare module 'cordis' { + interface Context { + httpServer: HttpServerService + } } -/** Listening web server handle. */ -export interface RunningWebServer { - /** The listening port, including the OS-assigned value when options.port is zero. */ +/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/. */ +export type WebRouteKind = 'exact' | 'prefix' + +/** One named route registration. */ +export interface WebRoute { + kind: WebRouteKind + /** Absolute pathname, no trailing slash. */ + path: string + /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */ + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise +} + +/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +export interface Config { + /** Listen host; the two supported values are loopback and all-interfaces. */ + host: '127.0.0.1' | '0.0.0.0' + /** Listen port; zero requests an OS-assigned port. */ port: number - /** - * Shutdown: close + closeAllConnections (SSE connections never end on their - * own; without the force-close, close() would hang). Idempotent. - */ - close(): Promise + /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ + distIndex: string } /** - * Start the web-shape HTTP server on the caller-selected host and port. - * Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else → - * static with the step1-locked semantics (403 traversal, SPA fallback 200). - * A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a - * server error after listen goes to onError. A request whose handling throws - * (malformed %-escapes, a client dropping mid-body) is answered 400 — or the - * socket destroyed when headers are already out — and reported to onError; - * it never becomes an unhandled rejection. - * @param options - port, static root anchor, and the API carrier. - * @param onError - sink for post-listen server errors and per-request handling failures. - * @returns the running server handle once listening. + * The web-shape HTTP carrier service. Activation listens immediately (route + * registration order carries no request-facing semantics: named routes are + * composed to be disjoint, and the static dist fallback answers anything not + * yet claimed during the boot window). A listen failure throws out of init — + * a FAILED fiber the boot's fail-loud sweep reports. */ -export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise { - const { host, port, distIndex, apiHandler, webPlugins } = options - const distRoot = dirname(distIndex) - const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { - const html = await readFile(distIndex, 'utf8') - return injectBootManifest(html, webPlugins.graph()) - } - const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel() - // Rebuilt frames come from the registry's own bundle watch (dev mode); a - // prod registry without watching simply never notifies. - const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined - ? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) }) - : undefined +export class HttpServerService extends Service { + static Config: z = z.object({ + host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(), + port: z.natural().max(65535).required(), + distIndex: z.string().required(), + }) - const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server - requests; the field is only optional on the client-side IncomingMessage type */ - const rawPath = new URL(req.url ?? '/', 'http://x').pathname - if (rawPath.startsWith('/api/')) { - await bridge(req, res, apiHandler) - return - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) - res.end() - return - } - if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') { - pluginEvents.connect(res, webPlugins.graph()) - return - } - if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) { - await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins) - return - } - await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex) + private readonly exact = new Map() + private readonly prefixes = new Map() + private readonly indexTaps: ((html: string) => string)[] = [] + private readonly distRoot: string + private readonly distIndex: string + private server!: Server + private listenedPort!: number + + constructor(ctx: Context, private config: Config) { + super(ctx, 'httpServer') + this.distIndex = config.distIndex + this.distRoot = dirname(config.distIndex) } - // Last-resort guard: handle() rejecting would otherwise be an unhandled - // rejection, and one malformed request (a bad %-escape hitting - // decodeURIComponent, a client dropping mid-body) would kill the whole - // process. Nothing after this catch can throw again on the same response. - const server = createServer((req, res) => { - handle(req, res).catch((err: unknown) => { - onError(err instanceof Error ? err : new Error(String(err))) - if (res.headersSent) { - res.destroy() + + /** The listening port (the OS-assigned value when config.port is 0). */ + get port(): number { + return this.listenedPort + } + + /** + * Register a named route. Duplicate (kind, path) throws — route patterns are + * a composition-level contract, so a collision is a misconfiguration. + * @param route - kind, path, and the owning handler. + * @returns the disposer removing the route. + */ + register(route: WebRoute): () => void { + const table = route.kind === 'exact' ? this.exact : this.prefixes + if (table.has(route.path)) { + throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`) + } + table.set(route.path, route) + return () => { table.delete(route.path) } + } + + /** + * Register an index.html transform, applied to every index response in + * registration order. + * @param transform - pure html-to-html function. + * @returns the disposer removing the transform. + */ + tapIndex(transform: (html: string) => string): () => void { + this.indexTaps.push(transform) + return () => { + const at = this.indexTaps.indexOf(transform) + if (at !== -1) this.indexTaps.splice(at, 1) + } + } + + /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */ + async [Service.init](): Promise { + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server + requests; the field is only optional on the client-side IncomingMessage type */ + const rawPath = new URL(req.url ?? '/', 'http://x').pathname + const route = this.match(rawPath) + if (route !== undefined) { + await route.handler(req, res) return } - res.writeHead(400) - res.end() - }) - }) - - let closing: Promise | undefined - const close = (): Promise => (closing ??= new Promise((resolveClose) => { - unsubscribeRebuilt?.() - server.close(() => { resolveClose() }) - server.closeAllConnections() - })) - - return new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen) - server.listen(port, host, () => { - server.off('error', rejectListen) - server.on('error', onError) - resolveListen({ port: (server.address() as AddressInfo).port, close }) - }) - }) -} - -/** - * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the - * first script in (before the shell bundle reads it). `<` is escaped in - * the JSON so plugin-controlled strings cannot break out of the script element. - * @param html - the index.html source. - * @param graph - the composed entry graph from the registry. - * @returns the html with the graph script injected. - */ -export function injectBootManifest(html: string, graph: WebBootGraph): string { - const json = JSON.stringify(graph).replaceAll('<', '\\u003c') - const script = `` - const head = html.indexOf('') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - // Headless fixture pages may lack ; prepending keeps the read-before-shell ordering. - return `${script}${html}` -} - -/** - * Serve one plugin client bundle from the registry table (unknown id = 404; - * the id may contain a scope slash). The `?rev=` query is a cache-busting - * parameter only — serving ignores it; `no-cache` makes the browser revalidate - * so a stale rev never sticks. - */ -async function servePluginBundle( - pathname: string, res: ServerResponse, webPlugins: Pick, -): Promise { - const id = pathname.slice('/plugins/'.length, -'/client.js'.length) - const path = webPlugins.clientPath(id) - if (path === undefined) { - res.writeHead(404) - res.end() - return - } - try { - const body = await readFile(path) - res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }) - res.end(body) - } catch { - // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page. - res.writeHead(404) - res.end() - } -} - -/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */ -async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { - const abort = new AbortController() - // Client-disconnect detection MUST hang off the response, not the request: - // since Node 16, IncomingMessage 'close' fires as soon as the request body is - // fully consumed (immediately for a bodyless GET), which would abort every SSE - // stream right after open. ServerResponse 'close' fires on connection teardown; - // writableEnded distinguishes a normal end() from the client going away. - res.on('close', () => { - if (!res.writableEnded) abort.abort() - }) - const chunks: Buffer[] = [] - for await (const chunk of req) chunks.push(chunk as Buffer) - /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server - requests; the fields are only optional on the client-side IncomingMessage type */ - const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { - method: req.method ?? 'GET', - headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), - ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, - signal: abort.signal, - }) - const response = await apiHandler.fetch(request) - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) - if (response.body === null) { - res.end() - return - } - for await (const chunk of response.body) { - // Backpressure: a false return means the socket buffer is full — wait for drain - // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also - // resolves so a mid-wait disconnect can't park this loop forever; the close - // handler above aborts the handler stream, which then ends the iteration. - if (!res.write(chunk)) { - await new Promise((resolve) => { - const done = (): void => { - res.off('drain', done) - res.off('close', done) - resolve() - } - res.once('drain', done) - res.once('close', done) - }) + // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405, + // traversal 403, miss falls back to index.html 200 (SPA routing). + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex()) } + // Last-resort guard: handle() rejecting would otherwise be an unhandled + // rejection killing the process on one malformed request (bad %-escape, + // client dropping mid-body). Per-request failures log and answer 400 — + // never a process exit. + this.server = createServer((req, res) => { + handle(req, res).catch((err: unknown) => { + this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err))) + if (res.headersSent) { + res.destroy() + return + } + res.writeHead(400) + res.end() + }) + }) + + await new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(this.config.port, this.config.host, () => { + this.server.off('error', reject) + this.server.on('error', (err) => { this.ctx.logger.error(err) }) + this.listenedPort = (this.server.address() as AddressInfo).port + resolve() + }) + }) + + // close + closeAllConnections: held-open responses (SSE) never end on + // their own; without the force-close, close() would hang teardown. + this.ctx.effect(() => () => new Promise((resolve) => { + this.server.close(() => { resolve() }) + this.server.closeAllConnections() + }), 'httpServer.listen') + } + + /** Longest-prefix-wins over the prefix table after an exact-table miss. */ + private match(pathname: string): WebRoute | undefined { + const exact = this.exact.get(pathname) + if (exact !== undefined) return exact + let best: WebRoute | undefined + for (const [prefix, route] of this.prefixes) { + if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue + if (best === undefined || prefix.length > best.path.length) best = route + } + return best + } + + /** Index body: dist index.html through the registered taps in order. */ + private async renderIndex(): Promise { + let html = await readFile(this.distIndex, 'utf8') + for (const transform of this.indexTaps) html = transform(html) + return html } - res.end() } + +export default HttpServerService diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index a204c93775..b5c8492566 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -15,28 +15,30 @@ export const name = 'host-webserver-invariant' export const inject = ['invariants'] /** - * Owned relation: the web plugin registry's boot entry graph must stay - * self-consistent — every row must resolve a clientPath under the same id - * (the /plugins//client.js URL it advertises would otherwise 404 on a - * browser that just received the graph). Checked synchronously on every - * rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read - * the same table object, so the relation is self-consistent at any instant — - * no need to wait out the registry's own debounced rescan. The registry - * arrives through the context key the assembly publishes it under. + * Owned relation: route registrations and their disposers must stay + * symmetric — after the owning fiber of a registered route unloads, the + * route table must no longer answer for its path (a stale route would keep + * serving a disposed plugin's handler). Checked on every fiber teardown + * (cordis 'internal/plugin'): the service's own registry state is compared + * against the set of live fibers' registrations indirectly, by probing that + * dispose really removed the entry — the register() disposer contract. */ const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/plugin', () => { - const registry = ctx.get('webPlugins') as - | { - graph(): { entries: { id: string; url: string }[] } - clientPath(id: string): string | undefined - } + const server = ctx.get('httpServer') as + | { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void } | undefined - if (registry === undefined) return // carrier-only deployments never publish the registry - for (const row of registry.graph().entries) { - if (registry.clientPath(row.id) === undefined) { - fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`) - } + if (server === undefined) return // no webserver row in this composition + // Register/dispose probe on a reserved path: if dispose leaves the route + // behind, a second register throws the duplicate error — the asymmetry. + // Each register(probe)() is one register+dispose cycle, so the probe never + // leaves residue; a leftover from the first cycle makes the second throw. + const probe = { kind: 'exact' as const, path: '/__dsh_invariant_probe__', handler: () => {} } + try { + server.register(probe)() + server.register(probe)() + } catch { + fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged') } }, { global: true }) } diff --git a/packages/host/webserver/src/plugin-events.ts b/packages/host/webserver/src/plugin-events.ts deleted file mode 100644 index b438edf948..0000000000 --- a/packages/host/webserver/src/plugin-events.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `/plugins/events` SSE channel: the system-side push surface for the client - * entry graph (connect → current graph frame; dev rebuild → rebuilt frame). - * Presentation-only wire — frames never enter the session log (distinct from - * the /api/* session SSE, which is api-contract territory). Connections are - * plain node:http responses held in a set; the server's closeAllConnections - * tears them down on shutdown. - */ - -import type { ServerResponse } from 'node:http' -import type { WebBootGraph } from './web-plugins.ts' - -/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */ -export type PluginEventFrame = - | { type: 'graph'; graph: WebBootGraph } - | { type: 'rebuilt'; id: string; rev: string } - -/** Broadcast surface owned by the webserver routing layer. */ -export interface PluginEventChannel { - /** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */ - connect(res: ServerResponse, graph: WebBootGraph): void - /** Push one frame to every open connection. */ - broadcast(frame: PluginEventFrame): void -} - -/** Serialize one frame as an SSE data line. */ -function sseData(frame: PluginEventFrame): string { - return `data: ${JSON.stringify(frame)}\n\n` -} - -/** - * Create the channel (one per running server). - * @returns the connect/broadcast surface. - */ -export function createPluginEventChannel(): PluginEventChannel { - const connections = new Set() - return { - connect(res, graph) { - res.writeHead(200, { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - 'connection': 'keep-alive', - }) - // Comment line on open so clients/proxies see a live channel even when - // no rebuild ever happens; EventSource frame parsing skips it naturally. - res.write(': connected\n\n') - res.write(sseData({ type: 'graph', graph })) - connections.add(res) - res.on('close', () => { connections.delete(res) }) - }, - broadcast(frame) { - const line = sseData(frame) - for (const res of connections) res.write(line) - }, - } -} diff --git a/packages/host/webserver/src/web-plugins.ts b/packages/host/webserver/src/web-plugins.ts deleted file mode 100644 index 4e95e7a091..0000000000 --- a/packages/host/webserver/src/web-plugins.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * HostWebPluginRegistry: composes the client entry graph served as - * `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the - * host Loader's loaded entries by its package.json `dshClient` declaration - * (all client plugin packages arrive by fetch — one uniform bundle shape), - * resolving each one's client bundle path from `exports["./client"]` and - * hashing the bundle content into a `rev` (cache busting + HMR diff anchor). - * `inject` edges and the `immediately` prefetch mark come from the manifest - * (dshClient — the package owns its dependency edges and its boot tier); the - * composition layer contributes only the roster. The webserver consumes the - * table to emit the boot graph and to serve `GET /plugins//client.js`; - * in dev mode the registry additionally stat-polls each scanned bundle file - * and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild - * signal is the registry's own observation — no builder protocol exists). - * - * The vendored loader emits no "entry loaded" event (only `loader/entry-init`, - * which fires at Entry construction before import/apply), so the registry - * scans `loader.entries()` and rescans on cordis `internal/plugin` (fiber - * create/dispose), microtask-debounced. Plugin-set changes take effect on - * restart per the config-source ruling; the subscription only keeps the table - * fresh within a process lifetime. - */ - -import { createHash } from 'node:crypto' -import { readFileSync, statSync, type Stats } from 'node:fs' -import { dirname, join } from 'node:path' -import type { Context } from 'cordis' - -/** One composed client entry (`window.__DSH_BOOT__.entries` row). */ -export interface WebBootEntry { - /** Entry name == package name. */ - id: string - /** Bundle URL served by this webserver (`/plugins//client.js?rev=`). */ - url: string - /** Bundle content hash (sha1, shortened). */ - rev: string - /** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */ - inject?: string[] - /** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */ - immediately?: boolean -} - -/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */ -export interface WebBootGraph { - /** Consistency anchor over all rows: changes whenever any entry row changes. */ - rev: string - /** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */ - entries: WebBootEntry[] -} - -/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */ -export interface HostWebPluginRegistry { - /** Current composed entry graph (stable object between changes). */ - graph(): WebBootGraph - /** - * Absolute path of an entry's client bundle. - * @param id - entry id (package name). - * @returns the path, or undefined for an unknown id. - */ - clientPath(id: string): string | undefined - /** - * Re-hash one entry's bundle: updates the row's rev/url and the graph rev. - * The dev bundle watch calls this on every observed file change. - * @param id - entry id (package name). - * @returns the new bundle rev, or undefined for an unknown id. - */ - rebuilt(id: string): string | undefined - /** - * Subscribe to bundle rebuilds observed by the dev watch (only fires when - * the re-hash produced a different rev — an unchanged bundle is silent). - * @param listener - receives the entry id and its new bundle rev. - * @returns the unsubscriber. - */ - onRebuilt(listener: (id: string, rev: string) => void): () => void - /** Remove the loader subscription, all bundle watches, and all rebuild listeners. */ - dispose(): void -} - -/** Structural view of a loader entry (webserver keeps zero workspace dependencies; cordis stays a type-only peer). */ -export interface LoaderEntryView { - options: { name: string } - /** Present once the entry's plugin fiber exists (import succeeded and apply ran/started). */ - fiber?: unknown - /** True when the entry or an owning group is disabled. */ - disabled: boolean -} - -/** Structural view of the host Loader (entry enumeration is all the registry needs). */ -export interface LoaderView { - entries(): Iterable -} - -/** Dependencies injected by the assembly layer. */ -export interface WebPluginRegistryDeps { - /** Host root context; used only to subscribe `internal/plugin` for rescans. */ - ctx: Context - /** The host Loader owning the plugin entries. */ - loader: LoaderView - /** - * Resolve a package specifier to its package.json absolute path (assembly - * passes `createRequire(...).resolve(`${name}/package.json`)`); injected so - * the registry makes no module-resolution assumptions of its own. - */ - resolvePkgJson: (name: string) => string - /** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */ - onError: (err: Error) => void - /** - * Dev-mode bundle watching: stat-poll every scanned row's client bundle - * with an explicit stat baseline (polling by design: network mounts deliver - * no inotify events) and re-hash + notify onRebuilt subscribers on change. - * Absent = no watching (prod composition). - */ - watch?: { - /** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */ - intervalMs?: number - } -} - -/** package.json `dshClient` declaration shape (file boundary — validated field by field). */ -interface DshClientDeclaration { - inject?: string[] - platform: string - /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */ - immediately?: boolean -} - -interface WebPluginRecord { - entry: WebBootEntry - clientPath: string -} - -interface WatchedBundle { - path: string - mtimeMs: number - size: number - dirty: boolean -} - -/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */ -function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined { - if (value === undefined) return undefined - if (typeof value !== 'object' || value === null) { - throw new Error(`web-plugins: ${name} has a non-object dshClient declaration`) - } - const decl = value as Record - if (typeof decl.platform !== 'string') { - throw new Error(`web-plugins: ${name} dshClient.platform must be a string`) - } - if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) { - throw new Error(`web-plugins: ${name} dshClient.inject must be a string array`) - } - if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { - throw new Error(`web-plugins: ${name} dshClient.immediately must be a boolean`) - } - return { - platform: decl.platform, - ...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}), - ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), - } -} - -/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */ -function clientExportOf(name: string, exportsField: unknown): string | undefined { - if (typeof exportsField !== 'object' || exportsField === null) return undefined - const client = (exportsField as Record)['./client'] - if (client === undefined) return undefined - if (typeof client === 'string') return client - if (typeof client === 'object' && client !== null) { - const fallback = (client as Record).default - if (typeof fallback === 'string') return fallback - } - throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`) -} - -/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ -function shortHash(input: string | Buffer): string { - return createHash('sha1').update(input).digest('hex').slice(0, 12) -} - -/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */ -function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry { - return { - id, - url: `/plugins/${id}/client.js?rev=${rev}`, - rev, - ...(inject !== undefined ? { inject } : {}), - ...(immediately ? { immediately: true } : {}), - } -} - -/** Compose the graph value from the current table. */ -function composeGraph(table: Map): WebBootGraph { - const entries = [...table.values()].map(record => record.entry) - return { rev: shortHash(JSON.stringify(entries)), entries } -} - -/** - * Build the web plugin registry: scan once synchronously (a malformed - * declaration, an unbuilt bundle, or an invalid watch interval throws here — - * load-time fail loud), then rescan on `internal/plugin`, microtask-debounced - * (failures go to `deps.onError`). With `deps.watch`, every scanned bundle - * file is stat-polled and a content change re-hashes the row and notifies - * `onRebuilt` subscribers. - * @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}). - * @returns the registry handle. - */ -export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry { - const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500 - if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) { - throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`) - } - - const stageWatches = ( - candidateTable: Map, - currentWatches: Map, - ): Map => { - const candidateWatches = new Map() - if (watchInterval === undefined) return candidateWatches - for (const [id, record] of candidateTable) { - const current = currentWatches.get(id) - if (current?.path === record.clientPath) { - candidateWatches.set(id, { ...current }) - continue - } - const baseline = statSync(record.clientPath) - candidateWatches.set(id, { - path: record.clientPath, - mtimeMs: baseline.mtimeMs, - size: baseline.size, - dirty: false, - }) - } - return candidateWatches - } - - let table = scan(deps) - let graph = composeGraph(table) - let watched = stageWatches(table, new Map()) - const rebuildListeners = new Set<(id: string, rev: string) => void>() - - const rebuilt = (id: string): string | undefined => { - const record = table.get(id) - if (record === undefined) return undefined - const rev = shortHash(readFileSync(record.clientPath)) - record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true) - graph = composeGraph(table) - return rev - } - - // Dev bundle watch: capture every row's baseline synchronously before the - // registry is returned, then poll those baselines. fs.watchFile establishes - // its first baseline asynchronously, so an immediate rebuild can otherwise - // become the baseline and disappear without an observed delta. - const pollWatches = (): void => { - for (const [id, watch] of watched) { - let current: Stats - try { - current = statSync(watch.path) - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT') { - watch.dirty = true - continue - } - deps.onError(error instanceof Error ? error : new Error(String(error))) - continue - } - if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue - const before = table.get(id)?.entry.rev - let rev: string | undefined - try { - rev = rebuilt(id) - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT') { - watch.dirty = true - continue - } - watch.mtimeMs = current.mtimeMs - watch.size = current.size - deps.onError(error instanceof Error ? error : new Error(String(error))) - continue - } - watch.mtimeMs = current.mtimeMs - watch.size = current.size - watch.dirty = false - if (rev === undefined || rev === before) continue - for (const notify of rebuildListeners) { - // A throwing subscriber must not skip later subscribers or escape the - // polling callback into the process event loop. - try { - notify(id, rev) - } catch (error) { - deps.onError(error instanceof Error ? error : new Error(String(error))) - } - } - } - } - const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval) - watchTimer?.unref() - - let pending = false - const unsubscribe = deps.ctx.on('internal/plugin', () => { - if (pending) return - pending = true - queueMicrotask(() => { - pending = false - try { - const candidateTable = scan(deps) - const candidateGraph = composeGraph(candidateTable) - const candidateWatches = stageWatches(candidateTable, watched) - table = candidateTable - graph = candidateGraph - watched = candidateWatches - } catch (error) { - // Keep serving the previous graph: a mid-flight rescan failure must not - // take down the boot manifest for plugins that were fine. - deps.onError(error instanceof Error ? error : new Error(String(error))) - } - }) - }) - - return { - graph: () => graph, - clientPath: id => table.get(id)?.clientPath, - rebuilt, - onRebuilt: (listener) => { - rebuildListeners.add(listener) - return () => { rebuildListeners.delete(listener) } - }, - dispose: () => { - unsubscribe() - if (watchTimer !== undefined) clearInterval(watchTimer) - watched.clear() - rebuildListeners.clear() - }, - } -} - -/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */ -function scan(deps: WebPluginRegistryDeps): Map { - const table = new Map() - for (const entry of deps.loader.entries()) { - if (entry.fiber === undefined || entry.disabled) continue - const name = entry.options.name - if (table.has(name)) continue - const pkgPath = deps.resolvePkgJson(name) - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record - const decl = parseDshClient(name, pkg.dshClient) - if (decl === undefined || decl.platform !== 'web') continue - const clientRel = clientExportOf(name, pkg.exports) - if (clientRel === undefined) { - throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`) - } - const clientPath = join(dirname(pkgPath), clientRel) - const rev = shortHash(readFileSync(clientPath)) - table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath }) - } - return table -} diff --git a/packages/host/webserver/tests/invariant.spec.ts b/packages/host/webserver/tests/invariant.spec.ts deleted file mode 100644 index f9d5ba4490..0000000000 --- a/packages/host/webserver/tests/invariant.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Webserver invariant companion: the boot-graph consistency audit — every - * fetch-arrival graph row must resolve a clientPath, checked on fiber - * lifecycle events against the assembly-published 'webPlugins' context key. - */ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as WebserverInvariant from '../src/invariant.ts' - -interface RegistryStub { - graph(): { entries: { id: string; url: string }[] } - clientPath(id: string): string | undefined -} - -async function setup(registry?: RegistryStub): Promise { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(WebserverInvariant).await() - if (registry !== undefined) ctx.reflect.provide('webPlugins', registry) - return ctx -} - -/** Fire the audit trigger directly (same technique as the scope invariant - * spec): a synchronous emit propagates the fail() throw to the caller. */ -function trigger(ctx: Context): void { - ;(ctx.emit as (event: string, ...args: unknown[]) => void)('internal/plugin', ctx.fiber) -} - -describe('webserver manifest invariant', () => { - it('stays silent without a registry (carrier-only deployment) and with a consistent table', async () => { - const bare = await setup() - expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published - - const consistent = await setup({ - graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }), - clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined, - }) - expect(() => { trigger(consistent) }).not.toThrow() - }) - - it('throws on a graph row whose bundle path no longer resolves', async () => { - const ctx = await setup({ - graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }), - clientPath: () => undefined, - }) - expect(() => { trigger(ctx) }) - .toThrow(/graph row "ghost".*resolves no client bundle path/) - }) -}) diff --git a/packages/host/webserver/tests/web-plugins.spec.ts b/packages/host/webserver/tests/web-plugins.spec.ts deleted file mode 100644 index 0acff4d78b..0000000000 --- a/packages/host/webserver/tests/web-plugins.spec.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - statSync, - type PathLike, - type Stats, - unlinkSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts' -import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts' - -const fsControl = vi.hoisted(() => ({ failNextStatPath: undefined as string | undefined })) - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - statSync: (path: PathLike): Stats => { - if (String(path) === fsControl.failNextStatPath) { - fsControl.failNextStatPath = undefined - throw Object.assign(new Error('staged bundle missing'), { code: 'ENOENT' }) - } - return actual.statSync(path) - }, - } -}) - -afterEach(() => { - fsControl.failNextStatPath = undefined - vi.useRealTimers() -}) - -/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */ -function makePkg(root: string, name: string, pkg: Record, withBundle = true): string { - const dir = join(root, name.replaceAll('/', '__')) - mkdirSync(join(dir, 'lib'), { recursive: true }) - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...pkg })) - if (withBundle) writeFileSync(join(dir, 'lib', 'client.js'), `// bundle of ${name}`) - return join(dir, 'package.json') -} - -const webDecl = (extra: Record = {}): Record => ({ - dshClient: { inject: [], platform: 'web', ...extra }, - exports: { '.': './lib/index.js', './client': './lib/client.js' }, -}) - -interface Fixture { - deps: WebPluginRegistryDeps - entries: LoaderEntryView[] - errors: Error[] - ctx: Context - root: string -} - -function makeDeps( - specs: { name: string; pkg: Record; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[], -): Fixture { - const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-')) - const paths = new Map() - const entries: LoaderEntryView[] = specs.map((spec) => { - paths.set(spec.name, makePkg(root, spec.name, spec.pkg, spec.withBundle ?? true)) - return { options: { name: spec.name }, fiber: spec.loaded === false ? undefined : {}, disabled: spec.disabled ?? false } - }) - const ctx = new Context() - const errors: Error[] = [] - const deps: WebPluginRegistryDeps = { - ctx, - loader: { entries: () => entries }, - resolvePkgJson: (name) => { - const path = paths.get(name) - if (path === undefined) throw new Error(`unresolvable ${name}`) - return path - }, - onError: err => void errors.push(err), - } - return { deps, entries, errors, ctx, root } -} - -describe('createHostWebPluginRegistry', () => { - it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => { - const { deps } = makeDeps([ - { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) }, - { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) }, - { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped - ]) - const registry = createHostWebPluginRegistry(deps) - const graph = registry.graph() - expect(graph.rev).toMatch(/^[0-9a-f]{12}$/) - const connection = graph.entries[0] - expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection') - expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/) - expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`) - expect(connection?.immediately).toBe(true) - const layout = graph.entries[1] - expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout') - expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime']) - expect(layout?.immediately).toBeUndefined() - expect(graph.entries).toHaveLength(2) - expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/) - expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined() - registry.dispose() - }) - - it('skips entries that are unloaded, disabled, or declare another platform', () => { - const { deps } = makeDeps([ - { name: 'not-loaded', pkg: webDecl(), loaded: false }, - { name: 'disabled', pkg: webDecl(), disabled: true }, - { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } }, - ]) - const registry = createHostWebPluginRegistry(deps) - expect(registry.graph().entries).toEqual([]) - registry.dispose() - }) - - it('fails loud at build time on a dshClient declaration without a "./client" export', () => { - const { deps } = makeDeps([ - { name: 'broken', pkg: { dshClient: { platform: 'web' }, exports: { '.': './lib/index.js' } } }, - ]) - expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/) - }) - - it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => { - const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }]) - expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/) - }) - - it('fails loud on malformed declaration fields', () => { - for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) { - const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }]) - expect(() => createHostWebPluginRegistry(deps)).toThrow(/dshClient/) - } - }) - - it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => { - const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }]) - const registry = createHostWebPluginRegistry(deps) - const before = registry.graph() - const beforeRow = before.entries.find(e => e.id === 'hot') - writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents') - const rev = registry.rebuilt('hot') - expect(rev).toMatch(/^[0-9a-f]{12}$/) - expect(rev).not.toBe(beforeRow?.rev) - const after = registry.graph() - const afterRow = after.entries.find(e => e.id === 'hot') - expect(afterRow?.rev).toBe(rev) - expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`) - expect(afterRow?.immediately).toBe(true) - expect(after.rev).not.toBe(before.rev) - // Unknown ids are not rebuildable. - expect(registry.rebuilt('nope')).toBeUndefined() - registry.dispose() - }) - - it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => { - const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) - deps.watch = { intervalMs: 20 } - const registry = createHostWebPluginRegistry(deps) - const before = registry.graph().entries[0]?.rev - const rebuilds: { id: string; rev: string }[] = [] - registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) - - writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents') - await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 }) - expect(rebuilds[0]?.id).toBe('watched') - expect(rebuilds[0]?.rev).not.toBe(before) - expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) - - registry.dispose() - writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents') - await new Promise((resolve) => { setTimeout(resolve, 100) }) - expect(rebuilds).toHaveLength(1) - }) - - it('watch mode: a failed rescan baseline preserves the published table and graph', async () => { - const { deps, entries, errors, ctx, root } = makeDeps([ - { name: 'stable', pkg: webDecl() }, - { name: 'late', pkg: webDecl(), loaded: false }, - ]) - deps.watch = { intervalMs: 1_000 } - const registry = createHostWebPluginRegistry(deps) - const before = registry.graph() - - ;(entries[1] as { fiber?: unknown }).fiber = {} - fsControl.failNextStatPath = join(root, 'late', 'lib', 'client.js') - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - - expect(errors[0]?.message).toContain('staged bundle missing') - expect(registry.graph()).toBe(before) - expect(registry.clientPath('late')).toBeUndefined() - - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - expect(registry.graph().entries.map(row => row.id)).toEqual(['stable', 'late']) - registry.dispose() - }) - - it('watch mode: a missing bundle forces a re-hash when identical metadata reappears', async () => { - vi.useFakeTimers() - const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) - const bundle = join(root, 'watched', 'lib', 'client.js') - const fixedTime = new Date(1_600_000_000_000) - utimesSync(bundle, fixedTime, fixedTime) - deps.watch = { intervalMs: 20 } - const registry = createHostWebPluginRegistry(deps) - const baseline = statSync(bundle) - const rebuilds: { id: string; rev: string }[] = [] - registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) - - unlinkSync(bundle) - await vi.advanceTimersByTimeAsync(20) - writeFileSync(bundle, 'x'.repeat(baseline.size)) - utimesSync(bundle, fixedTime, fixedTime) - const restored = statSync(bundle) - expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({ - mtimeMs: baseline.mtimeMs, - size: baseline.size, - }) - await vi.advanceTimersByTimeAsync(20) - - expect(rebuilds).toHaveLength(1) - expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) - registry.dispose() - }) - - it('rejects a non-positive or non-integer watch interval at build time', () => { - for (const intervalMs of [0, -5, 1.5]) { - const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }]) - deps.watch = { intervalMs } - expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/) - } - }) - - it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => { - const { deps, entries, errors, ctx } = makeDeps([ - { name: 'late-loader', pkg: webDecl(), loaded: false }, - ]) - const registry = createHostWebPluginRegistry(deps) - expect(registry.graph().entries).toEqual([]) - - // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan. - ;(entries[0] as { fiber?: unknown }).fiber = {} - ctx.emit('internal/plugin', ctx.fiber) - ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan - await Promise.resolve() - expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) - - // A failing rescan reports the error and keeps serving the previous graph. - entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false }) - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - expect(errors).toHaveLength(1) - expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) - - // After dispose, further fiber events no longer rescan. - registry.dispose() - entries.pop() - ctx.emit('internal/plugin', ctx.fiber) - await Promise.resolve() - expect(errors).toHaveLength(1) - }) -}) - -describe('injectBootManifest', () => { - it('injects the graph as the first script inside and escapes breakouts', () => { - const html = '' - const out = injectBootManifest(html, { - rev: 'r1', - entries: [{ id: 'x` + const head = html.indexOf('') + if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` + // Headless fixture pages may lack ; prepending keeps the read-before-shell ordering. + return `${script}${html}` } /** - * The internal-seam subset the vendored Loader and the client HMR plugin - * consume. Mounted on `ctx.loader.internal` by the shell boot and provided - * as `ctx.modules` (contract C5). + * The web plugin table service: incremental dshClient scan + wire composition + * + bundle route + index tap. Construction runs the activation scan + * synchronously — a malformed declaration or missing bundle among the + * already-loaded entries aggregates into one loud throw (FAILED fiber; the + * boot sweep reports it). */ -export interface ClientModuleLoader { - /** Discriminant against Node's internal loader shapes ('v1'/'v2'). */ - version: 'client' - /** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */ - loadCache: Map +export class ClientModuleHostService extends Service { + static inject = ['httpServer', 'loader'] + + private readonly table = new Map() + // Negative verdicts (unresolvable specifier — builtins like cordis:include, + // subpath rows — or a package without a web dshClient declaration) are + // cached as null and never expire: plugin-set changes take effect on restart. + private readonly pkgMeta = new Map() + private readonly rebuildListeners = new Set<(id: string, rev: string) => void>() + private readonly graphListeners = new Set<() => void>() + private readonly dirty = new Set() + private readonly resolvePkgJson: (spec: string) => string + private flushQueued = false + private composed: WebBootGraph + /** - * Internal seam consumed by the vendored Loader's `tree.import`. Resolves - * `specifier` through the branch order documented on the module, fetching - * and executing a bundle when needed. - * @param specifier - module specifier (entry name or table word). - * @param parentURL - importer URL (unused — the client module graph is flat). - * @param attrs - import attributes (unused; interface parity with Node's seam). - * @returns the module's export surface. + * Build the service: subscribe, seed, and run the activation flush. + * @param ctx - plugin context carrying httpServer and loader. */ - import(specifier: string, parentURL: string, attrs: Record): Promise + constructor(ctx: Context) { + super(ctx, 'clientModuleHost') + // Resolution anchor: the config tree's baseUrl (the cordis.yml directory, + // whose package declares every composed plugin as a dependency). The + // modules package's own URL would miss sibling packages under pnpm's + // isolated node_modules. + if (ctx.baseUrl === undefined) { + throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages') + } + const require = createRequire(ctx.baseUrl) + this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`) + + // Subscribe before seeding so a fiber arriving mid-activation lands in the + // same dirty set (Set idempotence makes the overlap harmless). An entry-less + // fiber is a child plugin or a manual mount — never a loader row; O(1) drop. + ctx.on('internal/plugin', (fiber) => { + const entryName = fiber.entry?.options.name + if (entryName === undefined) return + this.dirty.add(entryName) + if (this.flushQueued) return + this.flushQueued = true + queueMicrotask(() => { + this.flushQueued = false + this.flush((err) => { ctx.logger.warn(err) }) + }) + }) + + // Activation pass: the initial scan IS the incremental path over the + // current entries, flushed synchronously (nothing async between subscribe, + // seed, and flush). + for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name) + this.composed = this.compose() + const failures: Error[] = [] + this.flush((err) => failures.push(err)) + if (failures.length > 0) { + throw new AggregateError( + failures, + `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`, + ) + } + + ctx.effect( + () => ctx.httpServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), + 'client-modules: bundle route', + ) + ctx.effect( + () => ctx.httpServer.tapIndex(html => injectBootManifest(html, this.composed)), + 'client-modules: boot manifest injection', + ) + } + /** - * Register a shell-own module (app-shell — code that ships inside the shell - * bundle and never arrives as a plugin bundle). - * @param id - entry name (shell-owned pseudo id). - * @param module - the statically imported module namespace. + * Current composed entry graph (stable object between changes). + * @returns the graph served as `window.__DSH_BOOT__`. */ - registerStatic(id: string, module: unknown): void + graph(): WebBootGraph { + return this.composed + } + /** - * Stage-one arrival: fetch the entry's bundle and execute it, registering - * its factory (no materialization — module side effects wait for import). - * No-op for static-registered ids and ids whose factory is already - * registered; concurrent calls share one in-flight task. To force a fresh - * fetch (HMR), {@link invalidate} first. - * @param id - graph entry name. + * Absolute path of an entry's client bundle. + * @param id - entry id (package name). + * @returns the path, or undefined for an unknown id. */ - prefetch(id: string): Promise + clientPath(id: string): string | undefined { + return this.table.get(id)?.clientPath + } + /** - * Full reset of one module: drop its registered factory, its materialized - * record, and any consumed bundle text, so the next prefetch/import - * refetches and re-executes (the HMR invalidation hook). - * @param id - entry name to invalidate. + * Re-hash one bundle (the HMR watch's registration hook — the only entry + * point through which bundle content changes reach the graph). + * @param id - entry id (package name). + * @returns the new rev, or undefined for an unknown id. */ - invalidate(id: string): void + rebuilt(id: string): string | undefined { + const record = this.table.get(id) + if (record === undefined) return undefined + const rev = shortHash(readFileSync(record.clientPath)) + if (rev === record.entry.rev) return rev + record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true) + this.composed = this.compose() + for (const notify of this.rebuildListeners) { + // Containment: rebuilt() runs inside the HMR watch callback — a + // throwing subscriber must not kill the poll or skip later subscribers. + try { + notify(id, rev) + } catch (error) { + this.ctx.logger.error(error) + } + } + this.notifyGraphChanged() + return rev + } + + /** + * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev. + * @param listener - receives the entry id and its new bundle rev. + * @returns the unsubscriber. + */ + onRebuilt(listener: (id: string, rev: string) => void): () => void { + this.rebuildListeners.add(listener) + return () => { this.rebuildListeners.delete(listener) } + } + + /** + * Fires after any flush that recomposed the graph (row added/removed, or a + * rebuilt rev change). Pull model: listeners re-read {@link graph}. + * @param listener - notified with no payload. + * @returns the unsubscriber. + */ + onGraphChanged(listener: () => void): () => void { + this.graphListeners.add(listener) + return () => { this.graphListeners.delete(listener) } + } + + private compose(): WebBootGraph { + const entries = [...this.table.values()].map(record => record.entry) + return { rev: shortHash(JSON.stringify(entries)), entries } + } + + private notifyGraphChanged(): void { + for (const listener of this.graphListeners) { + // A throwing subscriber must not skip later subscribers (or escape into + // whatever triggered the flush — possibly an fs.watchFile callback). + try { + listener() + } catch (error) { + this.ctx.logger.error(error) + } + } + } + + private resolveMeta(pkgName: string): PkgMeta | null { + const cached = this.pkgMeta.get(pkgName) + if (cached !== undefined) return cached + let pkgPath: string + try { + pkgPath = this.resolvePkgJson(pkgName) + } catch { + // Not a resolvable package root: loader builtins (cordis:include) and + // subpath entries (…/gateway) land here — permanently not a client row. + this.pkgMeta.set(pkgName, null) + return null + } + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record + const decl = parseDshClient(pkgName, pkg.dshClient) + if (decl === undefined || decl.platform !== 'web') { + this.pkgMeta.set(pkgName, null) + return null + } + const clientRel = clientExportOf(pkgName, pkg.exports) + if (clientRel === undefined) { + throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`) + } + const meta: PkgMeta = { + clientPath: join(dirname(pkgPath), clientRel), + ...(decl.inject !== undefined ? { inject: decl.inject } : {}), + immediately: decl.immediately === true, + } + this.pkgMeta.set(pkgName, meta) + return meta + } + + /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ + private processOne(entryName: string): boolean { + let qualifies = false + for (const entry of this.ctx.loader.entries()) { + if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) { + qualifies = true + break + } + } + if (!qualifies) return this.table.delete(entryName) + if (this.table.has(entryName)) return false + const meta = this.resolveMeta(entryName) + if (meta === null) return false + // The rev rides the row from here on: a fiber restart reuses the row (and + // its rev) untouched; only rebuilt() re-reads the bundle. + const rev = shortHash(readFileSync(meta.clientPath)) + this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath }) + return true + } + + private flush(onError: (err: Error) => void): void { + let changed = false + for (const entryName of [...this.dirty]) { + this.dirty.delete(entryName) + try { + if (this.processOne(entryName)) changed = true + } catch (error) { + // Steady state: one broken package must not poison the others; the + // activation pass aggregates these into a loud throw instead. + onError(error instanceof Error ? error : new Error(String(error))) + } + } + if (changed) { + this.composed = this.compose() + this.notifyGraphChanged() + } + } + + private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405) + res.end() + return + } + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname) + // The id may contain a scope slash. Anything else under /plugins (including + // /plugins/events when the HMR row is absent) is an unknown resource. + const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js') + ? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length)) + : undefined + if (path === undefined) { + res.writeHead(404) + res.end() + return + } + try { + const body = await readFile(path) + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }) + res.end(body) + } catch { + // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page. + res.writeHead(404) + res.end() + } + } } -/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */ -export interface ClientModuleLoaderOptions { - /** Host-composed entry graph. */ - graph: WebBootGraph - /** Module-table seed: platform-singleton specifier → shell instance. */ - staticModules: Record - /** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */ - fetchBundle?: (url: string) => Promise - /** - * Bundle execution seam (synchronously performs the load() registration). - * Defaults to a