worker-host.ts 20 KB

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