Merge remote-tracking branch 'origin/master' into codex/trim-redundant-comments
# Conflicts: # packages/client/connection/src/index.ts # packages/host/webserver/tests/web-plugins.spec.ts
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
8
packages/client/connection/src/api-path.ts
Normal file
8
packages/client/connection/src/api-path.ts
Normal 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'
|
||||
59
packages/client/connection/src/http-bridge.ts
Normal file
59
packages/client/connection/src/http-bridge.ts
Normal 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()
|
||||
}
|
||||
@@ -1,4 +1,29 @@
|
||||
/** Host loader entry for the browser wire client exported from `./client`. */
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
// Activates the httpServer Context merge used below.
|
||||
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'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* @param ctx - Host plugin context.
|
||||
*/
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
16
packages/client/hmr/src/events.ts
Normal file
16
packages/client/hmr/src/events.ts
Normal 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'
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
34
packages/client/modules/src/client/index.ts
Normal file
34
packages/client/modules/src/client/index.ts
Normal 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)
|
||||
}
|
||||
243
packages/client/modules/src/client/manifest.ts
Normal file
243
packages/client/modules/src/client/manifest.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
3
packages/client/modules/tsdown.config.ts
Normal file
3
packages/client/modules/tsdown.config.ts
Normal 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'])
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user