1
0

worker-host.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. /**
  2. * Worker assembly entry: the whole harness Cordis tree inside one dedicated
  3. * Web Worker.
  4. *
  5. * Every platform object arrives through options — the `node:*` proxy table, the
  6. * request listener the app's fake `node:http` captured, the image bytes — so this
  7. * package never reaches back into the application that composes it. **Platform
  8. * readiness before the call is the caller's responsibility**: anything the
  9. * proxies need initialized (the zstd WebAssembly module, for one) must be ready
  10. * before {@link startWorkerHost} runs.
  11. *
  12. * Construction is split in two on purpose. {@link createWorkerHost} is
  13. * synchronous so the worker can accept messages and queue requests that arrive
  14. * during boot; {@link WorkerHost.start} then mounts the image, the module
  15. * loader, and the tree. {@link startWorkerHost} performs both and installs the
  16. * message handler before its first await.
  17. *
  18. * The tree itself boots through the host's own `boot()` glue loaded from the
  19. * image, so entry mounting, the activation audit, and its diagnostics are the
  20. * same code the Node deployment runs. Only the module seam and the command line
  21. * are supplied from here.
  22. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host
  23. */
  24. import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
  25. import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
  26. import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
  27. import { dirname, join } from './module-system/posix-path.ts'
  28. import { installProcessGlobal } from './node/globals/process.ts'
  29. import type { RequestListener } from './transport/synthetic-http.ts'
  30. import { TunnelServer, type TunnelPort } from './transport/tunnel.ts'
  31. import { inflateImage, inflateImageStream } from './storage/image-gzip.ts'
  32. import { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts'
  33. import { setActiveVfs } from './storage/active.ts'
  34. import {
  35. DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH,
  36. LOWERING_VERSION,
  37. } from './image-layout.ts'
  38. export { DEFAULT_ROOT } from './image-layout.ts'
  39. /** Port reported to the tree when the caller names none; the bind is fake either way. */
  40. export const DEFAULT_PORT = 3080
  41. // Every literal `require`/`resolve` of an image package below must appear in
  42. // the packer's IMAGE_ENTRY_SEEDS: no image file references these requests, so
  43. // the reachability sweep only keeps them when seeded.
  44. /** One structured log record, as cordis delivers it to an exporter. */
  45. export interface LogMessage {
  46. readonly name: string
  47. readonly type: 'error' | 'info' | 'warn' | 'debug'
  48. readonly args: readonly unknown[]
  49. }
  50. /** The exporter face `ctx.logger.exporter()` accepts. */
  51. export interface LogExporter {
  52. readonly colors: false
  53. /** Verbosity gate, per logger name or `default`; cordis drops a message when its level exceeds this. */
  54. readonly levels: { readonly default: number }
  55. export(message: LogMessage): void
  56. }
  57. /** Minimal view of the Cordis context the entry itself touches. */
  58. export interface HostContext {
  59. loader: { internal: unknown }
  60. logger: { exporter(exporter: LogExporter): unknown }
  61. get(service: string): unknown
  62. provide(name: string, value: unknown): void
  63. fiber: { dispose(): Promise<void> }
  64. }
  65. /** Construction inputs for {@link createWorkerHost}. */
  66. export interface WorkerHostOptions {
  67. /**
  68. * Modules served from the worker bundle rather than the image: the `node:*`
  69. * proxies, the not-implemented stubs for excluded npm packages, and anything else whose
  70. * platform behavior differs. `node:process` and `process` are added when absent,
  71. * as factories reading the installed global.
  72. */
  73. readonly staticModules: Readonly<Record<string, StaticModuleFactory>>
  74. /** Prefix-matched proxies, for packages whose subpaths are open-ended. */
  75. readonly staticModulePrefixes?: Readonly<Record<string, StaticModuleFactory>>
  76. /**
  77. * The webserver's request listener, captured by the app's fake `node:http`.
  78. * Awaited on first tunnel use, so it may resolve after the tree binds.
  79. */
  80. readonly requestListener: () => Promise<RequestListener>
  81. /** Image bytes, or the URL the worker fetches them from. */
  82. readonly image: Uint8Array | string
  83. /** Ordered data overlays applied after the base image and before boot. */
  84. readonly overlays?: readonly (Uint8Array | string)[]
  85. /** Virtual root; defaults to {@link DEFAULT_ROOT}. */
  86. readonly root?: string
  87. /** Composed configuration inside the image; defaults to `<root>/config/cordis.yml`. */
  88. readonly configPath?: string
  89. /**
  90. * Inner arguments the tree parses. The default binds the web server to the
  91. * loopback authority the tunnel synthesizes, which also keeps
  92. * `networkInterfaces()` out of the trust snapshot.
  93. */
  94. readonly cmdlineArgs?: readonly string[]
  95. /** Port named on the default command line; defaults to {@link DEFAULT_PORT}. */
  96. readonly port?: number
  97. /** Environment for the process shim; `DSH_HOME` defaults to `<root>/home`. */
  98. readonly env?: Readonly<Record<string, string>>
  99. /**
  100. * Image manifest path; defaults to `<root>/config/vfs-manifest.json`. Its
  101. * `lowered` field must name this build's wrapper contract.
  102. */
  103. readonly manifestPath?: string
  104. /**
  105. * Ambient-store snapshot face exported by the app's `node:async_hooks` proxy.
  106. * The rewrite that carries stores across suspension points moves state through
  107. * it; the proxy remains the only owner of that state.
  108. */
  109. readonly alsCausality?: AlsCausality
  110. /** Privileged API methods that skip the route lane; see {@link TunnelServer}. */
  111. readonly privilegedMethods?: ReadonlySet<string>
  112. /** Escape hatch for the unary `/api` lane; see {@link TunnelServer}. */
  113. readonly unaryApiLane?: 'route' | 'direct'
  114. /** Channel back to the page; defaults to the worker global scope. */
  115. readonly channel?: TunnelPort
  116. }
  117. /** The assembled worker host. */
  118. export interface WorkerHost {
  119. /** Feed one `postMessage` payload; safe before {@link WorkerHost.start}. */
  120. handleMessage(data: unknown): void
  121. /**
  122. * Mount the image and boot the tree, then start serving queued requests.
  123. * @returns Resolves once the tree is active and the tunnel is serving.
  124. */
  125. start(): Promise<void>
  126. /** Dispose the tree; the tunnel keeps refusing afterwards. */
  127. stop(): Promise<void>
  128. /** Filesystem the tree reads, once {@link WorkerHost.start} mounted it. */
  129. readonly vfs: MemoryVfs | undefined
  130. /** Module loader behind the Cordis module seam. */
  131. readonly modules: WorkerModuleLoader | undefined
  132. }
  133. function requireGlobalPort(channel: TunnelPort | undefined): TunnelPort {
  134. if (channel !== undefined) return channel
  135. const scope = globalThis as { postMessage?: TunnelPort['postMessage'] }
  136. const post = scope.postMessage
  137. if (typeof post !== 'function') {
  138. throw new Error('webworker host: no channel; pass options.channel outside a dedicated worker')
  139. }
  140. return { postMessage: (message, transfer) => { post(message, transfer) } }
  141. }
  142. async function readImage(image: Uint8Array | string): Promise<Uint8Array> {
  143. if (typeof image !== 'string') return await inflateImage(image, 'the image bytes given to createWorkerHost')
  144. const response = await fetch(image)
  145. if (!response.ok) throw new Error(`webworker host: image fetch failed with ${String(response.status)} for ${image}`)
  146. if (response.body === null) throw new Error(`webworker host: image response for ${image} carried no body`)
  147. // Inflated off the response stream: the archive is built while the rest of the
  148. // image is still arriving.
  149. return await inflateImageStream(response.body, image)
  150. }
  151. /**
  152. * Build the worker host without touching the network or the image.
  153. * @param options - Assembly inputs.
  154. * @returns Handle whose `handleMessage` is ready immediately.
  155. */
  156. export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
  157. const root = options.root ?? DEFAULT_ROOT
  158. const configPath = options.configPath ?? join(root, IMAGE_CONFIG_PATH)
  159. const port = options.port ?? DEFAULT_PORT
  160. const tunnel = new TunnelServer({
  161. port: requireGlobalPort(options.channel),
  162. requestListener: options.requestListener,
  163. ...options.privilegedMethods === undefined ? {} : { privilegedMethods: options.privilegedMethods },
  164. ...options.unaryApiLane === undefined ? {} : { unaryApiLane: options.unaryApiLane },
  165. })
  166. let vfs: MemoryVfs | undefined
  167. let modules: WorkerModuleLoader | undefined
  168. let context: HostContext | undefined
  169. const start = async (): Promise<void> => {
  170. try {
  171. const home = join(root, IMAGE_HOME_DIRECTORY)
  172. installProcessGlobal({ cwd: root, env: { DSH_HOME: home, HOME: home, ...options.env } })
  173. const [bytes, overlays] = await Promise.all([
  174. readImage(options.image),
  175. Promise.all((options.overlays ?? []).map(readImage)),
  176. ])
  177. const mounted = loadVfsImage(bytes, root)
  178. for (const overlay of overlays) loadVfsOverlay(overlay, root, mounted)
  179. // Belt and braces over the image's own empty-directory entries: a hand
  180. // -built image without them still boots.
  181. for (const directory of IMAGE_EMPTY_DIRECTORIES) {
  182. mounted.seedDirectory(join(root, directory.replace(/\/$/, '')))
  183. }
  184. setActiveVfs(mounted)
  185. vfs = mounted
  186. const manifestPath = options.manifestPath ?? join(root, IMAGE_MANIFEST_PATH)
  187. requireLoweredImage(mounted, manifestPath)
  188. const staticModules: Record<string, StaticModuleFactory> = { ...options.staticModules }
  189. // Read at require time, not here: the table entry then answers whichever
  190. // global `installProcessGlobal` left in place, in this role's order.
  191. for (const key of ['node:process', 'process']) {
  192. staticModules[key] ??= (): unknown => (globalThis as { process?: unknown }).process
  193. }
  194. const loader = new WorkerModuleLoader({
  195. vfs: mounted,
  196. root,
  197. staticModules,
  198. ...options.staticModulePrefixes === undefined ? {} : { staticModulePrefixes: options.staticModulePrefixes },
  199. ...options.alsCausality === undefined ? {} : { alsCausality: options.alsCausality },
  200. })
  201. setActiveModuleLoader(loader)
  202. modules = loader
  203. const require = loader.requireFrom(dirname(configPath))
  204. const appBoot = require('@deepseek-ai/dsh-app-boot') as {
  205. boot(
  206. binName: string,
  207. configPath: string,
  208. patches: unknown[],
  209. prepare: (ctx: HostContext) => void,
  210. ): Promise<HostContext>
  211. }
  212. const cmdline = require('@deepseek-ai/dsh-cmdline') as {
  213. provideCmdline(ctx: unknown, host: { args: readonly string[]; exit: (code: number) => void }): void
  214. }
  215. const { patches, presetOverlay } = bootPatches(loader, mounted, configPath, root)
  216. const ctx = await appBoot.boot('dsh-webworker', configPath, patches, (hostCtx) => {
  217. // Before any entry mounts: the Loader would otherwise fall back to the
  218. // runtime's own dynamic import for every row.
  219. hostCtx.loader.internal = loader.internal
  220. installLogSink(hostCtx, require)
  221. cmdline.provideCmdline(hostCtx, {
  222. args: [...(options.cmdlineArgs ?? ['--host', '127.0.0.1', '--port', String(port), '--no-open'])],
  223. exit: (code: number) => { console.warn(`webworker host: tree requested exit(${String(code)})`) },
  224. })
  225. })
  226. context = ctx
  227. const apiProxy = ctx.get('apiProxy')
  228. if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
  229. const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined
  230. if (typertGateway === undefined) {
  231. throw new Error('webworker host: the tree activated without a typertGateway service')
  232. }
  233. const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as {
  234. toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
  235. }
  236. const shared = ctx.get('connection') !== undefined
  237. const handler = directFetchHandler(ctx, toFetchHandler(apiProxy))
  238. const usage = loader.usage()
  239. 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=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`)
  240. tunnel.serve({
  241. directFetch: (request: Request) => handler.fetch(request),
  242. bootPayload: () => readBootPayload(ctx),
  243. openStream: typertGateway.wireStream.open,
  244. streamFailure: typertGateway.wireStream.failure,
  245. })
  246. } catch (reason) {
  247. tunnel.fail(reason)
  248. throw reason
  249. }
  250. }
  251. return {
  252. handleMessage: (data: unknown): void => { tunnel.handleMessage(data) },
  253. start,
  254. stop: async (): Promise<void> => {
  255. tunnel.fail(new Error('webworker host: the tree was disposed'))
  256. await context?.fiber.dispose()
  257. },
  258. get vfs(): MemoryVfs | undefined {
  259. return vfs
  260. },
  261. get modules(): WorkerModuleLoader | undefined {
  262. return modules
  263. },
  264. }
  265. }
  266. /** The cordis message renderer this sink formats through. */
  267. export interface LogRenderer {
  268. format(exporter: LogExporter, message: LogMessage): string
  269. }
  270. /**
  271. * Send the tree's own warnings and errors to the worker console.
  272. *
  273. * Cordis's `LoggerService` always exists and always accepts messages, but with
  274. * no exporter mounted it only fills a ring buffer — and no profile in this
  275. * repository mounts one, so `ctx.logger.warn(...)` reaches nothing. A provider
  276. * that fails and is skipped (the skill registry logs exactly that) then looks
  277. * identical to one that found nothing, which is how an empty skill catalog hid a
  278. * filesystem fault twice.
  279. *
  280. * Warnings and errors only: `info`/`debug` from 131 plugin rows would bury the
  281. * page console, and this exists to make failures visible rather than to trace.
  282. * @param ctx - Host context, before any entry mounts.
  283. * @param require - Image resolver, for cordis's own message renderer.
  284. */
  285. export function installLogSink(ctx: HostContext, require: (specifier: string) => unknown): void {
  286. const { Logger } = require('@deepseek-ai/cordis') as { Logger: LogRenderer }
  287. const exporter: LogExporter = {
  288. colors: false,
  289. // cordis compares `exporter.levels ?? logger.level ?? INFO` against the
  290. // message level and drops anything higher, and its scale counts UP with
  291. // verbosity (ERROR 0, INFO 1, WARN 2, DEBUG 3). An exporter that declares no
  292. // level therefore admits errors and info but silently drops every warning —
  293. // which is what the built-in ring-buffer exporter does, so the skipped-provider
  294. // warning this sink exists for never even reached the buffer.
  295. levels: { default: 2 },
  296. export: (message) => {
  297. if (message.type !== 'warn' && message.type !== 'error') return
  298. const line = `${message.name}: ${Logger.format(exporter, message)}`
  299. if (message.type === 'error') console.error(line)
  300. else console.warn(line)
  301. },
  302. }
  303. ctx.logger.exporter(exporter)
  304. }
  305. /**
  306. * Require the mounted image to carry bodies this build can wrap.
  307. *
  308. * The manifest the packer writes is the single source of truth: the worker holds
  309. * no transform, so an image that was never lowered — or was lowered against
  310. * different wrapper semantics — cannot be recovered at load and must be rebuilt.
  311. * @param vfs - Mounted filesystem.
  312. * @param path - Manifest path inside the image.
  313. * @throws When the manifest is missing, unreadable, or names another contract.
  314. */
  315. function requireLoweredImage(vfs: MemoryVfs, path: string): void {
  316. if (!vfs.existsSync(path)) {
  317. throw new Error(`webworker host: ${path} is missing, so the image records no lowering; rebuild the image`)
  318. }
  319. const parsed: unknown = JSON.parse(vfs.readFileSync(path, 'utf8') as string)
  320. if (typeof parsed !== 'object' || parsed === null) {
  321. throw new Error(`webworker host: ${path} does not hold an object`)
  322. }
  323. const lowered = (parsed as { lowered?: unknown }).lowered
  324. if (lowered !== LOWERING_VERSION) {
  325. throw new Error(`webworker host: image was lowered by ${String(lowered)}, this build runs ${LOWERING_VERSION}; rebuild the image`)
  326. }
  327. }
  328. /**
  329. * Build the tunnel's direct API entry.
  330. *
  331. * The core API surface alone is not the whole `/api` channel: Typert RPC
  332. * endpoints (`/api/<service>/<method>`) are served by an interceptor the gateway
  333. * registers on the Connection service, and answer 404 from the core routes. The
  334. * Connection service composes both halves in `createSharedFetchHandler`, whose
  335. * fallback — not the composition — carries network authentication and trust, so
  336. * composing it here keeps every interceptor while leaving out the fences the
  337. * worker-local direct lane exists to bypass.
  338. * @param ctx - Booted host context.
  339. * @param core - Fetch handler over the API surface.
  340. * @returns Handler covering interceptors and the core surface.
  341. */
  342. function directFetchHandler(
  343. ctx: HostContext,
  344. core: { fetch(request: Request): Promise<Response> },
  345. ): { fetch(request: Request): Promise<Response> } {
  346. const connection = ctx.get('connection') as {
  347. createSharedFetchHandler(
  348. channel: '/api',
  349. fallback: { fetch(request: Request): Promise<Response> },
  350. ): { fetch(request: Request): Promise<Response> }
  351. } | undefined
  352. return connection?.createSharedFetchHandler('/api', core) ?? core
  353. }
  354. /**
  355. * The shipped preset root, as the application layer that owns the composition
  356. * supplies it.
  357. *
  358. * A launcher appends this root itself rather than writing it into the roster —
  359. * `apps/cli` does it in `composeProfile` (`profile-boot.ts:159-166`) because only
  360. * the application knows where its own presets sit. The worker's presets travel
  361. * in the image, so the same overlay names their virtual path. Patching replaces
  362. * a row's whole `config`, so the current one is read and spread, and a roster
  363. * that already names roots keeps them.
  364. * @param loader - Module loader, for the image's YAML reader.
  365. * @param vfs - Filesystem holding the composed configuration.
  366. * @param configPath - Composed configuration path.
  367. * @param root - Virtual root.
  368. * @returns Boot patches (preset root overlay, frontend serving off) and
  369. * whether the preset overlay was applied.
  370. */
  371. function bootPatches(
  372. loader: WorkerModuleLoader,
  373. vfs: MemoryVfs,
  374. configPath: string,
  375. root: string,
  376. ): { patches: unknown[]; presetOverlay: boolean } {
  377. const text = vfs.readFileSync(configPath, 'utf8') as string
  378. let rows: unknown
  379. if (configPath.endsWith('.json')) {
  380. rows = JSON.parse(text)
  381. } else {
  382. // The roster's `!!js` scalars need Include's own YAML dialect.
  383. const include = loader.load(loader.resolve('@deepseek-ai/cordis-plugin-include', root)) as { entryListSchema: unknown }
  384. const yaml = loader.load(loader.resolve('js-yaml', root)) as { load(source: string, options: { schema: unknown }): unknown }
  385. rows = yaml.load(text, { schema: include.entryListSchema })
  386. }
  387. const find = (entries: unknown, id: string): Record<string, unknown> | undefined => {
  388. if (!Array.isArray(entries)) return undefined
  389. for (const entry of entries as Array<Record<string, unknown>>) {
  390. if (entry.id === id) return entry
  391. const nested = find(entry.config, id)
  392. if (nested !== undefined) return nested
  393. }
  394. return undefined
  395. }
  396. const configOf = (row: Record<string, unknown>): Record<string, unknown> =>
  397. (typeof row.config === 'object' && row.config !== null && !Array.isArray(row.config)
  398. ? row.config
  399. : {}) as Record<string, unknown>
  400. const patches: unknown[] = []
  401. let presetOverlay = false
  402. const presets = find(rows, 'agent-presets')
  403. if (presets !== undefined && configOf(presets).roots === undefined) {
  404. presetOverlay = true
  405. patches.push({
  406. id: 'agent-presets',
  407. config: { ...configOf(presets), roots: [{ path: join(root, 'config/agent-presets'), trust: 'system' }] },
  408. })
  409. }
  410. // The worker carries no compression codec, and the VFS is in-memory anyway:
  411. // the JSONL backend's plaintext path is the composition's one legal encoding.
  412. const jsonl = find(rows, 'session-persistence-jsonl')
  413. if (jsonl !== undefined) {
  414. patches.push({ id: 'session-persistence-jsonl', config: { ...configOf(jsonl), compression: 'none' } })
  415. }
  416. return { patches, presetOverlay }
  417. }
  418. /**
  419. * Assemble the payload the page's pre-Cordis bootstrap needs: the structured
  420. * index injection table the served form renders into index.html. Collected
  421. * from the in-process webserver service, never from the API surface, because
  422. * the page has no Cordis tree yet.
  423. * @param ctx - Booted host context.
  424. * @returns Boot payload for `GET /__boot__`.
  425. */
  426. function readBootPayload(ctx: HostContext): { injections: unknown } {
  427. const webServer = ctx.get('webServer') as { collectIndexInjections(): unknown } | undefined
  428. if (webServer === undefined) {
  429. throw new Error('webworker host: no webServer service, so the page cannot receive its boot injections')
  430. }
  431. return { injections: webServer.collectIndexInjections() }
  432. }
  433. /**
  434. * Install the message handler and boot the tree.
  435. *
  436. * The handler is attached before the first await, so requests that arrive
  437. * during boot queue instead of being dropped. A boot failure refuses the queue
  438. * with 503 and rejects.
  439. * @param options - Assembly inputs; `channel` also replaces the message source.
  440. * @returns Resolves once the tunnel is serving.
  441. */
  442. export async function startWorkerHost(options: WorkerHostOptions): Promise<void> {
  443. const host = createWorkerHost(options)
  444. if (options.channel === undefined) {
  445. const scope = globalThis as { addEventListener?: (type: string, listener: (event: MessageEvent) => void) => void }
  446. if (typeof scope.addEventListener !== 'function') {
  447. throw new Error('webworker host: no message source; pass options.channel outside a dedicated worker')
  448. }
  449. scope.addEventListener('message', (event: MessageEvent) => { host.handleMessage(event.data) })
  450. }
  451. await host.start()
  452. }