Merge branch 'master' into worktree-wspace-storage

This commit is contained in:
Tianyi Cui
2026-07-25 12:50:36 +08:00
committed by GitHub
78 changed files with 2720 additions and 2539 deletions

View File

@@ -43,10 +43,12 @@
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,8 @@
/**
* The /api URL prefix — single source for both halves of the web transport.
* The node half registers this prefix on the web server; browser-side path
* literals currently live in the apiproxy client layer (out of scope here).
*/
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'

View File

@@ -0,0 +1,59 @@
/**
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
* web carrier; the fetch-shaped handler itself is transport-agnostic).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; SSE bodies stream out chunk by chunk).
* @param req - incoming node:http request (fully read before dispatch).
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
*/
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
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<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}

View File

@@ -1,10 +1,36 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
* Connection plugin, node half: the host end of the web transport. Registers
* the /api prefix route on the web server and bridges node:http requests to
* the transport-agnostic fetch-shaped api handler. The wire consumer layer
* lives in the client half (src/client/ — contract: api-contracts v3
* section 3); consumers import the /client subpath.
*/
import type { Context } from 'cordis'
// Type-only route import; it also carries the httpServer Context merge.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}
export { API_PATH } from './api-path.ts'
/** Cordis plugin name. */
export const name = 'client-connection'
/** Required services: the route registry and the api gateway. */
export const inject = ['httpServer', 'apiProxy']
/**
* Mount the /api transport: wrap the api gateway into a fetch handler and
* serve it under the /api prefix.
* @param ctx - host plugin context carrying httpServer and apiProxy.
*/
export function apply(ctx: Context): void {
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: (req, res) => bridge(req, res, apiHandler),
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
}

View File

@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the pure wire layer emits no cordis events and owns no
* No runtime invariant: the wire layer emits no cordis events and owns no
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
* directly by its behavior specs, and rpcId round-trip discipline is owned by
* the apiproxy contract layer.
* directly by its behavior specs, rpcId round-trip discipline is owned by the
* apiproxy contract layer, and the node half's single route registration's
* register/dispose symmetry is audited by the webserver package's invariant.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,10 +1,33 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
await fiber.dispose()
expect(routes).toHaveLength(0)
})
})

View File

@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
"outDir": "lib/types",
"types": ["node"]
},
"include": [
"src"
@@ -20,6 +21,9 @@
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../ui/user-approval"
},

View File

@@ -28,15 +28,20 @@
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -64,20 +64,11 @@
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
import type { PluginsEventFrame } from '../events.ts'
import { EVENTS_ENDPOINT } from '../events.ts'
/**
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
* wire boundary: frames arrive as JSON text and are validated at the parse
* point, not shared as a same-process typed seam.
*/
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
export type { PluginsEventFrame } from '../events.ts'
export { EVENTS_ENDPOINT } from '../events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'

View File

@@ -0,0 +1,16 @@
/**
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
* both halves of this package. Frames still cross a wire boundary: the
* browser half validates them at its JSON parse point; sharing the type keeps
* the two ends from drifting, not from parsing.
*/
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'

View File

