| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- /**
- * Worker assembly entry: the whole harness Cordis tree inside one dedicated
- * Web Worker.
- *
- * Every platform object arrives through options — the `node:*` proxy table, the
- * request listener the app's fake `node:http` captured, the image bytes — so this
- * package never reaches back into the application that composes it. **Platform
- * readiness before the call is the caller's responsibility**: anything the
- * proxies need initialized (the zstd WebAssembly module, for one) must be ready
- * before {@link startWorkerHost} runs.
- *
- * Construction is split in two on purpose. {@link createWorkerHost} is
- * synchronous so the worker can accept messages and queue requests that arrive
- * during boot; {@link WorkerHost.start} then mounts the image, the module
- * loader, and the tree. {@link startWorkerHost} performs both and installs the
- * message handler before its first await.
- *
- * The tree itself boots through the host's own `boot()` glue loaded from the
- * image, so entry mounting, the activation audit, and its diagnostics are the
- * same code the Node deployment runs. Only the module seam and the command line
- * are supplied from here.
- * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host
- */
- import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
- import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
- import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
- import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
- import { dirname, join } from './module-system/posix-path.ts'
- import { installProcessGlobal } from './node/globals/process.ts'
- import type { RequestListener } from './transport/synthetic-http.ts'
- import { TunnelServer, type TunnelPort } from './transport/tunnel.ts'
- import { inflateImage, inflateImageStream } from './storage/image-gzip.ts'
- import { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts'
- import { setActiveVfs } from './storage/active.ts'
- import {
- DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH,
- LOWERING_VERSION,
- } from './image-layout.ts'
- export { DEFAULT_ROOT } from './image-layout.ts'
- /** Port reported to the tree when the caller names none; the bind is fake either way. */
- export const DEFAULT_PORT = 3080
- // Every literal `require`/`resolve` of an image package below must appear in
- // the packer's IMAGE_ENTRY_SEEDS: no image file references these requests, so
- // the reachability sweep only keeps them when seeded.
- /** One structured log record, as cordis delivers it to an exporter. */
- export interface LogMessage {
- readonly name: string
- readonly type: 'error' | 'info' | 'warn' | 'debug'
- readonly args: readonly unknown[]
- }
- /** The exporter face `ctx.logger.exporter()` accepts. */
- export interface LogExporter {
- readonly colors: false
- /** Verbosity gate, per logger name or `default`; cordis drops a message when its level exceeds this. */
- readonly levels: { readonly default: number }
- export(message: LogMessage): void
- }
- /** Minimal view of the Cordis context the entry itself touches. */
- export interface HostContext {
- loader: { internal: unknown }
- logger: { exporter(exporter: LogExporter): unknown }
- get(service: string): unknown
- provide(name: string, value: unknown): void
- fiber: { dispose(): Promise<void> }
- }
- /** Construction inputs for {@link createWorkerHost}. */
- export interface WorkerHostOptions {
- /**
- * Modules served from the worker bundle rather than the image: the `node:*`
- * proxies, the not-implemented stubs for excluded npm packages, and anything else whose
- * platform behavior differs. `node:process` and `process` are added when absent,
- * as factories reading the installed global.
- */
- readonly staticModules: Readonly<Record<string, StaticModuleFactory>>
- /** Prefix-matched proxies, for packages whose subpaths are open-ended. */
- readonly staticModulePrefixes?: Readonly<Record<string, StaticModuleFactory>>
- /**
- * The webserver's request listener, captured by the app's fake `node:http`.
- * Awaited on first tunnel use, so it may resolve after the tree binds.
- */
- readonly requestListener: () => Promise<RequestListener>
- /** Image bytes, or the URL the worker fetches them from. */
- readonly image: Uint8Array | string
- /** Ordered data overlays applied after the base image and before boot. */
- readonly overlays?: readonly (Uint8Array | string)[]
- /** Virtual root; defaults to {@link DEFAULT_ROOT}. */
- readonly root?: string
- /** Composed configuration inside the image; defaults to `<root>/config/cordis.yml`. */
- readonly configPath?: string
- /**
- * Inner arguments the tree parses. The default binds the web server to the
- * loopback authority the tunnel synthesizes, which also keeps
- * `networkInterfaces()` out of the trust snapshot.
- */
- readonly cmdlineArgs?: readonly string[]
- /** Port named on the default command line; defaults to {@link DEFAULT_PORT}. */
- readonly port?: number
- /** Environment for the process shim; `DSH_HOME` defaults to `<root>/home`. */
- readonly env?: Readonly<Record<string, string>>
- /**
- * Image manifest path; defaults to `<root>/config/vfs-manifest.json`. Its
- * `lowered` field must name this build's wrapper contract.
- */
- readonly manifestPath?: string
- /**
- * Ambient-store snapshot face exported by the app's `node:async_hooks` proxy.
- * The rewrite that carries stores across suspension points moves state through
- * it; the proxy remains the only owner of that state.
- */
- readonly alsCausality?: AlsCausality
- /** Privileged API methods that skip the route lane; see {@link TunnelServer}. */
- readonly privilegedMethods?: ReadonlySet<string>
- /** Escape hatch for the unary `/api` lane; see {@link TunnelServer}. */
- readonly unaryApiLane?: 'route' | 'direct'
- /** Channel back to the page; defaults to the worker global scope. */
- readonly channel?: TunnelPort
- }
- /** The assembled worker host. */
- export interface WorkerHost {
- /** Feed one `postMessage` payload; safe before {@link WorkerHost.start}. */
- handleMessage(data: unknown): void
- /**
- * Mount the image and boot the tree, then start serving queued requests.
- * @returns Resolves once the tree is active and the tunnel is serving.
- */
- start(): Promise<void>
- /** Dispose the tree; the tunnel keeps refusing afterwards. */
- stop(): Promise<void>
- /** Filesystem the tree reads, once {@link WorkerHost.start} mounted it. */
- readonly vfs: MemoryVfs | undefined
- /** Module loader behind the Cordis module seam. */
- readonly modules: WorkerModuleLoader | undefined
- }
- function requireGlobalPort(channel: TunnelPort | undefined): TunnelPort {
- if (channel !== undefined) return channel
- const scope = globalThis as { postMessage?: TunnelPort['postMessage'] }
- const post = scope.postMessage
- if (typeof post !== 'function') {
- throw new Error('webworker host: no channel; pass options.channel outside a dedicated worker')
- }
- return { postMessage: (message, transfer) => { post(message, transfer) } }
- }
- async function readImage(image: Uint8Array | string): Promise<Uint8Array> {
- if (typeof image !== 'string') return await inflateImage(image, 'the image bytes given to createWorkerHost')
- const response = await fetch(image)
- if (!response.ok) throw new Error(`webworker host: image fetch failed with ${String(response.status)} for ${image}`)
- if (response.body === null) throw new Error(`webworker host: image response for ${image} carried no body`)
- // Inflated off the response stream: the archive is built while the rest of the
- // image is still arriving.
- return await inflateImageStream(response.body, image)
- }
- /**
- * Build the worker host without touching the network or the image.
- * @param options - Assembly inputs.
- * @returns Handle whose `handleMessage` is ready immediately.
- */
- export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
- const root = options.root ?? DEFAULT_ROOT
- const configPath = options.configPath ?? join(root, IMAGE_CONFIG_PATH)
- const port = options.port ?? DEFAULT_PORT
- const tunnel = new TunnelServer({
- port: requireGlobalPort(options.channel),
- requestListener: options.requestListener,
- ...options.privilegedMethods === undefined ? {} : { privilegedMethods: options.privilegedMethods },
- ...options.unaryApiLane === undefined ? {} : { unaryApiLane: options.unaryApiLane },
- })
- let vfs: MemoryVfs | undefined
- let modules: WorkerModuleLoader | undefined
- let context: HostContext | undefined
- const start = async (): Promise<void> => {
- try {
- const home = join(root, IMAGE_HOME_DIRECTORY)
- installProcessGlobal({ cwd: root, env: { DSH_HOME: home, HOME: home, ...options.env } })
- const [bytes, overlays] = await Promise.all([
- readImage(options.image),
- Promise.all((options.overlays ?? []).map(readImage)),
- ])
- const mounted = loadVfsImage(bytes, root)
- for (const overlay of overlays) loadVfsOverlay(overlay, root, mounted)
- // Belt and braces over the image's own empty-directory entries: a hand
- // -built image without them still boots.
- for (const directory of IMAGE_EMPTY_DIRECTORIES) {
- mounted.seedDirectory(join(root, directory.replace(/\/$/, '')))
- }
- setActiveVfs(mounted)
- vfs = mounted
- const manifestPath = options.manifestPath ?? join(root, IMAGE_MANIFEST_PATH)
- requireLoweredImage(mounted, manifestPath)
- const staticModules: Record<string, StaticModuleFactory> = { ...options.staticModules }
- // Read at require time, not here: the table entry then answers whichever
- // global `installProcessGlobal` left in place, in this role's order.
- for (const key of ['node:process', 'process']) {
- staticModules[key] ??= (): unknown => (globalThis as { process?: unknown }).process
- }
- const loader = new WorkerModuleLoader({
- vfs: mounted,
- root,
- staticModules,
- ...options.staticModulePrefixes === undefined ? {} : { staticModulePrefixes: options.staticModulePrefixes },
- ...options.alsCausality === undefined ? {} : { alsCausality: options.alsCausality },
- })
- setActiveModuleLoader(loader)
- modules = loader
- const require = loader.requireFrom(dirname(configPath))
- const appBoot = require('@deepseek-ai/dsh-app-boot') as {
- boot(
- binName: string,
- configPath: string,
- patches: unknown[],
- prepare: (ctx: HostContext) => void,
- ): Promise<HostContext>
- }
- const cmdline = require('@deepseek-ai/dsh-cmdline') as {
- provideCmdline(ctx: unknown, host: { args: readonly string[]; exit: (code: number) => void }): void
- }
- const { patches, presetOverlay } = bootPatches(loader, mounted, configPath, root)
- const ctx = await appBoot.boot('dsh-webworker', configPath, patches, (hostCtx) => {
- // Before any entry mounts: the Loader would otherwise fall back to the
- // runtime's own dynamic import for every row.
- hostCtx.loader.internal = loader.internal
- installLogSink(hostCtx, require)
- cmdline.provideCmdline(hostCtx, {
- args: [...(options.cmdlineArgs ?? ['--host', '127.0.0.1', '--port', String(port), '--no-open'])],
- exit: (code: number) => { console.warn(`webworker host: tree requested exit(${String(code)})`) },
- })
- })
- context = ctx
- const connection = ctx.get('connection') as HostConnectionHandle | undefined
- if (connection === undefined) throw new Error('webworker host: the tree activated without a Connection service')
- const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined
- if (typertGateway === undefined) {
- throw new Error('webworker host: the tree activated without a typertGateway service')
- }
- const handler = connection.createSharedFetchHandler('/api')
- const usage = loader.usage()
- console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`)
- tunnel.serve({
- directFetch: (request: Request) => handler.fetch(request),
- bootPayload: () => readBootPayload(ctx),
- openStream: typertGateway.wireStream.open,
- streamFailure: typertGateway.wireStream.failure,
- })
- } catch (reason) {
- tunnel.fail(reason)
- throw reason
- }
- }
- return {
- handleMessage: (data: unknown): void => { tunnel.handleMessage(data) },
- start,
- stop: async (): Promise<void> => {
- tunnel.fail(new Error('webworker host: the tree was disposed'))
- await context?.fiber.dispose()
- },
- get vfs(): MemoryVfs | undefined {
- return vfs
- },
- get modules(): WorkerModuleLoader | undefined {
- return modules
- },
- }
- }
- /** The cordis message renderer this sink formats through. */
- export interface LogRenderer {
- format(exporter: LogExporter, message: LogMessage): string
- }
- /**
- * Send the tree's own warnings and errors to the worker console.
- *
- * Cordis's `LoggerService` always exists and always accepts messages, but with
- * no exporter mounted it only fills a ring buffer — and no profile in this
- * repository mounts one, so `ctx.logger.warn(...)` reaches nothing. A provider
- * that fails and is skipped (the skill registry logs exactly that) then looks
- * identical to one that found nothing, which is how an empty skill catalog hid a
- * filesystem fault twice.
- *
- * Warnings and errors only: `info`/`debug` from 131 plugin rows would bury the
- * page console, and this exists to make failures visible rather than to trace.
- * @param ctx - Host context, before any entry mounts.
- * @param require - Image resolver, for cordis's own message renderer.
- */
- export function installLogSink(ctx: HostContext, require: (specifier: string) => unknown): void {
- const { Logger } = require('@deepseek-ai/cordis') as { Logger: LogRenderer }
- const exporter: LogExporter = {
- colors: false,
- // cordis compares `exporter.levels ?? logger.level ?? INFO` against the
- // message level and drops anything higher, and its scale counts UP with
- // verbosity (ERROR 0, INFO 1, WARN 2, DEBUG 3). An exporter that declares no
- // level therefore admits errors and info but silently drops every warning —
- // which is what the built-in ring-buffer exporter does, so the skipped-provider
- // warning this sink exists for never even reached the buffer.
- levels: { default: 2 },
- export: (message) => {
- if (message.type !== 'warn' && message.type !== 'error') return
- const line = `${message.name}: ${Logger.format(exporter, message)}`
- if (message.type === 'error') console.error(line)
- else console.warn(line)
- },
- }
- ctx.logger.exporter(exporter)
- }
- /**
- * Require the mounted image to carry bodies this build can wrap.
- *
- * The manifest the packer writes is the single source of truth: the worker holds
- * no transform, so an image that was never lowered — or was lowered against
- * different wrapper semantics — cannot be recovered at load and must be rebuilt.
- * @param vfs - Mounted filesystem.
- * @param path - Manifest path inside the image.
- * @throws When the manifest is missing, unreadable, or names another contract.
- */
- function requireLoweredImage(vfs: MemoryVfs, path: string): void {
- if (!vfs.existsSync(path)) {
- throw new Error(`webworker host: ${path} is missing, so the image records no lowering; rebuild the image`)
- }
- const parsed: unknown = JSON.parse(vfs.readFileSync(path, 'utf8') as string)
- if (typeof parsed !== 'object' || parsed === null) {
- throw new Error(`webworker host: ${path} does not hold an object`)
- }
- const lowered = (parsed as { lowered?: unknown }).lowered
- if (lowered !== LOWERING_VERSION) {
- throw new Error(`webworker host: image was lowered by ${String(lowered)}, this build runs ${LOWERING_VERSION}; rebuild the image`)
- }
- }
- /**
- * The shipped preset root, as the application layer that owns the composition
- * supplies it.
- *
- * A launcher appends this root itself rather than writing it into the roster —
- * `apps/cli` does it in `composeProfile` (`profile-boot.ts:159-166`) because only
- * the application knows where its own presets sit. The worker's presets travel
- * in the image, so the same overlay names their virtual path. Patching replaces
- * a row's whole `config`, so the current one is read and spread, and a roster
- * that already names roots keeps them.
- * @param loader - Module loader, for the image's YAML reader.
- * @param vfs - Filesystem holding the composed configuration.
- * @param configPath - Composed configuration path.
- * @param root - Virtual root.
- * @returns Boot patches (preset root overlay, frontend serving off) and
- * whether the preset overlay was applied.
- */
- function bootPatches(
- loader: WorkerModuleLoader,
- vfs: MemoryVfs,
- configPath: string,
- root: string,
- ): { patches: unknown[]; presetOverlay: boolean } {
- const text = vfs.readFileSync(configPath, 'utf8') as string
- let rows: unknown
- if (configPath.endsWith('.json')) {
- rows = JSON.parse(text)
- } else {
- // The roster's `!!js` scalars need Include's own YAML dialect.
- const include = loader.load(loader.resolve('@deepseek-ai/cordis-plugin-include', root)) as { entryListSchema: unknown }
- const yaml = loader.load(loader.resolve('js-yaml', root)) as { load(source: string, options: { schema: unknown }): unknown }
- rows = yaml.load(text, { schema: include.entryListSchema })
- }
- const find = (entries: unknown, id: string): Record<string, unknown> | undefined => {
- if (!Array.isArray(entries)) return undefined
- for (const entry of entries as Array<Record<string, unknown>>) {
- if (entry.id === id) return entry
- const nested = find(entry.config, id)
- if (nested !== undefined) return nested
- }
- return undefined
- }
- const configOf = (row: Record<string, unknown>): Record<string, unknown> =>
- (typeof row.config === 'object' && row.config !== null && !Array.isArray(row.config)
- ? row.config
- : {}) as Record<string, unknown>
- const patches: unknown[] = []
- let presetOverlay = false
- const presets = find(rows, 'agent-presets')
- if (presets !== undefined && configOf(presets).roots === undefined) {
- presetOverlay = true
- patches.push({
- id: 'agent-presets',
- config: { ...configOf(presets), roots: [{ path: join(root, 'config/agent-presets'), trust: 'system' }] },
- })
- }
- // The worker carries no compression codec, and the VFS is in-memory anyway:
- // the JSONL backend's plaintext path is the composition's one legal encoding.
- const jsonl = find(rows, 'session-persistence-jsonl')
- if (jsonl !== undefined) {
- patches.push({ id: 'session-persistence-jsonl', config: { ...configOf(jsonl), compression: 'none' } })
- }
- return { patches, presetOverlay }
- }
- /**
- * Assemble the payload the page's pre-Cordis bootstrap needs: the structured
- * index injection table the served form renders into index.html. Collected
- * from the in-process webserver service, never from the API surface, because
- * the page has no Cordis tree yet.
- * @param ctx - Booted host context.
- * @returns Boot payload for `GET /__boot__`.
- */
- function readBootPayload(ctx: HostContext): { injections: unknown } {
- const webServer = ctx.get('webServer') as { collectIndexInjections(): unknown } | undefined
- if (webServer === undefined) {
- throw new Error('webworker host: no webServer service, so the page cannot receive its boot injections')
- }
- return { injections: webServer.collectIndexInjections() }
- }
- /**
- * Install the message handler and boot the tree.
- *
- * The handler is attached before the first await, so requests that arrive
- * during boot queue instead of being dropped. A boot failure refuses the queue
- * with 503 and rejects.
- * @param options - Assembly inputs; `channel` also replaces the message source.
- * @returns Resolves once the tunnel is serving.
- */
- export async function startWorkerHost(options: WorkerHostOptions): Promise<void> {
- const host = createWorkerHost(options)
- if (options.channel === undefined) {
- const scope = globalThis as { addEventListener?: (type: string, listener: (event: MessageEvent) => void) => void }
- if (typeof scope.addEventListener !== 'function') {
- throw new Error('webworker host: no message source; pass options.channel outside a dedicated worker')
- }
- scope.addEventListener('message', (event: MessageEvent) => { host.handleMessage(event.data) })
- }
- await host.start()
- }
|