@@ -1,9 +1,152 @@
/**
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
* the host graph): the reload driver lives in its client half in full
* (src/client/); the empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery).
* HMR plugin, node half: the host end of the dev reload chain. Stat-polls
* every graph row's client bundle (fs.watchFile — polling by design: network
* mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* Dev-only row: prod compositions never mount this plugin.
*/
import type { Stats } from 'node:fs'
import { unwatchFile, watchFile } from 'node:fs'
import type { ServerResponse } from 'node:http'
import type { Context } from 'cordis'
import z from 'schemastery'
// Empty type imports carry the clientModuleHost/httpServer Context merges.
import type {} from '@deepseek-ai/dsh-client-modules'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { PluginsEventFrame } from './events.ts'
import { EVENTS_ENDPOINT } from './events.ts'
/** Host plugin body — no host-side behavior for the HMR plugin. */
export function apply(): void {}
export type { PluginsEventFrame } from './events.ts'
export { EVENTS_ENDPOINT } from './events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the web plugin table and the route registry. */
export const inject = ['clientModuleHost', 'httpServer']
/** Plugin config, validated by the same-named schemastery schema. */
export interface Config {
/** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
pollIntervalMs?: number
}
export const Config: z<Config> = z.object({
pollIntervalMs: z.number().step(1).min(1).default(500),
})
/** Serialize one frame as an SSE data line. */
function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n`
}
/**
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
// schemastery's .default() guarantees the field is set after validation.
const pollIntervalMs = config.pollIntervalMs as number
// --- bundle watch: one fs.watchFile stat poll per graph row -------------
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
const watchRow = (id: string, path: string): void => {
const listener = (curr: Stats, prev: Stats): void => {
// fs.watchFile fires on any stat delta (atime included); only content
// signals count. An all-zero curr means the file vanished mid-rebuild
// — the completing write fires the next tick, so skipping is safe.
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
if (curr.mtimeMs === 0) return
try {
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
// fires onRebuilt only on a real rev change). A torn read of a
// half-written bundle self-heals on the next poll tick.
ctx.clientModuleHost.rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
ctx.logger.warn(error)
}
}
watchFile(path, { interval: pollIntervalMs, persistent: false }, listener)
watched.set(id, { path, listener })
}
// Diff the watch set against the current graph: drop watches for removed
// rows (or rows whose bundle path moved), add watches for new rows.
const syncWatches = (): void => {
const rows = new Map<string, string>()
for (const row of ctx.clientModuleHost.graph().entries) {
const path = ctx.clientModuleHost.clientPath(row.id)
if (path !== undefined) rows.set(row.id, path)
}
for (const [id, watch] of watched) {
if (rows.get(id) === watch.path) continue
unwatchFile(watch.path, watch.listener)
watched.delete(id)
}
for (const [id, path] of rows) {
if (!watched.has(id)) watchRow(id, path)
}
}
ctx.effect(() => {
// Initial sync covers rows already in the graph; the subscription covers
// rows arriving later (boot-window activations, including this plugin's
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
syncWatches()
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
return () => {
unsubscribe()
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
watched.clear()
}
}, 'client-hmr: bundle watches')
// --- /plugins/events SSE channel ----------------------------------------
const connections = new Set<ServerResponse>()
const connect = (res: ServerResponse): void => {
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: ctx.clientModuleHost.graph() }))
connections.add(res)
res.on('close', () => { connections.delete(res) })
}
ctx.effect(() => {
const disposeRoute = ctx.httpServer.register({
kind: 'exact',
path: EVENTS_ENDPOINT,
handler: (req, res) => {
// Named routes match ahead of the carrier's method gate; keep the old
// global 405 semantics for non-GET hits on this endpoint.
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
connect(res)
},
})
const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
const line = sseData({ type: 'rebuilt', id, rev })
for (const res of connections) res.write(line)
})
return () => {
unsubscribe()
disposeRoute()
for (const res of connections) res.destroy()
connections.clear()
}
}, 'client-hmr: /plugins/events channel')
}

View File

@@ -3,8 +3,7 @@
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
@@ -14,14 +13,42 @@ export const name = 'client-hmr-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
function statWatchers(): number {
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
}
/**
* No runtime invariant: a dev-only reload driver — it consumes the loader
* entry tree and module cache but owns no events and no cross-plugin mutable
* state; reload correctness (dispose → style removal → re-execute ordering)
* is observable only through the assembled browser runtime, not a host-side
* event relation.
* Owned relation: every bundle stat watcher the node half starts must die
* with its fiber — a surviving poller would keep re-hashing bundles for a
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
* count observed at fiber creation must be restored once disposal has drained
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
* hop lets the disposer queue its unload before `fiber.await()` joins it).
* SSE-connection and listener teardown live inside the same ctx.effect
* disposers, so the watcher count is the relation's observable proxy.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
// Async listener by design: emitPluginDisposed awaits-and-logs returned
// promises, so a violation surfaces loudly instead of unhandled.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ctx.on('internal/plugin', async (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {
baselines.set(fiber, statWatchers())
return
}
const baseline = baselines.get(fiber)
if (baseline === undefined) return
await Promise.resolve()
await fiber.await()
const remaining = statWatchers()
if (remaining > baseline) {
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
@@ -30,4 +57,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,14 +1,116 @@
/**
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
* lives in the client half) whose only contract is mounting and disposing
* cleanly in the host Loader.
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '@deepseek-ai/dsh-client-hmr'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
const POLL_MS = 20
let dir: string
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
/**
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
* Structural (Pick+cast): the plugin only touches the read/notify surface;
* the service class carries private scan state a literal need not reproduce.
*/
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
function fakeClientModuleHost(rows: Map<string, string>): FakeHost {
const graphListeners = new Set<() => void>()
const rebuiltCalls: string[] = []
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
rebuiltCalls,
fireGraphChanged: () => { for (const l of graphListeners) l() },
graph: (): WebBootGraph => ({
rev: 'r',
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
}),
clientPath: id => rows.get(id),
rebuilt: (id) => { rebuiltCalls.push(id); return 'r2' },
onRebuilt: () => () => {},
onGraphChanged: (listener) => {
graphListeners.add(listener)
return () => { graphListeners.delete(listener) }
},
}
return fake as FakeHost
}
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
return fake as HttpServerService
}
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
const ctx = new Context()
ctx.provide('clientModuleHost', clientModuleHost)
ctx.provide('httpServer', httpServer)
const fiber = ctx.plugin(
{ inject: [...inject], Config, apply },
{ pollIntervalMs: POLL_MS },
)
await fiber.await()
return fiber
}
describe('hmr node half', () => {
it('apply is a no-op host placeholder', () => {
apply()
expect(true).toBe(true) // reaching here without throw is the contract
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'v1')
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
const routes: WebRoute[] = []
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
// Nudge mtime past stat granularity so the poller sees a content signal.
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(bundle, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose()
expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing.
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(bundle, 'v3-even-longer')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
})
it('follows graph changes: rows added after activation get watched', async () => {
const early = join(dir, 'early.js')
const late = join(dir, 'late.js')
writeFileSync(early, 'v1')
const rows = new Map([['pkg-early', early]])
const clientModuleHost = fakeClientModuleHost(rows)
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
writeFileSync(late, 'v1')
rows.set('pkg-late', late)
clientModuleHost.fireGraphChanged()
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(late, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
await fiber.dispose()
})
})

View File

@@ -8,7 +8,7 @@
"DOM",
"DOM.Iterable"
],
"types": []
"types": ["node"]
},
"include": [
"src"
@@ -23,6 +23,12 @@
{
"path": "../modules"
},
{
"path": "../../host/webserver"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -11,6 +11,10 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
@@ -18,14 +22,26 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"platform": "web",
"inject": [],
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -0,0 +1,34 @@
/**
* Browser half (the standard `./client` export): the module-system class and
* wire contract, plus the enrollment plugin face. The module system itself is
* built by the shell kernel BEFORE cordis exists (the bootstrap exception,
* design §4.7 — the mechanism that loads plugins cannot arrive through
* itself); the plugin face only enrolls that pre-existing instance by
* providing it as `ctx.modules`. The kernel statically registers this module,
* so the graph row for this package never triggers a real fetch — arrival is
* a no-op against the already-registered entry.
* @module @deepseek-ai/dsh-client-modules/client
*/
import type { Context } from 'cordis'
import type { DshWindow } from './manifest.ts'
export { ClientModuleSystem } from './system.ts'
export { parseBootManifest } from './manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
} from './manifest.ts'
/**
* Enroll the kernel-built module system as `ctx.modules`.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const modules = (globalThis as DshWindow).__DSH_MODULES__
// The kernel writes the slot right after constructing the instance, before
// any cordis entry exists — a missing slot means the kernel sequencing broke.
if (modules === undefined) {
throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot')
}
ctx.reflect.provide('modules', modules)
}

View File

@@ -0,0 +1,243 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
*
* This file is the browser-safe contract face (zero node imports): the
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
* {@link ClientModuleSystem}. The package root is the host-side service that
* composes the wire.
*/
import type {} from 'cordis'
import type { ClientModuleSystem } from './system.ts'
declare module 'cordis' {
interface Context {
/** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
modules: ClientModuleLoader
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row). Wire
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*/
export interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash (cache-busting consistency anchor). */
rev: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
export interface BootModuleRow {
/** Entry name == package name (module-table key). */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash. */
rev: string
}
/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
export interface BootPluginRow {
/** Entry name == package name. */
id: string
/** Package-name dependency edges ([] when the wire omits them). */
inject: string[]
/** Stage-one prefetch tier (false when the wire omits it). */
immediately: boolean
}
/** The parsed boot manifest: one wire, two consumer views. */
export interface BootManifest {
/** Consistency anchor over the whole graph. */
rev: string
/** Rows as the module table consumes them. */
modules: BootModuleRow[]
/** Rows as entry composition consumes them. */
plugins: BootPluginRow[]
}
/**
* Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
* a missing or malformed graph throws (the shell shows the loud failure —
* a page without a valid manifest cannot boot anything).
* @param wire - the raw `window.__DSH_BOOT__` value.
* @returns the manifest with optional plugin-view fields normalized.
*/
export function parseBootManifest(wire: unknown): BootManifest {
if (typeof wire !== 'object' || wire === null) {
throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object')
}
const graph = wire as Record<string, unknown>
if (typeof graph.rev !== 'string') {
throw new Error('client-modules: boot manifest rev must be a string')
}
if (!Array.isArray(graph.entries)) {
throw new Error('client-modules: boot manifest entries must be an array')
}
const modules: BootModuleRow[] = []
const plugins: BootPluginRow[] = []
for (const value of graph.entries as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error('client-modules: boot manifest entry is not an object')
}
const row = value as Record<string, unknown>
const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row)
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
}
if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
}
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
}
modules.push({ id: row.id, url: row.url, rev: row.rev })
plugins.push({
id: row.id,
inject: row.inject === undefined ? [] : [...row.inject as string[]],
immediately: row.immediately === true,
})
}
return { rev: graph.rev, modules, plugins }
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
__DSH_BOOT__?: unknown
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
/**
* Kernel handoff slot: the shell kernel stores the instance here right
* after construction (before cordis exists) so the `./client` wrapper
* plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
* time = kernel sequencing bug, thrown loud.
*/
__DSH_MODULES__?: ClientModuleSystem
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
}
/**
* 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).
*/
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<string, ClientModuleRecord>
/**
* 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.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* 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.
*/
registerStatic(id: string, module: unknown): void
/**
* 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.
*/
prefetch(id: string): Promise<void>
/**
* 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.
*/
invalidate(id: string): void
}
/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
export interface ClientModuleSystemOptions {
/** Boot rows in the module-table view (from {@link parseBootManifest}). */
modules: BootModuleRow[]
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}

View File

@@ -1,13 +1,13 @@
/**
* ClientModuleLoaderImpl the implementation behind the {@link ClientModuleLoader}
* ClientModuleSystem the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the package module and the public interfaces in `./index.ts`;
* this file owns the state tables and the fetch/execute/materialize machinery.
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the fetch/execute/materialize machinery.
*/
import type {
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
ClientPluginHandoff, DshWindow, WebBootEntry,
} from './index.ts'
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
@@ -35,13 +35,6 @@ const defaultExecuteBundle = (code: string, url: string): void => {
el.remove()
}
const urlOf = (row: WebBootEntry): string => {
// url is conditional on the wire (shell-own pseudo rows omit it); those
// ids resolve through the static registry and never reach a fetch.
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
return row.url
}
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
@@ -70,10 +63,10 @@ const claimStyles = (id: string): string[] => {
/**
* The client module system: state tables plus the arrival/materialization
* machinery implementing {@link ClientModuleLoader} (whose members carry the
* seam contract docs). Construction indexes the boot graph and installs the
* seam contract docs). Construction indexes the boot rows and installs the
* `window.__ModuleLoader__` registration sink (contract C6) once per page.
*/
export class ClientModuleLoaderImpl implements ClientModuleLoader {
export class ClientModuleSystem implements ClientModuleLoader {
readonly version = 'client'
readonly loadCache = new Map<string, ClientModuleRecord>()
@@ -84,7 +77,7 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, WebBootEntry>()
private readonly graphRows = new Map<string, BootModuleRow>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
@@ -97,17 +90,17 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
private readonly executeBundle: (code: string, url: string) => void
/**
* Build the module system over the host graph.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* Build the module system over the parsed boot rows.
* @param options - module rows, module-table staticModules, fetch/execute seams.
*/
constructor(options: ClientModuleLoaderOptions) {
constructor(options: ClientModuleSystemOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
for (const entry of options.graph.entries) {
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
this.graphRows.set(entry.id, entry)
for (const row of options.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
this.graphRows.set(row.id, row)
}
const win = globalThis as DshWindow
@@ -129,13 +122,12 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: WebBootEntry): Promise<void> {
const { id } = row
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const url = urlOf(row)
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id

View File

@@ -1,175 +1,393 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
* index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
* Scanning is incremental per package — there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per name and never
* expires — plugin-set changes take effect on restart per the config-source
* ruling; bundle content changes reach the graph only through
* {@link ClientModuleHostService.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
import { ClientModuleLoaderImpl } from './loader.ts'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
export { ClientModuleLoaderImpl }
export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
} from './client/manifest.ts'
declare module 'cordis' {
interface Context {
/** The client module system the web shell provides at boot (contract C5). */
modules: ClientModuleLoader
/** The web plugin table (provided by the client-modules node half). */
clientModuleHost: ClientModuleHostService
}
}
/** 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
}
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
interface PkgMeta {
clientPath: string
inject?: string[]
immediately: boolean
}
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
clientPath: string
}
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
if (typeof value !== 'object' || value === null) {
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
}
const decl = value as Record<string, unknown>
if (typeof decl.platform !== 'string') {
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
}
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
}
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
throw new Error(`client-modules: ${pkgName} 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(pkgName: string, exportsField: unknown): string | undefined {
if (typeof exportsField !== 'object' || exportsField === null) return undefined
const client = (exportsField as Record<string, unknown>)['./client']
if (client === undefined) return undefined
if (typeof client === 'string') return client
if (typeof client === 'object' && client !== null) {
const fallback = (client as Record<string, unknown>).default
if (typeof fallback === 'string') return fallback
}
throw new Error(`client-modules: ${pkgName} 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, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
return {
id,
url: `/plugins/${id}/client.js?rev=${rev}`,
rev,
...(injectEdges !== undefined ? { inject: injectEdges } : {}),
...(immediately ? { immediately: true } : {}),
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row).
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*
* Wire contract, held on both sides: the producing peer lives in
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
* dependencies, so neither side imports the other's shape — drift between
* the two declarations is a bug against the web2 contract).
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
* first script in <head> (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.
* @returns the html with the graph script injected.
*/
export interface WebBootEntry {
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
id: string
/**
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
* shell-owned pseudo rows (app-shell) whose module is statically registered
* — a row that is neither fetchable nor static-registered fails loud.
*/
url?: string
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
rev?: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs. */
__DSH_BOOT__?: WebBootGraph
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; 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<string, ClientModuleRecord>
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
private readonly table = new Map<string, WebPluginRecord>()
// 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<string, PkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
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<string, unknown>): Promise<unknown>
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<void>
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<string, unknown>
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<void> => {
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<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/**
* Build the client module system.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
*/
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
return new ClientModuleLoaderImpl(options)
}
export default ClientModuleHostService

View File

@@ -15,14 +15,25 @@ export const name = 'client-modules-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the module loader is pre-plugin kernel machinery —
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
* and its mutable state (loadCache, handoff slot) lives below the plugin
* layer where invariant observers cannot mount before it runs; resolve branch
* order and handoff discipline are asserted by the web boot specs against the
* real execution path.
* Owned relation: the node half's boot entry graph must stay self-consistent
* — every row must resolve a clientPath under the same id (the
* /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
* that just received the graph). Checked on every scan trigger (cordis
* 'internal/plugin'): graph() and clientPath() read the same table object,
* so the relation holds at any instant — no need to wait out the node half's
* own microtask-debounced flush.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const host = ctx.get('clientModuleHost')
if (host === undefined) return // browser side / host without the node half: nothing to audit
for (const row of host.graph().entries) {
if (host.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`)
}
}
}, { global: true })
}
/**
* Register this package's invariant companion.

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
* ClientModuleSystem behavior: lazy CJS arrival (bundle execution only
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
@@ -9,9 +9,9 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ClientModuleLoaderImpl, createClientModuleLoader,
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
} from '../src/index.ts'
ClientModuleSystem,
type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
} from '../src/client/index.ts'
const win = globalThis as DshWindow
@@ -24,7 +24,7 @@ afterEach(() => {
for (const el of document.querySelectorAll('style, script')) el.remove()
})
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
const row = (id: string): BootModuleRow => ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0' })
interface Bench {
loader: ClientModuleLoader
@@ -38,14 +38,14 @@ interface Bench {
* through the window sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: WebBootEntry[],
entries: BootModuleRow[],
bundles: Record<string, Factory | null> = {},
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const fetched: string[] = []
const gates = new Map<string, () => void>()
const loader = createClientModuleLoader({
graph: { rev: 'test', entries },
const loader = new ClientModuleSystem({
modules: entries,
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
fetched.push(url)
@@ -175,7 +175,7 @@ describe('require resolution', () => {
describe('static registry', () => {
it('serves shell-own modules to import and require without any fetch', async () => {
const shell = { marker: 'app-shell' }
const b = bench([row('a'), { id: 'app-shell' }], {
const b = bench([row('a')], {
a: req => ({ dep: req('app-shell') }),
})
b.loader.registerStatic('app-shell', shell)
@@ -216,18 +216,13 @@ describe('failure modes', () => {
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
})
it('a graph row with no url and no static registration is loud', async () => {
const b = bench([{ id: 'ghost' }])
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
})
it('a duplicate graph entry is loud at construction', () => {
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
})
it('double boot is loud', () => {
bench([])
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
.toThrow('already installed (double boot?)')
})
})
@@ -289,7 +284,7 @@ describe('default transport seams', () => {
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
@@ -300,7 +295,7 @@ describe('default transport seams', () => {
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
})
})

View File

@@ -3,22 +3,14 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": [
"src"
],
"include": ["src"],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/loader" },
{ "path": "../../host/webserver" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-modules', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-web
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.

View File

@@ -1,23 +1,32 @@
/**
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
* here is machinery that cannot itself be an entry, and none of it
* Web shell boot kernel — the face consumed by the apps/web entry. Everything
* here is machinery that cannot itself be a loader entry, and none of it
* value-imports a plugin package (web2 shell self-sufficiency rule: the
* loading page must work while — especially when — plugins fail).
* loading page must work while — especially when — plugins fail). The one
* sanctioned exception is the modules package (design §4.7 bootstrap
* identity): the module system cannot arrive through itself, so its class
* and its client-half wrapper are shell-bundled and the kernel adopts its
* plugin entry once cordis is up.
*
* Two-stage boot (web2 §0):
* Stage one (module face): build the module system over the host graph
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
* — fetch + execute registers factories only; module side effects wait for
* materialization. Prefetch failures are non-fatal here: stage two's
* import path retries the fetch and owns the loud failure.
* Stage two (plugin face): mount the vendored cordis Loader, inject the
* module system as its internal seam (BEFORE any entry exists — the
* bare-import fallback in tree.import must never run in a browser), create
* one loader entry per graph row (tree.import materializes each module),
* let fibers activate on service availability, then loader.await() + a
* full fiber sweep (all ACTIVE, else reject listing who/what/which
* service) → flip the settled signal so AppRoot switches to the real UI in
* one pass.
* AppWebEntry.run(), module face first, then plugin face: parse
* `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
* → build the module system over the module-view rows → render the loading
* page → prefetch every `immediately` row in parallel with mounting the
* vendored cordis Loader (internal-seam injection BEFORE any entry exists —
* the bare-import fallback in tree.import must never run in a browser) →
* await the prefetch tier, THEN adopt the modules entry and create one
* loader entry per plugin-view row plus the shell-own app-shell assembly
* entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
* listing who/what/which service) → flip the settled signal so AppRoot
* switches to the real UI in one pass.
*
* Entry creation waits for the whole immediately tier: materialization runs
* synchronous cross-package require edges (e.g. i18n → runtime/client) that
* fiber inject waiting cannot protect — a bundle's factory must be
* registered before any dependent entry materializes. Per-row prefetch
* failures still resolve silently (the create-side import refetches and
* owns the loud failure), so the barrier never turns one bad bundle into a
* boot-wide fail-fast.
*
* Composition lives in the host graph; the shell makes zero composition
* decisions (the app-shell assembly is itself a graph entry, the only
@@ -25,148 +34,205 @@
*/
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { createRoot } from 'react-dom/client'
import { createRoot, type Root } from 'react-dom/client'
import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
import {
createClientModuleLoader,
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
} from '@deepseek-ai/dsh-client-modules'
ClientModuleSystem, parseBootManifest,
type BootManifest, type ClientModuleSystemOptions, type DshWindow,
} from '@deepseek-ai/dsh-client-modules/client'
import * as AppShell from './app-shell.ts'
import { APP_SHELL_ID } from './app-shell.ts'
import { AppRoot } from './AppRoot.tsx'
import { getStaticModules } from './seed.ts'
import {
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
} from './loader-status.ts'
import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
import './base.css'
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
/**
* Sweep every loader entry after the tree quiesced: an entry without a fiber
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
* (a required service never arrived — cordis inject waiting has no timeout,
* so this sweep is the fail-loud compensation).
* The modules package's own graph row id. The kernel adopts that entry
* itself (its wrapper is statically registered — shell-bundled code, never
* fetched), so the plugin-row loop must skip it: the vendored Group.create
* does not deduplicate by name, and a second fiber would provide 'modules'
* twice.
*/
function assertEntriesActive(ctx: Context): void {
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
const name = entry.options.name
if (entry.fiber === undefined) {
failures.push(`${name}: import failed (see console for the import error)`)
continue
}
const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue
if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${name}: ${state}`)
}
}
if (failures.length > 0) {
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
await Promise.all(graph.entries
.filter((row) => row.immediately === true)
.map((row) => modules.prefetch(row.id).catch(() => {
// Import (stage two) refetches and reports this loudly per entry;
// swallowing here keeps one failing prefetch from masking the others.
})))
}
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
async function runPluginBoot(
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
): Promise<void> {
await ctx.plugin(Loader)
const loader = ctx.loader
// Inject the module system BEFORE any entry exists: tree.import falls back
// to a bare dynamic import when internal is undefined, which in a browser
// is a guaranteed loud failure — correct as a tripwire, never as a path.
loader.internal = modules as never
// Status projection: AppRoot displays fiber truth. Every internal/status
// transition under an entry re-projects that entry's row from its ROOT
// fiber (child plugin fibers share the same entry).
ctx.on('internal/status', (fiber) => {
const entry = fiber.entry
if (entry === undefined || entry.fiber === undefined) return
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
})
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
await Promise.all(rows.map(async (name) => {
status.set(name, 'loading')
const id = await loader.create({ name })
// A failed import leaves the entry fiberless (Entry._init logs and
// returns); project it as failed — no fiber means no status event.
if (loader.resolve(id).fiber === undefined) {
status.set(name, 'failed')
}
}))
await loader.await()
assertEntriesActive(ctx)
}
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
/**
* Mount the web shell into a DOM element and start the two-stage boot chain.
* @param el - mount point (the app's #root).
* @param seams - optional module transport overrides (test environments).
* @returns unmount disposer.
* The web shell kernel: mounts the loading page into a DOM element and runs
* the two-stage boot over the host graph. Fields hold only what must exist
* before cordis does — the parsed manifest, the module system, and the
* loading-page UI handles; everything else lives in plugins.
*/
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
const graph = (globalThis as DshWindow).__DSH_BOOT__
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
export class AppWebEntry {
private readonly el: HTMLElement
private readonly seams: BootSeams | undefined
private readonly status = createLoaderStatusStore()
private readonly settled = createSignal(false)
private readonly error = createSignal<string | undefined>(undefined)
// Assigned by run() before any private method or settled-gated closure reads them.
private ctx!: Context
private modules!: ClientModuleSystem
private manifest!: BootManifest
private root: Root | undefined
const ctx = new Context()
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
// The app-shell assembly is the only shell-own module: every other graph
// row is a plugin bundle arriving through fetch (web2 single package form).
modules.registerStatic(APP_SHELL_ID, AppShell)
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
ctx.reflect.provide('modules', modules)
/**
* Hold the mount point; all work happens in {@link run}.
* @param el - mount point (the app's #root).
* @param seams - optional module transport overrides (test environments).
*/
constructor(el: HTMLElement, seams?: BootSeams) {
this.el = el
this.seams = seams
}
const status = createLoaderStatusStore()
const settled = createSignal(false)
const error = createSignal<string | undefined>(undefined)
/**
* Run the boot chain to settlement. Boot-chain failures resolve (not
* reject): the loading page stays up and renders the failure report (the
* fail-loud surface the kernel owns). Rejects only when the boot manifest
* is missing or malformed — there is nothing to boot against.
* @returns resolves once the UI settled or the failure report rendered.
*/
async run(): Promise<void> {
this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
const root = createRoot(el)
root.render(
<AppRoot
settled={settled}
status={status}
error={error}
renderApp={() => {
const shell = ctx.get('appShell')
// Unreachable after a clean settle (the app-shell entry is in every graph).
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
return shell.renderApp()
}}
/>,
)
this.modules = new ClientModuleSystem({
modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
})
// The app-shell assembly is the only shell-own module: every other graph
// row is a plugin bundle arriving through fetch (web2 single package form).
this.modules.registerStatic(APP_SHELL_ID, AppShell)
// Adoption handoff, supply side (design §4.7): register the modules
// package's own client half under its bare package name (= graph row id
// = entry name — a suffixed key would miss the statics branch and
// trigger a real fetch), and put the instance on the kernel slot the
// wrapper's apply reads to provide ctx.modules.
this.modules.registerStatic(MODULES_ID, ModulesClient)
;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
prefetchImmediateTier(modules, graph)
.then(() => runPluginBoot(ctx, modules, graph, status))
.then(
() => { settled.set(true) },
(reason: unknown) => {
// Stay on the loading page; surface the sweep report (fail loud).
console.error(reason)
error.set(reason instanceof Error ? reason.message : String(reason))
},
this.root = createRoot(this.el)
this.root.render(
<AppRoot
settled={this.settled}
status={this.status}
error={this.error}
renderApp={() => {
const shell = this.ctx.get('appShell')
// Unreachable after a clean settle (the app-shell entry is in every graph).
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
return shell.renderApp()
}}
/>,
)
return () => { root.unmount() }
// The immediately tier prefetches in parallel with Loader mounting;
// runPluginBoot awaits it before creating entries (see module comment:
// cross-package synchronous require edges need every immediately-tier
// factory registered before any materialization).
const prefetching = this.prefetchImmediateTier()
this.ctx = new Context()
try {
await this.runPluginBoot(prefetching)
this.settled.set(true)
} catch (reason) {
// Stay on the loading page; surface the sweep report (fail loud).
console.error(reason)
this.error.set(reason instanceof Error ? reason.message : String(reason))
}
}
/** Unmount the shell (loading page or settled UI). */
dispose(): void {
this.root?.unmount()
}
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
private async prefetchImmediateTier(): Promise<void> {
await Promise.all(this.manifest.plugins
.filter((row) => row.immediately)
.map((row) => this.modules.prefetch(row.id).catch(() => {
// Import refetches and reports this loudly per entry; swallowing
// here keeps one failing prefetch from masking the others.
})))
}
/** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
const ctx = this.ctx
await ctx.plugin(Loader)
const loader = ctx.loader
// Inject the module system BEFORE any entry exists: tree.import falls back
// to a bare dynamic import when internal is undefined, which in a browser
// is a guaranteed loud failure — correct as a tripwire, never as a path.
loader.internal = this.modules as never
// Status projection: AppRoot displays fiber truth. Every internal/status
// transition under an entry re-projects that entry's row from its ROOT
// fiber (child plugin fibers share the same entry).
ctx.on('internal/status', (fiber) => {
const entry = fiber.entry
if (entry === undefined || entry.fiber === undefined) return
this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
})
// Barrier before any entry exists: entry creation materializes bundles,
// and materialization runs synchronous cross-package require edges that
// need every immediately-tier factory already registered (module
// comment). Resolves even when individual prefetches failed.
await prefetching
// Adoption handoff, plugin side: the modules entry is created first —
// its wrapper apply reads the kernel slot and provides ctx.modules (the
// provide lives on the plugin face; see MODULES_ID for why the row loop
// must then skip it).
const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.
await Promise.all(rows.map(async (name) => {
this.status.set(name, 'loading')
const id = await loader.create({ name })
// A failed import leaves the entry fiberless (Entry._init logs and
// returns); project it as failed — no fiber means no status event.
if (loader.resolve(id).fiber === undefined) {
this.status.set(name, 'failed')
}
}))
await loader.await()
this.assertEntriesActive()
}
/**
* Sweep every loader entry after the tree quiesced: an entry without a
* fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
* PENDING (a required service never arrived — cordis inject waiting has no
* timeout, so this sweep is the fail-loud compensation).
*/
private assertEntriesActive(): void {
const ctx = this.ctx
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
const name = entry.options.name
if (entry.fiber === undefined) {
failures.push(`${name}: import failed (see console for the import error)`)
continue
}
const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue
if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${name}: ${state}`)
}
}
if (failures.length > 0) {
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
}

View File

@@ -1,13 +1,13 @@
/**
* Web shell library entry. The shell's product is {@link bootWebShell} —
* apps/web's vite entry calls it against #root; everything else (AppRoot
* Web shell library entry. The shell's product is {@link AppWebEntry} —
* apps/web's vite entry runs it against #root; everything else (AppRoot
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
* single source of truth for the tsdown client externals projection.
* @module @deepseek-ai/dsh-client-web
*/
export { bootWebShell, type BootSeams } from './boot.tsx'
export { AppWebEntry, type BootSeams } from './boot.tsx'
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'

View File

@@ -188,6 +188,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'clientModuleHost',
summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.',
methods: [
{
signature: 'graph(): WebBootGraph',
jsDoc: '/**\n * Current composed entry graph (stable object between changes).\n * @returns the graph served as `window.__DSH_BOOT__`.\n */',
},
{
signature: 'clientPath(id: string): string | undefined',
jsDoc: '/**\n * Absolute path of an entry\'s client bundle.\n * @param id - entry id (package name).\n * @returns the path, or undefined for an unknown id.\n */',
},
{
signature: 'rebuilt(id: string): string | undefined',
jsDoc: '/**\n * Re-hash one bundle (the HMR watch\'s registration hook — the only entry\n * point through which bundle content changes reach the graph).\n * @param id - entry id (package name).\n * @returns the new rev, or undefined for an unknown id.\n */',
},
{
signature: 'onRebuilt(listener: (id: string, rev: string) => void): () => void',
jsDoc: '/**\n * Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.\n * @param listener - receives the entry id and its new bundle rev.\n * @returns the unsubscriber.\n */',
},
{
signature: 'onGraphChanged(listener: () => void): () => void',
jsDoc: '/**\n * Fires after any flush that recomposed the graph (row added/removed, or a\n * rebuilt rev change). Pull model: listeners re-read {@link graph}.\n * @param listener - notified with no payload.\n * @returns the unsubscriber.\n */',
},
],
},
{
key: 'codeRuntime',
summary: 'Registers one `ctx.codeRuntime` implementation.',
@@ -314,6 +340,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'httpServer',
summary: 'The web-shape HTTP carrier service.',
methods: [
{
signature: 'register(route: WebRoute): () => void',
jsDoc: '/**\n * Register a named route. Duplicate (kind, path) throws — route patterns are\n * a composition-level contract, so a collision is a misconfiguration.\n * @param route - kind, path, and the owning handler.\n * @returns the disposer removing the route.\n */',
},
{
signature: 'tapIndex(transform: (html: string) => string): () => void',
jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
},
],
},
{
key: 'invariants',
summary: 'Package-owned invariant registry with global and regex-based selection.',
@@ -2338,6 +2378,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'WebFetchResult',
declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
},
{
name: 'WebRoute',
declaration: 'export interface WebRoute {\n kind: WebRouteKind;\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}',
},
{
name: 'WebRouteKind',
declaration: 'export type WebRouteKind = \'exact\' | \'prefix\';',
},
{
name: 'WebSearchProvider',
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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.

View File

@@ -1,13 +1,70 @@
/**
* @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<Config> = 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
// createApiProxy returns closures (no `this` capture); bind only satisfies
// the unbound-method lint without changing behavior.
this.respond = api.respond.bind(api)
}
}
export default ApiProxyService

View File

@@ -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 = () => {}

View File

@@ -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"
},

View File

@@ -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.

View File

@@ -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:^",

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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<MountedWebPlugins> {
// 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`),
}
}

View File

@@ -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

View File

@@ -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<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])

View File

@@ -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<FakeEntry> {
return this.entriesList
}
async create(options: { name: string }): Promise<void> {
this.created.push(options.name)
this.onCreate?.(options.name)
}
async await(): Promise<void> {
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/')
})
})

View File

@@ -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.

View File

@@ -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:^"

View File

@@ -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/<id>/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<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
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/<anything>. */
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<void>
}
/** 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<void>
/** 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<RunningWebServer> {
const { host, port, distIndex, apiHandler, webPlugins } = options
const distRoot = dirname(distIndex)
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
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<Config> = 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<void> => {
/* 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<string, WebRoute>()
private readonly prefixes = new Map<string, WebRoute>()
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<void> {
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
/* 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<void> | undefined
const close = (): Promise<void> => (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 <head> (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 = `<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; 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<HostWebPluginRegistry, 'clientPath'>,
): Promise<void> {
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<void> {
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<void>((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<void>((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<void>((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<string> {
let html = await readFile(this.distIndex, 'utf8')
for (const transform of this.indexTaps) html = transform(html)
return html
}
res.end()
}
export default HttpServerService

View File

@@ -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/<id>/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 })
}

View File

@@ -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<ServerResponse>()
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)
},
}
}

View File

@@ -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/<id>/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/<id>/client.js?rev=<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<LoaderEntryView>
}
/** 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<string, unknown>
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<string, unknown>)['./client']
if (client === undefined) return undefined
if (typeof client === 'string') return client
if (typeof client === 'object' && client !== null) {
const fallback = (client as Record<string, unknown>).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<string, WebPluginRecord>): 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<string, WebPluginRecord>,
currentWatches: Map<string, WatchedBundle>,
): Map<string, WatchedBundle> => {
const candidateWatches = new Map<string, WatchedBundle>()
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<string, WebPluginRecord> {
const table = new Map<string, WebPluginRecord>()
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<string, unknown>
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
}

View File

@@ -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<Context> {
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/)
})
})

View File

@@ -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<typeof import('node:fs')>()
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<string, unknown>, 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<string, unknown> = {}): Record<string, unknown> => ({
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<string, unknown>; loaded?: boolean; disabled?: boolean; withBundle?: boolean }[],
): Fixture {
const root = mkdtempSync(join(tmpdir(), 'dsh-webplugins-'))
const paths = new Map<string, string>()
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 <head> and escapes </script> breakouts', () => {
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
const out = injectBootManifest(html, {
rev: 'r1',
entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
})
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
expect(out).not.toContain('</script><script>alert(1)')
expect(out).toContain('\\u003c/script')
})
it('prepends when the page has no <head>', () => {
const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
})
})
describe('clientExportOf shapes (through the registry build)', () => {
it('accepts the conditional {types, default} export form', () => {
const { deps } = makeDeps([{
name: 'conditional',
pkg: {
dshClient: { platform: 'web' },
exports: { './client': { types: './lib/types/client/index.d.ts', default: './lib/client.js' } },
},
}])
const registry = createHostWebPluginRegistry(deps)
expect(registry.clientPath('conditional')).toMatch(/lib[/\\]client\.js$/)
registry.dispose()
})
it('rejects a conditional form without a string default, an array form, and a non-object exports field', () => {
for (const exportsField of [
{ './client': { types: './x.d.ts' } },
{ './client': ['./a.js'] },
]) {
const { deps } = makeDeps([{ name: 'bad-shape', pkg: { dshClient: { platform: 'web' }, exports: exportsField } }])
expect(() => createHostWebPluginRegistry(deps)).toThrow(/unsupported shape/)
}
// Non-object exports: treated as "no ./client export" → the declares-but-no-bundle throw.
const { deps } = makeDeps([{ name: 'no-exports', pkg: { dshClient: { platform: 'web' }, exports: './single.js' } }])
expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/)
})
it('skips duplicate loader entries for the same package name (first wins)', () => {
const { deps, entries } = makeDeps([{ name: 'dup-entry', pkg: webDecl() }])
const first = entries[0] as LoaderEntryView
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
void first
const registry = createHostWebPluginRegistry(deps)
expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
registry.dispose()
})
it('rejects a null conditional form and wraps a non-Error rescan throw', async () => {
// client: null → the object-form branch's null guard.
const nulled = makeDeps([{ name: 'null-client', pkg: { dshClient: { platform: 'web' }, exports: { './client': null } } }])
expect(() => createHostWebPluginRegistry(nulled.deps)).toThrow(/unsupported shape/)
// Non-Error rescan throw: resolvePkgJson throws a string; onError must get a wrapped Error.
const { deps, entries, errors, ctx } = makeDeps([{ name: 'ok-one', pkg: webDecl() }])
const registry = createHostWebPluginRegistry(deps)
entries.push({ options: { name: 'ghost-two' }, fiber: {}, disabled: false })
const original = deps.resolvePkgJson
deps.resolvePkgJson = (name) => {
if (name === 'ghost-two') throw 'string failure'
return original(name)
}
ctx.emit('internal/plugin', ctx.fiber)
await Promise.resolve()
expect(errors[0]).toBeInstanceOf(Error)
expect(String(errors[0])).toContain('string failure')
registry.dispose()
})
})

View File

@@ -1,400 +0,0 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { Server as NetServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startWebServer, type RunningWebServer } from '../src/index.ts'
/** dist fixture: index.html + one asset of each MIME class + a subdir. */
function makeDist(): { distIndex: string; distRoot: string } {
const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-'))
writeFileSync(join(distRoot, 'index.html'), '<html>INDEX</html>')
writeFileSync(join(distRoot, 'app.js'), 'console.log(1)')
writeFileSync(join(distRoot, 'app.css'), 'body{}')
writeFileSync(join(distRoot, 'logo.svg'), '<svg/>')
writeFileSync(join(distRoot, 'data.json'), '{}')
writeFileSync(join(distRoot, 'app.js.map'), '{}')
writeFileSync(join(distRoot, 'blob.bin'), 'BIN')
mkdirSync(join(distRoot, 'sub'))
writeFileSync(join(distRoot, 'sub', 'page.html'), '<html>SUB</html>')
return { distIndex: join(distRoot, 'index.html'), distRoot }
}
const echoingApi = {
fetch: async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const req = input instanceof Request ? input : new Request(input, init)
if (req.url.endsWith('/api/echo')) {
return Response.json({ method: req.method, body: await req.text(), header: req.headers.get('x-probe') })
}
if (req.url.endsWith('/api/empty')) return new Response(null, { status: 204 })
if (req.url.endsWith('/api/big')) {
// Chunks far above any socket highWaterMark force res.write to return false.
const big = new Uint8Array(4 * 1024 * 1024).fill(65)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(big)
controller.enqueue(big)
controller.close()
},
})
return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } })
}
if (req.url.endsWith('/api/sse')) {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: one\n\n'))
controller.enqueue(encoder.encode('data: two\n\n'))
controller.close()
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
}
if (req.url.endsWith('/api/throw-string')) {
// Non-Error rejection: the guard must wrap it for onError.
throw 'string failure'
}
if (req.url.endsWith('/api/explode-mid-stream')) {
// Headers go out with the first chunk, then the source errors: the
// guard's headersSent leg must destroy the socket, not writeHead again.
// The error is deferred a tick so the 200 + first chunk actually flush
// to the client before the teardown.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: first\n\n'))
setTimeout(() => { controller.error(new Error('stream exploded')) }, 20)
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
}
if (req.url.endsWith('/api/abort-probe')) {
// Endless SSE that only ends when the request signal aborts.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
req.signal.addEventListener('abort', () => {
try {
controller.close()
} catch { /* already closed by teardown: nothing else can reach this */ }
}, { once: true })
controller.enqueue(new TextEncoder().encode('data: open\n\n'))
},
})
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } })
}
return new Response('nope', { status: 404 })
},
}
let server: RunningWebServer | undefined
afterEach(async () => {
await server?.close()
server = undefined
})
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
const { distIndex } = makeDist()
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError)
return `http://127.0.0.1:${String(server.port)}`
}
describe('startWebServer', () => {
it('reports the listening port and closes idempotently', async () => {
const { distIndex } = makeDist()
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
expect(server.port).toBeGreaterThan(0)
const first = server.close()
const second = server.close()
expect(second).toBe(first)
await first
server = undefined
})
it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
const { distIndex } = makeDist()
const port = 3080
const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
this: NetServer, ...args: unknown[]
): NetServer {
const callback = args.at(-1)
if (typeof callback !== 'function') throw new TypeError('listen callback missing')
queueMicrotask(callback as () => void)
return this
})
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
try {
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
await inertServer.close()
} finally {
address.mockRestore()
listen.mockRestore()
}
})
it('rejects when the port is already taken', async () => {
const { distIndex } = makeDist()
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
const { port } = server
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
.rejects.toMatchObject({ code: 'EADDRINUSE' })
})
})
describe.skipIf(process.platform === 'win32')('static serving', () => {
it('serves index at /, subpaths by MIME, octet-stream for unknown, SPA fallback on miss', async () => {
const base = await boot()
const index = await fetch(`${base}/`)
expect(index.status).toBe(200)
expect(index.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(await index.text()).toBe('<html>INDEX</html>')
expect((await fetch(`${base}/app.js`)).headers.get('content-type')).toBe('text/javascript; charset=utf-8')
expect((await fetch(`${base}/app.css`)).headers.get('content-type')).toBe('text/css; charset=utf-8')
expect((await fetch(`${base}/logo.svg`)).headers.get('content-type')).toBe('image/svg+xml')
expect((await fetch(`${base}/data.json`)).headers.get('content-type')).toBe('application/json')
expect((await fetch(`${base}/app.js.map`)).headers.get('content-type')).toBe('application/json')
expect((await fetch(`${base}/blob.bin`)).headers.get('content-type')).toBe('application/octet-stream')
expect(await (await fetch(`${base}/sub/page.html`)).text()).toBe('<html>SUB</html>')
const miss = await fetch(`${base}/routes/deep/link`)
expect(miss.status).toBe(200)
expect(await miss.text()).toBe('<html>INDEX</html>')
})
it('403s traversal outside the dist root and 405s non-GET/HEAD', async () => {
const base = await boot()
// %2e%2e would be dot-collapsed by WHATWG URL parsing on both ends; an
// encoded slash keeps the segment intact until the server's decodeURIComponent.
const traversal = await fetch(`${base}/..%2f..%2fetc%2fpasswd`)
expect(traversal.status).toBe(403)
const put = await fetch(`${base}/index.html`, { method: 'PUT', body: 'x' })
expect(put.status).toBe(405)
})
it('answers HEAD like GET (no 405)', async () => {
const base = await boot()
const head = await fetch(`${base}/`, { method: 'HEAD' })
expect(head.status).toBe(200)
})
})
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
const graphValue = {
rev: 'graphrev00001',
entries: [
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
{ id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
],
}
/** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
interface RebuiltHarness {
notify: (id: string, rev: string) => void
unsubscribed: boolean
}
async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
const { distIndex, distRoot } = makeDist()
writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
const webPlugins = {
graph: () => graphValue,
clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
onRebuilt: (listener: (id: string, rev: string) => void) => {
if (harness !== undefined) harness.notify = listener
return () => {
if (harness !== undefined) harness.unsubscribed = true
}
},
}
server = await startWebServer(
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
return `http://127.0.0.1:${String(server.port)}`
}
it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
const base = await bootWithPlugins()
const index = await (await fetch(`${base}/`)).text()
expect(index).toContain('window.__DSH_BOOT__')
const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
expect(fallback).toContain('window.__DSH_BOOT__')
const direct = await (await fetch(`${base}/index.html`)).text()
expect(direct).toContain('window.__DSH_BOOT__')
expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
})
it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
const base = await bootWithPlugins()
const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
expect(bundle.status).toBe(200)
expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
expect(bundle.headers.get('cache-control')).toBe('no-cache')
expect(await bundle.text()).toContain('DSHClientProxy')
expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
})
it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
const { distIndex } = makeDist()
const webPlugins = {
graph: () => graphValue,
clientPath: () => '/nonexistent/lib/client.js',
onRebuilt: () => () => undefined,
}
server = await startWebServer(
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
expect(res.status).toBe(404)
})
it('keeps all plugin surfaces off without the webPlugins option', async () => {
const base = await boot()
expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
// No plugin routes: fall through to static SPA fallback semantics.
const res = await fetch(`${base}/plugins/x/client.js`)
expect(res.status).toBe(200)
expect(await res.text()).toBe('<html>INDEX</html>')
const events = await fetch(`${base}/plugins/events`)
expect(await events.text()).toBe('<html>INDEX</html>')
})
it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
const base = await bootWithPlugins(harness)
const events = await fetch(`${base}/plugins/events`)
expect(events.status).toBe(200)
expect(events.headers.get('content-type')).toBe('text/event-stream')
const reader = events.body?.getReader()
const decoder = new TextDecoder()
let buffer = ''
async function readUntil(marker: string): Promise<void> {
while (!buffer.includes(marker)) {
const chunk = await reader?.read()
if (chunk?.done !== false) throw new Error('SSE stream ended early')
buffer += decoder.decode(chunk.value, { stream: true })
}
}
await readUntil('"type":"graph"')
expect(buffer).toContain(': connected')
const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
// The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
harness.notify(FETCH_ID, 'cccc1111dddd')
await readUntil('"type":"rebuilt"')
expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
await reader?.cancel()
// Shutdown unsubscribes the relay (no broadcast into a closed channel).
await server?.close()
server = undefined
expect(harness.unsubscribed).toBe(true)
})
})
describe('request-handling guard (one bad request must not kill the process)', () => {
it('400s malformed %-escapes, reports to onError, and stays alive', async () => {
const errors: Error[] = []
const base = await boot(err => errors.push(err))
for (const path of ['/%', '/%c0', '/%zz%']) {
expect((await fetch(`${base}${path}`)).status).toBe(400)
}
expect(errors.length).toBe(3)
expect(errors[0]?.name).toBe('URIError')
// The barrage left the server serving.
expect((await fetch(`${base}/`)).status).toBe(200)
})
it('wraps a non-Error throw for onError and still answers 400', async () => {
const errors: Error[] = []
const base = await boot(err => errors.push(err))
expect((await fetch(`${base}/api/throw-string`, { method: 'POST' })).status).toBe(400)
expect(errors[0]).toBeInstanceOf(Error)
expect(errors[0]?.message).toBe('string failure')
})
it('destroys the socket when the failure lands after headers went out', async () => {
const errors: Error[] = []
const base = await boot(err => errors.push(err))
const response = await fetch(`${base}/api/explode-mid-stream`)
expect(response.status).toBe(200) // headers made it out before the explosion
await expect(response.text()).rejects.toThrow() // then the socket is torn down
expect(errors.length).toBe(1)
expect((await fetch(`${base}/`)).status).toBe(200)
})
})
describe('/api bridge', () => {
it('forwards method, headers, and body; relays status and body back', async () => {
const base = await boot()
const response = await fetch(`${base}/api/echo`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-probe': 'p1' },
body: JSON.stringify({ n: 1 }),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' })
})
it('relays a bodyless response', async () => {
const base = await boot()
const response = await fetch(`${base}/api/empty`, { method: 'POST' })
expect(response.status).toBe(204)
expect(await response.text()).toBe('')
})
it('streams SSE frames through chunk by chunk', async () => {
const base = await boot()
const response = await fetch(`${base}/api/sse`)
expect(response.headers.get('content-type')).toBe('text/event-stream')
expect(await response.text()).toBe('data: one\n\ndata: two\n\n')
})
it('waits for drain when a streamed chunk overfills the socket buffer', async () => {
// 4 MiB chunks dwarf the socket highWaterMark, so res.write returns false
// and the bridge parks on 'drain'; reading the body to completion proves
// the loop resumed instead of dropping the remainder.
const base = await boot()
const response = await fetch(`${base}/api/big`)
const body = new Uint8Array(await response.arrayBuffer())
expect(body.length).toBe(8 * 1024 * 1024)
expect(body[0]).toBe(65)
expect(body[body.length - 1]).toBe(65)
})
it('releases a drain wait when the client disconnects mid-chunk', async () => {
// The 'close' leg of the drain race: abort while the socket buffer is
// still full so the parked write wakes via 'close', not 'drain'.
const base = await boot()
const ac = new AbortController()
const response = await fetch(`${base}/api/big`, { signal: ac.signal })
const reader = response.body?.getReader()
const first = await reader?.read()
expect(first?.value?.length).toBeGreaterThan(0)
ac.abort()
// afterEach close() completing is the leak assertion, same as abort-probe.
await new Promise((resolve) => { setTimeout(resolve, 50) })
})
it('aborts the bridged request when the client disconnects mid-SSE', async () => {
const base = await boot()
const ac = new AbortController()
const response = await fetch(`${base}/api/abort-probe`, { signal: ac.signal })
const reader = response.body?.getReader()
expect(reader).toBeDefined()
const first = await reader?.read()
expect(new TextDecoder().decode(first?.value)).toContain('open')
ac.abort()
// server-side abort propagation has no client-observable handshake beyond
// the closed connection; close() would hang on a leaked live SSE socket,
// so afterEach completing IS the assertion that the bridge released it.
await new Promise((resolve) => { setTimeout(resolve, 50) })
})
})

View File

@@ -11,6 +11,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
}