feat(webworker): browser worker host runtime and the vfs image packer

Two private experimental packages run the whole harness tree inside one
dedicated Web Worker. dsh-experimental-webworker-runtime owns the in-memory
VFS (BigInt stats with per-path identity and strictly increasing mtimes),
the CommonJS wrapper loader over a lazily-evaluated builtin table whose
shims typecheck against Node's own module types, the postMessage tunnel
speaking plain HTTP, the AsyncLocalStorage runtime, and the worker
assembly. dsh-experimental-webworker-packer lowers every module body at
pack time against the shared wrapper contract, sweeps the profile closure
by static reachability, and writes a deterministically gzip-compressed tar
the worker inflates through the browser's native DecompressionStream while
it downloads.
This commit is contained in:
imccyu
2026-08-21 20:35:32 +08:00
parent b150a551b8
commit f47b1ecac2
113 changed files with 13126 additions and 47 deletions
+6
View File
@@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT |
| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
@@ -51,7 +52,10 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
| [`acorn`](https://github.com/acornjs/acorn) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
| [`commander`](https://github.com/tj/commander.js) | MIT |
@@ -136,6 +140,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
@@ -148,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
| [`http-server`](https://github.com/http-party/http-server) | MIT |
| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause |
| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
+5
View File
@@ -18,6 +18,11 @@
"lib/*.js",
"config"
],
"dsh": {
"configTrees": [
{ "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true }
]
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/cordis-plugin-hmr": "workspace:^",
+16 -1
View File
@@ -211,6 +211,20 @@
"tests/**/*.ts"
]
},
"packages/experimental/webworker-runtime": {
"entry": [
"tests/**/*.spec.ts",
"tests/compile/transform-corpus-check.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"buffer",
"@deepseek-ai/dsh-client-modules"
]
},
"packages/typert/generator": {
"entry": [
"tests/**/*.spec.ts",
@@ -611,7 +625,8 @@
"tests/**/*.perf.ts",
"tests/**/*.snapshot.ts",
"tests/support.ts",
"src/node-module-stub.ts"
"src/node-module-stub.ts",
"src/preview.ts"
],
"project": [
"src/**/*.ts",
+11 -4
View File
@@ -13,6 +13,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
@@ -159,14 +160,20 @@ function localWebUrl(ctx: Context): string {
return `http://${LOOPBACK_HOST}:${String(port)}`
}
/** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */
/**
* Dist location is workspace knowledge of this bundle: anchored on the
* frontend package manifest, not configured. Existence is a request-time
* concern — the fallback owner reads files per request, so a composition
* whose page never reaches the fallback seat (the static worker preview
* ships its own page and carries no dist) boots without one.
*/
function resolveDistIndex(): string {
const require = createRequire(import.meta.url)
try {
return require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')
return join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/package.json')), 'dist', 'index.html')
} catch {
/* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */
throw new Error('web-app: frontend dist not built; run pnpm run build from the repository root first')
/* v8 ignore next 2 -- reachable only when the frontend package is absent from the checkout */
throw new Error('web-app: @deepseek-ai/dsh-web-frontend is not resolvable from this composition')
}
}
+6 -10
View File
@@ -286,16 +286,12 @@ describe('web-app runtime glue', () => {
await ctx.fiber.dispose()
})
it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
// The production resolver (not the test hook). A built checkout resolves
// the frontend package's index.html; a dist-less one (the CI coverage
// lane runs before any build) must fail with the build hint, never a
// silent fallback.
try {
expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
} catch (error) {
expect((error as Error).message).toContain('frontend dist not built')
}
it('anchors the dist index on the frontend package manifest without requiring a built dist', () => {
// The production resolver (not the test hook): the anchor resolves on any
// checkout, built or not — dist existence is the fallback owner's
// request-time concern, so a dist-less composition (the static worker
// preview ships its own page) still boots.
expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
})
it.each([
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/README.md
README.md: 0e92ebd2bd959ac807830400dd57807b87b1cbe2
README.zh.md: a1751c39f53bf8f6c0a9c623aba57355f35c2681
README.md: 43d96d1c539b2ec35d7a818f60270e6d17db54e9
README.zh.md: 27bb4d73b0d8baa4614e79abbf20a1f988f8652c
+2
View File
@@ -8,5 +8,7 @@ This group contains prototypes and internal-only Cordis plugins that use the rep
|---|---|---|
| `agent-team/` | Implicit-root Agent Teams roster, durable peer mailbox, shared task DAG, and runtime coordination | `ctx.agentTeams` |
| `tool-agent-team/` | Scoped model-facing Agent Teams tools and collaboration guidance | — |
| `webworker-runtime/` | Browser-only host runtime: in-memory VFS, module loader, postMessage tunnel, and the dedicated Web Worker assembly | — |
| `webworker-packer/` | Build-time packer that materializes a profile's package closure into the VFS image the worker mounts | — |
The [subtree rules](AGENTS.md) define dependency isolation, release exclusion, and promotion.
+2
View File
@@ -8,5 +8,7 @@
|---|---|---|
| `agent-team/` | 隐式 root Agent Teams roster、持久 peer mailbox、共享任务 DAG 与运行时协调 | `ctx.agentTeams` |
| `tool-agent-team/` | 按 Agent 作用域提供的 Agent Teams 模型工具与协作指引 | — |
| `webworker-runtime/` | 纯浏览器 host 运行时:内存 VFS、模块装载器、postMessage 隧道与 dedicated Web Worker 装配 | — |
| `webworker-packer/` | 构建期打包器:把 profile 的包闭包物化成 worker 挂载的 VFS 镜像 | — |
[子树规则](AGENTS.md)规定依赖隔离、发布排除与 promotion。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md
README.md: 15313f7b75169cc7a8749670900a0635605401e7
README.zh.md: 2e2daba4c006e4d15db619e138447d5e239df4f6
@@ -0,0 +1,27 @@
# `@deepseek-ai/dsh-experimental-webworker-packer`
English | [中文](README.zh.md)
The VFS image packer: turns one composed profile into the single gzip-compressed tar the browser worker inflates and mounts as its filesystem ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships.
The pack is a three-layer standard stack:
1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.
2. **Publish view** — each workspace package contributes the slice npm would publish (`files` through picomatch) minus the rule tables in `src/rules.ts` (no sources, no workspace `dist/`; external packages keep their trees minus the same exclude globs).
3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The CLI is `dsh-pack-vfs-image --out <file> [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build.
## Model Experience
None, as this package runs at build time and writes an image file; nothing it produces reaches a model request on its own.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The rule tables are judgement calls** (`rules.ts`: exclude globs, page-asset patterns, entry seeds) pinned by `tests/`; a new asset class the worker must reach needs a table row, not a scanner change.
- **Vendored package sources (`src/*.ts`) no longer pack** — nothing resolves them at runtime; a future in-worker source-inspection feature would need a dedicated include rule.
- **The packer assumes built `lib/` artifacts are current**: it never compiles, so a stale workspace build packs stale bytes. Run the repository build first.
@@ -0,0 +1,27 @@
# `@deepseek-ai/dsh-experimental-webworker-packer`
[English](README.md) | 中文
VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后当文件系统挂载的单个 gzip 压缩 tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。
打包是三层标准栈:
1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。
2. **发布视图**——每个 workspace 包贡献 npm 会发布的切片(`files` 走 picomatch),再减去 `src/rules.ts` 的规则表(无源码、无 workspace `dist/`;外部包保留整棵减同一套 exclude glob)。
3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
`repository.ts` 拥有仓库形态输入(`vendor/``packages/``apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。CLI 为 `dsh-pack-vfs-image --out <file> [--profile web]``apps/web``build:preview` 在预览壳构建后运行它。
## 模型体验
无:本包在构建期运行并写出镜像文件,其产物本身不进入任何模型请求。
#### KV Cache 影响
无:本包既不组装也不发送 provider 请求。
## Known Limitations and Deferred Work
- **规则表是判断题**`rules.ts`exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。
- **vendored 包源码(`src/*.ts`)不再打包**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。
- **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。
@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-experimental-webworker-packer",
"description": "Build-time packer for the browser runtime's VFS image: materializes a profile's package closure into one gzip-compressed tar the worker mounts, with every module body pre-transformed",
"version": "0.1.0-rc.8",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/experimental/webworker-packer"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-pack-vfs-image": "./lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/repository-*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"js-yaml": "^4.2.0",
"picomatch": "^4.0.4"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^3.0.2"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Pack a VFS image from this repository: compose the profile, materialize the
* closure, lower every module body, write the gzip-compressed tar.
*
* Usage: dsh-pack-vfs-image --out <file> [--profile web] [--root /dsh]
* node --import tsx/esm src/bin.ts --out ../../apps/web/dist/preview/vfs-image.tar.gz
* @module @deepseek-ai/dsh-experimental-webworker-packer/src/bin
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { packVfsImage } from './pack.ts'
import { composeProfile, configTrees, describePack, indexWorkspacePackages } from './repository.ts'
/**
* Read one `--flag value` pair.
* @param name - Flag name without dashes.
* @param fallback - Value when the flag is absent.
* @returns The value.
* @throws When the flag is present with no value, because silently packing the
* default profile is worse than stopping.
*/
function flag(name: string, fallback?: string): string {
const index = process.argv.indexOf(`--${name}`)
if (index === -1) {
if (fallback !== undefined) return fallback
throw new Error(`dsh-pack-vfs-image: --${name} is required`)
}
const value = process.argv[index + 1]
if (value === undefined || value.startsWith('--')) {
throw new Error(`dsh-pack-vfs-image: --${name} needs a value`)
}
return value
}
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const profile = flag('profile', 'web')
const out = flag('out')
const outputFile = isAbsolute(out) ? out : resolve(process.cwd(), out)
const result = packVfsImage({
config: composeProfile(repoRoot, profile),
profile,
root: flag('root', '/dsh'),
workspaces: indexWorkspacePackages(repoRoot),
resolveFrom: repoRoot,
configTrees: configTrees(repoRoot),
})
if (result.missing.length > 0) {
throw new Error(`vfs image: ${String(result.missing.length)} dependencies did not resolve; the image would be incomplete`)
}
mkdirSync(dirname(outputFile), { recursive: true })
writeFileSync(outputFile, result.image)
process.stdout.write(describePack(result, repoRoot, outputFile).join('\n'))
@@ -0,0 +1,15 @@
/**
* Build-time packer for the browser runtime's VFS image.
* @module @deepseek-ai/dsh-experimental-webworker-packer
*/
export {
WRAPPER_CONTRACT,
type ImageFiles, type TransformOutcome,
} from './transform-image.ts'
export {
CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage,
type ConfigTree, type PackOptions, type PackResult,
} from './pack.ts'
export {
composeProfile, configTrees, describePack, indexWorkspacePackages,
} from './repository.ts'
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-experimental-webworker-packer`.
* @module @deepseek-ai/dsh-experimental-webworker-packer/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-webworker-packer'
/** Cordis companion plugin name. */
export const name = 'webworker-packer-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package is a build-time pass with no
* production event stream or mutable data; the pack's own gates (unresolvable
* own requests, the all-or-nothing wrapper contract) fail the pack instead.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,575 @@
/**
* VFS image packer: turns one composed profile plus a package index into the single
* gzip-compressed tar the browser runtime inflates and mounts as its filesystem.
*
* Nothing is compiled here. The image carries the repository's real build products,
* so a preview deployment debugs exactly what the served deployment ships. What the
* pass does add is the pack-time module transform and the manifest that records the
* wrapper contract it was transformed against.
*
* This module holds no repository knowledge: paths, globs, and the composition come
* in as parameters, so the same library packs a different tree by being called
* differently. Locating those inputs is the CLI's job.
* @module @deepseek-ai/dsh-experimental-webworker-packer/src/pack
*/
import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { gzipSync } from 'node:zlib'
import {
lowerModuleSource, MemoryVfs, packTar, WorkerModuleLoader,
DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_MANIFEST_PATH,
} from '@deepseek-ai/dsh-experimental-webworker-runtime'
import picomatch from 'picomatch'
import yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { REPLACED_EXTERNAL_PACKAGES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/external_packages/replaced-externals.ts'
import { MODULE_PROXIES, MODULE_PROXY_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies.ts'
import { WRAPPER_CONTRACT, type ImageFiles, type TransformOutcome } from './transform-image.ts'
import { EXCLUDE, EXCLUDE_WORKSPACE, IMAGE_ENTRY_SEEDS, PAGE_ASSETS } from './rules.ts'
export { DEFAULT_ROOT } from '@deepseek-ai/dsh-experimental-webworker-runtime'
/** Image path of the manifest; the layout contract's name, re-exported for callers. */
export const MANIFEST_PATH: string = IMAGE_MANIFEST_PATH
/** Image path of the composed profile; the layout contract's name, re-exported for callers. */
export const CONFIG_PATH: string = IMAGE_CONFIG_PATH
/**
* Manifest field the runtime judges the image by: the wrapper contract every packed
* body was emitted against. The runtime refuses an image whose value is not its own
* contract, because those bodies assume different wrapper semantics.
*/
const CONTRACT_FIELD = 'lowered'
/** Exclude matcher over tree-root-relative paths ({@link EXCLUDE}). */
const excluded = picomatch([...EXCLUDE], { dot: true })
/** Workspace exclude matcher: {@link EXCLUDE} plus {@link EXCLUDE_WORKSPACE}. */
const workspaceExcluded = picomatch([...EXCLUDE, ...EXCLUDE_WORKSPACE], { dot: true })
/** Page-asset matcher over image paths ({@link PAGE_ASSETS}). */
const pageAsset = picomatch([...PAGE_ASSETS], { dot: true })
/** One directory tree to copy in verbatim beside the composition. */
export interface ConfigTree {
/** Image path to mount it at, relative to the virtual root. */
readonly mount: string
/** Absolute source directory. */
readonly directory: string
/**
* Whether plugin names inside its `.yml` files join the materialization closure.
* An agent preset mounts plugins the base composition never lists, and creating a
* session fails if any of them is missing from the image.
*/
readonly scanRoster?: boolean
}
/** Everything the packer needs that it cannot know by itself. */
export interface PackOptions {
/** Composed profile, `!!js` intact, as the CLI's `--dump-default-config` produced it. */
readonly config: string
/** Profile name, recorded in the manifest. */
readonly profile: string
/** Virtual root the image mounts under; defaults to {@link DEFAULT_ROOT}. */
readonly root?: string
/** Package name to absolute directory, for workspace and vendored packages. */
readonly workspaces: ReadonlyMap<string, string>
/** Directory Node-style dependency resolution walks up from for the roster. */
readonly resolveFrom: string
/** Config trees to copy in beside the composition. */
readonly configTrees?: readonly ConfigTree[]
/** Empty directories to create; defaults to `home/`, `workspace/`, `tmp/`. */
readonly emptyDirectories?: readonly string[]
/**
* Extra sweep roots: image specifiers requested by code outside the image.
* Defaults to the worker assembly's own entries.
*/
readonly entries?: readonly string[]
}
/** What one pack produced, for the caller to report or assert on. */
export interface PackResult {
/** The gzip-compressed tar archive to write; the runtime inflates it at mount. */
readonly image: Uint8Array
/** Every entry, before zipping; the manifest is already among them. */
readonly files: ImageFiles
/** Package name to how many files it contributed, in materialization order. */
readonly packages: ReadonlyMap<string, number>
/** How many of them came from the workspace rather than from `node_modules`. */
readonly workspacePackages: number
/** Roster package names the closure started from. */
readonly roster: readonly string[]
/** Dependencies that did not resolve; a non-empty list means an incomplete image. */
readonly missing: readonly string[]
/** Executable scripts dropped from the image. */
readonly executables: readonly string[]
/** Page bundles left verbatim, and so out of the transform. */
readonly pageBundles: readonly string[]
/** JavaScript entries the image carries. */
readonly javascriptEntries: number
/** JavaScript candidates no root reaches, dropped from the image. */
readonly droppedJavascriptEntries: number
/** Third-party requests that resolve nowhere; loud at require time if hit. */
readonly unresolvedExternalRequests: readonly string[]
/** What the pack-time transform did. */
readonly transform: TransformOutcome
/** Wrapper contract recorded in the manifest; every packed body meets it. */
readonly contract: string
}
const readJson = (file: string): Record<string, unknown> =>
JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
/**
* Package name of a module specifier.
* @param specifier - Module specifier, possibly with a subpath.
* @returns The package name (`@scope/pkg/sub` → `@scope/pkg`).
*/
function packageNameOf(specifier: string): string {
const [first = specifier, second = ''] = specifier.split('/')
return first.startsWith('@') ? `${first}/${second}` : first
}
/**
* Collect module-specifier `name` fields from parsed entry rows, recursively
* through nested `config` row lists (groups). Builtin rows (`cordis:group`)
* and preset metadata documents carry names that are not module specifiers;
* only names with a scope or a path separator count.
* @param rows - Parsed YAML value; anything but an entry array is ignored.
* @param names - Package names collected so far.
*/
function moduleNamesOf(rows: unknown, names: Set<string>): void {
if (!Array.isArray(rows)) return
for (const row of rows) {
if (typeof row !== 'object' || row === null) continue
const { name, config } = row as { name?: unknown; config?: unknown }
if (typeof name === 'string' && (name.startsWith('@') || name.includes('/'))) {
names.add(packageNameOf(name))
}
moduleNamesOf(config, names)
}
}
/**
* Package names the composition names.
* @param config - Composed profile; `!!js` scalars parse under Include's dialect.
* @returns Package names, deduplicated.
*/
function rosterOf(config: string): string[] {
const names = new Set<string>()
moduleNamesOf(yaml.load(config, { schema: entryListSchema }), names)
return [...names]
}
/**
* Package names the compositions under one config tree name.
* @param root - Directory to walk.
* @returns Package names, deduplicated.
*/
function treeRosterOf(root: string): string[] {
const names = new Set<string>()
const walk = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolute = join(directory, entry.name)
if (entry.isDirectory()) {
walk(absolute)
continue
}
if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue
moduleNamesOf(yaml.load(readFileSync(absolute, 'utf8'), { schema: entryListSchema }), names)
}
}
walk(root)
return [...names]
}
/**
* Resolve one dependency the way Node does: walk up from the importer.
* @param fromDirectory - Directory to start at.
* @param name - Package name.
* @returns The real path of the package directory, or undefined.
*/
function resolveDependency(fromDirectory: string, name: string): string | undefined {
let directory = fromDirectory
for (;;) {
const candidate = join(directory, 'node_modules', name)
if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate)
const parent = dirname(directory)
if (parent === directory) return undefined
directory = parent
}
}
/**
* Collect files under one directory. Traversal mechanics live here — nested
* `node_modules` never mounts (the image is flat) and dot directories are
* tooling residue at any depth — while every judgement call comes in through
* `keep` (the {@link EXCLUDE} tables and the npm publish view).
* @param root - Source directory.
* @param into - Image entries to add to.
* @param prefix - Image path prefix.
* @param keep - Filter over root-relative paths.
*/
function collectTree(root: string, into: ImageFiles, prefix: string, keep: (relativePath: string) => boolean): void {
const walk = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue
if (entry.name.startsWith('.')) continue
walk(join(directory, entry.name))
continue
}
if (!entry.isFile()) continue
const absolute = join(directory, entry.name)
const relativePath = relative(root, absolute).replaceAll('\\', '/')
if (!keep(relativePath)) continue
into[`${prefix}/${relativePath}`] = readFileSync(absolute)
}
}
walk(root)
}
/**
* Predicate for npm's `files` allowlist, with standard glob semantics
* (picomatch). A pattern admits the path itself and everything under it, so a
* bare directory name publishes its whole tree; `!` patterns subtract from the
* admitted set; package.json is always published.
* @param patterns - The package.json `files` array.
* @returns Predicate over package-root-relative paths.
*/
function publishedFilter(patterns: readonly unknown[]): (path: string) => boolean {
const strings = patterns.filter((pattern): pattern is string => typeof pattern === 'string')
const normalize = (pattern: string): string => pattern.replace(/^\.\//, '').replace(/\/+$/, '')
const widen = (pattern: string): string[] => [pattern, `${pattern}/**`]
const positive = strings.filter(pattern => !pattern.startsWith('!')).map(normalize).flatMap(widen)
const negative = strings.filter(pattern => pattern.startsWith('!')).map(pattern => normalize(pattern.slice(1))).flatMap(widen)
const admits = picomatch(positive, { dot: true })
const denies = negative.length > 0 ? picomatch(negative, { dot: true }) : (): boolean => false
return path => path === 'package.json' || (admits(path) && !denies(path))
}
/** What the reachability sweep kept, transformed, and dropped. */
interface SweepOutcome {
readonly swept: ImageFiles
readonly transform: TransformOutcome
readonly javascriptEntries: number
readonly droppedJavascriptEntries: number
/** Third-party requests that resolve nowhere; loud at require time if hit. */
readonly unresolvedExternalRequests: readonly string[]
}
/**
* Keep only the JavaScript the worker can reach, transforming it on the way.
*
* Roots are the export faces of every materialized workspace and vendored
* package — the harness addresses them by constructed name at runtime (Loader
* rows, typert faces, delegating providers such as `-auto` pickers), so the
* sweep prunes files only inside third-party packages — plus the worker
* assembly's own image entries. Resolution runs the runtime loader's own
* algorithm over the candidate set, so pack-time reachability and boot-time
* resolution cannot drift, and a request that resolves nowhere — an undeclared
* or missing dependency — fails the pack rather than the boot.
*
* Two entry classes stay out of the walk by rule: page assets
* ({@link PAGE_ASSETS}) are evaluated by the page's module system, and
* non-JavaScript entries always stay because data reads go through fs paths
* this pass cannot see.
* @param files - Candidate entries after the publish-view filter.
* @param options - Pack options carrying the sweep roots.
* @param rootPackages - Roster package names from the workspace.
* @param root - Virtual root the candidates mount under.
* @returns The final entries plus the sweep's counts.
*/
function sweepImage(
files: ImageFiles,
options: PackOptions,
rootPackages: readonly string[],
root: string,
): SweepOutcome {
const decoder = new TextDecoder()
const encoder = new TextEncoder()
const vfs = new MemoryVfs()
for (const [name, bytes] of Object.entries(files)) {
if (name.endsWith('/')) vfs.seedDirectory(`${root}/${name}`)
else vfs.seed(`${root}/${name}`, bytes)
}
// The walk resolves static specifiers and never loads them, so one shared
// factory stands for every replaced module.
const stub = (): unknown => ({})
const loader = new WorkerModuleLoader({
vfs,
root,
staticModules: Object.fromEntries(Object.keys(MODULE_PROXIES).map(name => [name, stub])),
staticModulePrefixes: Object.fromEntries(Object.keys(MODULE_PROXY_PREFIXES).map(name => [name, stub])),
})
const queue: { specifier: string; from: string; importer: string; meta?: boolean }[] = (options.entries ?? IMAGE_ENTRY_SEEDS)
.map(specifier => ({ specifier, from: root, importer: 'worker assembly entry' }))
for (const name of rootPackages) {
const manifestBytes = files[`node_modules/${name}/package.json`]
if (manifestBytes === undefined) continue // materialize already reported it under `missing`
let manifest: { exports?: Record<string, unknown> }
try {
manifest = JSON.parse(decoder.decode(manifestBytes)) as typeof manifest
} catch {
continue
}
// Every non-wildcard face is a root; a face resolving onto a page asset is
// kept verbatim below rather than excluded here.
const subpaths = manifest.exports === undefined
? ['.']
: Object.keys(manifest.exports).filter(key => key.startsWith('.') && !key.includes('*'))
for (const subpath of subpaths) {
queue.push({ specifier: subpath === '.' ? name : `${name}/${subpath.slice(2)}`, from: root, importer: `workspace face ${name}` })
}
}
const reached = new Map<string, Uint8Array>()
const seen = new Set<string>()
const failures: string[] = []
const tolerated = new Set<string>()
let visited = 0
let rewritten = 0
for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
const { specifier, from, importer } = entry
let resolution
try {
resolution = loader.resolve(specifier, from)
} catch (reason) {
// Our own packages must declare what they request: an unresolvable
// request from a workspace or vendored file, a roster face, or the
// assembly entries is a pack defect. Third-party files keep the runtime
// philosophy instead — platform-dispatch branches the worker never
// evaluates may request node-only modules, and such a request fails loud
// at require time if it ever runs.
const external = importer.startsWith('node_modules/') && !importer.startsWith('node_modules/@deepseek-ai/')
// A meta-resolve request is a URL mapping, not a load: a missing target
// is tolerable from any importer — the call throws if it ever runs.
if (external || entry.meta === true) tolerated.add(`${importer}: "${specifier}"`)
else failures.push(`${importer}: "${specifier}" — ${(reason as Error).message}`)
continue
}
if (resolution.kind === 'static') continue
const path = resolution.path
if (seen.has(path)) continue
seen.add(path)
const key = path.slice(root.length + 1)
const bytes = files[key]
if (bytes === undefined) continue
if (!/\.[cm]?js$/.test(key) || pageAsset(key)) {
reached.set(key, bytes)
continue
}
visited += 1
const { code, lowered, moduleRequests, metaResolveRequests } = lowerModuleSource({ filename: `/${key}`, source: decoder.decode(bytes) })
if (lowered) rewritten += 1
reached.set(key, lowered ? encoder.encode(code) : bytes)
const directory = path.slice(0, path.lastIndexOf('/'))
for (const request of moduleRequests) queue.push({ specifier: request, from: directory, importer: key })
for (const request of metaResolveRequests) queue.push({ specifier: request, from: directory, importer: key, meta: true })
}
if (failures.length > 0) {
throw new Error(
`vfs image: ${String(failures.length)} unresolvable module request(s); `
+ 'an undeclared or missing dependency fails the pack rather than the boot:\n '
+ failures.join('\n '),
)
}
const swept: ImageFiles = {}
let javascriptEntries = 0
let dropped = 0
for (const [name, bytes] of Object.entries(files)) {
const isJs = /\.[cm]?js$/.test(name)
if (!isJs || pageAsset(name)) {
swept[name] = bytes
if (isJs) javascriptEntries += 1
continue
}
const kept = reached.get(name)
if (kept === undefined) {
dropped += 1
continue
}
swept[name] = kept
javascriptEntries += 1
}
return {
swept,
transform: { visited, rewritten },
javascriptEntries,
droppedJavascriptEntries: dropped,
unresolvedExternalRequests: [...tolerated],
}
}
/**
* Drop executable scripts from the image.
*
* A shebang says "program", not "module": nothing in a browser can spawn one and no
* consumer reads their bytes (the packages that expose a launcher path are replaced
* by stubs that answer with a string). They are also the one place top-level `await`
* appears in the closure, which a CommonJS body cannot express.
* @param files - Image entries, mutated.
* @returns The dropped entry names.
*/
function dropExecutables(files: ImageFiles): string[] {
const decoder = new TextDecoder()
const dropped: string[] = []
for (const [name, bytes] of Object.entries(files)) {
if (!/\.[cm]?js$/.test(name)) continue
if (decoder.decode(bytes.subarray(0, 2)) !== '#!') continue
dropped.push(name)
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- the image is a plain path map
delete files[name]
}
return dropped
}
/**
* Materialize the dependency closure of every roster package into the image.
* @param roster - Package names to start from.
* @param options - Pack options carrying the workspace index and resolution root.
* @returns Image entries, per-package file counts, and unresolved dependencies.
*/
function materialize(
roster: readonly string[],
options: PackOptions,
): { files: ImageFiles; packages: Map<string, number>; missing: string[] } {
const files: ImageFiles = {}
const packages = new Map<string, number>()
const missing: string[] = []
const replaced = new Set(REPLACED_EXTERNAL_PACKAGES)
const queue: { name: string; from: string }[] = roster.map(name => ({ name, from: options.resolveFrom }))
for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
const { name, from } = entry
if (packages.has(name) || replaced.has(name)) continue
const directory = options.workspaces.get(name) ?? resolveDependency(from, name)
if (directory === undefined) {
missing.push(`${name} (from ${relative(options.resolveFrom, from) || '.'})`)
continue
}
const manifest = readJson(join(directory, 'package.json'))
const prefix = `node_modules/${name}`
const before = Object.keys(files).length
if (options.workspaces.has(name)) {
// A workspace package ships the slice npm would publish — `files`
// filters out build residue like the tsc mirror under lib/types/ —
// minus the workspace exclude table (no sources, no dist: the page
// serves its own assets).
const published = Array.isArray(manifest.files) ? publishedFilter(manifest.files) : undefined
collectTree(directory, files, prefix, relativePath =>
!workspaceExcluded(relativePath) && (published === undefined || published(relativePath)))
} else {
collectTree(directory, files, prefix, relativePath => !excluded(relativePath))
}
packages.set(name, Object.keys(files).length - before)
for (const field of ['dependencies', 'peerDependencies'] as const) {
// npm semantics: a peer is provided by the consumer. For an external
// package the consumer is the page (react behind the prebuilt client
// bundles), so its peer edges never bind the worker. Workspace and
// vendored packages declare real runtime seams as peers
// (@deepseek-ai/cordis is a peerDependency of every harness package),
// so their peer edges stay on the chain.
if (field === 'peerDependencies' && !options.workspaces.has(name)) continue
const dependencies = manifest[field]
if (typeof dependencies !== 'object' || dependencies === null) continue
for (const dependency of Object.keys(dependencies)) queue.push({ name: dependency, from: directory })
}
}
return { files, packages, missing }
}
/** Gzip header byte that records the packing platform; RFC 1952 §2.3.1 spells 255 "unknown". */
const GZIP_OS_UNKNOWN = 255
/** Offset of that byte in the gzip member header. */
const GZIP_OS_OFFSET = 9
/**
* Compress the archive into one gzip member the same tree always produces
* byte for byte.
*
* Two header fields would otherwise carry build facts: zlib writes no
* modification time and no original file name for a buffer (`gzipSync` is handed
* neither), and it fills the operating-system byte from the platform it was built
* for, which would make the same tree pack differently on Linux and macOS. That
* byte is overwritten with "unknown" — every gzip reader ignores it, and the
* artifact stops depending on where it was packed.
* @param archive - the ustar archive.
* @returns the compressed image bytes.
*/
function compressImage(archive: Uint8Array): Uint8Array {
const compressed = gzipSync(archive, { level: 9 })
compressed[GZIP_OS_OFFSET] = GZIP_OS_UNKNOWN
return compressed
}
/**
* Pack one VFS image.
*
* The manifest's claim is all-or-nothing: it names the one contract every packed body
* was emitted against. A module the transform cannot express therefore fails the pack
* rather than downgrading the image, because a mostly-transformed image boots into
* errors far from their cause.
* @param options - Composition, package index, and paths.
* @returns The compressed image plus what went into it.
* @throws When a config tree or workspace directory named in the options is missing,
* because a silently thinner image fails much later and much less clearly.
*/
export function packVfsImage(options: PackOptions): PackResult {
const root = options.root ?? DEFAULT_ROOT
const encoder = new TextEncoder()
const configTrees = options.configTrees ?? []
for (const tree of configTrees) {
if (!existsSync(tree.directory)) {
throw new Error(`vfs image: config tree ${tree.mount} is missing at ${tree.directory}`)
}
}
const roster = [...new Set([
...rosterOf(options.config),
...configTrees.filter(tree => tree.scanRoster === true).flatMap(tree => treeRosterOf(tree.directory)),
])]
const { files, packages, missing } = materialize(roster, options)
files[CONFIG_PATH] = encoder.encode(options.config)
for (const tree of configTrees) collectTree(tree.directory, files, tree.mount, relativePath => !excluded(relativePath))
const executables = dropExecutables(files)
const rootPackages = [...packages.keys()].filter(name => options.workspaces.has(name))
const { swept, transform, javascriptEntries, droppedJavascriptEntries, unresolvedExternalRequests } =
sweepImage(files, options, rootPackages, root)
swept[MANIFEST_PATH] = encoder.encode(`${JSON.stringify({
root,
profile: options.profile,
[CONTRACT_FIELD]: WRAPPER_CONTRACT,
javascriptEntries,
visitedEntries: transform.visited,
rewrittenEntries: transform.rewritten,
}, null, 2)}\n`)
for (const directory of options.emptyDirectories ?? IMAGE_EMPTY_DIRECTORIES) {
swept[directory] = new Uint8Array(0)
}
return {
image: compressImage(packTar(swept)),
files: swept,
packages,
workspacePackages: [...packages.keys()].filter(name => options.workspaces.has(name)).length,
roster,
missing,
executables,
pageBundles: Object.keys(swept).filter(name => pageAsset(name)),
javascriptEntries,
droppedJavascriptEntries,
unresolvedExternalRequests,
transform,
contract: WRAPPER_CONTRACT,
}
}
@@ -0,0 +1,173 @@
/**
* Repository knowledge for the packer: where this tree's workspaces, profile
* composition, and config trees are, and how to report a pack.
*
* The library half takes all of this as parameters. Keeping the lookup here is what
* lets the same library pack a different tree, and what keeps `pack.ts` free of
* assumptions about pnpm workspaces or the `dsh` CLI.
* @module @deepseek-ai/dsh-experimental-webworker-packer/src/repository
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths'
import type { ConfigTree, PackResult } from './pack.ts'
/**
* Repository directories scanned for workspace and vendored packages. The
* image only ever materializes runtime packages, which all live here;
* examples, python, and native are never on a roster's dependency chain (the
* native addon is a replaced external).
*/
const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'apps']
/** Composition entry point package: the `dsh` CLI, run from source. */
const CLI_PACKAGE = 'apps/cli'
/** Composition entry point: the `dsh` CLI, run from source. */
const CLI_ENTRY = `${CLI_PACKAGE}/src/bin.ts`
/**
* Index every workspace and vendored package by name.
* @param repoRoot - Absolute repository root.
* @returns Package name to absolute directory.
*/
export function indexWorkspacePackages(repoRoot: string): Map<string, string> {
const index = new Map<string, string>()
const visit = (directory: string): void => {
const manifest = join(directory, 'package.json')
if (existsSync(manifest)) {
const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
if (typeof name === 'string') index.set(name, directory)
// A package root owns its subtree; anything below (test fixtures,
// nested manifests) is not a separate workspace package.
return
}
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
visit(join(directory, entry.name))
}
}
for (const scanRoot of WORKSPACE_SCAN_ROOTS) {
const absolute = join(repoRoot, scanRoot)
if (existsSync(absolute)) visit(absolute)
}
return index
}
/**
* Compose one profile through the real CLI dump path, leaving `!!js`
* unevaluated. The dump runs against a throwaway Harness home and default
* layers only, so the image is the shipped profile: the machine's `$DSH_HOME`
* — its profile manifest with locally installed bundles, and its patch files —
* would otherwise leak this machine's plugins into the image and break the
* same-tree-same-bytes guarantee.
* @param repoRoot - Absolute repository root.
* @param profile - Profile name to compose.
* @returns The composed YAML.
*/
export function composeProfile(repoRoot: string, profile: string): string {
const home = mkdtempSync(join(tmpdir(), 'dsh-pack-home-'))
try {
return execFileSync(
process.execPath,
['--import', 'tsx/esm', join(repoRoot, CLI_ENTRY), '--profile', profile, '--dump-default-config'],
{ cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, [DSH_HOME_ENV]: home } },
)
} finally {
rmSync(home, { recursive: true, force: true })
}
}
/** One `dsh.configTrees` declaration entry, validated field by field. */
interface ConfigTreeDeclaration {
mount: string
path: string
scanRoster?: boolean
}
/**
* Config trees the CLI package declares for deployment images
* (`dsh.configTrees` in its package.json): `path` is relative to the CLI
* package root, `mount` is the image path, `scanRoster` feeds the tree's yml
* plugin rows into the pack roster. The CLI owns its config layout; this
* reader follows the declaration instead of naming directories. A malformed
* declaration refuses the pack.
* @param repoRoot - Absolute repository root.
* @returns Trees with absolute source directories.
*/
export function configTrees(repoRoot: string): ConfigTree[] {
const packageDir = join(repoRoot, CLI_PACKAGE)
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
dsh?: { configTrees?: unknown }
}
const declared = manifest.dsh?.configTrees
if (declared === undefined) return []
if (!Array.isArray(declared)) {
throw new Error(`vfs image: ${CLI_PACKAGE} dsh.configTrees must be an array`)
}
const mounts = new Set<string>()
return declared.map((entry, index) => {
const tree = entry as Partial<ConfigTreeDeclaration> | null
const at = `${CLI_PACKAGE} dsh.configTrees[${String(index)}]`
if (tree === null || typeof tree !== 'object'
|| typeof tree.mount !== 'string' || tree.mount === ''
|| typeof tree.path !== 'string' || tree.path === ''
|| (tree.scanRoster !== undefined && typeof tree.scanRoster !== 'boolean')) {
throw new Error(`vfs image: ${at} must declare a string mount, a string path, and an optional boolean scanRoster`)
}
if (mounts.has(tree.mount)) {
throw new Error(`vfs image: ${at} repeats mount ${JSON.stringify(tree.mount)}`)
}
mounts.add(tree.mount)
return {
mount: tree.mount,
directory: join(packageDir, tree.path),
...tree.scanRoster === undefined ? {} : { scanRoster: tree.scanRoster },
}
})
}
/**
* Render one pack as the lines a build log should carry.
*
* Refusals and unresolved dependencies are the two states a reader must not miss, so
* they are spelled out rather than counted.
* @param result - What the pack produced.
* @param repoRoot - Absolute repository root, for relative paths.
* @param outputFile - Where the image was written.
* @returns Lines to print.
*/
export function describePack(result: PackResult, repoRoot: string, outputFile: string): string[] {
const sizeOf = (prefix: string): number => Object.entries(result.files)
.filter(([name]) => name.startsWith(prefix))
.reduce((sum, [, bytes]) => sum + bytes.byteLength, 0)
const megabytes = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(2)} MB`
const workspaceCount = result.workspacePackages
const heaviest = [...result.packages.entries()]
.map(([name, count]) => ({ name, count, bytes: sizeOf(`node_modules/${name}/`) }))
.sort((left, right) => right.bytes - left.bytes)
.slice(0, 12)
return [
`vfs image: ${relative(repoRoot, outputFile)}`,
` roster entries ${String(result.roster.length)}`,
` packages ${String(result.packages.size)} (${String(workspaceCount)} workspace)`,
` files ${String(Object.keys(result.files).length)}`,
` raw ${megabytes(Object.values(result.files).reduce((sum, bytes) => sum + bytes.byteLength, 0))}`,
` compressed ${megabytes(result.image.byteLength)}`,
` config + presets ${megabytes(sizeOf('config/'))}`,
` javascript entries ${String(result.javascriptEntries)} (dropped ${String(result.executables.length)} executable scripts, ${String(result.pageBundles.length)} page bundles verbatim)`,
` wrapper contract ${result.contract}`,
` transform ${String(result.transform.rewritten)} of ${String(result.transform.visited)} reached entries rewritten, ${String(result.droppedJavascriptEntries)} unreachable dropped`,
` unresolved ${String(result.unresolvedExternalRequests.length)} third-party request(s) left to fail loud at require time`,
' heaviest packages:',
...heaviest.map(entry => ` ${entry.bytes.toString().padStart(9)} B ${entry.name} (${String(entry.count)} files)`),
...result.missing.length === 0
? []
: [' unresolved dependencies:', ...result.missing.map(entry => ` ${entry}`)],
'',
]
}
@@ -0,0 +1,70 @@
/**
* Pack rule tables: the one place the image's include/exclude decisions live.
* Patterns are picomatch globs. Exclude patterns match tree-root-relative
* paths (so `src/**` drops only a root-level source tree), page-asset
* patterns match image paths. Traversal mechanics — nested `node_modules`
* flattening and dot-directory pruning — stay in the collector; these tables
* hold the judgement calls.
*/
/**
* Paths dropped from every collected tree. Source and test trees never
* resolve at runtime (the artifact plane ships `lib/`), and sourcemaps,
* declarations, and archives never resolve either while dominating the byte
* count.
*/
export const EXCLUDE: readonly string[] = [
'src/**',
'tests/**',
'test/**',
'__tests__/**',
'coverage/**',
'**/*.map',
'**/*.tsbuildinfo',
'**/*.tgz',
'**/*.tar',
'**/*.tar.gz',
'**/*.d.ts',
'**/*.d.mts',
'**/*.d.cts',
]
/**
* Additional paths dropped from workspace packages only. A workspace `dist/`
* is a page-asset tree the static deployment serves itself; external packages
* legitimately ship runtime code under `dist/`.
*/
export const EXCLUDE_WORKSPACE: readonly string[] = [
'dist/**',
]
/**
* Image paths that belong to the PAGE, not to the worker's loader.
*
* A package's `lib/client.js` is its browser bundle behind the `./client`
* export: the page's own module system evaluates it with its own wrapper,
* which has no ambient-store parameter. Transforming those bodies would
* inject calls the page cannot resolve, so they ship verbatim — and the
* manifest's all-or-nothing claim stays true, because the worker loader never
* evaluates them (the tunnel serves them as bytes).
*/
export const PAGE_ASSETS: readonly string[] = [
'node_modules/*/lib/client.js',
'node_modules/@*/*/lib/client.js',
]
/**
* Image specifiers the worker assembly requires directly, beyond the composed
* roster: they are requested by worker-bundle code, so no image file
* references them and the reachability sweep must seed them as roots. Keep in
* step with the literal `require`/`resolve` calls in the runtime's
* `worker-host.ts`.
*/
export const IMAGE_ENTRY_SEEDS: readonly string[] = [
'@deepseek-ai/dsh-app-boot',
'@deepseek-ai/dsh-cmdline',
'@deepseek-ai/dsh-host-apiproxy',
'@deepseek-ai/cordis',
'@deepseek-ai/cordis-plugin-include',
'js-yaml',
]
@@ -0,0 +1,26 @@
/**
* The wrapper contract packed bodies are emitted against, and the image-entry
* types the pack pass consumes.
*
* One transform serves both sides — the pack pass lowers with the runtime's
* own `lowerModuleSource`, never a reimplementation — and the image records
* the contract version it was lowered against. Bodies emitted against a
* different wrapper contract are refused at mount time rather than
* half-working at run time.
* @module @deepseek-ai/dsh-experimental-webworker-packer/src/transform-image
*/
import { LOWERING_VERSION } from '@deepseek-ai/dsh-experimental-webworker-runtime'
/** Image entries, keyed by their path relative to the virtual root. */
export type ImageFiles = Record<string, Uint8Array>
/** Wrapper contract the packed bodies are emitted against. */
export const WRAPPER_CONTRACT: string = LOWERING_VERSION
/** What one pack-time transform pass did. */
export interface TransformOutcome {
/** JavaScript entries visited. */
readonly visited: number
/** How many changed; the rest were already in final form. */
readonly rewritten: number
}
@@ -0,0 +1,138 @@
/**
* End-to-end spec of the packer's actual product: an image this package builds must
* mount in the runtime's VFS and be `require`-able by the runtime's module loader,
* which holds no transform of its own.
*
* That last part is the point. "It boots" only proves nothing crashed; the loader
* wraps module bodies exactly as the image holds them, so the pack-time pass is the
* only thing that can make them wrappable. The refusal case is the positive
* evidence: restore one un-lowered body and the same setup fails loud.
*
* A small synthetic composition rather than the real profile: packing the full
* closure takes tens of seconds. The path under test — compose, materialize,
* transform, tar, compress, inflate, mount, require — is the same one.
*
* ONE module instance: every runtime import here goes through `src/`, because the VFS
* and the active loader are module-level slots. The "starts with nothing loaded"
* case asserts the instance the spec holds is the one that did the work.
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts'
import { WorkerModuleLoader } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts'
import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts'
import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
import { indexWorkspacePackages } from '../src/repository.ts'
import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage } from '../src/pack.ts'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
/** A leaf workspace package: real build output, no dependencies to drag in. */
const SUBJECT = '@deepseek-ai/dsh-timeout'
const workspaces = indexWorkspacePackages(repoRoot)
/**
* The pack consumes built `lib/` output. An unbuilt checkout (the unit
* coverage lane runs before any build) self-skips; the built lanes and every
* preview build exercise this same path against real artifacts.
*/
const subjectBuilt = existsSync(join(repoRoot, 'packages/util/timeout/lib/index.js'))
let memo: ReturnType<typeof packVfsImage> | undefined
const packed = (): ReturnType<typeof packVfsImage> => memo ??= packVfsImage({
// The composition's own shape: one entry per plugin, `name:` on its own line.
config: `- id: subject\n name: '${SUBJECT}'\n`,
profile: 'image-loadable-check',
workspaces,
resolveFrom: repoRoot,
// Synthetic composition: nothing boots the worker assembly, so its default
// image entries must not be demanded of this one-package closure.
entries: [],
})
/** The image's archive, inflated once: mounting reads the tar, not the gzip member. */
let archiveMemo: Uint8Array | undefined
const archive = async (): Promise<Uint8Array> =>
archiveMemo ??= await inflateImage(packed().image, 'the image this spec packed')
;(subjectBuilt ? describe : describe.skip)('packed image', () => {
it('materializes the roster with every dependency resolved', () => {
const result = packed()
expect(workspaces.has(SUBJECT)).toBe(true)
expect(result.roster).toEqual([SUBJECT])
expect(result.packages.has(SUBJECT)).toBe(true)
expect(result.missing).toEqual([])
})
it('records the wrapper contract in the manifest and rewrote what it visited', () => {
const result = packed()
expect(Object.hasOwn(result.files, MANIFEST_PATH)).toBe(true)
const manifest = JSON.parse(new TextDecoder().decode(result.files[MANIFEST_PATH])) as { lowered: string }
expect(manifest.lowered).toBe(result.contract)
expect(result.transform.rewritten).toBeGreaterThan(0)
})
it('writes one gzip member whose header records no build facts', () => {
const image = packed().image
// RFC 1952 §2.3: magic, deflate, then the flag byte — no FNAME (0x08) or
// FCOMMENT, a zero modification time, and "unknown" for the packing system.
expect([...image.slice(0, 4)]).toEqual([0x1f, 0x8b, 0x08, 0x00])
expect([...image.slice(4, 8)]).toEqual([0, 0, 0, 0])
expect(image[9]).toBe(255)
})
it('packs the same tree to the same bytes', () => {
// The preview build compares a freshly packed image against the shipped one,
// so anything the compressor takes from its environment would read as a
// changed tree.
const again = packVfsImage({
config: `- id: subject\n name: '${SUBJECT}'\n`,
profile: 'image-loadable-check',
workspaces,
resolveFrom: repoRoot,
entries: [],
})
expect(Buffer.from(again.image).equals(Buffer.from(packed().image))).toBe(true)
})
it('mounts and requires through the real loader, which carries no transform', async () => {
const vfs = loadVfsImage(await archive(), DEFAULT_ROOT)
expect(vfs.existsSync(`${DEFAULT_ROOT}/node_modules/${SUBJECT}/lib/index.js`)).toBe(true)
const loader = new WorkerModuleLoader({
vfs,
root: DEFAULT_ROOT,
staticModules: createNodeBuiltins(),
staticModulePrefixes: REPLACED_PREFIXES,
})
// The loader this spec reads counters from must be the one that did the
// requiring; a second instance would report an empty cache trivially.
expect(loader.usage().modules).toBe(0)
const required = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(SUBJECT) as Record<string, unknown>
expect(typeof required.timeoutOf).toBe('function')
expect(loader.usage().modules).toBeGreaterThan(0)
})
it('refuses a body the packer did not lower, naming the image', async () => {
// The case above only proves the packed bytes are wrappable. This is the
// other half: the loader has no transform to fall back on, so an entry the
// collector missed must fail loud against the image rather than boot.
const vfs = loadVfsImage(await archive(), DEFAULT_ROOT)
vfs.seed(
`${DEFAULT_ROOT}/node_modules/${SUBJECT}/lib/index.js`,
new TextEncoder().encode('export const timeoutOf = () => 0\n'),
)
const loader = new WorkerModuleLoader({
vfs,
root: DEFAULT_ROOT,
staticModules: createNodeBuiltins(),
staticModulePrefixes: REPLACED_PREFIXES,
})
expect(() => loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(SUBJECT))
.toThrow(/still carries module syntax, so the image was not lowered by the packer/)
})
})
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"types": [
"node"
]
},
"include": [
"src"
],
"references": [
{
"path": "../webworker-runtime"
},
{
"path": "../../util/home-paths"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -0,0 +1,18 @@
import { defineConfig } from 'tsdown'
/**
* The packer ships TWO entries: the library (`index`) and the `dsh-pack-vfs-image`
* CLI (`bin`), the latter referenced by package.json `bin`. The root tsdown
* builds only `lib/types/index.js`, so this override adds `lib/types/bin.js`.
* Declarations come from `tsc -b` (dts: false), matching every package.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/bin.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
README.md: b82c65b981be6a9405ae72e3a24a42c68b52696e
README.zh.md: 97160641a38095026d5103f2e423846bfb41d5a6
@@ -0,0 +1,33 @@
# `@deepseek-ai/dsh-experimental-webworker-runtime`
English | [中文](README.zh.md)
The browser worker host: the whole harness plugin tree runs inside one dedicated Web Worker, for preview deployments and packaging regressions ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). The worker inflates a packed VFS image off its download and mounts it in memory, loads its modules through a CommonJS wrapper loader, and serves the page over a postMessage tunnel that speaks plain HTTP.
Three artifacts from one tsdown pipeline:
- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and replaced externals. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). The grammar is `@yarnpkg/parsers`' `parseShell`; this package owns the evaluator (pipelines, `&&`/`||`, subshells, redirections, expansion, globs) and the command table, which is the only `/bin` that exists — a name it does not hold reports `command not found`, and `execSync`/`fork` still refuse, because they need a real process.
- **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam.
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium.
## Model Experience
None, as this package only hosts the tree in a browser worker and answers its `node:*` calls; every model-facing registration belongs to the plugins it boots.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`.
- **The skill catalog is never cached in the worker** — `skill-filesystem` watches its roots through `node:fs.watchFile`, which this package refuses, so every discovery pass returns an incomplete observation and re-scans. Discovery itself stays correct; the cost is a re-scan on every pass.
- **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here.
- **The bash tool runs only under `danger-full-access`**: a browser has no kernel to confine a command with, so `ctx.sandbox.confine` fails loud in every other permission preset and the command never starts. The mode is the deployment's own user-facing switch, not a worker-specific composition.
- **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there.
- **The shell is not bash**: no loops, functions, `case`, job control, or process substitution — the grammar stops at pipelines, `&&`/`||`, subshells, groups, redirections, and expansion. `&` runs its command to completion in place, `sed` accepts only substitution scripts, patterns are JavaScript regular expressions, and the command table holds coreutils only (no `git`, no network tools).
- **A shell process has no synchronous filesystem**: it reads and writes the host's VFS by message, because blocking on a reply would need `SharedArrayBuffer`, which requires a cross-origin isolation GitHub Pages cannot grant. Directory-walking commands therefore cost one round trip per entry, and two concurrent commands can interleave their writes.
- **Transport, worker-host, and page-half coverage needs a browser-grade harness** — the per-file coverage gate is unmet for those modules; unit specs cover storage, ALS, the transform, and the stub contracts.
@@ -0,0 +1,33 @@
# `@deepseek-ai/dsh-experimental-webworker-runtime`
[English](README.md) | 中文
浏览器 worker 宿主:整棵 harness 插件树跑在一个 dedicated Web Worker 里,用于预览部署与打包回归([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。worker 边下载边解压打包好的 VFS 镜像并挂载进内存,经 CommonJS 包装加载器装载模块,并通过一条讲纯 HTTP 的 postMessage 隧道服务页面。
一条 tsdown 管线出三个产物:
- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`
- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS/隧道/浏览器原语,浏览器做不到的走结构化 stub(调用即 console 报错并抛出),外部包整体替换。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。语法来自 `@yarnpkg/parsers``parseShell`;求值器(管道、`&&`/`||`、子 shell、重定向、展开、glob)与命令表由本包自持,而命令表就是这里唯一存在的 `/bin`——表里没有的名字报 `command not found``execSync`/`fork` 依然拒绝,因为它们需要真进程。
- **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。
## 模型体验
无:本包只在浏览器 worker 里承载插件树并应答它的 `node:*` 调用;所有面向模型的注册都属于它启动的那些插件。
#### KV Cache 影响
无:本包既不组装也不发送 provider 请求。
## Known Limitations and Deferred Work
- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`
- **worker 里的技能目录从不缓存**——`skill-filesystem``node:fs.watchFile` 监听各个根,而本包拒绝该调用,于是每轮发现都返回不完整观测并重新扫描。发现本身仍然正确,代价是每轮都要重扫。
- **`node:vm``node:net``node:sqlite``node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。
- **bash 工具只在 `danger-full-access` 下可用**:浏览器没有内核可以约束命令,因此在其余权限档位下 `ctx.sandbox.confine` 会响亮失败、命令根本不会启动。该档位是部署本身的用户面开关,不是 worker 特有的组合差异。
- **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。
- **这个 shell 不是 bash**:没有循环、函数、`case`、作业控制或进程替换——语法止步于管道、`&&`/`||`、子 shell、group、重定向与展开。`&` 会就地把命令跑完,`sed` 只接受替换脚本,模式是 JavaScript 正则,命令表只有 coreutils(没有 `git`,没有网络工具)。
- **shell 进程没有同步文件面**:它靠消息读写宿主的 VFS,因为阻塞等待回帧需要 `SharedArrayBuffer`,而那要求 GitHub Pages 给不了的跨源隔离。因此目录遍历类命令每个条目一次往返,并发的两条命令写入可以交错。
- **transport、worker-host、页面半的覆盖需要浏览器级 harness**——这些模块未达 per-file 覆盖门;单测覆盖 storage、ALS、transform 与 stub 契约。
@@ -0,0 +1,64 @@
{
"name": "@deepseek-ai/dsh-experimental-webworker-runtime",
"description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged",
"version": "0.1.0-rc.8",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/experimental/webworker-runtime"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./worker": "./lib/worker.js",
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
}
},
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.3.0",
"@yarnpkg/parsers": "^3.1.0",
"acorn": "^8.17.0",
"buffer": "^6.0.3",
"picomatch": "^4.0.4"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@types/picomatch": "^3.0.2"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/worker.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -0,0 +1,33 @@
/**
* Page-side API carrier over the postMessage tunnel. Only `doFetch` is
* implemented: the streaming methods stay on `AbstractApiClient`'s default
* `readSse`, which is exactly what the worker answers on the two event-stream
* paths — so unary calls and downstream streams share one framing and neither
* side needs a WebSocket.
*/
import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
import type { WorkerTunnel } from './client.ts'
/** API client whose requests travel the worker tunnel instead of the network. */
export class WorkerApiClient extends AbstractApiClient {
private readonly tunnel: WorkerTunnel
/**
* Bind the carrier to a tunnel.
* @param tunnel - page half of the worker tunnel.
*/
constructor(tunnel: WorkerTunnel) {
super()
this.tunnel = tunnel
}
/**
* Send one request through the tunnel.
* @param input - request URL.
* @param init - fetch init; the tunnel honours method, headers, body, and signal.
* @returns the reconstructed response.
*/
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
return this.tunnel.fetch(input, init)
}
}
@@ -0,0 +1,50 @@
/**
* Page-side interpreter for the structured index injection table. The served
* form renders the same rows into index.html text; a static worker page has
* no served HTML, so it executes the table directly. Rows execute strictly in
* table order, so a global row lands before the scripts that read it.
*/
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
function assertNever(row: never): never {
throw new Error(`webworker-runtime: unknown index injection row ${JSON.stringify(row)}`)
}
/**
* Execute every row in table order.
* @param rows - Injection table from the boot payload.
* @param loadScript - Executes one script-src row; the tunnel's `loadBundle`,
* because the row URLs (`/plugins/...`) resolve only through the worker.
*/
export async function applyIndexInjections(
rows: readonly IndexInjection[],
loadScript: (src: string) => Promise<void>,
): Promise<void> {
for (const row of rows) {
switch (row.kind) {
case 'global':
(globalThis as Record<string, unknown>)[row.name] = row.value
break
case 'script': {
const el = document.createElement('script')
el.textContent = row.text
;(row.placement === 'head' ? document.head : document.body).append(el)
break
}
case 'script-src':
await loadScript(row.src)
break
case 'style': {
const el = document.createElement('style')
el.textContent = row.text
document.head.append(el)
break
}
case 'html':
(row.placement === 'head' ? document.head : document.body).insertAdjacentHTML('beforeend', row.html)
break
default:
assertNever(row)
}
}
}
@@ -0,0 +1,342 @@
/**
* Page half of the postMessage tunnel. It
* turns fetch-shaped calls into `req` frames and rebuilds Responses from the
* worker's `res` / `res-head`+`res-chunk`+`res-end` frames, so every consumer
* (boot payload, bundle transport, ApiClient, Typert RPC) speaks plain HTTP.
*/
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
/** Frame sent to the worker. */
interface RequestFrame {
t: 'req'
id: number
method: string
/** Absolute URL; the worker derives `req.url` (pathname + search) from it. */
url: string
headers: Record<string, string>
body?: ArrayBuffer | undefined
}
/** Cancellation of an in-flight request or stream. */
interface AbortFrame {
t: 'abort'
id: number
}
/** Frames received from the worker. */
type ResponseFrame =
| { t: 'res'; id: number; status: number; headers: Record<string, string>; body?: ArrayBuffer; message?: string }
| { t: 'res-head'; id: number; status: number; headers: Record<string, string> }
| { t: 'res-chunk'; id: number; chunk: ArrayBuffer }
| { t: 'res-end'; id: number }
| { t: 'res-err'; id: number; message: string }
/** Boot payload of the tunnel bootstrap route. */
export interface BootPayload {
/** Structured index injection table, executed by the page interpreter. */
injections: IndexInjection[]
}
/** Fetch-shaped transport the client tree consumes. */
export type TunnelFetch = (input: URL | string, init?: RequestInit) => Promise<Response>
interface PendingUnary {
resolve(response: Response): void
reject(reason: Error): void
}
/**
* Statuses the worker only produces when the host refused the exchange rather than
* answered it; a route's own 4xx is the tree talking and stays silent here.
*/
const REFUSAL_STATUS = 500
const encoder = new TextEncoder()
/** Normalize a RequestInit body to a transferable ArrayBuffer. */
function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined {
if (body === undefined || body === null) return undefined
if (typeof body === 'string') return encoder.encode(body).buffer
if (body instanceof ArrayBuffer) return body
if (ArrayBuffer.isView(body)) {
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
}
throw new Error(`web-preview tunnel: unsupported request body ${Object.prototype.toString.call(body)}`)
}
/** Statuses whose Response must carry a null body. */
const NULL_BODY_STATUS = new Set([101, 204, 205, 304])
/** The page half of the tunnel: one `fetch`-shaped face over `postMessage`. */
export class WorkerTunnel {
private readonly worker: Worker
private nextId = 1
private readonly unary = new Map<number, PendingUnary>()
private readonly streams = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
/**
* In-flight request descriptions, so a refusal names what was refused.
*
* A tunnel failure and a failure inside the host tree look identical from the
* page — both surface as one rejected fetch — and the acceptance run keeps the
* page console but not the frames. Warning here separates the two without
* recording anything on the normal path, where no refusal frame ever arrives.
*/
private readonly inFlight = new Map<number, string>()
/** Body-phase abort listeners, released when their stream settles. */
private readonly releases = new Map<number, () => void>()
/**
* Attach to a spawned worker and start consuming response frames.
* @param worker - the host worker.
*/
constructor(worker: Worker) {
this.worker = worker
worker.addEventListener('message', (event: MessageEvent<ResponseFrame>) => {
this.receive(event.data)
})
worker.addEventListener('error', (event) => {
const reason = new Error(`web-preview tunnel: worker failed: ${event.message}`)
for (const id of this.inFlight.keys()) this.warnRefusal(id, `worker failed: ${event.message}`)
this.inFlight.clear()
for (const pending of this.unary.values()) pending.reject(reason)
this.unary.clear()
for (const controller of this.streams.values()) controller.error(reason)
this.streams.clear()
for (const release of this.releases.values()) release()
this.releases.clear()
})
}
/**
* Open the tunnel: the worker assembles its host from this frame.
* @param image - VFS image URL the worker fetches.
*/
init(image: string): void {
this.worker.postMessage({ t: 'init', image })
}
/** Fetch-shaped entry: one request frame, one Response (streamed when the worker streams). */
readonly fetch: TunnelFetch = async (input, init) => {
const signal = init?.signal
// Checked before any frame leaves: a request the caller already abandoned
// must not reach the worker, where a write-shaped route would still run.
if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError')
const id = this.nextId++
const frame: RequestFrame = {
t: 'req',
id,
method: init?.method ?? 'GET',
url: new URL(input, globalThis.location.origin).toString(),
headers: Object.fromEntries(new Headers(init?.headers).entries()),
...(init?.body === undefined || init.body === null
? {}
: { body: toBodyBuffer(init.body) }),
}
const response = new Promise<Response>((resolve, reject) => {
this.unary.set(id, { resolve, reject })
})
this.inFlight.set(id, `${frame.method} ${frame.url}`)
this.worker.postMessage(frame)
if (signal === undefined || signal === null) return await response
const raced = this.rejectOnAbort(id, signal)
try {
const settled = await Promise.race([response, raced.rejected])
// A streaming response outlives its head: hand the signal to the body
// phase, so a later stop still ends the stream and reaches the worker.
if (this.streams.has(id)) this.observeStreamAbort(id, signal)
return settled
} finally {
raced.release()
}
}
/**
* Read the pre-cordis boot payload (the injection table).
* @returns The payload the page applies before the client tree loads.
*/
async bootPayload(): Promise<BootPayload> {
const response = await this.fetch('/__boot__')
if (!response.ok) {
throw new Error(`web-preview tunnel: boot payload failed with HTTP ${String(response.status)}: ${await response.text()}`)
}
return await response.json() as BootPayload
}
/**
* `loadBundle` seam: take one client bundle through the tunnel and execute it
* as a classic script, exactly like the shell's same-origin `<script src>`.
* @param url - graph row url (`/plugins/<id>/client.js?rev=...`).
*/
async loadBundle(url: string): Promise<void> {
const response = await this.fetch(url)
if (!response.ok) {
throw new Error(`web-preview tunnel: bundle ${url} failed with HTTP ${String(response.status)}`)
}
const source = await response.text()
const blob = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
try {
await new Promise<void>((resolve, reject) => {
const el = document.createElement('script')
el.src = blob
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`web-preview tunnel: bundle ${url} failed to execute`))
}, { once: true })
document.head.append(el)
})
} finally {
URL.revokeObjectURL(blob)
}
}
private rejectOnAbort(id: number, signal: AbortSignal): { rejected: Promise<never>; release: () => void } {
let release = (): void => {}
const rejected = new Promise<never>((_resolve, reject) => {
const fail = (): void => { reject(this.abortRequest(id)) }
if (signal.aborted) {
fail()
return
}
signal.addEventListener('abort', fail, { once: true })
// A completed request must not leave its listener on a long-lived
// signal, where every further request would pile another one on.
release = () => { signal.removeEventListener('abort', fail) }
})
return { rejected, release }
}
/**
* Tear down one request the page abandoned: the maps forget it, the worker
* is told, and a live body stream errors for its reader.
* @param id - request id being abandoned.
* @returns The abort error the caller surfaces.
*/
private abortRequest(id: number): DOMException {
this.unary.delete(id)
const controller = this.streams.get(id)
this.streams.delete(id)
this.inFlight.delete(id)
this.releases.delete(id)
const abort: AbortFrame = { t: 'abort', id }
this.worker.postMessage(abort)
const reason = new DOMException('The operation was aborted.', 'AbortError')
controller?.error(reason)
return reason
}
/**
* Hold the caller's signal over the body phase: the head settled, so
* {@link rejectOnAbort}'s listener is about to go, but a stop must still
* end the stream. Released when the stream settles.
* @param id - request id whose body is still crossing.
* @param signal - the caller's signal.
*/
private observeStreamAbort(id: number, signal: AbortSignal): void {
const onAbort = (): void => { this.abortRequest(id) }
signal.addEventListener('abort', onAbort, { once: true })
this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) })
}
/** Release a body-phase abort listener a settled stream no longer needs. */
private releaseSignal(id: number): void {
const release = this.releases.get(id)
this.releases.delete(id)
release?.()
}
/** Cancel a stream the consumer stopped reading (the head already resolved). */
private cancelStream(id: number): void {
this.releaseSignal(id)
this.streams.delete(id)
this.inFlight.delete(id)
const abort: AbortFrame = { t: 'abort', id }
this.worker.postMessage(abort)
}
/**
* Report a refusal on the page console, where the acceptance run already keeps it.
*
* The prefix names the reporter, not the culprit: a 5xx can equally come from a
* handler inside the host tree. The message text decides — the worker expands
* nested causes into it, and its deepest layer is where the failure was thrown.
* @param id - request id the frame answers.
* @param outcome - what came back instead of a reply.
*/
private warnRefusal(id: number, outcome: string): void {
console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'}${outcome}`)
}
private receive(frame: ResponseFrame): void {
switch (frame.t) {
case 'res': {
const pending = this.unary.get(frame.id)
if (pending === undefined) return
if (frame.status >= REFUSAL_STATUS) {
this.warnRefusal(frame.id, `HTTP ${String(frame.status)}${frame.message === undefined ? '' : `: ${frame.message}`}`)
}
this.unary.delete(frame.id)
this.inFlight.delete(frame.id)
const body = NULL_BODY_STATUS.has(frame.status)
? null
: frame.body ?? frame.message ?? null
pending.resolve(new Response(body, { status: frame.status, headers: frame.headers }))
return
}
case 'res-head': {
const pending = this.unary.get(frame.id)
if (pending === undefined) return
this.unary.delete(frame.id)
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
this.streams.set(frame.id, controller)
},
cancel: () => {
this.cancelStream(frame.id)
},
})
pending.resolve(new Response(stream, { status: frame.status, headers: frame.headers }))
return
}
case 'res-chunk': {
this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
return
}
case 'res-end': {
const controller = this.streams.get(frame.id)
if (controller === undefined) return
this.streams.delete(frame.id)
this.inFlight.delete(frame.id)
this.releaseSignal(frame.id)
controller.close()
return
}
case 'res-err': {
const reason = new Error(`web-preview tunnel: ${frame.message}`)
this.warnRefusal(frame.id, `res-err: ${frame.message}`)
const pending = this.unary.get(frame.id)
this.inFlight.delete(frame.id)
if (pending !== undefined) {
this.unary.delete(frame.id)
pending.reject(reason)
return
}
const controller = this.streams.get(frame.id)
if (controller === undefined) return
this.streams.delete(frame.id)
this.releaseSignal(frame.id)
controller.error(reason)
return
}
default: {
const unknown: never = frame
throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`)
}
}
}
}
@@ -0,0 +1,94 @@
/**
* Page half: everything a deployment needs to reach a worker-hosted harness.
*
* This is **pre-Cordis glue, not a client plugin**: it installs the transport
* global and executes the boot injection table that the client plugin graph
* is later loaded through, so it cannot itself be a graph row. A page imports
* it directly and decides where the worker bundle and image live; nothing
* here mounts into a shipped roster.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/client
*/
import { IMAGE_FILE_NAME } from '../image-layout.ts'
import { WorkerApiClient } from './api-client.ts'
import { WorkerTunnel, type TunnelFetch } from './client.ts'
import { applyIndexInjections } from './apply-injections.ts'
export { WorkerApiClient } from './api-client.ts'
export { WorkerTunnel, type TunnelFetch } from './client.ts'
export { applyIndexInjections } from './apply-injections.ts'
export { IMAGE_FILE_NAME } from '../image-layout.ts'
/** Transport global the connection plugin reads instead of building an HTTP carrier. */
interface ClientTransportGlobal {
__DSH_TRANSPORT__?: {
createApiClient: () => WorkerApiClient
fetch: TunnelFetch
loadBundle: (url: string) => Promise<void>
}
}
/** Inputs for {@link connectWorkerHost}. */
export interface WorkerHostConnectOptions {
/**
* VFS image URL, the one deployment-shaped input. Defaults to
* {@link IMAGE_FILE_NAME} beside the page; a deployment that packs the
* image elsewhere passes its own URL.
*/
readonly image?: string | URL
}
/** A page connected to a worker-hosted harness, ready to run a shell entry. */
export interface WorkerHostConnection {
readonly worker: Worker
readonly tunnel: WorkerTunnel
/** Bundle transport for the shell's boot seam. */
loadBundle(url: string): Promise<void>
}
/** Boot-readiness deferred shared with the client entry's pre-boot await. */
interface BootReadyGlobal {
__DSH_BOOT_READY__?: PromiseWithResolvers<void>
}
/**
* Connect a spawned host worker and complete the pre-Cordis handshake.
*
* The caller constructs the Worker so its bundler resolves the bundle URL
* statically; the opening `init` frame then carries the image location, the
* only input the worker takes from outside.
*
* Order is fixed by the web boot protocol: the transport global must exist
* before any bundle executes; the injection table then reproduces the served
* boot rows — the `__ModuleLoader__` registration queue, the parser-preload
* bundles, `__DSH_BOOT__`, the theme bootstrap — in table order. The
* boot-readiness deferred (`__DSH_BOOT_READY__`) is installed before the
* first await and settles with the handshake, so a client entry evaluating
* concurrently in the same document holds at its pre-boot await until every
* row has taken effect, and surfaces a failed handshake instead of
* proceeding on missing globals.
* @param worker - The host worker.
* @param options - Image location override.
* @returns The connection; hand `loadBundle` to the shell entry's boot seam.
*/
export async function connectWorkerHost(worker: Worker, options?: WorkerHostConnectOptions): Promise<WorkerHostConnection> {
const ready = (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers<void>()
// The handshake may fail before any entry awaits the promise; this no-op
// subscription keeps that from surfacing as an unhandled rejection.
void ready.promise.catch(() => {})
try {
const tunnel = new WorkerTunnel(worker)
tunnel.init(new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href)
const payload = await tunnel.bootPayload()
;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
createApiClient: () => new WorkerApiClient(tunnel),
fetch: (input, init) => tunnel.fetch(input, init),
loadBundle: (url: string) => tunnel.loadBundle(url),
}
await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src))
ready.resolve()
return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) }
} catch (reason) {
ready.reject(reason)
throw reason
}
}
@@ -0,0 +1,571 @@
/**
* The worker's module transform: one acorn parse turns an ES module into a
* CommonJS body **and** routes every suspension point through the ambient-store
* protocol.
*
* Both jobs live in one pass because they are two edits over one syntax tree;
* running a lexer first and a parser second meant two scanners, two sets of
* blind spots, and a second pass reading the first pass's output. Editing is
* interval-based — the original text is sliced and spliced, never reprinted —
* so **line numbers survive**: a stack frame in a transformed module points at
* the same line as the built artifact it came from.
*
* The image packer is this transform's only caller: it lowers every JavaScript
* entry it packs and records `LOWERING_VERSION` in the image manifest, so the
* worker wraps those bodies without carrying a compiler of its own.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/compile/transform
*/
import { parse } from 'acorn'
const HELPER_SOURCE: Record<string, string> = {
def: 'const __dsh$def=(t,k,get)=>Object.defineProperty(t,k,{enumerable:true,configurable:true,get});',
default: 'const __dsh$default=(m)=>(m&&m.__esModule?m.default:m);',
ns: 'const __dsh$ns=(m)=>(m&&m.__esModule?m:Object.assign({},m,{default:m}));',
exportAll: 'const __dsh$exportAll=(t,m)=>{for(const k of Object.keys(m))if(k!=="default"&&!(k in t))__dsh$def(t,k,()=>m[k]);};',
dynImport: 'const __dsh$dynImport=(s)=>Promise.resolve().then(()=>__dsh$ns(require(s)));',
}
const HELPER_DEPENDENCIES: Record<string, readonly string[]> = {
exportAll: ['def'],
dynImport: ['ns'],
}
/** Runtime identifier the suspension protocol reaches. */
const ALS = '__als'
interface Node {
readonly type: string
readonly start: number
readonly end: number
readonly [key: string]: unknown
}
/** @returns Number of line breaks in a slice. */
function countNewlines(text: string): number {
let count = 0
for (let index = text.indexOf('\n'); index >= 0; index = text.indexOf('\n', index + 1)) count += 1
return count
}
interface Edit {
readonly start: number
readonly end: number
/** Rendered lazily so edits inside a replaced range still apply. */
readonly render: (inner: (from: number, to: number) => string) => string
}
/** One binding to publish on `exports`. */
interface Binding {
readonly exported: string
readonly local: string
}
class Transformer {
private readonly edits: Edit[] = []
private readonly source: string
private readonly helpers = new Set<string>()
private readonly bindings: Binding[] = []
private modules = 0
private temporaries = 0
private moduleSyntax = false
private readonly moduleRequests = new Set<string>()
private readonly metaResolveRequests = new Set<string>()
constructor(source: string, private readonly path: string) {
// A `#!` line is only legal at offset zero, and the prologue takes that spot;
// commenting it out in place keeps every offset and the line count intact.
this.source = source.startsWith('#!') ? `//${source.slice(2)}` : source
}
private fail(detail: string, index: number): never {
const line = this.source.slice(0, index).split('\n').length
throw new Error(`webworker transform: ${detail} (${this.path}:${line})`)
}
private helper(name: string): string {
for (const dependency of HELPER_DEPENDENCIES[name] ?? []) this.helper(dependency)
this.helpers.add(name)
return `__dsh$${name}`
}
private moduleTemp(): string {
this.modules += 1
return `__dsh$m${this.modules}`
}
private alsTemp(): string {
this.temporaries += 1
return `__als$${this.temporaries}`
}
/**
* Replace a range, keeping the module's line count.
*
* The padding is the newlines the original range held **minus** the ones the
* replacement re-emits: a rewrite that splices the original body back in
* (a desugared loop) already carries that body's newlines, and padding by the
* whole range again would push every later line down.
*/
private edit(start: number, end: number, build: (inner: (from: number, to: number) => string) => string): void {
const original = countNewlines(this.source.slice(start, end))
this.edits.push({
start,
end,
render: (inner) => {
const text = build(inner)
return text + '\n'.repeat(Math.max(0, original - countNewlines(text)))
},
})
}
private replace(start: number, end: number, text: string): void {
this.edit(start, end, () => text)
}
private insert(at: number, text: string): void {
this.edits.push({ start: at, end: at, render: () => text })
}
private structural(start: number, end: number, render: Edit['render']): void {
this.edit(start, end, render)
}
private literal(node: Node): string {
const value = node.value
if (typeof value !== 'string') this.fail('a module specifier must be a string literal', node.start)
this.moduleRequests.add(value)
return JSON.stringify(value)
}
/** @returns Static module requests the body makes, in first-appearance order. */
requests(): readonly string[] {
return [...this.moduleRequests]
}
/** @returns Literal `import.meta.resolve()` requests, in first-appearance order. */
metaRequests(): readonly string[] {
return [...this.metaResolveRequests]
}
// --- module syntax --------------------------------------------------------
private importDeclaration(node: Node): void {
this.moduleSyntax = true
if (Array.isArray(node.attributes) && node.attributes.length > 0) {
this.fail('import attributes are not supported', node.start)
}
const request = `require(${this.literal(node.source as Node)})`
const specifiers = node.specifiers as Node[]
if (specifiers.length === 0) {
this.replace(node.start, node.end, `${request};`)
return
}
const held = this.moduleTemp()
const lines = [`const ${held}=${request};`]
for (const specifier of specifiers) {
const local = (specifier.local as Node).name as string
if (specifier.type === 'ImportDefaultSpecifier') {
lines.push(`const ${local}=${this.helper('default')}(${held});`)
continue
}
if (specifier.type === 'ImportNamespaceSpecifier') {
lines.push(`const ${local}=${this.helper('ns')}(${held});`)
continue
}
const imported = specifier.imported as Node
const name = imported.type === 'Identifier' ? imported.name as string : imported.value as string
lines.push(`const ${local}=${held}[${JSON.stringify(name)}];`)
}
this.replace(node.start, node.end, lines.join(''))
}
private exportNamed(node: Node): void {
this.moduleSyntax = true
const declaration = node.declaration as Node | null
const source = node.source as Node | null
const specifiers = node.specifiers as Node[]
if (declaration !== null) {
// `export const x = 1` keeps its declaration; only the keyword goes.
this.replace(node.start, declaration.start, '')
for (const { exported, local } of declaredBindings(declaration, detail => this.fail(detail, declaration.start))) {
this.bindings.push({ exported, local })
}
return
}
if (source !== null) {
const held = this.moduleTemp()
const define = this.helper('def')
const lines = [`const ${held}=require(${this.literal(source)});`]
for (const specifier of specifiers) {
const local = nameOf(specifier.local as Node)
const exported = nameOf(specifier.exported as Node)
lines.push(`${define}(exports,${JSON.stringify(exported)},()=>${held}[${JSON.stringify(local)}]);`)
}
this.replace(node.start, node.end, lines.join(''))
return
}
// A bare `export {}` is a module marker with nothing to publish.
for (const specifier of specifiers) {
this.bindings.push({ exported: nameOf(specifier.exported as Node), local: nameOf(specifier.local as Node) })
}
this.replace(node.start, node.end, '')
}
private exportDefault(node: Node): void {
this.moduleSyntax = true
const declaration = node.declaration as Node
this.replace(node.start, declaration.start, 'exports.default = ')
}
private exportAll(node: Node): void {
this.moduleSyntax = true
const request = `require(${this.literal(node.source as Node)})`
const exported = node.exported as Node | null
if (exported === null) {
this.replace(node.start, node.end, `${this.helper('exportAll')}(exports,${request});`)
return
}
const held = this.moduleTemp()
const define = this.helper('def')
this.replace(
node.start,
node.end,
`const ${held}=${this.helper('ns')}(${request});${define}(exports,${JSON.stringify(nameOf(exported))},()=>${held});`,
)
}
// --- suspension points ----------------------------------------------------
private awaitExpression(node: Node): void {
const keywordEnd = node.start + 'await'.length
if (this.source.slice(node.start, keywordEnd) !== 'await') this.fail('unexpected await layout', node.start)
this.replace(node.start, keywordEnd, `${ALS}.resume(await ${ALS}.pause(`)
this.insert(node.end, '))')
}
/**
* `for await (L of R) B` becomes an explicit loop over the same protocol.
* `iterator.return` runs only on abrupt completion, as the language says, and
* is awaited so teardown still orders before the loop exits.
*/
private forAwait(node: Node): void {
const left = node.left as Node
const right = node.right as Node
const body = node.body as Node
const iterator = this.alsTemp()
const step = this.alsTemp()
const exhausted = this.alsTemp()
const binding = (inner: (from: number, to: number) => string): string => {
if (left.type !== 'VariableDeclaration') return `(${inner(left.start, left.end)})=${step}.value;`
const declarations = left.declarations as Node[]
const pattern = declarations[0]?.id as Node | undefined
if (declarations.length !== 1 || pattern === undefined) {
this.fail('for-await must declare exactly one binding', left.start)
}
return `${String(left.kind)} ${inner(pattern.start, pattern.end)}=${step}.value;`
}
this.structural(node.start, node.end, inner => [
`{const ${iterator}=${ALS}.iterator(${inner(right.start, right.end)});`,
`let ${step};let ${exhausted}=false;`,
`try{for(;;){${step}=${ALS}.resume(await ${ALS}.pause(${iterator}.next()));`,
`if(${step}.done){${exhausted}=true;break}`,
`{${binding(inner)}${body.type === 'BlockStatement' ? inner(body.start, body.end) : `{${inner(body.start, body.end)}}`}}}}`,
`finally{if(!${exhausted})${ALS}.resume(await ${ALS}.pause(${ALS}.close(${iterator})))}}`,
].join(''))
}
/**
* `yield` resumes with whatever the consumer sent, so the snapshot is taken
* before suspending and restored when the call completes. `yield*` delegates,
* which has no expression form here: it is desugared as a statement, and a
* consumer's `throw()` is not forwarded into the inner iterator (`next` and
* `return` are).
*/
private yieldExpression(node: Node, statement: Node | undefined): void {
if (node.delegate !== true) {
this.insert(node.start, `${ALS}.afterYield(${ALS}.snapshot(),`)
this.insert(node.end, ')')
return
}
const argument = node.argument as Node | null
if (argument === null) this.fail('yield* without an operand', node.start)
if (statement === undefined) this.fail('yield* is only supported as a statement', node.start)
if ((statement.expression as Node) !== node) {
// Anything around the delegation (`x = yield* g()`, `f(yield* g())`)
// would be silently dropped by the statement-wide rewrite below; the
// all-or-nothing lowering contract demands a loud refusal instead.
this.fail('yield* is only supported as the whole statement expression', node.start)
}
const iterator = this.alsTemp()
const step = this.alsTemp()
const sent = this.alsTemp()
const exhausted = this.alsTemp()
this.structural(statement.start, statement.end, inner => [
`{const ${iterator}=${ALS}.iterator(${inner(argument.start, argument.end)});`,
`let ${sent};let ${exhausted}=false;`,
`try{for(;;){const ${step}=${ALS}.resume(await ${ALS}.pause(${iterator}.next(${sent})));`,
`if(${step}.done){${exhausted}=true;break}`,
`${sent}=${ALS}.afterYield(${ALS}.snapshot(),yield ${step}.value)}}`,
`finally{if(!${exhausted})${ALS}.resume(await ${ALS}.pause(${ALS}.close(${iterator})))}}`,
].join(''))
}
// --- traversal ------------------------------------------------------------
private visit(node: unknown, context: { asyncGenerator: boolean; functionDepth: number; statement?: Node }): void {
if (node === null || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) this.visit(child, context)
return
}
const record = node as Node
if (typeof record.type !== 'string') return
let next = context
switch (record.type) {
case 'ImportDeclaration': this.importDeclaration(record); break
case 'ExportNamedDeclaration': this.exportNamed(record); break
case 'ExportDefaultDeclaration': this.exportDefault(record); break
case 'ExportAllDeclaration': this.exportAll(record); break
case 'ImportExpression': {
this.moduleSyntax = true
if (!this.source.startsWith('import', record.start)) this.fail('unexpected dynamic import layout', record.start)
this.replace(record.start, record.start + 'import'.length, this.helper('dynImport'))
// A computed dynamic import stays out of the request list; resolution
// then happens (and fails loud) at runtime, never silently at pack time.
const argument = record.source as Node | undefined
if (argument !== undefined && typeof argument.value === 'string') this.moduleRequests.add(argument.value)
break
}
case 'CallExpression': {
// CommonJS bodies pass through untransformed, but their literal
// `require()` calls are module requests all the same.
const callee = record.callee as Node
const callArguments = record.arguments as Node[]
if (callee.type === 'Identifier' && callee.name === 'require' && callArguments.length === 1
&& typeof callArguments[0]?.value === 'string') {
this.moduleRequests.add(callArguments[0].value)
}
// `import.meta.resolve('lit')` is the third static request face: the
// loader answers it from the image, so the pack sweep must keep the
// target. A computed argument stays out, same as dynamic import —
// resolution then fails loud at runtime, never silently at pack time.
if (callee.type === 'MemberExpression') {
const object = callee.object as Node
const property = callee.property as Node
if (object.type === 'MetaProperty' && (object.meta as Node).name === 'import'
&& property.type === 'Identifier' && property.name === 'resolve'
&& typeof callArguments[0]?.value === 'string') {
this.metaResolveRequests.add(callArguments[0].value)
}
}
break
}
case 'MetaProperty': {
// `new.target` is a MetaProperty too, and it must survive untouched:
// the abstract-seam guards in the roster read it (`new.target === X`).
const meta = record.meta as Node
if (meta.name === 'import') {
this.moduleSyntax = true
this.replace(record.start, record.end, '__dsh$meta')
}
break
}
case 'AwaitExpression':
if (context.functionDepth === 0) {
this.fail('top-level await cannot run as CommonJS in the worker', record.start)
}
this.awaitExpression(record)
break
case 'ForOfStatement':
if (record.await === true) {
if (context.functionDepth === 0) this.fail('a top-level for-await loop cannot run as CommonJS', record.start)
this.forAwait(record)
}
break
case 'LabeledStatement': {
const body = record.body as Node
if (body.type === 'ForOfStatement' && body.await === true) {
this.fail('a labeled for-await loop is not supported', record.start)
}
break
}
case 'YieldExpression':
if (context.asyncGenerator) this.yieldExpression(record, context.statement)
break
case 'FunctionDeclaration':
case 'FunctionExpression':
case 'ArrowFunctionExpression':
next = {
asyncGenerator: record.async === true && record.generator === true,
functionDepth: context.functionDepth + 1,
}
break
default: break
}
if (record.type === 'ExpressionStatement') next = { ...next, statement: record }
for (const [key, value] of Object.entries(record)) {
if (key === 'type' || key === 'start' || key === 'end') continue
this.visit(value, next)
}
}
run(): string {
// Transforming a lowered body again would nest the protocol inside itself:
// it still runs, only slower and unreadable, so a mis-wired manifest must
// surface here rather than as a silent tax on every load.
if (this.source.includes(`${ALS}.pause(`) || this.source.includes('__als$')) {
this.fail('the module is already lowered; check the image manifest wiring', 0)
}
let program: Node
try {
program = parse(this.source, {
ecmaVersion: 'latest',
sourceType: 'module',
allowAwaitOutsideFunction: true,
}) as unknown as Node
} catch (reason) {
this.fail(`parse failed: ${(reason as Error).message}`, 0)
}
this.visit(program, { asyncGenerator: false, functionDepth: 0 })
if (this.edits.length === 0 && !this.moduleSyntax) return this.source
const prologue: string[] = []
if (this.moduleSyntax) prologue.push('"use strict";Object.defineProperty(exports,"__esModule",{value:true});')
if (this.bindings.length > 0) this.helper('def')
for (const [name, source] of Object.entries(HELPER_SOURCE)) {
if (this.helpers.has(name)) prologue.push(source)
}
for (const { exported, local } of this.bindings) {
prologue.push(`__dsh$def(exports,${JSON.stringify(exported)},()=>${local});`)
}
const sorted = [...this.edits].sort((left, right) => left.start - right.start || left.end - right.end)
const render = (from: number, to: number): string => {
let cursor = from
let out = ''
for (const edit of sorted) {
if (edit.start < cursor || edit.end > to) continue
out += this.source.slice(cursor, edit.start) + edit.render(render)
cursor = edit.end
}
return out + this.source.slice(cursor, to)
}
const code = prologue.join('') + render(0, this.source.length)
// Proof that the emitted body is CommonJS a wrapper can compile: any leftover
// module syntax, or any mis-spliced interval, fails here rather than at load.
try {
parse(code, { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: false })
} catch (reason) {
this.fail(`the transform produced code that does not parse: ${(reason as Error).message}`, 0)
}
return code
}
}
/** @returns The name a specifier or identifier node carries. */
function nameOf(node: Node): string {
return node.type === 'Identifier' ? node.name as string : String(node.value)
}
/** Every binding an exported declaration introduces, including patterns. */
function declaredBindings(declaration: Node, fail: (detail: string) => never): Binding[] {
if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
const id = declaration.id as Node | null
if (id === null) fail('an exported declaration must be named')
const name = id.name as string
return [{ exported: name, local: name }]
}
if (declaration.type !== 'VariableDeclaration') fail(`unsupported exported declaration ${declaration.type}`)
const bindings: Binding[] = []
const collect = (pattern: Node): void => {
switch (pattern.type) {
case 'Identifier':
bindings.push({ exported: pattern.name as string, local: pattern.name as string })
return
case 'ObjectPattern':
for (const property of pattern.properties as Node[]) {
collect((property.type === 'RestElement' ? property.argument : property.value) as Node)
}
return
case 'ArrayPattern':
for (const element of pattern.elements as Array<Node | null>) if (element !== null) collect(element)
return
case 'AssignmentPattern':
collect(pattern.left as Node)
return
case 'RestElement':
collect(pattern.argument as Node)
return
default:
fail(`unsupported binding pattern ${pattern.type}`)
}
}
for (const declarator of declaration.declarations as Node[]) collect(declarator.id as Node)
return bindings
}
interface TransformedModule {
readonly code: string
readonly moduleRequests: readonly string[]
readonly metaResolveRequests: readonly string[]
}
const cache = new Map<string, TransformedModule>()
/**
* Transform one module into a body for the worker wrapper.
*
* Results are cached by source text, so a module reached through two paths, or
* a repeated build, parses once.
* @param source - Module source, ESM or CommonJS.
* @param path - Path used in diagnostics.
* @returns The lowered body and the module requests found in it.
*/
function transformDetailed(source: string, path: string): TransformedModule {
const cached = cache.get(source)
if (cached !== undefined) return cached
const transformer = new Transformer(source, path)
const transformed = { code: transformer.run(), moduleRequests: transformer.requests(), metaResolveRequests: transformer.metaRequests() }
cache.set(source, transformed)
return transformed
}
/** One module the collector considered. */
export interface LoweredModule {
/** Transformed body, or the input unchanged when nothing needed lowering. */
readonly code: string
/** False means the entry may be packed as it is. */
readonly lowered: boolean
/**
* Static module requests the body makes: import and re-export sources,
* literal dynamic imports, and literal `require()` calls. Computed requests
* are absent — they resolve (and fail loud) at runtime only.
*/
readonly moduleRequests: readonly string[]
/**
* Literal `import.meta.resolve()` requests. These are URL mappings, not
* loads: the pack sweep keeps a resolvable target and tolerates a missing
* one, and the loader answers or throws at the call site.
*/
readonly metaResolveRequests: readonly string[]
}
/**
* Lower one module at image-pack time.
*
* The collector calls this for every JavaScript entry it packs and records
* `LOWERING_VERSION` in the image manifest; the loader then wraps those entries
* without parsing them. `lowered: false` reports that the transform would have
* returned the input verbatim (already CommonJS, no suspension point), so the
* entry may be packed as it is.
*
* Throwing is the intended failure mode: a module this transform cannot express
* must fail the build rather than ship an image that breaks at load.
* @param options - Virtual path inside the image and the module source.
* @returns The code to pack and whether it changed.
*/
export function lowerModuleSource(options: { readonly filename: string; readonly source: string }): LoweredModule {
const { code, moduleRequests, metaResolveRequests } = transformDetailed(options.source, options.filename)
return { code, lowered: code !== options.source, moduleRequests, metaResolveRequests }
}
@@ -0,0 +1,47 @@
/**
* Image layout contract shared by the packer and the worker host: the virtual
* root, where the composed config and the manifest sit inside the image, and
* the working directories every image carries empty. One definition, two
* consumers — the packer writes this layout, the worker host mounts it.
*/
/** Default virtual root; the runtime mounts the image here unless told otherwise. */
export const DEFAULT_ROOT = '/dsh'
/**
* Leaf name of the packed image: one gzip member holding the ustar archive. The
* app build writes it beside the page and the page's boot fetches it from there,
* so the extension is part of what a deployment serves.
*/
export const IMAGE_FILE_NAME = 'vfs-image.tar.gz'
/** Image path the composed profile is written to; the runtime's Loader reads it. */
export const IMAGE_CONFIG_PATH = 'config/cordis.yml'
/** Image path of the manifest the runtime reads before it wraps a single module. */
export const IMAGE_MANIFEST_PATH = 'config/vfs-manifest.json'
/** Home directory under the root; the process shim's `DSH_HOME`/`HOME` default. */
export const IMAGE_HOME_DIRECTORY = 'home'
/** Working directories the host tree expects to exist, empty. */
export const IMAGE_EMPTY_DIRECTORIES: readonly string[] = ['home/', 'workspace/', 'tmp/']
/**
* Identity of the lowered code shape, recorded in the image manifest by the
* packer and required by the worker host: an image lowered by an older transform
* would otherwise run against newer wrapper semantics. Bump on any change to
* emitted code or to {@link WRAPPER_PARAMS}.
*/
export const LOWERING_VERSION = 'dsh-worker-transform/1'
/**
* Free variables a lowered body expects from its wrapper, in order.
*
* Part of the image layout rather than of the transform, because the loader
* wraps bodies it never parses: the packer emits against these names and the
* worker binds them, with no compiler in the worker bundle to agree with.
*/
export const WRAPPER_PARAMS = [
'exports', 'require', 'module', '__filename', '__dirname', '__dsh$meta', '__als',
] as const
@@ -0,0 +1,44 @@
/**
* Browser-only host runtime: the harness Cordis tree inside a dedicated Web Worker.
* @module @deepseek-ai/dsh-experimental-webworker-runtime
*/
export {
createAlsRuntime,
type AlsCausality, type AlsRuntime, type AlsSnapshot, type AlsToken,
} from './polyfill/async-context/als-runtime.ts'
export {
parseInboundFrame,
type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame,
type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame,
type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame,
} from './transport/frames.ts'
export {
DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader,
type Resolution, type StaticModuleFactory, type WorkerModuleLoaderOptions, type WorkerRequire,
} from './module-system/module-loader.ts'
export * as posixPath from './module-system/posix-path.ts'
export {
createSyntheticExchange,
type RequestListener, type ResponseSink, type SyntheticExchange,
} from './transport/synthetic-http.ts'
export { lowerModuleSource, type LoweredModule } from './compile/transform.ts'
export {
API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer,
type TunnelPort, type TunnelSeams, type TunnelServerOptions,
} from './transport/tunnel.ts'
export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts'
export {
createWorkerHost, type WorkerHost, type WorkerHostOptions,
} from './worker-host.ts'
export {
DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_FILE_NAME, IMAGE_HOME_DIRECTORY,
IMAGE_MANIFEST_PATH, LOWERING_VERSION, WRAPPER_PARAMS,
} from './image-layout.ts'
export { loadVfsImage, MemoryVfs } from './storage/memory.ts'
export { inflateImage, inflateImageStream } from './storage/image-gzip.ts'
export { packTar, parseTar, type TarEntry } from './storage/tar.ts'
export { requireActiveVfs, setActiveVfs } from './storage/active.ts'
export {
type VfsDir, type VfsDirent, type VfsEncoding, type VfsError, type VfsFileHandle,
type VfsReadOptions, type VfsStats, type VfsWriteOptions,
} from './storage/types.ts'
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-experimental-webworker-runtime`.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-webworker-runtime'
/** Cordis companion plugin name. */
export const name = 'webworker-runtime-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package is pre-Cordis platform glue —
* the tree it boots runs the product packages' own invariants, and the
* assembly's contracts (image contract gate, tunnel refusals) fail loud at
* boot rather than drifting at run time.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,76 @@
/**
* The worker bundle's module proxy table: the ONLY platform fork of the host
* tree. Every entry replaces a Node builtin or an external npm package;
* workspace and vendored modules are always mounted as they ship.
*
* The build turns these into bundler aliases, and `node/builtins.ts` turns the
* same modules into the loader's static table — one list, two consumers.
*
* The replacement path states the classification. `./node/builtin_modules/implemented/<module>.ts`
* carries the module's real semantics over a worker-side data source (VFS, the
* tunnel, a wasm codec, a browser primitive); `./node/builtin_modules/mock/<module>.ts` is a
* structural placeholder that mounts silently and reports the missing capability
* when a call finally reaches it. External npm replacements live in
* `./externals/`, named after the package they stand in for.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies
*/
/**
* Module proxy table — the ONLY platform fork of the worker host. Every entry
* replaces a Node builtin or an external npm package; workspace and vendored
* modules are always mounted as-is. Keys are exact module specifiers.
*/
export const MODULE_PROXIES: Record<string, string> = {
// VFS-backed real implementations.
'node:fs': './node/builtin_modules/implemented/fs.ts',
'fs': './node/builtin_modules/implemented/fs.ts',
'node:fs/promises': './node/builtin_modules/implemented/fs/promises.ts',
'fs/promises': './node/builtin_modules/implemented/fs/promises.ts',
'node:path': './node/builtin_modules/implemented/path.ts',
'path': './node/builtin_modules/implemented/path.ts',
'node:path/posix': './node/builtin_modules/implemented/path.ts',
'node:os': './node/builtin_modules/implemented/os.ts',
'os': './node/builtin_modules/implemented/os.ts',
'node:url': './node/builtin_modules/implemented/url.ts',
'node:module': './node/builtin_modules/implemented/module.ts',
'node:crypto': './node/builtin_modules/implemented/crypto.ts',
'crypto': './node/builtin_modules/implemented/crypto.ts',
// `buffer` itself stays unaliased: the shim is backed by that npm package.
'node:buffer': './node/builtin_modules/implemented/buffer.ts',
// Tunnel request source: fake bind, real route face. `node:process` and
// `process` are absent on purpose — the worker host installs that global
// (`./globals/process.ts`).
'node:http': './node/builtin_modules/implemented/http.ts',
// Sync-stack AsyncLocalStorage semantics.
'node:async_hooks': './node/builtin_modules/implemented/async_hooks.ts',
// Real implementations over browser primitives.
'node:util': './node/builtin_modules/implemented/util.ts',
'node:util/types': './node/builtin_modules/implemented/util/types.ts',
'node:events': './node/builtin_modules/implemented/events.ts',
'node:timers/promises': './node/builtin_modules/implemented/timers/promises.ts',
'node:perf_hooks': './node/builtin_modules/implemented/perf_hooks.ts',
// Real zstd codec: session-log appends compress on every write.
'node:zlib': './node/builtin_modules/implemented/zlib.ts',
// Structural mocks: every symbol exists, every call throws.
'node:net': './node/builtin_modules/mock/net.ts',
'node:stream': './node/builtin_modules/mock/stream.ts',
'node:vm': './node/builtin_modules/mock/vm.ts',
'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts',
'node:sqlite': './node/builtin_modules/mock/sqlite.ts',
// External npm replacements, named after the package each stands in for.
'koffi': './node/external_packages/koffi.ts',
'sharp': './node/external_packages/sharp.ts',
'node-pty': './node/external_packages/node-pty.ts',
'@vscode/ripgrep': './node/external_packages/ripgrep.ts',
'@earendil-works/pi-ai': './node/external_packages/pi-ai.ts',
'@deepseek-ai/node-addon-landlock-run': './node/external_packages/node-addon-landlock-run.ts',
// Constructible fakes whose methods are never reached.
'ws': './node/external_packages/ws.ts',
'chokidar': './node/external_packages/chokidar.ts',
}
/** pi-ai subpaths (`/providers/all`, `/api/*.lazy`) share the one structural stub. */
export const MODULE_PROXY_PREFIXES: Record<string, string> = {
'@earendil-works/pi-ai/': './node/external_packages/pi-ai.ts',
}
@@ -0,0 +1,407 @@
/**
* CommonJS module loader over the worker VFS. It fills the `loader.internal`
* seam Cordis uses for every entry import, and backs the `node:module`
* `createRequire` proxy that `typert-loader` and `client-modules` resolve
* package metadata through.
*
* Resolution is a narrowed Node `require` algorithm: `exports` walk with a
* fixed condition order, extension probing, and one cache keyed by resolved
* absolute path. Module bodies are wrapped as the image holds them: lowering is
* the packer's job, so nothing here parses JavaScript.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader
*/
import { createAlsRuntime, type AlsCausality, type AlsRuntime } from '../polyfill/async-context/als-runtime.ts'
import { dirname, fileUrlToPath, isAbsolute, join, pathToFileUrl, resolve as resolvePath } from './posix-path.ts'
import { WRAPPER_PARAMS } from '../image-layout.ts'
import type { MemoryVfs } from '../storage/memory.ts'
/** Condition keys honoured in `exports`, in order; `node` is deliberately absent. */
export const DEFAULT_CONDITIONS = ['browser', 'require', 'import', 'default'] as const
/** Extensions probed when a specifier has no usable one. */
const EXTENSIONS = ['.js', '.json', '.mjs', '.cjs'] as const
type ExportsField = string | null | readonly ExportsField[] | { readonly [key: string]: ExportsField }
interface PackageManifest {
readonly name?: string
readonly main?: string
readonly exports?: ExportsField
}
/**
* One entry of the static-module table. The loader calls it when a `require`
* names that specifier and never before, so resolution alone — `require.resolve`
* or `import.meta.resolve` — evaluates nothing. Repeated requires of one
* specifier must answer the same module instance: callers depend on class
* identity across requires (`instanceof EventEmitter`, `Buffer.isBuffer`), so a
* factory that builds its value has to memoize it.
* @returns The module object served for that specifier.
*/
export type StaticModuleFactory = () => unknown
/** Where a specifier resolved to. */
export type Resolution =
| { readonly kind: 'static'; readonly specifier: string; readonly factory: StaticModuleFactory }
| { readonly kind: 'file'; readonly path: string }
interface ModuleRecord {
readonly module: { exports: unknown }
}
/** The `require` function shape the roster consumes through `createRequire`. */
export interface WorkerRequire {
(specifier: string): unknown
resolve(specifier: string): string
}
/** Construction inputs for {@link WorkerModuleLoader}. */
export interface WorkerModuleLoaderOptions {
/** Filesystem holding package metadata and module sources. */
readonly vfs: MemoryVfs
/** Virtual root whose `node_modules` bare specifiers resolve against. */
readonly root?: string
/**
* Modules served from the worker bundle instead of the VFS: `node:*` proxies
* and the loud stubs for excluded npm packages, each behind a
* {@link StaticModuleFactory}.
*/
readonly staticModules: Readonly<Record<string, StaticModuleFactory>>
/**
* Prefix-matched proxies for packages whose subpaths are open-ended: a
* specifier starting with the key resolves to its module. Exact keys win, and
* the longest matching prefix wins among prefixes.
*/
readonly staticModulePrefixes?: Readonly<Record<string, StaticModuleFactory>>
/** Overrides {@link DEFAULT_CONDITIONS}. */
readonly conditions?: readonly string[]
/**
* Ambient-store snapshot face for the suspended `rewrite-await` route; it is
* read only when that route is the configured {@link lowering}.
*/
readonly alsCausality?: AlsCausality
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Loader for one VFS mount; construct once per worker. */
export class WorkerModuleLoader {
private readonly vfs: MemoryVfs
private readonly root: string
private readonly staticModules: ReadonlyMap<string, StaticModuleFactory>
private readonly staticPrefixes: ReadonlyArray<readonly [string, StaticModuleFactory]>
private readonly conditions: ReadonlySet<string>
private readonly als: AlsRuntime
private readonly modules = new Map<string, ModuleRecord>()
private readonly manifests = new Map<string, PackageManifest>()
private readonly stack: string[] = []
/**
* The Cordis module seam. `parentURL` positions relative specifiers;
* import attributes are ignored, as the client implementation does.
*/
readonly internal: {
readonly version: 'worker'
import(specifier: string, parentURL?: string, attributes?: unknown): Promise<unknown>
}
constructor(options: WorkerModuleLoaderOptions) {
this.vfs = options.vfs
this.root = options.root ?? '/dsh'
// A Map, not the record itself: a specifier that names an Object prototype
// member must miss the table the way any other unregistered name does.
this.staticModules = new Map(Object.entries(options.staticModules))
this.staticPrefixes = Object.entries(options.staticModulePrefixes ?? {})
.sort(([left], [right]) => right.length - left.length)
this.conditions = new Set(options.conditions ?? DEFAULT_CONDITIONS)
this.als = createAlsRuntime(options.alsCausality)
this.internal = {
version: 'worker',
import: async (specifier: string, parentURL?: string): Promise<unknown> => {
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
return this.load(this.resolve(specifier, from))
},
}
}
private fail(detail: string): never {
const chain = this.stack.length === 0 ? '' : ` (importer chain: ${this.stack.join(' -> ')})`
throw new Error(`webworker modules: ${detail}${chain}`)
}
/** @returns Directory a base path or URL resolves specifiers from. */
private baseDirectoryOf(base: string | URL): string {
const text = typeof base === 'string' ? base : base.href
const path = text.startsWith('file://') ? fileUrlToPath(text) : text
if (path.endsWith('/')) return resolvePath(path)
return this.vfs.existsSync(path) && this.vfs.statSync(path).isDirectory() ? resolvePath(path) : dirname(path)
}
private manifestOf(directory: string): PackageManifest {
const cached = this.manifests.get(directory)
if (cached !== undefined) return cached
const path = join(directory, 'package.json')
const text = this.vfs.readFileSync(path, 'utf8') as string
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch (reason) {
this.fail(`${path} is not valid JSON: ${(reason as Error).message}`)
}
if (!isRecord(parsed)) this.fail(`${path} does not hold an object`)
const manifest = parsed as PackageManifest
this.manifests.set(directory, manifest)
return manifest
}
/** Walk one `exports` value against the condition set and requested subpath. */
private selectExport(field: ExportsField, subpath: string, packageName: string): string | undefined {
if (field === null) return undefined
if (typeof field === 'string') return subpath === '.' ? field : undefined
if (Array.isArray(field)) {
for (const candidate of field as readonly ExportsField[]) {
const picked = this.selectExport(candidate, subpath, packageName)
if (picked !== undefined) return picked
}
return undefined
}
const entries = Object.entries(field as { [key: string]: ExportsField })
const isSubpathMap = entries.some(([key]) => key === '.' || key.startsWith('./'))
if (!isSubpathMap) {
if (subpath !== '.') return undefined
return this.selectCondition(field, packageName)
}
for (const [key, value] of entries) {
if (key === subpath) {
return typeof value === 'string' ? value : this.selectCondition(value, packageName, subpath)
}
}
for (const [key, value] of entries) {
const star = key.indexOf('*')
if (star < 0) continue
const prefix = key.slice(0, star)
const suffix = key.slice(star + 1)
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
const captured = subpath.slice(prefix.length, subpath.length - suffix.length)
const target = typeof value === 'string' ? value : this.selectCondition(value, packageName, subpath)
if (target !== undefined) return target.replaceAll('*', captured)
}
return undefined
}
/** Pick the first condition branch this runtime satisfies. */
private selectCondition(field: ExportsField, packageName: string, subpath = '.'): string | undefined {
if (field === null) return undefined
if (typeof field === 'string') return field
if (Array.isArray(field)) {
for (const candidate of field as readonly ExportsField[]) {
const picked = this.selectCondition(candidate, packageName, subpath)
if (picked !== undefined) return picked
}
return undefined
}
for (const [key, value] of Object.entries(field as { [key: string]: ExportsField })) {
if (!this.conditions.has(key)) continue
const picked = this.selectCondition(value, packageName, subpath)
if (picked !== undefined) return picked
}
return undefined
}
/** Extension and directory probing for a concrete path. */
private probe(path: string, specifier: string): string {
const candidates: string[] = [path, ...EXTENSIONS.map(extension => path + extension)]
for (const candidate of candidates) {
if (this.vfs.existsSync(candidate) && this.vfs.statSync(candidate).isFile()) return candidate
}
if (this.vfs.existsSync(path) && this.vfs.statSync(path).isDirectory()) {
if (this.vfs.existsSync(join(path, 'package.json'))) {
const main = this.manifestOf(path).main
if (main !== undefined) return this.probe(join(path, main), specifier)
}
return this.probe(join(path, 'index'), specifier)
}
return this.fail(`cannot resolve "${specifier}": no file at ${candidates.join(', ')}`)
}
/**
* Resolve a specifier the way the module that requested it would.
* @param specifier - Bare name, relative path, absolute path, or file URL.
* @param fromDirectory - Directory of the requesting module.
* @returns Static module or the resolved VFS path.
*/
resolve(specifier: string, fromDirectory: string): Resolution {
const exact = this.staticModules.get(specifier)
if (exact !== undefined) return { kind: 'static', specifier, factory: exact }
for (const [prefix, factory] of this.staticPrefixes) {
if (specifier.startsWith(prefix)) return { kind: 'static', specifier, factory }
}
if (specifier.startsWith('cordis:') || specifier.startsWith('node:')) {
return this.fail(`no static module is registered for "${specifier}"`)
}
if (specifier.startsWith('file://')) {
return { kind: 'file', path: this.probe(fileUrlToPath(specifier), specifier) }
}
if (specifier.startsWith('.')) {
return { kind: 'file', path: this.probe(join(fromDirectory, specifier), specifier) }
}
if (isAbsolute(specifier)) {
return { kind: 'file', path: this.probe(specifier, specifier) }
}
// Node resolves `fs` and `node:fs` to the same builtin; the proxy table may register either.
const prefixed = this.staticModules.get(`node:${specifier}`)
if (prefixed !== undefined) return { kind: 'static', specifier, factory: prefixed }
const segments = specifier.split('/')
const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] ?? specifier
const rest = specifier.slice(packageName.length).replace(/^\//, '')
const packageDirectory = join(this.root, 'node_modules', packageName)
if (!this.vfs.existsSync(join(packageDirectory, 'package.json'))) {
return this.fail(`cannot resolve "${specifier}": ${packageDirectory}/package.json is not in the image`)
}
const manifest = this.manifestOf(packageDirectory)
const subpath = rest === '' ? '.' : `./${rest}`
if (manifest.exports !== undefined) {
const target = this.selectExport(manifest.exports, subpath, packageName)
if (target === undefined) {
return this.fail(`"${packageName}" does not export "${subpath}" under conditions [${[...this.conditions].join(', ')}]`)
}
return { kind: 'file', path: this.probe(join(packageDirectory, target), specifier) }
}
const legacy = subpath === '.' ? manifest.main ?? 'index.js' : rest
return { kind: 'file', path: this.probe(join(packageDirectory, legacy), specifier) }
}
/**
* Load a resolved module, reusing the cache and tolerating cycles with
* CommonJS partial-export semantics.
* @param resolution - Result of {@link resolve}.
* @returns The module's exports.
*/
load(resolution: Resolution): unknown {
if (resolution.kind === 'static') return resolution.factory()
const path = resolution.path
const cached = this.modules.get(path)
if (cached !== undefined) return cached.module.exports
if (path.endsWith('.json')) {
const parsed: unknown = JSON.parse(this.vfs.readFileSync(path, 'utf8') as string)
this.modules.set(path, { module: { exports: parsed } })
return parsed
}
const exports: Record<string, unknown> = {}
const record: ModuleRecord = { module: { exports } }
this.modules.set(path, record)
this.stack.push(path)
try {
const source = this.vfs.readFileSync(path, 'utf8') as string
const factory = this.compile(source, path)
const directory = dirname(path)
factory(
record.module.exports,
this.requireFrom(directory),
record.module,
path,
directory,
{
url: pathToFileUrl(path),
// Node parity for the lowered `import.meta` face: a path resolution
// answers a file URL; a static (built-in or proxied) module answers
// its own specifier, the way Node echoes `node:*` back.
resolve: (specifier: string): string => {
const resolution = this.resolve(specifier, directory)
return resolution.kind === 'static' ? resolution.specifier : pathToFileUrl(resolution.path)
},
},
this.als,
)
return record.module.exports
} catch (reason) {
this.modules.delete(path)
throw reason
} finally {
this.stack.pop()
}
}
/**
* Compile a body the image already lowered.
*
* Module syntax reaching here means the image was packed by something other
* than the packer, or its collector missed the entry. The worker carries no
* transform to recover with, so it names the image as the thing to rebuild.
* @param code - Module body as the image holds it.
* @param path - Resolved VFS path.
* @returns The wrapper factory.
*/
private compile(code: string, path: string): (...args: unknown[]) => void {
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- wrapping an image body is this loader's job
return new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void
} catch (reason) {
if (reason instanceof SyntaxError && /await/i.test(reason.message)) {
this.fail(`${path} uses top-level await, which cannot run as CommonJS in the worker: ${reason.message}`)
}
if (reason instanceof SyntaxError && /import|export/i.test(reason.message)) {
this.fail(`${path} still carries module syntax, so the image was not lowered by the packer `
+ `(${reason.message}); rebuild the image`)
}
this.fail(`${path} failed to compile: ${(reason as Error).message}`)
}
}
/**
* Build a `require` bound to a directory.
* @param fromDirectory - Directory relative specifiers resolve against.
* @returns Callable require with `resolve`.
*/
requireFrom(fromDirectory: string): WorkerRequire {
const require = ((specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory))) as WorkerRequire
require.resolve = (specifier: string): string => {
const resolution = this.resolve(specifier, fromDirectory)
if (resolution.kind === 'static') {
return this.fail(`"${specifier}" is a worker-provided module and has no VFS path`)
}
return resolution.path
}
return require
}
/**
* `node:module` `createRequire` for the VFS.
* @param base - Module path, directory path, or `file:` URL.
* @returns Require bound to that base.
*/
createRequire(base: string | URL): WorkerRequire {
return this.requireFrom(this.baseDirectoryOf(base))
}
/**
* Report what this loader has done, for the host's boot diagnostics.
* @returns How many module bodies it has run.
*/
usage(): { modules: number } {
return { modules: this.modules.size }
}
}
let active: WorkerModuleLoader | undefined
/**
* Publish the loader the `node:module` proxy resolves through.
* @param loader - Loader built by the worker entry.
*/
export function setActiveModuleLoader(loader: WorkerModuleLoader): void {
active = loader
}
/**
* Read the published loader.
* @returns The active loader.
*/
export function requireActiveModuleLoader(): WorkerModuleLoader {
if (active === undefined) {
throw new Error('webworker modules: no loader is mounted; the worker entry must call setActiveModuleLoader before any createRequire use')
}
return active
}
@@ -0,0 +1,169 @@
/**
* POSIX path helpers for the worker VFS: one absolute root, no drive letters,
* no symlinks.
*
* **Not a `node:path` substitute.** {@link dirname}, {@link basename}, and
* {@link parse} normalize first, because every caller here hands the result to
* the VFS, which keys files by normalized absolute path — `dirname('/a/b/..')`
* answers `/`, the directory that actually holds the entry. Node's three are
* purely lexical and answer `/a/b`. A `node:path` proxy owes callers Node's
* literal answers, so it needs its own port of Node's implementation rather than
* a facade over this module (`apps/web-preview` keeps one; the divergence covers
* 45 of ~200 cases, all in these three functions).
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/posix-path
*/
/** Path separator of the virtual filesystem. */
export const SEP = '/'
/**
* Collapse `.` and `..` segments.
* @param path - Path with any number of separators.
* @returns Normalized path; a relative input keeps leading `..` segments.
*/
export function normalize(path: string): string {
const absolute = path.startsWith(SEP)
const trailing = path.length > 1 && path.endsWith(SEP)
const out: string[] = []
for (const segment of path.split(SEP)) {
if (segment === '' || segment === '.') continue
if (segment === '..' && out.length > 0 && out[out.length - 1] !== '..') {
out.pop()
continue
}
if (segment === '..' && absolute) continue
out.push(segment)
}
const body = out.join(SEP)
if (absolute) return SEP + body + (trailing && body !== '' ? SEP : '')
if (body === '') return trailing ? './' : '.'
return body + (trailing ? SEP : '')
}
/**
* Join segments and normalize the result.
* @param segments - Path segments.
* @returns Joined path, `.` when nothing remains.
*/
export function join(...segments: string[]): string {
const joined = segments.filter(segment => segment !== '').join(SEP)
return joined === '' ? '.' : normalize(joined)
}
/**
* Resolve segments right to left against a base directory.
* @param segments - Path segments; the first absolute one wins.
* @returns Absolute normalized path.
*/
export function resolve(...segments: string[]): string {
let path = ''
for (const segment of [...segments].reverse()) {
if (segment === '') continue
path = path === '' ? segment : `${segment}${SEP}${path}`
if (segment.startsWith(SEP)) break
}
return normalize(path.startsWith(SEP) ? path : `${SEP}${path}`)
}
/**
* Directory part of a path, after normalization (see the module note).
* @param path - Path to inspect.
* @returns Parent path; `/` for root children and `.` for bare names.
*/
export function dirname(path: string): string {
const normalized = normalize(path).replace(/\/+$/, '')
const index = normalized.lastIndexOf(SEP)
if (index < 0) return '.'
if (index === 0) return SEP
return normalized.slice(0, index)
}
/**
* Last segment of a path, after normalization (see the module note).
* @param path - Path to inspect.
* @param suffix - Optional suffix to strip.
* @returns Final segment.
*/
export function basename(path: string, suffix?: string): string {
const normalized = normalize(path).replace(/\/+$/, '')
const name = normalized.slice(normalized.lastIndexOf(SEP) + 1)
if (suffix !== undefined && suffix !== name && name.endsWith(suffix)) return name.slice(0, -suffix.length)
return name
}
/**
* Extension of the last segment, dot included.
* @param path - Path to inspect.
* @returns Extension, or an empty string when there is none.
*/
export function extname(path: string): string {
const name = basename(path)
const index = name.lastIndexOf('.')
return index <= 0 ? '' : name.slice(index)
}
/**
* Report whether a path starts at the root.
* @param path - Path to inspect.
* @returns True for absolute paths.
*/
export function isAbsolute(path: string): boolean {
return path.startsWith(SEP)
}
/**
* Relative path from one absolute path to another.
* @param from - Source directory.
* @param to - Target path.
* @returns Relative path using `..` segments.
*/
export function relative(from: string, to: string): string {
const source = resolve(from).split(SEP).filter(segment => segment !== '')
const target = resolve(to).split(SEP).filter(segment => segment !== '')
let shared = 0
while (shared < source.length && shared < target.length && source[shared] === target[shared]) shared += 1
const up = new Array(source.length - shared).fill('..') as string[]
return [...up, ...target.slice(shared)].join(SEP)
}
/**
* Split a path into components, after normalization (see the module note).
* @param path - Path to split.
* @returns Root, directory, base name, extension, and stem.
*/
export function parse(path: string): { root: string; dir: string; base: string; ext: string; name: string } {
const root = isAbsolute(path) ? SEP : ''
const base = basename(path)
const ext = extname(path)
return { root, dir: dirname(path), base, ext, name: ext === '' ? base : base.slice(0, -ext.length) }
}
/**
* Node's Windows-only namespaced-path conversion.
* @param path - the path to convert.
* @returns The path unchanged; namespaced paths are a Windows concept.
*/
export function toNamespacedPath(path: string): string {
return path
}
/**
* Convert a VFS path into a `file:` URL string.
* @param path - Absolute VFS path.
* @returns URL text with each segment percent-encoded.
*/
export function pathToFileUrl(path: string): string {
const absolute = resolve(path)
return `file://${absolute.split(SEP).map(segment => encodeURIComponent(segment)).join(SEP)}`
}
/**
* Convert a `file:` URL back into a VFS path.
* @param url - URL text or URL instance.
* @returns Absolute VFS path.
*/
export function fileUrlToPath(url: string | URL): string {
const text = typeof url === 'string' ? url : url.href
if (!text.startsWith('file://')) throw new Error(`webworker vfs: not a file URL: ${text}`)
return decodeURIComponent(text.slice('file://'.length).replace(/[?#].*$/, '')) || SEP
}
@@ -0,0 +1,406 @@
/**
* `node:async_hooks` for the worker: `AsyncLocalStorage` over an EXPLICIT-SWITCH
* model with two fallbacks. A browser has no async-context tracking, so the store
* a read answers is decided by three slots, in this order:
*
* 1. HOOK OVERLAY — set for the duration of one callback by the hook layer
* (`./async-context-hooks.ts`), which captures the context where a callback was
* REGISTERED (`.then`, `queueMicrotask`, timers, `fetch`) and restores it where
* the callback RUNS.
* 2. RESUMED CONTEXT — the explicit-switch slot. {@link __snapshotAll} copies every
* live instance's effective store and {@link __restoreAll} publishes a copy; the
* module loader's `await` rewriting pauses with the first and resumes with the
* second, which is what makes attribution causally correct across an `await`
* even while another chain interleaves. The rewriter's `restore` returns nothing,
* so this slot holds ONE value per instance and a resume REPLACES it rather than
* stacking: a frame that resumes again at its next await re-publishes its own
* context anyway, and a new `run()` boundary shadows the slot for its extent.
* (Callers that want scoping get a disposer back from {@link __restoreAll}.)
* 2b. BOUNDARY AMBIENT — `run()` also publishes its own store here, so rewritten and
* un-rewritten code agree on what the innermost boundary is.
* 3. FOLDING STACK — the fallback for code the rewriter has not touched: `run()`
* pushes an entry that is removed synchronously for a synchronous operation, or
* when the returned promise settles for an asynchronous one, so a store stays
* visible across `await` inside that operation.
*
* Every slot is removed by IDENTITY, never blindly: boundaries settle and frames
* resume out of order, so a blind pop would drop somebody else's context — and a
* slot that is released while shadowed must leave the chain without promoting
* itself back over whoever came after it. The three slots are separate for the same reason — a restored
* copy pushed onto the folding stack could unwind another boundary's entry.
*
* A snapshot with no stores at all is `undefined`, and the hook layer then wraps
* nothing: a callback registered outside every boundary keeps inheriting the
* enclosing boundary rather than being masked to `undefined`. `__snapshotAll` is
* the transformer-facing counterpart and always captures every instance, including
* the ones reading `undefined`, because a resumed frame must see exactly what it
* saw at its pause point.
*
* BOUNDARY (structural, documented rather than worked around): native
* `async`/`await` resumption inside code the rewriter has NOT transformed is
* invisible to user code. Such a frame falls back to the folding stack, which is
* ordered by nesting rather than by causal chain, so two boundaries overlapping
* there can attribute to the wrong one. Nothing crashes, the stacks still unwind by
* identity, and everything the hook layer or the rewriter covers is exact.
*/
import { notImplementedFail } from '../../notImplementedFail.ts'
interface Entry<T> {
readonly store: T | undefined
}
interface Overlay<T> {
readonly store: T | undefined
}
/** Pristine `then`, so this module's own bookkeeping never re-enters the hook layer. */
// eslint-disable-next-line @typescript-eslint/unbound-method -- taking `then` unbound is the point; it is `.call`ed on its own promise
const nativeThen = Promise.prototype.then
/** Every live instance, so one snapshot can capture all of their stores at once. */
const instances = new Set<AsyncLocalStorage<unknown>>()
function isThenable(value: unknown): value is PromiseLike<unknown> {
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
return typeof (value as { then?: unknown }).then === 'function'
}
/** Node's AsyncLocalStorage face, restricted to the members the host tree uses. */
export class AsyncLocalStorage<T> {
private readonly entries: Entry<T>[] = []
private overlay: Overlay<T> | undefined
private readonly ambients: Overlay<T>[] = []
private resumed: Overlay<T> | undefined
constructor() {
instances.add(this)
}
/**
* Run a callback with the store visible for the operation's whole lifetime:
* until it returns, or until the promise it returned settles.
* @param store - value {@link getStore} answers inside the boundary.
* @param callback - the operation.
* @param args - callback arguments.
* @returns the exact value the callback returned.
*/
run<R>(store: T | undefined, callback: (...args: never[]) => R, ...args: never[]): R {
const entry: Entry<T> = { store }
this.entries.push(entry)
// Removal is by entry identity: overlapping boundaries settle out of order,
// and a blind pop would drop somebody else's entry.
const remove = (): void => {
const at = this.entries.lastIndexOf(entry)
if (at !== -1) this.entries.splice(at, 1)
}
// The boundary also publishes an ambient slot until its entry goes away.
// Removal is by identity here too: a shadowed slot must leave the chain
// without promoting itself back over whoever came after it.
const ambient: Overlay<T> = { store }
this.ambients.push(ambient)
const removeBoundary = (): void => {
const at = this.ambients.lastIndexOf(ambient)
if (at !== -1) this.ambients.splice(at, 1)
if (this.resumed === undefined) this.resumed = restoreResumed
remove()
}
// A boundary opened under an overlay or a resumed context (a hook-restored
// callback, or a rewritten frame, that starts a new run) must not keep reading
// them: its own entry is the truth.
const restoreOverlay = this.overlay
const restoreResumed = this.resumed
this.overlay = undefined
this.resumed = undefined
let result: R
try {
result = callback(...args)
} catch (error) {
this.overlay = restoreOverlay
removeBoundary()
throw error
}
this.overlay = restoreOverlay
if (!isThenable(result)) {
removeBoundary()
return result
}
try {
// `then.call` on the caller's own promise: no species construction, and the
// rejection stays the caller's to observe (both handlers are attached, so
// this observation never becomes an unhandled rejection itself).
void nativeThen.call(result, removeBoundary, removeBoundary)
} catch {
// A branded promise may expose a failing @@species; the boundary then ends
// here rather than leaking an entry that nothing would ever remove.
removeBoundary()
}
return result
}
/**
* Current store, resolved through the slot order this module documents: the
* hook-restored overlay, then the ambient context a resume installed (or a
* boundary owns), then the folding stack's innermost entry.
* @returns the store, or undefined outside every boundary.
*/
getStore(): T | undefined {
if (this.overlay !== undefined) return this.overlay.store
if (this.resumed !== undefined) return this.resumed.store
const ambient = this.ambients.at(-1)
if (ambient !== undefined) return ambient.store
return this.entries.at(-1)?.store
}
/**
* Run a callback with no store, folding over its lifetime like {@link run}.
* @param callback - the operation.
* @param args - callback arguments.
* @returns the exact value the callback returned.
*/
exit<R>(callback: (...args: never[]) => R, ...args: never[]): R {
return this.run(undefined, callback, ...args)
}
/**
* Enter a boundary that lasts until {@link disable}, as Node's `enterWith` does
* for the remainder of the current chain.
* @param store - value {@link getStore} answers from now on.
*/
enterWith(store: T): void {
this.entries.push({ store })
}
/** Drop every slot; teardown calls this unconditionally. */
disable(): void {
this.entries.length = 0
this.overlay = undefined
this.ambients.length = 0
this.resumed = undefined
}
/**
* Copy every live instance's effective store, including the instances reading
* `undefined`: a resumed frame must see exactly what its pause point saw.
* @returns the ambient snapshot.
*/
static snapshotAll(): AmbientSnapshot {
return [...instances].map(instance => ({ instance, store: instance.getStore() }))
}
/**
* Install a snapshot as the ambient context of every instance it names.
* @param snapshot - a copy from {@link snapshotAll}.
* @returns a disposer that restores the previous ambients, identity-checked.
*/
static restoreAll(snapshot: AmbientSnapshot): () => void {
const installed = snapshot.map(({ instance, store }) => {
const slot = { store }
const before = instance.resumed
instance.resumed = slot
return { instance, slot, before }
})
return () => {
for (const { instance, slot, before } of installed) {
if (instance.resumed === slot) instance.resumed = before
}
}
}
/**
* Copy every live instance's current store. Not part of the Node face: this is
* the shim's own mechanism, kept in the class so the overlay stays private.
* @returns the snapshot, or undefined when no instance has a store.
*/
static captureContext(): AsyncContextSnapshot | undefined {
let captured: CapturedStore[] | undefined
for (const instance of instances) {
const store = instance.getStore()
if (store === undefined) continue
captured ??= []
captured.push({ instance, store })
}
return captured
}
/**
* Run a callback with a captured context restored into the overlay slots.
* @param snapshot - context copy, or undefined to run unchanged.
* @param callback - the callback.
* @returns the callback's return value.
*/
static runWithContext<R>(snapshot: AsyncContextSnapshot | undefined, callback: () => R): R {
if (snapshot === undefined) return callback()
const previous = snapshot.map(({ instance, store }) => {
const before = instance.overlay
instance.overlay = { store }
return { instance, before }
})
try {
return callback()
} finally {
for (const { instance, before } of previous) instance.overlay = before
}
}
/**
* Every live instance, for {@link runAtAsyncContextRoot}.
* @returns The stores a snapshot must capture.
*/
static liveInstances(): readonly AsyncLocalStorage<unknown>[] {
return [...instances]
}
/**
* Bind a callback to the current context.
* @param callback - the callback to bind.
* @returns a callback that restores this context when invoked.
*/
static bind<F extends (...args: never[]) => unknown>(callback: F): F {
return bindAsyncContext(callback)
}
/**
* Snapshot helper matching Node's static: run a callback in the context
* captured now.
* @returns a function that runs its argument in the captured context.
*/
static snapshot(): <R>(callback: () => R) => R {
const snapshot = AsyncLocalStorage.captureContext()
return callback => AsyncLocalStorage.runWithContext(snapshot, callback)
}
}
/** One instance's captured store. */
interface CapturedStore {
readonly instance: AsyncLocalStorage<unknown>
readonly store: unknown
}
/** Opaque context copy produced by {@link captureAsyncContext}. */
export type AsyncContextSnapshot = readonly CapturedStore[]
/** Opaque ambient copy produced by {@link __snapshotAll}; covers every live instance. */
export type AmbientSnapshot = readonly CapturedStore[]
/**
* Copy every live instance's current store.
* @returns the snapshot, or undefined when no instance has a store (the hook
* layer then wraps nothing and callbacks inherit the stack top).
*/
export function captureAsyncContext(): AsyncContextSnapshot | undefined {
return AsyncLocalStorage.captureContext()
}
/**
* Run a callback with a captured context restored into the overlay slots.
* @param snapshot - context copy, or undefined to run unchanged.
* @param callback - the callback.
* @returns the callback's return value.
*/
export function runWithAsyncContext<R>(snapshot: AsyncContextSnapshot | undefined, callback: () => R): R {
return AsyncLocalStorage.runWithContext(snapshot, callback)
}
/**
* Capture the current context now and restore it around every later invocation.
* @param callback - the callback to bind.
* @returns the bound callback, or the original when no context is active.
*/
export function bindAsyncContext<F extends (...args: never[]) => unknown>(callback: F): F {
const snapshot = captureAsyncContext()
if (snapshot === undefined) return callback
const bound = (...args: never[]): unknown => runWithAsyncContext(snapshot, () => callback(...args))
return bound as F
}
/**
* Run a callback at the root: every instance reads `undefined`, whatever was open
* before. The tunnel's message entry uses this so a queued request never inherits
* a boundary from unrelated work that happened to run first.
* @param callback - the callback.
* @returns the callback's return value.
*/
export function runAtAsyncContextRoot<R>(callback: () => R): R {
const root: CapturedStore[] = AsyncLocalStorage.liveInstances().map(instance => ({ instance, store: undefined }))
return runWithAsyncContext(root, callback)
}
/**
* Pause point of the loader's `await` rewriting: copy the context every live
* instance currently reads.
*
* The transformed module reaches this through the module proxy table
* (`require('node:async_hooks').__snapshotAll()`), so the rewriter needs no
* additional plumbing.
* @returns the ambient snapshot to hand to {@link __restoreAll} after the await.
*/
export function __snapshotAll(): AmbientSnapshot {
return AsyncLocalStorage.snapshotAll()
}
/**
* Resume point of the loader's `await` rewriting: publish a paused context as the
* ambient one, so reads after the await answer what the frame saw before it —
* even while another chain interleaves.
* @param snapshot - the copy {@link __snapshotAll} produced at the pause point.
* @returns a disposer that restores the previous ambient context, identity-checked;
* a rewriter that wraps a whole function body calls it in that body's `finally`.
*/
export function __restoreAll(snapshot: AmbientSnapshot): () => void {
return AsyncLocalStorage.restoreAll(snapshot)
}
/**
* Snapshot face the module loader's `await` rewriting consumes (its `AlsCausality`):
* the same pair as {@link __snapshotAll}/{@link __restoreAll}, with `restore`
* narrowed to void because the rewritten code has no place to keep a disposer.
*/
export const alsCausality = {
snapshot: (): AmbientSnapshot => __snapshotAll(),
restore: (snapshot: AmbientSnapshot): void => { __restoreAll(snapshot) },
}
/**
* Async ids are not tracked; a stable id keeps callers that log it working.
* @returns Always 1.
*/
export function executionAsyncId(): number {
return 1
}
/**
* Trigger ids are not tracked either.
* @returns Always 0.
*/
export function triggerAsyncId(): number {
return 0
}
/**
* Async hooks cannot be created: no async resource tracking exists in the worker.
* @returns Never — it throws naming the unavailable member.
*/
export function createHook(): never {
throw new Error('web-preview: node:async_hooks.createHook is not available in the worker host')
}
/** Resource construction is likewise unavailable. */
export const AsyncResource: typeof import('node:async_hooks').AsyncResource
= notImplementedFail('node:async_hooks', 'AsyncResource')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:async_hooks` declarations this module stands in for.
* `AsyncLocalStorage` keeps this module's own class: it carries the store
* bookkeeping the rewrite route reads through statics Node does not declare, and
* its `run` is typed for the callback arguments the host tree passes.
*/
type NodeFace = Partial<Omit<typeof import('node:async_hooks'), 'AsyncLocalStorage'>>
& Record<'AsyncLocalStorage', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
AsyncLocalStorage, AsyncResource, executionAsyncId, triggerAsyncId, createHook,
} satisfies NodeFace
@@ -0,0 +1,28 @@
/**
* `node:buffer` for the worker, backed by the `buffer` npm package (feross), and
* the matching `globalThis.Buffer` install. Node code treats Buffer as ambient,
* so the global must exist before any host module evaluates.
*/
import { Buffer, kMaxLength } from 'buffer'
Object.defineProperty(globalThis, 'Buffer', { value: Buffer, writable: true, configurable: true })
export { Buffer, kMaxLength }
/**
* Size limits, as `node:buffer` publishes them. The npm package exposes only
* `kMaxLength`, so the string bound is Node's own value for a 64-bit build.
*/
export const constants = {
MAX_LENGTH: kMaxLength,
MAX_STRING_LENGTH: 536_870_888,
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** The `node:buffer` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:buffer')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { Buffer, constants, kMaxLength } satisfies NodeFace
@@ -0,0 +1,123 @@
/**
* `node:crypto` for the worker: WebCrypto for randomness, `@noble/hashes` for the
* synchronous digests Node's streaming Hash object provides (SubtleCrypto is
* async, and every caller here hashes synchronously).
*/
import { sha1 } from '@noble/hashes/legacy.js'
import { sha256, sha512 } from '@noble/hashes/sha2.js'
import { Buffer } from 'buffer'
type Hasher = (input: Uint8Array) => Uint8Array
const HASHERS: Record<string, Hasher> = {
sha1,
sha256,
sha512,
}
const encoder = new TextEncoder()
const toBytes = (data: string | Uint8Array | ArrayBuffer): Uint8Array => {
if (typeof data === 'string') return encoder.encode(data)
if (data instanceof ArrayBuffer) return new Uint8Array(data)
return data
}
/** Node's streaming Hash face, restricted to the update/digest pair in use. */
export interface Hash {
update(data: string | Uint8Array | ArrayBuffer, encoding?: string): Hash
digest(): Buffer
digest(encoding: 'hex' | 'base64'): string
}
/**
* Create a synchronous hash object.
* @param algorithm - digest name; only the algorithms the host tree uses exist.
* @returns the streaming hash face.
*/
export function createHash(algorithm: string): Hash {
const hasher = HASHERS[algorithm.toLowerCase().replace('-', '')]
if (hasher === undefined) {
throw new Error(`web-preview: node:crypto.createHash("${algorithm}") is not available in the worker host`)
}
const chunks: Uint8Array[] = []
const hash: Hash = {
update(data) {
chunks.push(toBytes(data))
return hash
},
digest(encoding?: 'hex' | 'base64') {
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
const joined = new Uint8Array(total)
let at = 0
for (const chunk of chunks) {
joined.set(chunk, at)
at += chunk.byteLength
}
const digest = Buffer.from(hasher(joined))
return (encoding === undefined ? digest : digest.toString(encoding)) as Buffer & string
},
}
return hash
}
/**
* Random bytes.
* @param size - byte count.
* @returns a Buffer of cryptographically strong random bytes.
*/
export function randomBytes(size: number): Buffer<ArrayBuffer> {
const bytes = new Uint8Array(size)
globalThis.crypto.getRandomValues(bytes)
return Buffer.from(bytes)
}
/**
* Random v4 UUID.
* @returns the UUID string.
*/
export function randomUUID(): import('node:crypto').UUID {
return globalThis.crypto.randomUUID()
}
/**
* Fill a typed array with random bytes.
* @param target - the array to fill.
* @returns the same array.
*/
export function getRandomValues<T extends ArrayBufferView<ArrayBuffer>>(target: T): T {
return globalThis.crypto.getRandomValues(target)
}
/**
* Random integer in `[0, max)`.
* @param max - exclusive upper bound.
* @returns the integer.
*/
export function randomInt(max: number): number {
const sample = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] ?? 0
return Math.floor((sample / 2 ** 32) * max)
}
/** WebCrypto instance, as Node exposes it. */
export const webcrypto = globalThis.crypto
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:crypto` declarations this module stands in for. Three members keep
* this module's own types: Node declares `createHash` as returning a Transform
* stream, while this Hash is the synchronous update/digest pair the host tree
* calls; `webcrypto` is the browser `Crypto` object, whose `subtle` face is
* declared by the DOM library rather than by Node; and `getRandomValues` accepts
* only a typed-array view, the values WebCrypto can fill, where Node's
* declaration also admits a bare `ArrayBuffer`.
*/
type NodeFace = Partial<Omit<typeof import('node:crypto'), 'createHash' | 'getRandomValues' | 'webcrypto'>>
& Record<'createHash' | 'getRandomValues' | 'webcrypto', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
createHash, randomBytes, randomUUID, getRandomValues, randomInt, webcrypto,
} satisfies NodeFace
@@ -0,0 +1,156 @@
/**
* `node:events`: a minimal EventEmitter with the members harness code uses.
* Emission order and listener identity follow Node; anything beyond the basic
* on/once/off/emit set throws.
*/
type Listener = (...args: unknown[]) => void
/**
* A `once` wrapper, carrying the listener it stands for. Node publishes the same
* `listener` member, and `removeListener(event, original)` matches through it, so
* a caller that registered with `once` can withdraw with the function it wrote.
*/
type OnceWrapper = Listener & { listener: Listener }
/** The `node:events` subset the harness registers on: add, remove, and emit. */
export class EventEmitter {
private readonly registry = new Map<string, Listener[]>()
/**
* Register a listener.
* @param event - event name.
* @param listener - the listener.
* @returns this emitter.
*/
on(event: string, listener: Listener): this {
const list = this.registry.get(event) ?? []
list.push(listener)
this.registry.set(event, list)
return this
}
/**
* Register a listener removed after its first call.
* @param event - event name.
* @param listener - the listener.
* @returns this emitter.
*/
once(event: string, listener: Listener): this {
const wrapper = ((...args: unknown[]): void => {
this.off(event, wrapper)
listener(...args)
}) as OnceWrapper
wrapper.listener = listener
return this.on(event, wrapper)
}
/**
* Register a listener ahead of the existing ones.
* @param event - event name.
* @param listener - the listener.
* @returns this emitter.
*/
prependListener(event: string, listener: Listener): this {
const list = this.registry.get(event) ?? []
list.unshift(listener)
this.registry.set(event, list)
return this
}
/**
* Remove a listener, by the function that was registered or by the one a
* `once` wrapper stands for.
* @param event - event name.
* @param listener - the listener.
* @returns this emitter.
*/
off(event: string, listener: Listener): this {
const list = this.registry.get(event)
if (list !== undefined) {
// Last registration first, as Node removes it.
for (let at = list.length - 1; at >= 0; at--) {
const registered = list[at]
if (registered === listener || (registered as OnceWrapper | undefined)?.listener === listener) {
list.splice(at, 1)
break
}
}
}
return this
}
/**
* Alias of {@link off}.
* @param event - event name.
* @param listener - the listener.
* @returns this emitter.
*/
removeListener(event: string, listener: Listener): this {
return this.off(event, listener)
}
/**
* Drop listeners for one event, or all of them.
* @param event - event name; omitted clears every event.
* @returns this emitter.
*/
removeAllListeners(event?: string): this {
if (event === undefined) this.registry.clear()
else this.registry.delete(event)
return this
}
/**
* Emit an event.
* @param event - event name.
* @param args - listener arguments.
* @returns whether any listener ran.
*/
emit(event: string, ...args: unknown[]): boolean {
const list = this.registry.get(event)
if (list === undefined || list.length === 0) return false
for (const listener of [...list]) listener(...args)
return true
}
/**
* Listeners of one event.
* @param event - event name.
* @returns a copy of the listener list.
*/
listeners(event: string): Listener[] {
return [...this.registry.get(event) ?? []]
}
/**
* Listener count of one event.
* @param event - event name.
* @returns the count.
*/
listenerCount(event: string): number {
return this.registry.get(event)?.length ?? 0
}
/**
* Node's max-listener knob has no effect here.
* @returns This emitter, for chaining.
*/
setMaxListeners(): this {
return this
}
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:events` declarations this module stands in for. `EventEmitter` keeps
* this module's own class: Node's declaration carries the promise helpers and
* statics (`once`, `on`, `getEventListeners`, `errorMonitor`) that no worker
* caller registers through.
*/
type NodeFace = Partial<Omit<typeof import('node:events'), 'EventEmitter'>> & Record<'EventEmitter', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { EventEmitter } satisfies NodeFace
@@ -0,0 +1,574 @@
/**
* `node:fs` bridge over the worker's in-memory VFS. `MemoryVfs` owns paths,
* bytes, the directory tree, and Node's error codes; this module adds only what
* is Node-API-shaped and not VFS business: Buffer results, `Dirent` objects,
* file descriptors, `mkdtemp`, access checks, inert watches, and the promise face.
*/
import { requireActiveVfs } from '../../../storage/active.ts'
import type { MemoryVfs } from '../../../storage/memory.ts'
import type { VfsBigIntStats, VfsStatOptions, VfsStats } from '../../../storage/types.ts'
import { Buffer } from 'buffer'
import { dirname } from './path.ts'
const vfs = (): MemoryVfs => requireActiveVfs()
const notImplemented = (method: string, subject: string): never => {
throw new Error(`web-preview: node:fs.${method} is not implemented in the worker host (${subject})`)
}
type PathArg = string | URL | Uint8Array
const asPath = (path: PathArg): string => {
if (typeof path === 'string') return path
if (path instanceof URL) return decodeURIComponent(path.pathname)
return new TextDecoder().decode(path)
}
type EncodingOption = BufferEncoding | { encoding?: BufferEncoding | null } | null | undefined
const encodingOf = (options: EncodingOption): BufferEncoding | undefined => {
if (options === undefined || options === null) return undefined
if (typeof options === 'string') return options
return options.encoding ?? undefined
}
const bytesOf = (path: string): Uint8Array => vfs().readFileSync(path) as Uint8Array
/** Share the VFS bytes rather than copying them. */
const asBuffer = (bytes: Uint8Array): Buffer =>
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
/** Node `Dirent` subset returned by `readdirSync(dir, { withFileTypes: true })`. */
export class Dirent {
/** Entry name, without its directory. */
readonly name: string
/** Directory this entry was listed from. */
readonly parentPath: string
private readonly file: boolean
/**
* Build one directory entry.
* @param name - entry name.
* @param parentPath - directory holding it.
* @param file - whether the entry is a regular file.
*/
constructor(name: string, parentPath: string, file: boolean) {
this.name = name
this.parentPath = parentPath
this.file = file
}
/**
* Entry kind, as `readdirSync` observed it.
* @returns Whether the entry is a regular file.
*/
isFile(): boolean {
return this.file
}
/**
* Entry kind, as `readdirSync` observed it.
* @returns Whether the entry is a directory.
*/
isDirectory(): boolean {
return !this.file
}
/**
* Symlink test, answered from the image's own shape.
* @returns False — the image is materialized without symlinks.
*/
isSymbolicLink(): boolean {
return false
}
}
/** Access-mode constants; the VFS has no permission model, so all bits pass. */
export const constants = {
F_OK: 0,
R_OK: 4,
W_OK: 2,
X_OK: 1,
COPYFILE_EXCL: 1,
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_TRUNC: 512,
O_APPEND: 1024,
}
/**
* Read a file.
* @param path - file path.
* @param options - encoding, or an options object carrying one.
* @returns bytes, or text when an encoding is given.
*/
export function readFileSync(path: PathArg, options?: EncodingOption): Buffer | string {
const encoding = encodingOf(options)
const bytes = bytesOf(asPath(path))
return encoding === undefined || encoding === 'utf8' || encoding === 'utf-8'
? (encoding === undefined ? asBuffer(bytes) : new TextDecoder().decode(bytes))
: asBuffer(bytes).toString(encoding)
}
/**
* Write a file.
* @param path - file path.
* @param data - bytes or text.
*/
export function writeFileSync(path: PathArg, data: string | Uint8Array): void {
vfs().writeFileSync(asPath(path), data)
}
/**
* Append to a file, creating it when absent.
* @param path - file path.
* @param data - bytes or text.
*/
export function appendFileSync(path: PathArg, data: string | Uint8Array): void {
vfs().appendFileSync(asPath(path), data)
}
/**
* Whether a path exists.
* @param path - the path.
* @returns true when present.
*/
export function existsSync(path: PathArg): boolean {
return vfs().existsSync(asPath(path))
}
/**
* Stat a path.
* @param path - the path.
* @param options - `bigint` selects the BigInt stats the filesystem service reads.
* @returns the stats, in the plain or BigInt shape.
*/
export function statSync(path: PathArg, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
return vfs().statSync(asPath(path), options)
}
/**
* Stat a path without following symlinks (the image has none).
* @param path - the path.
* @param options - `bigint` selects the BigInt stats the filesystem service reads.
* @returns the stats, in the plain or BigInt shape.
*/
export function lstatSync(path: PathArg, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
return statSync(path, options)
}
/**
* Canonical path (normalization only: the image is symlink-free).
* @param path - the path.
* @returns the resolved path.
*/
export function realpathSync(path: PathArg): string {
return vfs().realpathSync(asPath(path))
}
/**
* List a directory.
* @param path - directory path.
* @param options - `withFileTypes` selects Dirent objects.
* @returns names, or Dirent objects.
*/
export function readdirSync(
path: PathArg,
options?: { withFileTypes?: boolean } | BufferEncoding | null,
): string[] | Dirent[] {
const target = asPath(path)
const names = vfs().readdirSync(target)
if (typeof options !== 'object' || options === null || options.withFileTypes !== true) return names
return names.map(name => new Dirent(name, target, vfs().statSync(`${target}/${name}`).isFile()))
}
/**
* Create a directory.
* @param path - directory path.
* @param options - `recursive` creates parents.
* @returns the first created path when recursive, else undefined.
*/
export function mkdirSync(path: PathArg, options?: { recursive?: boolean }): string | undefined {
return vfs().mkdirSync(asPath(path), options)
}
/**
* Create a uniquely named directory.
* @param prefix - path prefix; six random characters are appended.
* @returns the created directory path.
*/
export function mkdtempSync(prefix: string): string {
const suffix = globalThis.crypto.randomUUID().replaceAll('-', '').slice(0, 6)
const target = `${prefix}${suffix}`
vfs().mkdirSync(target, { recursive: true })
return target
}
/**
* Remove a file or directory.
* @param path - the path.
* @param options - `recursive`/`force`, as in Node.
*/
export function rmSync(path: PathArg, options?: { recursive?: boolean; force?: boolean }): void {
vfs().rmSync(asPath(path), options)
}
/**
* Remove a file.
* @param path - the path.
*/
export function unlinkSync(path: PathArg): void {
vfs().rmSync(asPath(path))
}
/**
* Rename a path.
* @param from - source path.
* @param to - target path.
*/
export function renameSync(from: PathArg, to: PathArg): void {
vfs().renameSync(asPath(from), asPath(to))
}
/**
* Access check: existence only.
* @param path - the path.
*/
export function accessSync(path: PathArg): void {
vfs().realpathSync(asPath(path))
}
interface OpenFile {
path: string
position: number
append: boolean
}
const openFiles = new Map<number, OpenFile>()
let nextFd = 3
/**
* Open a file descriptor.
* @param path - file path.
* @param flags - Node flag string: 'r', 'w', 'a', with optional '+' and the
* exclusive 'x' (create-only) modifier.
* @returns the descriptor.
*/
export function openSync(path: PathArg, flags = 'r'): number {
const target = asPath(path)
const exists = vfs().existsSync(target)
if (flags.includes('x') && exists) {
const error = new Error(`EEXIST: file already exists, open '${target}'`) as Error & { code: string; path: string }
error.code = 'EEXIST'
error.path = target
throw error
}
if (flags.startsWith('r')) vfs().realpathSync(target)
else if (flags.startsWith('w') || !exists) vfs().writeFileSync(target, new Uint8Array(0))
const fd = nextFd++
openFiles.set(fd, { path: target, position: 0, append: flags.startsWith('a') })
return fd
}
const fileOf = (fd: number, syscall: string): OpenFile => {
const file = openFiles.get(fd)
if (file === undefined) throw new Error(`EBADF: bad file descriptor, ${syscall}`)
return file
}
/**
* Read from a descriptor.
* @param fd - descriptor.
* @param buffer - destination.
* @param offset - destination offset.
* @param length - byte count.
* @param position - file position, or null to continue from the cursor.
* @returns bytes read.
*/
export function readSync(
fd: number,
buffer: Uint8Array,
offset = 0,
length = buffer.byteLength,
position: number | null = null,
): number {
const file = fileOf(fd, 'read')
const bytes = bytesOf(file.path)
const from = position ?? file.position
const slice = bytes.subarray(from, from + length)
buffer.set(slice, offset)
if (position === null) file.position = from + slice.byteLength
return slice.byteLength
}
/**
* Write through a descriptor.
* @param fd - descriptor.
* @param data - bytes or text.
* @returns bytes written.
*/
export function writeSync(fd: number, data: string | Uint8Array): number {
const file = fileOf(fd, 'write')
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
if (file.append) {
vfs().appendFileSync(file.path, bytes)
return bytes.byteLength
}
const existing = vfs().existsSync(file.path) ? bytesOf(file.path) : new Uint8Array(0)
const merged = new Uint8Array(Math.max(existing.byteLength, file.position + bytes.byteLength))
merged.set(existing, 0)
merged.set(bytes, file.position)
vfs().writeFileSync(file.path, merged)
file.position += bytes.byteLength
return bytes.byteLength
}
/**
* Close a descriptor.
* @param fd - descriptor.
*/
export function closeSync(fd: number): void {
openFiles.delete(fd)
}
/**
* Create a second name for one file's contents. Hard links do not exist in the
* VFS, so the bytes are copied.
* @param from - existing path.
* @param to - new path.
*/
export function linkSync(from: PathArg, to: PathArg): void {
writeFileSync(to, bytesOf(asPath(from)))
}
/**
* Open file handle (`fs.FileHandle` subset): the atomic-write and durability
* pair the storage backends use. `sync`/`datasync` are no-ops — an in-memory
* filesystem has nothing to flush, and a worker reload loses it either way.
*/
export interface FileHandle {
readonly fd: number
readFile(options?: EncodingOption): Promise<Buffer | string>
writeFile(data: string | Uint8Array, encoding?: BufferEncoding): Promise<void>
write(data: string | Uint8Array): Promise<{ bytesWritten: number }>
read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }>
stat(): Promise<VfsStats>
truncate(length?: number): Promise<void>
sync(): Promise<void>
datasync(): Promise<void>
close(): Promise<void>
}
/**
* Open a file handle. Directories open read-only, which is what the durability
* helpers do before an fsync.
* @param path - file or directory path.
* @param flags - Node flag string.
* @returns the handle.
*/
export function openHandleSync(path: PathArg, flags = 'r'): FileHandle {
const target = asPath(path)
const directory = vfs().existsSync(target) && vfs().statSync(target).isDirectory()
const append = flags.startsWith('a')
const fd = directory ? -1 : openSync(target, flags)
return {
fd,
readFile: async (options?: EncodingOption) => readFileSync(target, options),
// Node appends when the handle was opened with 'a'. The JSONL session log
// depends on it — `open(path, 'a')` then `writeFile(batch)` — and replacing
// the file there destroys the header frame its reader requires.
writeFile: async (data: string | Uint8Array) => {
if (append) appendFileSync(target, data)
else writeFileSync(target, data)
},
write: async (data: string | Uint8Array) => ({ bytesWritten: writeSync(fd, data) }),
read: async (buffer: Uint8Array, offset = 0, length = buffer.byteLength, position: number | null = null) => ({
bytesRead: readSync(fd, buffer, offset, length, position),
buffer,
}),
stat: async () => statSync(target) as VfsStats,
truncate: async (length = 0) => {
writeFileSync(target, bytesOf(target).subarray(0, length))
},
sync: async () => { /* memory-backed: nothing to flush */ },
datasync: async () => { /* memory-backed: nothing to flush */ },
close: async () => {
if (fd !== -1) closeSync(fd)
},
}
}
/**
* Watch registration refuses loudly, and NOT because watching is hard.
*
* The inert form was tried: `chokidar.ts` records that "no events" is the truth
* about a filesystem with no external writer, and the same reasoning seemed to
* cover this. It does not, because of the caller. `skill-filesystem` does not
* merely register a listener — `openStableWatcher` opens a watcher and then
* loops until two consecutive mode probes agree, so a watcher that reports
* success and never fires leaves `observeRoots()` awaiting forever: the skill
* catalog RPC never answers and the worker's single thread stops serving `/api`
* for the rest of the session. A refusal instead fails that path fast, which the
* provider already handles by returning an incomplete observation.
*
* So the family split is about what the CALLER does with the capability, not
* about the capability: a listener registration tolerates absence, a watcher
* whose progress is awaited does not.
* @param path - the path a caller wanted watched, named in the refusal.
* @returns Never — it throws naming the unavailable member.
*/
export function watchFile(path: PathArg): never {
return notImplemented('watchFile', asPath(path))
}
/** Watch removal; teardown paths call it unconditionally, and nothing was watched. */
export function unwatchFile(): void {
// No watch was ever established.
}
/**
* Streaming read is unavailable: node:stream has no implementation here.
* @param path - the path a caller wanted streamed, named in the refusal.
* @returns Never — it throws naming the unavailable member.
*/
export function createReadStream(path: PathArg): never {
return notImplemented('createReadStream', asPath(path))
}
/**
* Streaming write counterpart of {@link createReadStream}.
* @param path - the path a caller wanted streamed, named in the refusal.
* @returns Never — it throws naming the unavailable member.
*/
export function createWriteStream(path: PathArg): never {
return notImplemented('createWriteStream', asPath(path))
}
/** Open directory handle (`fs.Dir` subset): iteration plus the close pair. */
export interface Dir {
readonly path: string
read(): Promise<Dirent | null>
close(): Promise<void>
closeSync(): void
[Symbol.asyncIterator](): AsyncIterableIterator<Dirent>
}
/**
* Open a directory handle. Callers use it to assert "this path is a directory"
* and to walk entries; the listing is taken once, since the VFS has no external
* writer to race with.
* @param path - directory path.
* @returns the handle.
*/
export function opendirSync(path: PathArg): Dir {
const target = asPath(path)
const entries = readdirSync(target, { withFileTypes: true }) as Dirent[]
let index = 0
const next = (): Dirent | null => entries[index++] ?? null
return {
path: target,
read: async () => next(),
close: async () => { index = entries.length },
closeSync: () => { index = entries.length },
async *[Symbol.asyncIterator]() {
for (let entry = next(); entry !== null; entry = next()) yield entry
},
}
}
/**
* Promise face (`node:fs/promises`) over the same VFS. Each member answers the
* union the VFS produces rather than Node's encoding-dependent overloads, so the
* check here is that every name is a real `node:fs/promises` export.
*/
export const promises = {
readFile: async (path: PathArg, options?: EncodingOption): Promise<Buffer | string> => readFileSync(path, options),
writeFile: async (
path: PathArg,
data: string | Uint8Array,
options?: { flag?: string } | BufferEncoding | null,
): Promise<void> => {
const flag = typeof options === 'object' && options !== null ? options.flag : undefined
if (flag !== undefined && flag.includes('x') && existsSync(path)) {
const error = new Error(`EEXIST: file already exists, open '${asPath(path)}'`) as Error & { code: string }
error.code = 'EEXIST'
throw error
}
if (flag !== undefined && flag.startsWith('a')) appendFileSync(path, data)
else writeFileSync(path, data)
},
appendFile: async (path: PathArg, data: string | Uint8Array): Promise<void> => { appendFileSync(path, data) },
mkdir: async (path: PathArg, options?: { recursive?: boolean }): Promise<string | undefined> => mkdirSync(path, options),
mkdtemp: async (prefix: string): Promise<string> => mkdtempSync(prefix),
readdir: async (
path: PathArg,
options?: { withFileTypes?: boolean } | BufferEncoding,
): Promise<string[] | Dirent[]> => readdirSync(path, options),
stat: async (path: PathArg, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => statSync(path, options),
lstat: async (path: PathArg, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => lstatSync(path, options),
realpath: async (path: PathArg): Promise<string> => realpathSync(path),
rm: async (path: PathArg, options?: { recursive?: boolean; force?: boolean }): Promise<void> => { rmSync(path, options) },
unlink: async (path: PathArg): Promise<void> => { unlinkSync(path) },
rename: async (from: PathArg, to: PathArg): Promise<void> => { renameSync(from, to) },
access: async (path: PathArg): Promise<void> => { accessSync(path) },
// Permission bits have no meaning in the VFS; callers only ever relax them.
chmod: async (): Promise<void> => { /* no permission model */ },
cp: async (from: PathArg, to: PathArg): Promise<void> => {
const source = asPath(from)
const target = asPath(to)
if (statSync(source).isDirectory()) {
mkdirSync(target, { recursive: true })
for (const name of vfs().readdirSync(source)) await promises.cp(`${source}/${name}`, `${target}/${name}`)
return
}
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, bytesOf(source))
},
// The VFS has no inodes, so a hard link is a byte copy: the caller's contract
// is only that both names read the same content until one is removed.
link: async (from: PathArg, to: PathArg): Promise<void> => { linkSync(from, to) },
open: async (path: PathArg, flags?: string): Promise<FileHandle> => openHandleSync(path, flags),
opendir: async (path: PathArg): Promise<Dir> => opendirSync(path),
truncate: async (path: PathArg, length = 0): Promise<void> => {
writeFileSync(path, bytesOf(asPath(path)).subarray(0, length))
},
constants,
} satisfies Partial<Record<keyof typeof import('node:fs/promises'), unknown>>
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* Members Node declares as encoding- and option-dependent overload ladders
* (`readFileSync` answering `Buffer` XOR `string`, `statSync` answering `Stats`
* XOR `BigIntStats`, `mkdirSync` answering `string` XOR `void`). This module
* answers the union its VFS actually produces from one signature, which no single
* signature can present as all of Node's overloads; `realpathSync` additionally
* carries Node's `.native` member, and `constants`, `promises`, and `Dirent` hold
* the subsets the host tree reads.
*/
type OwnSignature =
| 'constants' | 'promises' | 'Dirent'
| 'readFileSync' | 'writeFileSync' | 'appendFileSync' | 'statSync' | 'lstatSync' | 'realpathSync'
| 'readdirSync' | 'mkdirSync' | 'mkdtempSync' | 'rmSync' | 'opendirSync'
| 'openSync' | 'readSync' | 'writeSync'
/**
* The `node:fs` declarations this module stands in for. Every other member is
* checked against Node; `openHandleSync` is the worker's own handle opener, which
* `promises.open` answers with and Node has no synchronous counterpart for.
*/
type NodeFace = Partial<Omit<typeof import('node:fs'), OwnSignature>>
& Record<OwnSignature | 'openHandleSync', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
constants, promises, Dirent,
readFileSync, writeFileSync, appendFileSync, existsSync, statSync, lstatSync, realpathSync,
readdirSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, renameSync, accessSync, opendirSync,
openHandleSync, linkSync,
openSync, readSync, writeSync, closeSync, watchFile, unwatchFile,
createReadStream, createWriteStream,
} satisfies NodeFace
@@ -0,0 +1,20 @@
/**
* `node:fs/promises` face: the promise members of the VFS bridge, re-exported as
* named bindings so `import { readFile } from 'node:fs/promises'` resolves. The
* member set is checked against Node where it is built, on `promises` in
* `../fs.ts`.
*/
import { Dirent, promises } from '../fs.ts'
/** The promise members of the VFS bridge, as `node:fs/promises` names them. */
export const {
readFile, writeFile, appendFile, mkdir, mkdtemp, readdir, stat, lstat, realpath, rm, unlink,
rename, access, chmod, cp, link, open, opendir, truncate, constants,
} = promises
export { Dirent }
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
export default promises
@@ -0,0 +1,179 @@
/**
* `node:http` for the worker: `createServer` returns a Server whose `listen`
* succeeds immediately without a socket, and retains the captured request
* listener so the tunnel server can feed synthesized requests into the real
* route table (research/transport.md §5.1: 7 Server members, all pure values).
* The worker entry hands {@link whenRequestListener} to the host assembly, so the
* package never reaches back into this app.
*/
import type { RequestListener } from '../../../transport/synthetic-http.ts'
type Listener = (...args: unknown[]) => void
export type { RequestListener }
/** Port reported by `address()`; it becomes `webServer.port`. */
const VIRTUAL_PORT = 3080
let captured: RequestListener | undefined
const waiting = new Set<(listener: RequestListener) => void>()
/**
* The webserver's request listener, once `[Service.init]` has installed it.
* @returns the listener, or undefined before the webserver row activates.
*/
export function requestListener(): RequestListener | undefined {
return captured
}
/**
* Await the request listener.
* @returns a promise resolved with the listener as soon as it is captured.
*/
export async function whenRequestListener(): Promise<RequestListener> {
if (captured !== undefined) return captured
return await new Promise<RequestListener>(resolve => waiting.add(resolve))
}
/** Fake Server: event registrations are stored and never emitted. */
class FakeServer {
private readonly listeners = new Map<string, Set<Listener>>()
/**
* Register an event listener (`upgrade`, `error`); never emitted.
* @param event - event name.
* @param listener - the listener.
* @returns this server.
*/
on(event: string, listener: Listener): this {
const set = this.listeners.get(event) ?? new Set<Listener>()
set.add(listener)
this.listeners.set(event, set)
return this
}
/**
* One-shot registration counterpart of {@link on}.
* @param event - event name.
* @param listener - the listener.
* @returns this server.
*/
once(event: string, listener: Listener): this {
return this.on(event, listener)
}
/**
* Remove a listener.
* @param event - event name.
* @param listener - the listener.
* @returns this server.
*/
off(event: string, listener: Listener): this {
this.listeners.get(event)?.delete(listener)
return this
}
/**
* Bind: succeeds immediately. The callback must run or the webserver fiber
* stays in LOADING forever.
* @param args - Node's listen arguments; only a trailing callback matters.
* @returns this server.
*/
listen(...args: unknown[]): this {
const callback = args.at(-1)
if (typeof callback === 'function') queueMicrotask(() => { (callback as Listener)() })
return this
}
/**
* Bound address.
* @returns the loopback authority the tunnel synthesizes.
*/
address(): { address: string; family: string; port: number } {
return { address: '127.0.0.1', family: 'IPv4', port: VIRTUAL_PORT }
}
/**
* Close: no socket to release.
* @param callback - completion callback, invoked immediately.
* @returns this server.
*/
close(callback?: Listener): this {
if (callback !== undefined) queueMicrotask(() => { callback() })
return this
}
/** No connection was ever accepted. */
closeAllConnections(): void {
// Nothing is ever accepted through this Server.
}
/** No idle connection exists either. */
closeIdleConnections(): void {
// Nothing is ever accepted through this Server.
}
}
/**
* Create the fake server and retain its request listener for the tunnel.
* @param listener - the request listener the webserver installs.
* @returns the fake Server.
*/
export function createServer(listener?: RequestListener): FakeServer {
if (listener !== undefined) {
captured = listener
for (const resolve of waiting) resolve(listener)
waiting.clear()
}
return new FakeServer()
}
/**
* Outbound HTTP has one carrier in the worker: `fetch`.
* @returns Never — it throws naming the unavailable member.
*/
export function request(): never {
throw new Error('web-preview: node:http.request is not available in the worker host — use fetch')
}
/**
* Same as {@link request}.
* @returns Never — it throws naming the unavailable member.
*/
export function get(): never {
throw new Error('web-preview: node:http.get is not available in the worker host — use fetch')
}
/** Status text table Node exposes; a few handlers write status lines by hand. */
export const STATUS_CODES: typeof import('node:http').STATUS_CODES = {
200: 'OK',
204: 'No Content',
304: 'Not Modified',
400: 'Bad Request',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
413: 'Payload Too Large',
415: 'Unsupported Media Type',
426: 'Upgrade Required',
500: 'Internal Server Error',
503: 'Service Unavailable',
}
export { FakeServer as Server }
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:http` declarations this module stands in for. `Server` and
* `createServer` keep this module's own types: Node declares the server as a
* `net.Server` carrying sockets and a Node `RequestListener`, while this one binds
* nothing and captures the synthesized-request listener the tunnel feeds.
*/
type NodeFace = Partial<Omit<typeof import('node:http'), 'Server' | 'createServer'>>
& Record<'Server' | 'createServer', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { createServer, request, get, STATUS_CODES, Server: FakeServer } satisfies NodeFace
@@ -0,0 +1,73 @@
/**
* `node:module` for the worker: `createRequire` hands out the worker module
* loader's synchronous require, so typert's `require.resolve('<pkg>/package.json')
* + readFileSync + import()` bypass runs unmodified over the VFS.
*/
import { requireActiveModuleLoader, type WorkerRequire } from '../../../module-system/module-loader.ts'
/** Node `require` face the harness consumes. */
export type NodeRequire = WorkerRequire
/**
* Build a `require` bound to a base path or file URL.
* @param base - directory, file path, or file URL the resolution starts from.
* @returns the synchronous require face.
*/
export function createRequire(base: string | URL): NodeRequire {
return requireActiveModuleLoader().createRequire(base)
}
/** Builtin specifiers the module proxy table answers (without the `node:` prefix). */
export const builtinModules = [
'assert', 'async_hooks', 'buffer', 'child_process', 'crypto', 'events', 'fs', 'http', 'module',
'net', 'os', 'path', 'process', 'stream', 'url', 'util', 'worker_threads',
]
/**
* Whether a specifier names a Node builtin.
* @param specifier - the module specifier.
* @returns true for builtin names, with or without the `node:` prefix.
*/
export function isBuiltin(specifier: string): boolean {
return builtinModules.includes(specifier.replace(/^node:/, ''))
}
/**
* TypeScript stripping is a Node 22+ loader feature with no worker counterpart.
* @returns Never — it throws naming the unavailable member.
*/
export function stripTypeScriptTypes(): never {
throw new Error('web-preview: node:module.stripTypeScriptTypes is not available in the worker host')
}
/**
* Loader hooks have no meaning here: the worker loader owns resolution.
* @returns Never — it throws naming the unavailable member.
*/
export function register(): never {
throw new Error('web-preview: node:module.register is not available in the worker host')
}
/** ESM/CJS export syncing is a no-op: the worker loader materializes CommonJS only. */
export function syncBuiltinESMExports(): void {
// Nothing to sync: every builtin is a plain module object from the proxy table.
}
/** Erased type peer for the vendored loader's type-only LoadHookContext import. */
export type LoadHookContext = never
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:module` declarations this module stands in for. `createRequire`
* keeps this module's own face: the loader's require carries the call and
* `resolve` the harness uses, not Node's `cache`, `extensions`, and `main`,
* which describe a CommonJS module registry the worker has no counterpart for.
*/
type NodeFace = Partial<Omit<typeof import('node:module'), 'createRequire'>> & Record<'createRequire', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
createRequire, builtinModules, isBuiltin, register, syncBuiltinESMExports, stripTypeScriptTypes,
} satisfies NodeFace
@@ -0,0 +1,118 @@
/**
* `node:os` for the worker: every value points into the VFS or reports the fixed
* platform identity the host tree is built for (`linux`, one CPU). Values are
* real rather than throwing because several `[Service.init]` bodies read them
* during construction.
*/
import { DSH_HOME, DSH_TMP } from '../../../storage/paths.ts'
import type { CpuInfo, NetworkInterfaceInfo } from 'node:os'
/** Line ending of the virtual platform. */
export const EOL = '\n'
/**
* Temporary directory.
* @returns the VFS temp path.
*/
export function tmpdir(): string {
return DSH_TMP
}
/**
* Home directory.
* @returns `$DSH_HOME` inside the VFS.
*/
export function homedir(): string {
return DSH_HOME
}
/**
* Platform identity.
* @returns always 'linux'.
*/
export function platform(): NodeJS.Platform {
return 'linux'
}
/**
* Operating-system type.
* @returns always 'Linux'.
*/
export function type(): string {
return 'Linux'
}
/**
* CPU architecture.
* @returns always 'x64'.
*/
export function arch(): string {
return 'x64'
}
/**
* Kernel release.
* @returns a synthetic release string.
*/
export function release(): string {
return '0.0.0-dsh-worker'
}
/**
* Host name.
* @returns a synthetic name.
*/
export function hostname(): string {
return 'dsh-worker'
}
/**
* Usable parallelism.
* @returns the browser's hardware concurrency, at least 1.
*/
export function availableParallelism(): number {
return Math.max(1, navigator.hardwareConcurrency)
}
/**
* CPU inventory.
* @returns an empty list (no per-core facts inside a worker).
*/
export function cpus(): CpuInfo[] {
return []
}
/**
* Network interfaces.
* @returns an empty record — the worker webserver binds the loopback literal, so
* no LAN address is ever derived.
*/
export function networkInterfaces(): NodeJS.Dict<NetworkInterfaceInfo[]> {
return {}
}
/** OS constants: only the signal table is read (terminal signal name mapping). */
export const constants = {
signals: {
SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7, SIGFPE: 8,
SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15,
},
errno: {},
priority: {},
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:os` declarations this module stands in for. `constants` keeps this
* module's own value: Node declares the full `errno`, `priority`, and `dlopen`
* tables, while only the signal-name mapping is read here.
*/
type NodeFace = Partial<Omit<typeof import('node:os'), 'constants'>> & Record<'constants', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
EOL, tmpdir, homedir, platform, type, arch, release, hostname, availableParallelism, cpus,
networkInterfaces, constants,
} satisfies NodeFace
@@ -0,0 +1,396 @@
/**
* `node:path` for the worker: the POSIX algorithm, transliterated from Node's
* implementation. It is NOT a face over the worker host's `posixPath`: that helper
* normalizes before splitting, so `dirname('/a/b/..')` answers `/` where Node
* answers `/a/b` (45 cases diverge — `.artifacts/p2/path-diff.ts` enumerates them).
* A `node:` proxy has to answer what Node answers, since VFS paths were built with
* Node semantics. `win32` members throw: the worker host reports
* `process.platform === 'linux'`, so a Windows branch means a bug.
*/
import { DSH_ROOT } from '../../../storage/paths.ts'
const CHAR_DOT = 46
const CHAR_FORWARD_SLASH = 47
/** Parsed path object returned by {@link parse}. */
export interface ParsedPath {
root: string
dir: string
base: string
ext: string
name: string
}
const cwd = (): string => {
const scope = globalThis as { process?: { cwd?: () => string } }
return scope.process?.cwd?.() ?? DSH_ROOT
}
function assertPath(path: unknown): asserts path is string {
if (typeof path !== 'string') {
throw new TypeError(`Path must be a string. Received ${JSON.stringify(path)}`)
}
}
/** Resolve `.` and `..` segments; `allowAboveRoot` keeps leading `..` for relative inputs. */
function normalizeString(path: string, allowAboveRoot: boolean): string {
let res = ''
let lastSegmentLength = 0
let lastSlash = -1
let dots = 0
let code = 0
for (let i = 0; i <= path.length; ++i) {
if (i < path.length) code = path.charCodeAt(i)
else if (code === CHAR_FORWARD_SLASH) break
else code = CHAR_FORWARD_SLASH
if (code === CHAR_FORWARD_SLASH) {
if (lastSlash === i - 1 || dots === 1) {
// empty segment or `.`
} else if (dots === 2) {
if (res.length < 2 || lastSegmentLength !== 2
|| res.charCodeAt(res.length - 1) !== CHAR_DOT
|| res.charCodeAt(res.length - 2) !== CHAR_DOT) {
if (res.length > 2) {
const lastSlashIndex = res.lastIndexOf('/')
if (lastSlashIndex === -1) {
res = ''
lastSegmentLength = 0
} else {
res = res.slice(0, lastSlashIndex)
lastSegmentLength = res.length - 1 - res.lastIndexOf('/')
}
lastSlash = i
dots = 0
continue
} else if (res.length !== 0) {
res = ''
lastSegmentLength = 0
lastSlash = i
dots = 0
continue
}
}
if (allowAboveRoot) {
res += res.length > 0 ? '/..' : '..'
lastSegmentLength = 2
}
} else {
if (res.length > 0) res += `/${path.slice(lastSlash + 1, i)}`
else res = path.slice(lastSlash + 1, i)
lastSegmentLength = i - lastSlash - 1
}
lastSlash = i
dots = 0
} else if (code === CHAR_DOT && dots !== -1) {
++dots
} else {
dots = -1
}
}
return res
}
/**
* Resolve a sequence of paths into an absolute path.
* @param paths - path segments, right to left until an absolute one is found.
* @returns the absolute, normalized path.
*/
export function resolve(...paths: string[]): string {
let resolved = ''
let absolute = false
for (let i = paths.length - 1; i >= 0 && !absolute; i--) {
const path = paths[i]
assertPath(path)
if (path.length === 0) continue
resolved = resolved.length === 0 ? path : `${path}/${resolved}`
absolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH
}
if (!absolute) {
const base = cwd()
resolved = resolved.length === 0 ? base : `${base}/${resolved}`
absolute = base.charCodeAt(0) === CHAR_FORWARD_SLASH
}
const normalized = normalizeString(resolved, !absolute)
if (absolute) return `/${normalized}`
return normalized.length > 0 ? normalized : '.'
}
/**
* Normalize a path, resolving `.`, `..`, and duplicate separators.
* @param path - the path.
* @returns the normalized path.
*/
export function normalize(path: string): string {
assertPath(path)
if (path.length === 0) return '.'
const isAbsolutePath = path.charCodeAt(0) === CHAR_FORWARD_SLASH
const trailingSeparator = path.charCodeAt(path.length - 1) === CHAR_FORWARD_SLASH
let normalized = normalizeString(path, !isAbsolutePath)
if (normalized.length === 0) {
if (isAbsolutePath) return '/'
return trailingSeparator ? './' : '.'
}
if (trailingSeparator) normalized += '/'
return isAbsolutePath ? `/${normalized}` : normalized
}
/**
* Whether the path is absolute.
* @param path - the path.
* @returns true when it starts at the root.
*/
export function isAbsolute(path: string): boolean {
assertPath(path)
return path.length > 0 && path.charCodeAt(0) === CHAR_FORWARD_SLASH
}
/**
* Join path segments with the separator, then normalize.
* @param paths - the segments.
* @returns the joined path.
*/
export function join(...paths: string[]): string {
if (paths.length === 0) return '.'
let joined: string | undefined
for (const path of paths) {
assertPath(path)
if (path.length === 0) continue
joined = joined === undefined ? path : `${joined}/${path}`
}
return joined === undefined ? '.' : normalize(joined)
}
/**
* Relative path from one location to another.
* @param from - source path.
* @param to - target path.
* @returns the relative path, or '' when both resolve identically.
*/
export function relative(from: string, to: string): string {
assertPath(from)
assertPath(to)
if (from === to) return ''
const fromResolved = resolve(from)
const toResolved = resolve(to)
if (fromResolved === toResolved) return ''
const fromParts = fromResolved.split('/').filter(part => part.length > 0)
const toParts = toResolved.split('/').filter(part => part.length > 0)
let shared = 0
while (shared < fromParts.length && shared < toParts.length && fromParts[shared] === toParts[shared]) shared++
const up = Array.from({ length: fromParts.length - shared }, () => '..')
return [...up, ...toParts.slice(shared)].join('/')
}
/**
* Directory portion of a path (lexical, as Node defines it: no normalization).
* @param path - the path.
* @returns the parent directory.
*/
export function dirname(path: string): string {
assertPath(path)
if (path.length === 0) return '.'
const hasRoot = path.charCodeAt(0) === CHAR_FORWARD_SLASH
let end = -1
let matchedSlash = true
for (let i = path.length - 1; i >= 1; --i) {
if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) {
if (!matchedSlash) {
end = i
break
}
} else {
matchedSlash = false
}
}
if (end === -1) return hasRoot ? '/' : '.'
if (hasRoot && end === 1) return '//'
return path.slice(0, end)
}
/**
* Last portion of a path, optionally without a suffix (lexical, as in Node).
* @param path - the path.
* @param suffix - extension to strip when the base ends with it.
* @returns the base name.
*/
export function basename(path: string, suffix?: string): string {
assertPath(path)
let start = 0
let end = -1
let matchedSlash = true
if (suffix !== undefined && suffix.length > 0 && suffix.length <= path.length) {
if (suffix === path) return ''
let extIdx = suffix.length - 1
let firstNonSlashEnd = -1
for (let i = path.length - 1; i >= 0; --i) {
const code = path.charCodeAt(i)
if (code === CHAR_FORWARD_SLASH) {
if (!matchedSlash) {
start = i + 1
break
}
continue
}
if (firstNonSlashEnd === -1) {
matchedSlash = false
firstNonSlashEnd = i + 1
}
if (extIdx >= 0) {
if (code === suffix.charCodeAt(extIdx)) {
if (--extIdx === -1) end = i
} else {
extIdx = -1
end = firstNonSlashEnd
}
}
}
if (start === end) end = firstNonSlashEnd
else if (end === -1) end = path.length
return path.slice(start, end)
}
for (let i = path.length - 1; i >= 0; --i) {
if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) {
if (!matchedSlash) {
start = i + 1
break
}
} else if (end === -1) {
matchedSlash = false
end = i + 1
}
}
return end === -1 ? '' : path.slice(start, end)
}
/**
* Extension of the last path segment, including the leading dot.
* @param path - the path.
* @returns the extension, or '' when there is none.
*/
export function extname(path: string): string {
assertPath(path)
let startDot = -1
let startPart = 0
let end = -1
let matchedSlash = true
let preDotState = 0
for (let i = path.length - 1; i >= 0; --i) {
const code = path.charCodeAt(i)
if (code === CHAR_FORWARD_SLASH) {
if (!matchedSlash) {
startPart = i + 1
break
}
continue
}
if (end === -1) {
matchedSlash = false
end = i + 1
}
if (code === CHAR_DOT) {
if (startDot === -1) startDot = i
else if (preDotState !== 1) preDotState = 1
} else if (startDot !== -1) {
preDotState = -1
}
}
if (startDot === -1 || end === -1 || preDotState === 0
|| (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)) {
return ''
}
return path.slice(startDot, end)
}
/**
* Build a path from its parsed parts.
* @param pathObject - dir/root/base/name/ext parts.
* @returns the assembled path.
*/
export function format(pathObject: Partial<ParsedPath>): string {
const dir = pathObject.dir ?? pathObject.root ?? ''
const base = pathObject.base ?? `${pathObject.name ?? ''}${pathObject.ext ?? ''}`
if (dir === '') return base
return dir === pathObject.root ? `${dir}${base}` : `${dir}/${base}`
}
/**
* Split a path into root/dir/base/ext/name (lexical, as in Node).
* @param path - the path.
* @returns the parsed parts.
*/
export function parse(path: string): ParsedPath {
assertPath(path)
const base = basename(path)
const ext = extname(path)
const trimmed = path.length > 1 ? path.replace(/\/+$/, '') : path
const lastSlash = trimmed.lastIndexOf('/')
const root = isAbsolute(path) ? '/' : ''
return {
root,
dir: trimmed === '' ? root : lastSlash === -1 ? '' : lastSlash === 0 ? '/' : trimmed.slice(0, lastSlash),
base,
ext,
name: ext.length > 0 ? base.slice(0, base.length - ext.length) : base,
}
}
/** POSIX path separator. */
export const sep = '/' as const
/** POSIX path-list delimiter. */
export const delimiter = ':' as const
/**
* Windows namespace prefixes do not exist here.
* @param path - the path.
* @returns the path unchanged.
*/
export function toNamespacedPath(path: string): string {
return path
}
const posixFace = {
resolve, normalize, isAbsolute, join, relative, dirname, basename, extname, format, parse,
sep, delimiter, toNamespacedPath,
}
/** POSIX member set: the module face, plus Node's self-referential namespaces. */
export const posix: typeof posixFace & { readonly posix: unknown; readonly win32: unknown } = {
...posixFace,
get posix(): unknown { return posix },
get win32(): unknown { return win32 },
}
const win32Member = (name: string) => (): never => {
throw new Error(`web-preview: node:path.win32.${name} is unreachable — the worker host reports platform "linux"`)
}
/** Windows member set: reaching it means a platform branch went the wrong way. */
export const win32 = {
resolve: win32Member('resolve'),
normalize: win32Member('normalize'),
isAbsolute: win32Member('isAbsolute'),
join: win32Member('join'),
relative: win32Member('relative'),
dirname: win32Member('dirname'),
basename: win32Member('basename'),
extname: win32Member('extname'),
format: win32Member('format'),
parse: win32Member('parse'),
toNamespacedPath: win32Member('toNamespacedPath'),
sep: '\\',
delimiter: ';',
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:path` declarations this module stands in for. The two platform
* namespaces stay unknown-typed: `posix` is this module reached through itself,
* and `win32` holds throwing members rather than Node's `PlatformPath`, because
* the worker host reports `linux` and a Windows branch is a bug.
*/
type NodeFace = Partial<Omit<typeof import('node:path'), 'posix' | 'win32'>> & Record<'posix' | 'win32', unknown>
export default posix satisfies NodeFace
@@ -0,0 +1,27 @@
/**
* `node:perf_hooks`: the worker's own high-resolution clock.
*/
import { notImplementedFail } from '../../notImplementedFail.ts'
const MODULE = 'node:perf_hooks'
/** Same clock object the worker global exposes. */
export const performance = globalThis.performance
/** Observation of performance entries has no consumer here. */
export const PerformanceObserver: typeof import('node:perf_hooks').PerformanceObserver
= notImplementedFail(MODULE, 'PerformanceObserver')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:perf_hooks` declarations this module stands in for. `performance`
* keeps the worker's own clock: Node declares its clock with `nodeTiming`,
* `timerify`, and event-loop utilization, none of which a browser `Performance`
* object carries.
*/
type NodeFace = Partial<Omit<typeof import('node:perf_hooks'), 'performance'>> & Record<'performance', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { performance, PerformanceObserver } satisfies NodeFace
@@ -0,0 +1,60 @@
/**
* `node:timers/promises`: real implementations over the worker's timer globals.
*/
import type { TimerOptions } from 'node:timers'
/** The rejection an aborted wait reports, as Node and the DOM both spell it. */
const abortError = (): DOMException => new DOMException('The operation was aborted.', 'AbortError')
/**
* Resolve after a delay.
* @param delayMs - milliseconds to wait.
* @param value - value to resolve with; Node resolves undefined when none is handed in.
* @param options - abort support, as Node provides.
* @returns the value after the delay, or a rejection when the signal aborts.
*/
export function setTimeout<T = void>(
delayMs?: number,
value?: T,
options?: TimerOptions,
): Promise<T> {
return new Promise((resolve, reject) => {
// A signal that has already aborted emits no further `abort` event, so the
// timer must not be armed at all; Node rejects such a call straight away.
if (options?.signal?.aborted === true) {
reject(abortError())
return
}
const timer = globalThis.setTimeout(() => { resolve(value as T) }, delayMs)
options?.signal?.addEventListener('abort', () => {
globalThis.clearTimeout(timer)
reject(abortError())
}, { once: true })
})
}
/**
* Resolve on the next macrotask.
* @param value - resolution value handed back after the timer.
* @returns a promise resolved after a zero-delay timer.
*/
export function setImmediate<T = void>(value?: T): Promise<T> {
return setTimeout(0, value)
}
/** Cooperative scheduling helpers Node exposes on this module. */
export const scheduler = {
wait: async (delayMs?: number, options?: TimerOptions): Promise<void> => {
await setTimeout(delayMs, undefined, options)
},
yield: async (): Promise<void> => { await setTimeout(0) },
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** The `node:timers/promises` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:timers/promises')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { setTimeout, setImmediate, scheduler } satisfies NodeFace
@@ -0,0 +1,73 @@
/**
* `node:url` for the worker: the two conversions the host tree uses, plus the
* WHATWG classes the browser already provides. VFS paths are POSIX, so the
* file-URL mapping is the simple percent-encoding pair.
*/
/**
* Filesystem path of a `file:` URL.
* @param url - file URL or its string form.
* @returns the decoded POSIX path.
*/
export function fileURLToPath(url: string | URL): string {
const parsed = typeof url === 'string' ? new URL(url) : url
if (parsed.protocol !== 'file:') {
throw new TypeError(`The URL must be of scheme file (received ${parsed.protocol})`)
}
return decodeURIComponent(parsed.pathname)
}
/**
* `file:` URL of a filesystem path.
* @param path - absolute or relative POSIX path.
* @returns the URL.
*/
export function pathToFileURL(path: string): URL {
// Only the characters the URL path parser would not escape itself are escaped
// here (Node does the same), so `@`, `:` and `~` survive verbatim — scoped
// package directories must round-trip unchanged.
const escaped = path
.replaceAll('%', '%25')
.replaceAll('\\', '%5C')
.replaceAll('\n', '%0A')
.replaceAll('\r', '%0D')
.replaceAll('\t', '%09')
const url = new globalThis.URL('file:///')
url.pathname = escaped.startsWith('/') ? escaped : `/${escaped}`
return url
}
/**
* Absolute URL from a specifier and its base.
* @param specifier - relative or absolute specifier.
* @param base - base URL.
* @returns the resolved URL string.
*/
export function resolve(specifier: string, base: string): string {
return new URL(specifier, base).toString()
}
/** WHATWG URL class, as `node:url` re-exports it. */
const UrlClass = globalThis.URL
/** WHATWG URLSearchParams class, as `node:url` re-exports it. */
const UrlSearchParamsClass = globalThis.URLSearchParams
export { UrlClass as URL, UrlSearchParamsClass as URLSearchParams }
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:url` declarations this module stands in for. The two classes stay
* the browser globals this worker runs on: the DOM and Node libraries declare
* `URL.createObjectURL` and the `URLSearchParams` initializer union differently,
* and re-declaring either would replace the objects the platform hands out.
*/
type NodeFace = Partial<Omit<typeof import('node:url'), 'URL' | 'URLSearchParams'>>
& Record<'URL' | 'URLSearchParams', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
fileURLToPath, pathToFileURL, resolve, URL: UrlClass, URLSearchParams: UrlSearchParamsClass,
} satisfies NodeFace
@@ -0,0 +1,156 @@
/**
* `node:util` for the worker: the members harness code actually imports. Node's
* inspect output is only used in diagnostics, so a JSON-shaped rendering is
* enough; `promisify` follows Node's error-first callback convention exactly
* because zlib-style APIs are wrapped with it at module scope.
*/
/**
* Wrap an error-first callback function as a promise-returning one.
* @param fn - callback-style function.
* @returns the promise-returning wrapper.
*/
export function promisify<A extends unknown[], R>(
fn: (...args: [...A, (error: unknown, value: R) => void]) => void,
): (...args: A) => Promise<R> {
return (...args: A) => new Promise<R>((resolve, reject) => {
fn(...args, (error: unknown, value: R) => {
if (error !== null && error !== undefined) reject(error instanceof Error ? error : new Error(inspect(error)))
else resolve(value)
})
})
}
/**
* Wrap a promise-returning function as an error-first callback one.
* @param fn - promise-returning function.
* @returns the callback-style wrapper.
*/
export function callbackify<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
): (...args: [...A, (error: unknown, value?: R) => void]) => void {
return (...args) => {
const callback = args.at(-1) as (error: unknown, value?: R) => void
const rest = args.slice(0, -1) as unknown as A
fn(...rest).then((value) => { callback(null, value) }, (error: unknown) => { callback(error) })
}
}
/**
* Diagnostic rendering of a value.
* @param value - the value.
* @returns a readable one-line rendering.
*/
export function inspect(value: unknown): string {
if (typeof value === 'string') return `'${value}'`
if (value instanceof Error) return value.stack ?? `${value.name}: ${value.message}`
try {
// `JSON.stringify` is typed as returning a string but answers undefined for
// undefined, functions, and symbols.
const rendered = JSON.stringify(value, (_key, item: unknown) =>
typeof item === 'bigint' ? item.toString() : item) as string | undefined
return rendered ?? String(value)
} catch {
// Cyclic or otherwise unserializable values still need a rendering.
return String(value)
}
}
/**
* printf-style formatting for the `%s`/`%d`/`%j`/`%o` placeholders Node supports.
* @param template - format string, or any value when used without placeholders.
* @param args - substitution values.
* @returns the formatted string.
*/
export function format(template: unknown, ...args: unknown[]): string {
if (typeof template !== 'string') return [template, ...args].map(value => inspect(value)).join(' ')
let index = 0
const substituted = template.replaceAll(/%[sdifjoO%]/g, (token) => {
if (token === '%%') return '%'
if (index >= args.length) return token
const value = args[index++]
if (token === '%d' || token === '%i') return String(Number(value))
if (token === '%f') return String(Number(value))
if (token === '%s') return typeof value === 'string' ? value : inspect(value)
return inspect(value)
})
const rest = args.slice(index)
return rest.length === 0 ? substituted : `${substituted} ${rest.map(value => inspect(value)).join(' ')}`
}
/**
* Structural deep equality, as `isDeepStrictEqual` defines it for plain data.
* @param left - first value.
* @param right - second value.
* @returns true when both sides are structurally identical.
*/
export function isDeepStrictEqual(left: unknown, right: unknown): boolean {
/* jscpd:ignore-start -- the walk necessarily matches credentials-local's
sameJsonValue (both are structural equality over plain data); a shared
helper would couple the self-contained builtin face packed into the worker
image to a host package. */
if (Object.is(left, right)) return true
if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false
if (Array.isArray(left) !== Array.isArray(right)) return false
const leftKeys = Object.keys(left)
const rightKeys = Object.keys(right)
if (leftKeys.length !== rightKeys.length) return false
return leftKeys.every(key => key in right
&& isDeepStrictEqual((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))
/* jscpd:ignore-end */
}
/** Runtime type predicates (`node:util/types`), checked against the Node module of that name. */
export const types = {
isPromise: (value: unknown): value is Promise<unknown> => value instanceof Promise
|| (typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'),
isDate: (value: unknown): value is Date => value instanceof Date,
isRegExp: (value: unknown): value is RegExp => value instanceof RegExp,
// Node counts only the integer and float views, so a DataView answers false.
isTypedArray: (value: unknown): value is NodeJS.TypedArray => ArrayBuffer.isView(value) && !(value instanceof DataView),
} satisfies Partial<typeof import('node:util/types')>
/**
* CLI argument parsing has no caller inside the worker host.
* @returns Never — it throws naming the unavailable member.
*/
export function parseArgs(): never {
throw new Error('web-preview: node:util.parseArgs is not available in the worker host')
}
/**
* Deprecation wrappers pass the function through unchanged.
* @param fn - the function a caller wanted wrapped.
* @returns The same function, unwrapped.
*/
export function deprecate<F>(fn: F): F {
return fn
}
/** Text decoder class, as `node:util` re-exports it. */
const TextDecoderClass = globalThis.TextDecoder
/** Text encoder class, as `node:util` re-exports it. */
const TextEncoderClass = globalThis.TextEncoder
export { TextDecoderClass as TextDecoder, TextEncoderClass as TextEncoder }
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:util` declarations this module stands in for. Five members keep this
* module's own types: `promisify`, `callbackify`, and `inspect` are the plain
* conversions the harness calls, without Node's overload ladders and the
* `custom`/`styles`/`defaultOptions` members hung off them; `types` publishes the
* four predicates in use rather than Node's forty; and `TextDecoder` is the DOM
* class, whose `decode` input union the Node declaration does not accept.
*/
type NodeFace = Partial<Omit<typeof import('node:util'), 'promisify' | 'callbackify' | 'inspect' | 'types' | 'TextDecoder'>>
& Record<'promisify' | 'callbackify' | 'inspect' | 'types' | 'TextDecoder', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
promisify, callbackify, inspect, format, isDeepStrictEqual, types, parseArgs, deprecate,
TextDecoder: TextDecoderClass, TextEncoder: TextEncoderClass,
} satisfies NodeFace
@@ -0,0 +1,14 @@
/**
* `node:util/types` face: the predicate subset, re-exported from the util shim so
* both specifiers share one implementation. The predicates are checked against
* Node where they are built, on `types` in `../util.ts`.
*/
import { types } from '../util.ts'
/** The `node:util/types` predicates the harness reads, shared with the util shim. */
export const { isPromise, isDate, isRegExp, isTypedArray } = types
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
export default types
@@ -0,0 +1,85 @@
/**
* `node:zlib` for the worker. The worker composition carries no compression
* codec: the boot patch forces the JSONL session backend onto its plaintext
* path (`compression: 'none'`), because the VFS is in-memory and compressing
* it buys nothing. The Zstandard surface keeps its module-scope shape — the
* backend reads `constants` and `promisify`s the callback forms while
* loading — and every codec call fails loud, naming the missing capability.
*
* `createZstdDecompress` returns a handle-less object on purpose: the backend
* probes for Node's private stream shape and falls back to its public one-shot
* decoder when the probe declines.
*/
import { notImplementedFail } from '../../notImplementedFail.ts'
const MODULE = 'node:zlib'
/** Zstandard parameter/flush constants read at module scope by the JSONL backend. */
export const constants = {
ZSTD_c_compressionLevel: 100,
ZSTD_c_checksumFlag: 201,
ZSTD_e_continue: 0,
ZSTD_e_flush: 1,
ZSTD_e_end: 2,
ZSTD_CLEVEL_DEFAULT: 3,
Z_NO_FLUSH: 0,
Z_SYNC_FLUSH: 2,
Z_FINISH: 4,
}
/** One-shot Zstandard compression (unavailable; the composition writes plaintext logs). */
export const zstdCompressSync: typeof import('node:zlib').zstdCompressSync = notImplementedFail(MODULE, 'zstdCompressSync')
/** One-shot Zstandard decompression (unavailable; the worker never reads compressed logs). */
export const zstdDecompressSync: typeof import('node:zlib').zstdDecompressSync
= notImplementedFail(MODULE, 'zstdDecompressSync')
/** Callback form of {@link zstdCompressSync} (`promisify`'d at module scope by the backend). */
export const zstdCompress: typeof import('node:zlib').zstdCompress = notImplementedFail(MODULE, 'zstdCompress')
/** Callback form of {@link zstdDecompressSync}. */
export const zstdDecompress: typeof import('node:zlib').zstdDecompress = notImplementedFail(MODULE, 'zstdDecompress')
/**
* Streaming Zstandard decoder placeholder: the returned object deliberately
* lacks Node's private `_handle`/`_writeState` members, which is the signal the
* backend's private-shape probe checks before choosing that path.
* @returns the incompatible placeholder stream.
*/
export function createZstdDecompress(): Record<string, unknown> {
return { close: () => { /* nothing was opened */ } }
}
/** Streaming Zstandard encoder (unavailable; the backend only needs one-shot). */
export const createZstdCompress: typeof import('node:zlib').createZstdCompress
= notImplementedFail(MODULE, 'createZstdCompress')
/** gzip family (unavailable; no consumer in the reachable tree). */
export const gzip: typeof import('node:zlib').gzip = notImplementedFail(MODULE, 'gzip')
/** gzip sync counterpart. */
export const gzipSync: typeof import('node:zlib').gzipSync = notImplementedFail(MODULE, 'gzipSync')
/** gunzip counterpart. */
export const gunzip: typeof import('node:zlib').gunzip = notImplementedFail(MODULE, 'gunzip')
/** gunzip sync counterpart. */
export const gunzipSync: typeof import('node:zlib').gunzipSync = notImplementedFail(MODULE, 'gunzipSync')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:zlib` declarations this module stands in for. Two members keep this
* module's own types: `constants` carries only the Zstandard and flush values the
* JSONL backend reads, and `createZstdDecompress` answers the placeholder the
* same backend's private-shape probe must decline.
*/
type NodeFace = Partial<Omit<typeof import('node:zlib'), 'constants' | 'createZstdDecompress'>>
& Record<'constants' | 'createZstdDecompress', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
constants, zstdCompress, zstdCompressSync, zstdDecompress, zstdDecompressSync,
createZstdCompress, createZstdDecompress, gzip, gzipSync, gunzip, gunzipSync,
} satisfies NodeFace
@@ -0,0 +1,90 @@
/**
* `node:net` for the worker. Nothing accepts or dials a socket here: the fake
* HTTP server never emits `upgrade`, so only the address predicates and a
* constructible-but-loud Socket are reachable.
*/
const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/
const IPV6 = /^[0-9a-f:]+$/i
/** Constructible placeholder: the WebSocket upgrade path never runs in the worker. */
export class Socket {
/**
* Sockets are never written to; reaching this means an upgrade path activated.
* @returns Never — it throws naming the unavailable member.
*/
write(): never {
throw new Error('web-preview: node:net Socket.write is not available in the worker host')
}
/**
* Counterpart of {@link write}.
* @returns Never — it throws naming the unavailable member.
*/
end(): never {
throw new Error('web-preview: node:net Socket.end is not available in the worker host')
}
/** Teardown is accepted so disposal paths stay quiet. */
destroy(): void {
// No resource was ever held.
}
}
/**
* Whether a string is an IPv4 literal.
* @param value - candidate.
* @returns true for dotted-quad literals.
*/
export function isIPv4(value: string): boolean {
return IPV4.test(value) && value.split('.').every(part => Number(part) <= 255)
}
/**
* Whether a string is an IPv6 literal.
* @param value - candidate.
* @returns true for colon-hex literals.
*/
export function isIPv6(value: string): boolean {
return value.includes(':') && IPV6.test(value)
}
/**
* IP family of a literal.
* @param value - candidate.
* @returns 4, 6, or 0 when it is not an IP literal.
*/
export function isIP(value: string): number {
if (isIPv4(value)) return 4
if (isIPv6(value)) return 6
return 0
}
/**
* TCP listening is the fake HTTP server's business; a bare net server is unreachable.
* @returns Never — it throws naming the unavailable member.
*/
export function createServer(): never {
throw new Error('web-preview: node:net.createServer is not available in the worker host')
}
/**
* Outbound connections have no carrier in a worker.
* @returns Never — it throws naming the unavailable member.
*/
export function connect(): never {
throw new Error('web-preview: node:net.connect is not available in the worker host')
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/**
* The `node:net` declarations this module stands in for. `Socket` keeps this
* module's own class: Node declares it as a duplex stream, and a placeholder
* that holds no connection has no stream state to expose.
*/
type NodeFace = Partial<Omit<typeof import('node:net'), 'Socket'>> & Record<'Socket', unknown>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { Socket, isIP, isIPv4, isIPv6, createServer, connect } satisfies NodeFace
@@ -0,0 +1,31 @@
/**
* `node:sqlite` stub. The web profile configures session-query-sqlite with
* `:memory:` and `openAt: never`, so no database is opened during the acceptance
* chain; reaching the constructor means that configuration changed.
*/
import { notAvailableError, notImplementedFail } from '../../notImplementedFail.ts'
const MODULE = 'node:sqlite'
/** Synchronous database handle (unavailable). */
export const DatabaseSync: typeof import('node:sqlite').DatabaseSync = notImplementedFail(MODULE, 'DatabaseSync')
/** Prepared statement handle (unavailable). */
export const StatementSync: typeof import('node:sqlite').StatementSync = notImplementedFail(MODULE, 'StatementSync')
/**
* Backup helper (unavailable).
* @returns Never — it throws naming the unavailable member.
*/
export function backup(): never {
throw notAvailableError(MODULE, 'backup')
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** The `node:sqlite` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:sqlite')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { DatabaseSync, StatementSync, backup } satisfies NodeFace
@@ -0,0 +1,38 @@
/**
* `node:stream` stub. Every harness import of this module in the reachable tree
* is type-only (`Duplex`/`Readable`/`Writable` annotations), so nothing here runs
* unless a value import appears; then it says so.
*/
import { notImplementedFail } from '../../notImplementedFail.ts'
const MODULE = 'node:stream'
/** Readable stream (unavailable; use WHATWG ReadableStream). */
export const Readable: typeof import('node:stream').Readable = notImplementedFail(MODULE, 'Readable')
/** Writable stream (unavailable). */
export const Writable: typeof import('node:stream').Writable = notImplementedFail(MODULE, 'Writable')
/** Duplex stream (unavailable). */
export const Duplex: typeof import('node:stream').Duplex = notImplementedFail(MODULE, 'Duplex')
/** Transform stream (unavailable). */
export const Transform: typeof import('node:stream').Transform = notImplementedFail(MODULE, 'Transform')
/** PassThrough stream (unavailable). */
export const PassThrough: typeof import('node:stream').PassThrough = notImplementedFail(MODULE, 'PassThrough')
/** Pipeline helper (unavailable). */
export const pipeline: typeof import('node:stream').pipeline = notImplementedFail(MODULE, 'pipeline')
/** Finished helper (unavailable). */
export const finished: typeof import('node:stream').finished = notImplementedFail(MODULE, 'finished')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** The `node:stream` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:stream')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished } satisfies NodeFace
@@ -0,0 +1,37 @@
/**
* `node:vm` stub. Script compilation in a separate realm has no browser
* counterpart; the self-modification and workflow rows mount and report the gap
* when they try to compile.
*/
import { notImplementedFail } from '../../notImplementedFail.ts'
const MODULE = 'node:vm'
/** Compiled script (unavailable). */
export const Script: typeof import('node:vm').Script = notImplementedFail(MODULE, 'Script')
/** Context creation (unavailable). */
export const createContext: typeof import('node:vm').createContext = notImplementedFail(MODULE, 'createContext')
/** In-context evaluation (unavailable). */
export const runInContext: typeof import('node:vm').runInContext = notImplementedFail(MODULE, 'runInContext')
/** New-context evaluation (unavailable). */
export const runInNewContext: typeof import('node:vm').runInNewContext = notImplementedFail(MODULE, 'runInNewContext')
/** This-context evaluation (unavailable). */
export const runInThisContext: typeof import('node:vm').runInThisContext = notImplementedFail(MODULE, 'runInThisContext')
/** Context predicate (unavailable). */
export const isContext: typeof import('node:vm').isContext = notImplementedFail(MODULE, 'isContext')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** The `node:vm` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:vm')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
Script, createContext, runInContext, runInNewContext, runInThisContext, isContext,
} satisfies NodeFace
@@ -0,0 +1,50 @@
/**
* `node:worker_threads` stub. Nested workers are out of scope for v1, so the
* workflow and code-runtime plugin bodies mount and fail on use. The
* thread-identity values are real: they say "this is the main thread", which is
* what the worker host is from the tree's point of view.
*/
import { notImplementedFail } from '../../notImplementedFail.ts'
const MODULE = 'node:worker_threads'
/** Worker-thread construction (unavailable). */
export const Worker: typeof import('node:worker_threads').Worker = notImplementedFail(MODULE, 'Worker')
/** The host tree runs on the worker's main thread. */
export const isMainThread = true
/** Thread id of the worker's main thread. */
export const threadId = 0
/** No parent port exists, which Node reports as `null` outside a worker thread. */
export const parentPort = null
/** No thread data was handed in. */
export const workerData = undefined
/** Channel construction (unavailable). */
export const MessageChannel: typeof import('node:worker_threads').MessageChannel = notImplementedFail(MODULE, 'MessageChannel')
/** Port construction (unavailable). */
export const MessagePort: typeof import('node:worker_threads').MessagePort = notImplementedFail(MODULE, 'MessagePort')
/** Object transfer marking (unavailable). */
export const markAsUntransferable: typeof import('node:worker_threads').markAsUntransferable
= notImplementedFail(MODULE, 'markAsUntransferable')
/** Port receiving on a message channel (unavailable). */
export const receiveMessageOnPort: typeof import('node:worker_threads').receiveMessageOnPort
= notImplementedFail(MODULE, 'receiveMessageOnPort')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** The `node:worker_threads` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:worker_threads')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
Worker, isMainThread, threadId, parentPort, workerData, MessageChannel, MessagePort,
markAsUntransferable, receiveMessageOnPort,
} satisfies NodeFace
@@ -0,0 +1,122 @@
/**
* The Node-compatibility table, in one place. Two consumers share it, and they
* must resolve to the same module instances:
* - the worker vite build aliases these specifiers for code bundled statically
* into the worker (vendored loader, apiproxy, …);
* - the worker module loader answers `require('node:fs')` from VFS-loaded
* modules out of this table, before bare-name resolution.
* Anything absent here fails loudly at resolution instead of resolving to an
* empty module. `process` is deliberately absent: the worker host installs that
* global itself and fills it into this table at assembly time.
*
* Import paths carry the classification: `./implemented/<module>.ts` backs the
* module's real semantics over a worker data source, while `./mock/<module>.ts`
* is a structural placeholder whose calls report the missing capability. File
* names match their Node module specifiers exactly, nesting included.
*
* Every value is a {@link StaticModuleFactory}, so the loader reads a table
* entry only when a `require` names that specifier. What a factory defers is the
* table read, not module evaluation: each one answers a namespace object of the
* static ESM graph below, which the worker bundle evaluates at load like any
* other import. Deferring a shim's own start-up cost therefore belongs inside
* that shim, on the path that first needs it.
*/
import * as nodeAsyncHooks from './builtin_modules/implemented/async_hooks.ts'
import * as nodeBuffer from './builtin_modules/implemented/buffer.ts'
import * as nodeCrypto from './builtin_modules/implemented/crypto.ts'
import * as nodeEvents from './builtin_modules/implemented/events.ts'
import * as nodeFs from './builtin_modules/implemented/fs.ts'
import * as nodeFsPromises from './builtin_modules/implemented/fs/promises.ts'
import * as nodeHttp from './builtin_modules/implemented/http.ts'
import * as nodeModule from './builtin_modules/implemented/module.ts'
import * as nodeOs from './builtin_modules/implemented/os.ts'
import * as nodePath from './builtin_modules/implemented/path.ts'
import * as nodePerfHooks from './builtin_modules/implemented/perf_hooks.ts'
import * as nodeTimersPromises from './builtin_modules/implemented/timers/promises.ts'
import * as nodeUrl from './builtin_modules/implemented/url.ts'
import * as nodeUtil from './builtin_modules/implemented/util.ts'
import * as nodeUtilTypes from './builtin_modules/implemented/util/types.ts'
import * as nodeZlib from './builtin_modules/implemented/zlib.ts'
import * as nodeNet from './builtin_modules/mock/net.ts'
import * as nodeSqlite from './builtin_modules/mock/sqlite.ts'
import * as nodeStream from './builtin_modules/mock/stream.ts'
import * as nodeVm from './builtin_modules/mock/vm.ts'
import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts'
import * as chokidar from './external_packages/chokidar.ts'
import * as koffi from './external_packages/koffi.ts'
import * as landlockRun from './external_packages/node-addon-landlock-run.ts'
import * as nodePty from './external_packages/node-pty.ts'
import * as piAi from './external_packages/pi-ai.ts'
import * as ripgrep from './external_packages/ripgrep.ts'
import * as sharp from './external_packages/sharp.ts'
import * as ws from './external_packages/ws.ts'
import { REPLACED_EXTERNAL_PACKAGES } from './external_packages/replaced-externals.ts'
import type { StaticModuleFactory } from '../module-system/module-loader.ts'
/** Builtin modules, keyed with and without the `node:` prefix. */
const BUILTINS: Record<string, StaticModuleFactory> = {
async_hooks: () => nodeAsyncHooks,
buffer: () => nodeBuffer,
crypto: () => nodeCrypto,
events: () => nodeEvents,
fs: () => nodeFs,
'fs/promises': () => nodeFsPromises,
http: () => nodeHttp,
module: () => nodeModule,
net: () => nodeNet,
os: () => nodeOs,
path: () => nodePath,
'path/posix': () => nodePath,
perf_hooks: () => nodePerfHooks,
sqlite: () => nodeSqlite,
stream: () => nodeStream,
'timers/promises': () => nodeTimersPromises,
url: () => nodeUrl,
util: () => nodeUtil,
'util/types': () => nodeUtilTypes,
vm: () => nodeVm,
worker_threads: () => nodeWorkerThreads,
zlib: () => nodeZlib,
}
/** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */
const EXTERNALS: Record<string, StaticModuleFactory> = {
'chokidar': () => chokidar,
'koffi': () => koffi,
'sharp': () => sharp,
'node-pty': () => nodePty,
'ws': () => ws,
'@vscode/ripgrep': () => ripgrep,
'@earendil-works/pi-ai': () => piAi,
'@deepseek-ai/node-addon-landlock-run': () => landlockRun,
}
/**
* Prefixes whose every subpath resolves to one replacement module. The loader
* matches the longest prefix after its exact table misses, so pi-ai's
* `/providers/*` and `/api/*.lazy` entries need no enumeration.
*/
export const REPLACED_PREFIXES: Record<string, StaticModuleFactory> = {
'@earendil-works/pi-ai/': () => piAi,
}
// One list, two consumers: a package replaced here must also be kept out of the
// VFS image, so any divergence fails at worker start rather than at first require.
const declared = [...REPLACED_EXTERNAL_PACKAGES].sort().join(',')
const wired = Object.keys(EXTERNALS).sort().join(',')
if (declared !== wired) {
throw new Error(`web-preview: replaced-external lists diverge — declared [${declared}] vs wired [${wired}]`)
}
/**
* Build the specifier → factory table the worker module loader consults first.
* @returns every replaced specifier, including its `node:`-prefixed alias.
*/
export function createNodeBuiltins(): Record<string, StaticModuleFactory> {
const table: Record<string, StaticModuleFactory> = { ...EXTERNALS }
for (const [name, factory] of Object.entries(BUILTINS)) {
table[name] = factory
table[`node:${name}`] = factory
}
return table
}
@@ -0,0 +1,68 @@
/**
* `chokidar` stub: a constructible watcher that never fires. Settings and
* credentials call `watch()` unconditionally in `[Service.init]`, and the
* in-memory VFS has no external writer, so "no events" is the truth here rather
* than a degradation.
*/
/** No-op watcher with chokidar's chainable face. */
export class FSWatcher {
/**
* Register a listener; no event is ever emitted.
* @returns this watcher.
*/
on(): this {
return this
}
/**
* Register a one-shot listener; no event is ever emitted.
* @returns this watcher.
*/
once(): this {
return this
}
/**
* Add paths to the (inert) watch set.
* @returns this watcher.
*/
add(): this {
return this
}
/**
* Remove paths from the (inert) watch set.
* @returns this watcher.
*/
unwatch(): this {
return this
}
/**
* Watched paths, as chokidar reports them.
* @returns An empty record; nothing is ever watched.
*/
getWatched(): Record<string, string[]> {
return {}
}
/** Close the watcher. */
async close(): Promise<void> {
// Nothing was ever watched.
}
}
/**
* Create an inert watcher.
* @returns the watcher.
*/
export function watch(): FSWatcher {
return new FSWatcher()
}
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { watch, FSWatcher }
@@ -0,0 +1,155 @@
/**
* `koffi` stub: the FFI bridge the Windows ACL layer and the Landlock launcher
* use. Type constructors return opaque tokens because the ACL module builds its
* pointer and struct descriptors at module scope — the plugin must mount. Every
* entry that would actually cross into native code is loud; on this platform
* none of it is reachable (`process.platform === 'linux'`, no sandbox).
*/
import { notImplementedFail } from '../notImplementedFail.ts'
const MODULE = 'koffi'
/** Opaque type descriptor standing in for a koffi type handle. */
interface KoffiType {
readonly __dshKoffiType: string
/** Byte size under the x64 ABI; struct layout guards compare against it. */
readonly size: number
/** Byte alignment under the x64 ABI. */
readonly alignment: number
}
/** Primitive sizes koffi's own x64 ABI reports. */
const PRIMITIVES: Record<string, number> = {
void: 0,
bool: 1,
char: 1,
uchar: 1,
int8: 1,
uint8: 1,
short: 2,
ushort: 2,
int16: 2,
uint16: 2,
int: 4,
uint: 4,
int32: 4,
uint32: 4,
float: 4,
float32: 4,
long: 8,
ulong: 8,
longlong: 8,
ulonglong: 8,
int64: 8,
uint64: 8,
double: 8,
float64: 8,
str: 8,
str16: 8,
}
const token = (label: string, size: number, alignment = Math.min(size, 8) || 1): KoffiType =>
({ __dshKoffiType: label, size, alignment })
const typeOf = (target: unknown): KoffiType => {
if (typeof target === 'string') {
const size = PRIMITIVES[target]
if (size === undefined) throw new Error(`web-preview: koffi type "${target}" is unknown to the stub`)
return token(target, size)
}
const descriptor = target as KoffiType | undefined
if (descriptor?.__dshKoffiType === undefined) {
throw new Error(`web-preview: koffi type ${JSON.stringify(target)} is not a stub descriptor`)
}
return descriptor
}
const describe = (target: unknown): string =>
typeof target === 'string' ? target : (target as KoffiType | undefined)?.__dshKoffiType ?? 'anonymous'
/**
* Pointer type descriptor.
* @param target - pointee type name or descriptor.
* @returns the descriptor token.
*/
function pointer(target: unknown): KoffiType {
return token(`pointer(${describe(target)})`, 8)
}
/**
* Struct type descriptor. The size and alignment are computed with the same
* padding rules koffi uses on x64, because the Windows ACL layer compares them
* against its own header probe at module scope.
* @param name - struct name, or the field record when the name is omitted.
* @param fields - field name → type record.
* @returns the descriptor token.
*/
function struct(name: unknown, fields?: Record<string, unknown>): KoffiType {
const members = (typeof name === 'string' ? fields : name as Record<string, unknown>) ?? {}
let offset = 0
let alignment = 1
for (const member of Object.values(members)) {
const type = typeOf(member)
alignment = Math.max(alignment, type.alignment)
offset = Math.ceil(offset / type.alignment) * type.alignment + type.size
}
const size = Math.ceil(offset / alignment) * alignment
return token(`struct(${typeof name === 'string' ? name : 'anonymous'})`, size, alignment)
}
/**
* Array type descriptor.
* @param target - element type.
* @param length - element count.
* @returns the descriptor token.
*/
function array(target: unknown, length: number): KoffiType {
const element = typeOf(target)
return token(`array(${element.__dshKoffiType}, ${String(length)})`, element.size * length, element.alignment)
}
/**
* Opaque type descriptor.
* @param name - type name.
* @returns the descriptor token.
*/
function opaque(name?: string): KoffiType {
return token(`opaque(${name ?? 'anonymous'})`, 0, 1)
}
/** Primitive type table; members carry their x64 sizes. */
const types: Record<string, KoffiType> = new Proxy({}, {
get: (_target, property) => typeOf(String(property)),
has: property => typeof property === 'string' && property in PRIMITIVES,
})
/** The koffi face its consumers read; every call refuses. */
const koffi = {
pointer,
struct,
array,
opaque,
types,
alias: (name: string, target: unknown): KoffiType => {
const type = typeOf(target)
return token(`alias(${name})`, type.size, type.alignment)
},
sizeof: (target: unknown): number => typeOf(target).size,
alignof: (target: unknown): number => typeOf(target).alignment,
load: notImplementedFail(MODULE, 'load'),
alloc: notImplementedFail(MODULE, 'alloc'),
free: notImplementedFail(MODULE, 'free'),
decode: notImplementedFail(MODULE, 'decode'),
encode: notImplementedFail(MODULE, 'encode'),
address: notImplementedFail(MODULE, 'address'),
register: notImplementedFail(MODULE, 'register'),
unregister: notImplementedFail(MODULE, 'unregister'),
call: notImplementedFail(MODULE, 'call'),
}
export { pointer, struct, array, opaque, types }
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
export default koffi
@@ -0,0 +1,31 @@
/**
* `@deepseek-ai/node-addon-landlock-run` stub: the Landlock launcher. Sandboxing
* is part of the declared excluded surface, so `sandbox-local` mounts with the
* launcher path and probe present and fails when it tries to confine a process.
*/
import { notImplementedFail } from '../notImplementedFail.ts'
const MODULE = '@deepseek-ai/node-addon-landlock-run'
/** Launcher executable name, read at module scope by sandbox-local. */
export const LAUNCHER_BIN = 'landlock-run'
/** Exit code the launcher reports when confinement itself fails. */
export const LAUNCHER_FAILURE_EXIT = 126
/**
* Path of the launcher binary; nothing in a browser can execute it.
* @returns The image path consumers read before failing on their own terms.
*/
export function launcherPath(): string {
return `/dsh/bin/${LAUNCHER_BIN}`
}
/** Landlock availability probe (unavailable). */
export const probe = notImplementedFail(MODULE, 'probe')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, launcherPath, probe }
@@ -0,0 +1,19 @@
/**
* `node-pty` stub: pseudo-terminals belong to the excluded surface. Terminal
* plugins mount so their tools stay visible; spawning reports the gap.
*/
import { notImplementedFail } from '../notImplementedFail.ts'
const MODULE = 'node-pty'
/** Spawn a pseudo-terminal (unavailable). */
export const spawn = notImplementedFail(MODULE, 'spawn')
/** Open a pseudo-terminal pair (unavailable). */
export const open = notImplementedFail(MODULE, 'open')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { spawn, open }
@@ -0,0 +1,92 @@
/**
* `@earendil-works/pi-ai` stub, including its `/providers/all` and `/api/*.lazy`
* subpaths. The package is Node-only (no `require`/`browser` conditions, Node
* builtins plus five cloud SDKs in its transport layer) and `llm-pi-ai` imports it
* statically at module scope, so the row cannot mount without it.
*
* Every symbol `llm-pi-ai` imports by name is present: a missing CommonJS symbol
* would surface as `undefined` at call time instead of a link error
* (research/services-build.md §11.4 lists the ten). The three catalog readers
* return empty collections rather than throwing — the row reads them while it
* activates, and "this deployment ships no pi-ai provider" is the truth here.
* Everything on a request path is loud.
*/
import { notImplementedFail } from '../notImplementedFail.ts'
const MODULE = '@earendil-works/pi-ai'
/** Provider factory (unavailable). */
export const createProvider = notImplementedFail(MODULE, 'createProvider')
/** Model-list factory (unavailable). */
export const createModels = notImplementedFail(MODULE, 'createModels')
/** Thinking-level catalog (unavailable). */
export const getSupportedThinkingLevels = notImplementedFail(MODULE, 'getSupportedThinkingLevels')
/** Context-overflow predicate (unavailable). */
export const isContextOverflow = notImplementedFail(MODULE, 'isContextOverflow')
/** Builtin provider ids of pi-ai 0.82.1, in catalog order. */
const BUILTIN_PROVIDER_IDS: readonly string[] = [
'amazon-bedrock', 'ant-ling', 'anthropic', 'azure-openai-responses', 'cerebras',
'cloudflare-ai-gateway', 'cloudflare-workers-ai', 'deepseek', 'fireworks', 'github-copilot',
'google', 'google-vertex', 'groq', 'huggingface', 'kimi-coding', 'minimax', 'minimax-cn',
'mistral', 'moonshotai', 'moonshotai-cn', 'nvidia', 'openai', 'openai-codex', 'opencode',
'opencode-go', 'openrouter', 'qwen-token-plan', 'qwen-token-plan-cn', 'together',
'vercel-ai-gateway', 'xai', 'xiaomi', 'xiaomi-token-plan-ams', 'xiaomi-token-plan-cn',
'xiaomi-token-plan-sgp', 'zai', 'zai-coding-cn',
]
/**
* Installed catalog providers, read while `llm-pi-ai` activates. Each carries the
* api-key auth marker the adapter filters on, and no models: the provider
* directory therefore matches the served deployment while every request path
* lands on a loud symbol above.
* @returns one entry per builtin provider.
*/
export function builtinProviders(): unknown[] {
return BUILTIN_PROVIDER_IDS.map(id => ({
id,
name: id,
auth: { apiKey: { type: 'api-key' } },
models: [],
}))
}
/**
* Provider route ids of the installed catalog. `llm-pi-ai` registers the whole
* catalog as configurable the moment it mounts and rejects an empty
* registration, so these are pi-ai's real ids rather than an empty list.
* @returns the builtin provider ids.
*/
export function getBuiltinProviders(): string[] {
return [...BUILTIN_PROVIDER_IDS]
}
/**
* Models of one installed catalog provider.
* @returns no models.
*/
export function getBuiltinModels(): unknown[] {
return []
}
/** Anthropic messages API binding (unavailable). */
export const anthropicMessagesApi = notImplementedFail(MODULE, 'anthropicMessagesApi')
/** OpenAI completions API binding (unavailable). */
export const openAICompletionsApi = notImplementedFail(MODULE, 'openAICompletionsApi')
/** OpenAI responses API binding (unavailable). */
export const openAIResponsesApi = notImplementedFail(MODULE, 'openAIResponsesApi')
/** CommonJS interop marker: the worker loader hands `default` to default imports. */
export const __esModule = true
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default {
createProvider, createModels, getSupportedThinkingLevels, isContextOverflow, builtinProviders,
getBuiltinModels, getBuiltinProviders, anthropicMessagesApi, openAICompletionsApi,
openAIResponsesApi,
}
@@ -0,0 +1,19 @@
/**
* Names of external npm packages the worker replaces wholesale. Kept in a module
* with no imports so both consumers can read it: the runtime builtin table
* (`./builtins.ts`) and the build-time VFS image collector, which must leave
* these packages out of the image entirely — the loader answers them from the
* bundle before it ever reaches `node_modules`.
*/
/** External packages served from the worker bundle instead of the VFS. */
export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [
'@deepseek-ai/node-addon-landlock-run',
'@earendil-works/pi-ai',
'@vscode/ripgrep',
'chokidar',
'koffi',
'node-pty',
'sharp',
'ws',
]
@@ -0,0 +1,15 @@
/**
* `@vscode/ripgrep` stub. The package's only export is the binary path, read at
* module scope by search plugins; the path stays a plain string so construction
* succeeds, and the loud failure comes from the child_process stub when something
* tries to run it.
*/
/** Path the search plugins would spawn; nothing can execute it in a browser. */
export const rgPath = '/dsh/bin/rg'
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { rgPath }
@@ -0,0 +1,13 @@
/**
* `sharp` stub: native image transcoding has no browser counterpart in this
* layer. Attachment plugins mount; a resize attempt reports the gap.
*/
import { notImplementedFail } from '../notImplementedFail.ts'
/** Image processing has no worker counterpart; the call refuses. */
const sharp = notImplementedFail('sharp', 'default')
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
export default sharp
@@ -0,0 +1,62 @@
/**
* `ws` stub. `WebSocketDownlinks` constructs a `WebSocketServer` in a field
* initializer as soon as apiProxy is present, so the class must be constructible;
* no method is ever reached because the fake HTTP server never emits `upgrade`
* (the tunnel carries downstream events over the SSE branch instead).
*/
import { notImplementedFail } from '../notImplementedFail.ts'
const MODULE = 'ws'
/** Client socket (unavailable; the page side uses the tunnel, not WebSocket). */
/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
export const __esModule = true
/** Client socket (unavailable; the page side uses the tunnel, not WebSocket). */
export default class WebSocket {
/** Node's `CONNECTING` ready state, read by consumers that never connect. */
static readonly CONNECTING = 0
/** Node's `OPEN` ready state. */
static readonly OPEN = 1
/** Node's `CLOSING` ready state. */
static readonly CLOSING = 2
/** Node's `CLOSED` ready state. */
static readonly CLOSED = 3
constructor() {
throw new Error(`web-preview: ${MODULE} client sockets are not available in the worker host`)
}
}
/** Server whose construction must succeed and whose methods are unreachable. */
export class WebSocketServer {
/** Connected clients: always empty, since no upgrade ever completes. */
readonly clients = new Set<never>()
/** Upgrade handling (unreachable: no upgrade event is ever emitted). */
readonly handleUpgrade = notImplementedFail(MODULE, 'WebSocketServer.handleUpgrade')
/** Broadcast helper (unreachable). */
readonly emit = notImplementedFail(MODULE, 'WebSocketServer.emit')
/**
* Register a listener; nothing is ever emitted.
* @returns this server.
*/
on(): this {
return this
}
/**
* Close the server.
* @param callback - completion callback, invoked immediately.
*/
close(callback?: () => void): void {
callback?.()
}
}
/** Alias Node consumers sometimes import. */
export const Server = WebSocketServer
export { WebSocket }
@@ -0,0 +1,136 @@
/**
* The `process` global the worker needs before any VFS module runs. Cordis
* reads `process.env` and `process.versions.node` while the Loader is
* constructed, and `cordis.yml` keeps its `!!js process.*` expressions, so the
* configuration bytes stay identical to the Node deployment.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/process
*/
import { requireActiveModuleLoader } from '../../module-system/module-loader.ts'
import { processAlive, signalProcess } from '../process-table.ts'
/** Construction inputs for {@link installProcessGlobal}. */
export interface ProcessShimOptions {
/** Virtual root reported by `cwd()`. */
readonly cwd: string
/** Environment the tree reads; `DSH_HOME` belongs here. */
readonly env: Readonly<Record<string, string>>
/** Argument vector reported to the tree. */
readonly argv?: readonly string[]
}
/** The members this shim publishes. */
export interface ProcessShim {
readonly env: Record<string, string>
readonly argv: string[]
readonly execArgv: string[]
/**
* Node 22 `process.getBuiltinModule`: the worker's module proxy for a
* builtin id (`fs`, `node:fs`), or undefined for anything else — it never
* resolves image modules.
* @param id - Builtin module id, with or without the `node:` prefix.
* @returns the proxied builtin, or undefined.
*/
getBuiltinModule(id: string): unknown
readonly platform: string
readonly arch: string
readonly pid: number
readonly version: string
readonly versions: Record<string, string>
cwd(): string
/**
* Signal one command started through the `node:child_process` shim. Signal
* `0` is the liveness probe the subprocess service polls a process tree
* with; a negative pid addresses the group, which here holds exactly the one
* command that leads it.
* @param pid - the target pid, negative for its group.
* @param signal - signal name, or `0` to probe without delivering one.
* @returns true once the signal is recorded.
* @throws Error with `code: 'ESRCH'` when no such command is running.
*/
kill(pid: number, signal?: NodeJS.Signals | 0): boolean
nextTick(callback: (...args: unknown[]) => void, ...args: unknown[]): void
readonly stdout: { write(chunk: string): boolean }
readonly stderr: { write(chunk: string): boolean }
on(): ProcessShim
off(): ProcessShim
once(): ProcessShim
prependListener(): ProcessShim
prependOnceListener(): ProcessShim
removeListener(): ProcessShim
removeAllListeners(): ProcessShim
listeners(): unknown[]
listenerCount(): number
setMaxListeners(): ProcessShim
emit(): boolean
readonly hrtime: { bigint(): bigint }
uptime(): number
exit(code?: number): void
}
/**
* Publish `globalThis.process`.
*
* `versions.node` is `0.0.0` on purpose: it makes Cordis's
* `ModuleLoader.fromInternal()` return undefined instead of reaching for Node
* internals, which is what lets the worker install its own module seam.
* @param options - Root, environment, and argument vector.
* @returns The published object, for the module proxy table.
*/
export function installProcessGlobal(options: ProcessShimOptions): ProcessShim {
const start = performance.now()
const write = (target: 'log' | 'error') => (chunk: string): boolean => {
console[target](chunk.replace(/\n$/, ''))
return true
}
const shim: ProcessShim = {
env: { ...options.env },
argv: [...(options.argv ?? ['node', 'dsh-webworker'])],
execArgv: [],
platform: 'linux',
arch: 'x64',
pid: 1,
version: 'v0.0.0',
versions: { node: '0.0.0' },
cwd: () => options.cwd,
getBuiltinModule: (id: string): unknown => {
let resolution
try {
resolution = requireActiveModuleLoader().resolve(id, '/')
} catch {
// No loader mounted yet, or an id that resolves nowhere: Node answers
// undefined for non-builtins instead of throwing.
return undefined
}
return resolution.kind === 'static' ? resolution.factory() : undefined
},
kill: (pid: number, signal: NodeJS.Signals | 0 = 'SIGTERM'): boolean => {
if (signal === 0) {
if (processAlive(pid)) return true
const error = new Error('kill ESRCH') as NodeJS.ErrnoException
error.code = 'ESRCH'
error.syscall = 'kill'
throw error
}
return signalProcess(pid, signal)
},
nextTick: (callback, ...args) => { queueMicrotask(() => { callback(...args) }) },
stdout: { write: write('log') },
stderr: { write: write('error') },
on: () => shim,
off: () => shim,
once: () => shim,
prependListener: () => shim,
prependOnceListener: () => shim,
removeListener: () => shim,
removeAllListeners: () => shim,
listeners: () => [],
listenerCount: () => 0,
setMaxListeners: () => shim,
emit: () => false,
hrtime: { bigint: () => BigInt(Math.round((performance.now() - start) * 1e6)) },
uptime: () => (performance.now() - start) / 1000,
exit: (code?: number) => { console.warn(`webworker process: exit(${String(code ?? 0)}) requested; the worker keeps running`) },
}
;(globalThis as { process?: unknown }).process = shim
return shim
}
@@ -0,0 +1,68 @@
/**
* Node-shaped timer handles. The browser's `setTimeout`/`setInterval` return
* numeric ids, while harness and vendored code calls `.unref()` on the handle
* (`client-hmr`'s poll interval, cordis's timer plugin). The wrappers return a
* handle object with Node's `ref`/`unref`/`hasRef`, and `clear*` accepts either
* form — the object also converts to its numeric id, so any code that stores it
* as a number keeps working.
*
* Handlers are also bound to the async context where the timer was registered
* (`./async-context-hooks.ts`), so a callback scheduled inside an initiator
* boundary is attributed to that boundary when it fires.
*/
import { bindAsyncContext } from '../builtin_modules/implemented/async_hooks.ts'
/** Node `Timeout`/`Immediate` face the harness relies on. */
export interface TimerHandle {
ref(): TimerHandle
unref(): TimerHandle
hasRef(): boolean
[Symbol.toPrimitive](): number
}
type Scheduler = (handler: TimerHandler, timeout?: number, ...args: unknown[]) => number
type Clear = (id?: number) => void
const handleOf = (id: number): TimerHandle => {
const handle: TimerHandle = {
ref: () => handle,
unref: () => handle,
hasRef: () => true,
[Symbol.toPrimitive]: () => id,
}
return handle
}
const idOf = (handle: unknown): number | undefined => {
if (typeof handle === 'number') return handle
if (typeof handle === 'object' && handle !== null && Symbol.toPrimitive in handle) {
return Number(handle)
}
return undefined
}
const wrapScheduler = (schedule: Scheduler): ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => TimerHandle) =>
(handler, timeout, ...args) => handleOf(schedule(bindHandler(handler), timeout, ...args))
/** Bind a timer handler to its registration context; string handlers have none to bind. */
const bindHandler = (handler: TimerHandler): TimerHandler =>
typeof handler === 'function' ? bindAsyncContext(handler as (...args: never[]) => unknown) : handler
const wrapClear = (clear: Clear): ((handle?: unknown) => void) =>
(handle) => { clear(idOf(handle)) }
/** Replace the worker's timer globals with the Node-shaped wrappers. */
export function installTimerGlobals(): void {
const scope = globalThis as unknown as Record<string, unknown>
const setTimeoutRaw = globalThis.setTimeout.bind(globalThis) as unknown as Scheduler
const setIntervalRaw = globalThis.setInterval.bind(globalThis) as unknown as Scheduler
const clearTimeoutRaw = globalThis.clearTimeout.bind(globalThis) as unknown as Clear
const clearIntervalRaw = globalThis.clearInterval.bind(globalThis) as unknown as Clear
scope.setTimeout = wrapScheduler(setTimeoutRaw)
scope.setInterval = wrapScheduler(setIntervalRaw)
scope.clearTimeout = wrapClear(clearTimeoutRaw)
scope.clearInterval = wrapClear(clearIntervalRaw)
scope.setImmediate = (handler: TimerHandler, ...args: unknown[]) =>
handleOf(setTimeoutRaw(bindHandler(handler), 0, ...args))
scope.clearImmediate = wrapClear(clearTimeoutRaw)
}
@@ -0,0 +1,44 @@
/**
* Structural not-implemented stubs: a replaced module must expose every symbol
* its importers name (a missing CommonJS symbol degrades to `undefined` at call
* time instead of failing at link time), and every one of those symbols must
* report exactly what is unavailable when it is finally called.
*/
/**
* Build a function that throws naming its module and symbol. The refusal is
* also written to the console before it propagates: callers routinely swallow
* these errors far from their cause, and the console line is what places the
* failure while debugging a worker session.
*
* `Face` is the Node declaration this stub stands in for, so the replaced module
* publishes the type its importers compile against. The value is one throwing
* function whatever that declaration says: a caller reaches the throw before any
* declared parameter, return value, or `new` result exists, so the assertion
* below cannot be observed as a lie. It is a function expression rather than an
* arrow because a stub standing in for a class must refuse under `new` too, and
* an arrow has no construct behavior to reach.
* @param module - module specifier being stubbed.
* @param symbol - exported symbol name.
* @returns the throwing stand-in, typed as the member it replaces.
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- only the return position carries the Node declaration
export function notImplementedFail<Face = (...args: never[]) => never>(module: string, symbol: string): Face {
return (function refuse(): never {
throw notAvailableError(module, symbol)
}) as Face
}
/**
* Build the refusal error and write it to the console first, for stubs that
* cannot be a plain throwing function (constructors, methods on structural
* fakes).
* @param module - module specifier being stubbed.
* @param symbol - unavailable member, named as the importer sees it.
* @returns the error to throw.
*/
export function notAvailableError(module: string, symbol: string): Error {
const message = `web-preview: ${module}.${symbol} is not available in the worker host`
console.error(message)
return new Error(message)
}
@@ -0,0 +1,102 @@
/**
* Runtime the transformed modules call at every suspension point.
*
* `pause` snapshots every ambient store and hands back a token that **always
* fulfills** (a rejection travels inside it); `resume` restores that snapshot as
* the first thing the resumed frame does, then returns the value or rethrows the
* error, so both completion paths are causally exact. The state itself belongs to
* the `node:async_hooks` proxy — this module only moves it.
*
* The transform that inserts these calls lives in `transform.ts`.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/polyfill/async-context/als-runtime
*/
/** Snapshot of every ambient store, opaque to this module. */
export type AlsSnapshot = unknown
/** Result of a suspension: a rejection travels inside it, so the token always fulfills. */
export interface AlsToken {
readonly ok: boolean
readonly value?: unknown
readonly error?: unknown
readonly snapshot: AlsSnapshot
}
/** The state face the rewrite moves snapshots through; the shim owns the state itself. */
export interface AlsCausality {
/** Capture every instance's current store. */
snapshot(): AlsSnapshot
/** Restore a captured snapshot. */
restore(snapshot: AlsSnapshot): void
}
/** Runtime the rewritten modules call; built by {@link createAlsRuntime}. */
export interface AlsRuntime {
pause(value: unknown): Promise<AlsToken>
resume(token: AlsToken): unknown
snapshot(): AlsSnapshot
afterYield(snapshot: AlsSnapshot, sent: unknown): unknown
iterator(value: unknown): AsyncIterator<unknown>
close(iterator: AsyncIterator<unknown>): Promise<unknown>
}
/**
* Build the runtime the rewritten code calls.
* @param causality - Snapshot face from the `node:async_hooks` proxy; omitted
* leaves the rewrite inert (it still hops a microtask, but moves no state).
* @returns Runtime object passed to every module wrapper.
*/
export function createAlsRuntime(causality?: AlsCausality): AlsRuntime {
const snapshot = (): AlsSnapshot => causality?.snapshot()
const restore = (value: AlsSnapshot): void => { causality?.restore(value) }
return {
snapshot,
pause: (value: unknown): Promise<AlsToken> => {
const captured = snapshot()
return Promise.resolve(value).then(
settled => ({ ok: true, value: settled, snapshot: captured }),
(error: unknown) => ({ ok: false, error, snapshot: captured }),
)
},
resume: (token: AlsToken): unknown => {
restore(token.snapshot)
if (token.ok) return token.value
throw token.error
},
afterYield: (captured: AlsSnapshot, sent: unknown): unknown => {
restore(captured)
return sent
},
iterator: (value: unknown): AsyncIterator<unknown> => {
const source = value as {
[Symbol.asyncIterator]?: () => AsyncIterator<unknown>
[Symbol.iterator]?: () => Iterator<unknown, unknown>
}
const asyncFactory = source[Symbol.asyncIterator]
if (typeof asyncFactory === 'function') return asyncFactory.call(source)
const syncFactory = source[Symbol.iterator]
if (typeof syncFactory !== 'function') {
throw new TypeError('webworker als: for-await source is neither async nor sync iterable')
}
const inner = syncFactory.call(source)
// Async-from-sync: a sync iterator's values may be promises the loop awaits.
return {
next: async (...args: [] | [unknown]): Promise<IteratorResult<unknown>> => {
const step = inner.next(...args as [unknown])
return { done: step.done ?? false, value: await step.value }
},
return: async (sent?: unknown): Promise<IteratorResult<unknown>> => {
const step = inner.return?.(sent) ?? { done: true, value: undefined }
return { done: step.done ?? true, value: await step.value }
},
} as AsyncIterator<unknown>
},
close: async (iterator: AsyncIterator<unknown>): Promise<unknown> => {
try {
return await iterator.return?.(undefined)
} catch {
// Closing an iterator that already failed has nothing left to release.
return undefined
}
},
}
}
@@ -0,0 +1,80 @@
/**
* Global hook layer for the ALS shim: capture the async context where a callback
* is REGISTERED and restore it where the callback RUNS. Together with the folding
* stack in `./async-hooks.ts` this gives the worker two kinds of coverage —
* `await` inside a boundary keeps its store because the boundary's stack entry is
* still open, and work handed to the platform (`.then`, `queueMicrotask`, timers,
* `fetch`) keeps its store because it was captured at registration.
*
* Patched here: `Promise.prototype.then` and `queueMicrotask` and `fetch`. Node's
* `catch`/`finally` are specified to invoke `then` on the receiver, so they inherit
* the patch instead of needing their own (`als-check.ts` proves it). The worker's
* `setTimeout`/`setInterval`/`setImmediate` are bound in `./timers-global.ts`, and
* the host's `process.nextTick` shim is built on `queueMicrotask`, so both arrive
* here too.
*
* Two properties the patches keep:
* - the values stay native promises — a handler is wrapped, never the chain, so
* `then` still returns what the original returned;
* - an empty handler slot stays empty (`.then(undefined, onRejected)` must not
* grow a fulfilled handler, or a rejection would be swallowed).
*
* Not covered (structural): native `async`/`await` resumption is invisible to user
* code, so the folding stack remains what carries a store across an `await`.
*/
import { bindAsyncContext, captureAsyncContext, runWithAsyncContext } from '../../node/builtin_modules/implemented/async_hooks.ts'
type Handler = ((value: never) => unknown) | null | undefined
let installed = false
/** Wrap one handler slot, leaving a non-function slot exactly as it was. */
const bindSlot = (handler: Handler, snapshot: ReturnType<typeof captureAsyncContext>): Handler => {
if (typeof handler !== 'function') return handler
return (value: never) => runWithAsyncContext(snapshot, () => handler(value))
}
/**
* Patch the platform registration points. Idempotent; call once from the worker
* entry before the host tree boots.
*/
export function installAsyncContextHooks(): void {
if (installed) return
installed = true
// eslint-disable-next-line @typescript-eslint/unbound-method -- the pristine `then` is `.call`ed on its own promise below
const nativeThen = Promise.prototype.then
// A browser has no async-context tracking, so registration points are where a
// store can be captured at all — patching them is the point of this module.
Promise.prototype.then = function patchedThen<T, R1, R2>(
this: Promise<T>,
onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null,
onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
): Promise<R1 | R2> {
const snapshot = captureAsyncContext()
if (snapshot === undefined) return nativeThen.call(this, onFulfilled, onRejected) as Promise<R1 | R2>
return nativeThen.call(
this,
bindSlot(onFulfilled as Handler, snapshot) as typeof onFulfilled,
bindSlot(onRejected as Handler, snapshot) as typeof onRejected,
) as Promise<R1 | R2>
}
const nativeQueueMicrotask = globalThis.queueMicrotask.bind(globalThis)
globalThis.queueMicrotask = (callback: VoidFunction): void => {
nativeQueueMicrotask(bindAsyncContext(callback))
}
const nativeFetch = globalThis.fetch.bind(globalThis)
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const snapshot = captureAsyncContext()
if (snapshot === undefined) return nativeFetch(input, init)
// Bind the response continuation to the call site, for consumers that hand
// the promise on before attaching handlers. `nativeThen` keeps the chain native.
return nativeThen.call(
nativeFetch(input, init),
(response: Response) => runWithAsyncContext(snapshot, () => response),
(reason: unknown) => runWithAsyncContext(snapshot, () => { throw reason }),
) as Promise<Response>
})
}
@@ -0,0 +1,28 @@
/**
* Process-wide slot holding the mounted filesystem. Kept apart from any
* backend implementation: the `node:fs` proxy depends on the slot, not on
* which backend the worker entry mounted.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active
*/
import type { MemoryVfs } from './memory.ts'
let active: MemoryVfs | undefined
/**
* Publish the filesystem the `node:fs` proxy reads.
* @param vfs - Filesystem mounted by the worker entry.
*/
export function setActiveVfs(vfs: MemoryVfs): void {
active = vfs
}
/**
* Read the mounted filesystem.
* @returns The active filesystem.
*/
export function requireActiveVfs(): MemoryVfs {
if (active === undefined) {
throw new Error('webworker vfs: no filesystem is mounted; the worker entry must call setActiveVfs before any node:fs access')
}
return active
}
@@ -0,0 +1,100 @@
/**
* The image byte envelope. The packer writes one gzip member holding the ustar
* archive, and the worker inflates it with the platform's own decompressor before
* the tar reader sees a byte — `storage/tar.ts` stays a pure ustar reader with no
* codec in it.
*
* Inflation runs on the fetch stream rather than on downloaded bytes: the
* decompressor consumes each chunk as it lands, so unpacking overlaps the
* download instead of following it, and the compressed copy never has to be held
* whole in memory beside the archive it produces.
*
* One format, no negotiation: a body that does not start a gzip member is refused
* by name, in the stream, before the decompressor sees it. Without that check a
* plain tar, a truncated download, or a proxy's HTML error page would reach
* `parseTar` and fail as a corrupt header field, which says nothing about what
* the deployment actually served.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip
*/
/** gzip member identification bytes (RFC 1952 §2.3.1). */
const GZIP_MAGIC = [0x1f, 0x8b] as const
/** Bytes of a refused body quoted in the failure, enough to recognize text served in its place. */
const QUOTED_BYTES = 8
const hex = (bytes: Uint8Array): string =>
[...bytes.slice(0, QUOTED_BYTES)].map(byte => byte.toString(16).padStart(2, '0')).join(' ')
/**
* A pass-through that refuses a body which is not a gzip member.
*
* The check spans chunks: a transport may deliver the first byte alone, so the
* head is held until it can be judged and then forwarded intact. A body that ends
* before two bytes arrive is refused in `flush`, where "too short" is the only
* thing left to report.
* @param source - the image URL, or how the bytes arrived; named in a refusal.
* @returns The transform to pipe the body through before the decompressor.
*/
function requireGzipMember(source: string): TransformStream<Uint8Array, Uint8Array> {
let head: Uint8Array = new Uint8Array(0)
let judged = false
const refuse = (read: Uint8Array): Error => new Error(
`webworker image: ${source} is not the gzip-compressed tar this deployment serves as its image `
+ `(expected a member starting 1f 8b, read ${read.byteLength === 0 ? 'an empty body' : hex(read)}); `
+ 'a host that answered with a Content-Encoding the transport already decoded, or a build that wrote '
+ 'the archive uncompressed, arrives exactly this way',
)
return new TransformStream<Uint8Array, Uint8Array>({
transform: (chunk, controller): void => {
if (judged) {
controller.enqueue(chunk)
return
}
const merged = new Uint8Array(head.byteLength + chunk.byteLength)
merged.set(head)
merged.set(chunk, head.byteLength)
head = merged
if (head.byteLength < GZIP_MAGIC.length) return
if (GZIP_MAGIC.some((byte, at) => head[at] !== byte)) throw refuse(head)
judged = true
controller.enqueue(head)
},
flush: (): void => {
if (!judged) throw refuse(head)
},
})
}
/**
* Inflate a packed VFS image as it arrives.
* @param body - the image body, straight from `fetch` or wrapped around bytes.
* @param source - the image URL, or how the bytes arrived; named in a refusal.
* @returns the ustar archive the image carries.
* @throws When the body does not start a gzip member, or the member is corrupt.
*/
export async function inflateImageStream(body: ReadableStream<Uint8Array>, source: string): Promise<Uint8Array> {
const inflated = body
.pipeThrough(requireGzipMember(source))
// The decompressor's writable half takes any BufferSource, which a
// `ReadableStream<Uint8Array>` is not assignable to.
.pipeThrough(new DecompressionStream('gzip') as unknown as TransformStream<Uint8Array, Uint8Array>)
return new Uint8Array(await new Response(inflated).arrayBuffer())
}
/**
* Inflate a packed VFS image held in memory.
*
* The bytes become a body so both entries run the same stream: one decompression
* path, one refusal, whether the image came off the network or out of a caller's
* buffer.
* @param bytes - the image bytes.
* @param source - how the bytes arrived; named in a refusal.
* @returns the ustar archive the image carries.
* @throws When the bytes do not start a gzip member, or the member is corrupt.
*/
export async function inflateImage(bytes: Uint8Array, source: string): Promise<Uint8Array> {
const body = new Response(bytes as Uint8Array<ArrayBuffer>).body
if (body === null) throw new Error(`webworker image: ${source} produced no readable body`)
return await inflateImageStream(body, source)
}
@@ -0,0 +1,595 @@
/**
* In-memory filesystem behind the worker's `node:fs` proxy. Contents come from
* the build-time image (see {@link loadVfsImage}); writes stay in memory and
* vanish with the worker.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory
*/
import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts'
import { parseTar } from './tar.ts'
import type {
VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsReadOptions, VfsStatOptions,
VfsStats, VfsWriteOptions,
} from './types.ts'
const decoder = new TextDecoder()
const encoder = new TextEncoder()
interface FileNode {
bytes: Uint8Array
mtimeMs: number
}
function fail(code: string, syscall: string, path: string, detail?: string): never {
const error = new Error(`${code}: ${detail ?? syscall} failed, ${syscall} '${path}'`) as VfsError
error.code = code
error.path = path
error.syscall = syscall
throw error
}
function encodingOf(options: VfsReadOptions): VfsEncoding | undefined {
if (options === null || options === undefined) return undefined
if (typeof options === 'string') return options
return options.encoding ?? undefined
}
// Owner-only permission bits (600/700), here and in the BigInt shape below:
// the VFS has one owner, and dsh-credentials-local refuses to start over a
// secrets file readable beyond its owner.
function statsOf(size: number, mtimeMs: number, directory: boolean): VfsStats {
return {
size,
mtimeMs,
mtime: new Date(mtimeMs),
mode: directory ? 0o040700 : 0o100600,
isFile: () => !directory,
isDirectory: () => directory,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
}
}
/**
* The same entry as {@link statsOf}, in the BigInt shape.
*
* Timestamps carry millisecond resolution scaled to nanoseconds, which is what
* the underlying `mtimeMs` holds; the VFS keeps that value strictly increasing
* per entry so two writes inside one millisecond still differ.
* @param size - Byte length; zero for a directory.
* @param mtimeMs - Modification time the entry carries.
* @param directory - Whether the entry is a directory.
* @param ino - Identity of the entry at this path.
* @returns Stats in the shape Node returns under `{ bigint: true }`.
*/
function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint): VfsBigIntStats {
const milliseconds = BigInt(Math.trunc(mtimeMs))
const nanoseconds = milliseconds * 1_000_000n
const time = new Date(mtimeMs)
return {
size: BigInt(size),
mode: directory ? 0o040700n : 0o100600n,
dev: 1n,
ino,
nlink: 1n,
mtimeMs: milliseconds,
mtimeNs: nanoseconds,
ctimeMs: milliseconds,
ctimeNs: nanoseconds,
atimeMs: milliseconds,
atimeNs: nanoseconds,
birthtimeMs: milliseconds,
birthtimeNs: nanoseconds,
mtime: time,
ctime: time,
atime: time,
birthtime: time,
isFile: () => !directory,
isDirectory: () => directory,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
}
}
/**
* Filesystem held in two maps: one for file bytes, one for directories.
* Every path is normalized to an absolute POSIX path without a trailing
* separator, so callers may pass either form.
*/
export class MemoryVfs {
private readonly files = new Map<string, FileNode>()
private readonly directories = new Set<string>([SEP])
private temporaries = 0
// Identity per path, assigned on first stat and dropped when the path goes:
// the filesystem service builds its version token from `ino` plus the
// timestamp, so a recreated path must not look like the entry it replaced.
private readonly identities = new Map<string, bigint>()
private lastIdentity = 0n
/** Promise face mirroring `node:fs/promises` for the methods the roster uses. */
readonly promises = {
readFile: async (path: string, options?: VfsReadOptions): Promise<string | Uint8Array> => this.readFileSync(path, options),
writeFile: async (path: string, data: string | Uint8Array, options?: VfsWriteOptions): Promise<void> => {
this.writeFileSync(path, data, options)
},
appendFile: async (path: string, data: string | Uint8Array): Promise<void> => { this.appendFileSync(path, data) },
mkdir: async (path: string, options?: { recursive?: boolean }): Promise<string | undefined> => this.mkdirSync(path, options),
readdir: async (path: string, options?: { withFileTypes?: boolean }): Promise<string[] & VfsDirent[]> =>
this.readdirSync(path, options),
stat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
lstat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
realpath: async (path: string): Promise<string> => this.realpathSync(path),
rename: async (from: string, to: string): Promise<void> => { this.renameSync(from, to) },
unlink: async (path: string): Promise<void> => { this.unlinkSync(path) },
rm: async (path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> => { this.rmSync(path, options) },
mkdtemp: async (prefix: string): Promise<string> => this.mkdtempSync(prefix),
link: async (existing: string, next: string): Promise<void> => { this.linkSync(existing, next) },
truncate: async (path: string, length?: number): Promise<void> => { this.truncateSync(path, length) },
chmod: async (): Promise<void> => {},
opendir: async (path: string): Promise<VfsDir> => this.opendir(path),
open: async (path: string, flags?: string, mode?: number): Promise<VfsFileHandle> => this.open(path, flags, mode),
/** Resolves for any existing path: the VFS grants read and write to everything it holds. */
access: async (path: string): Promise<void> => {
const target = normalize(resolve(path))
if (!this.files.has(target) && !this.directories.has(target)) fail('ENOENT', 'access', target)
},
}
/** @returns Absolute path with no trailing separator. */
private key(path: string): string {
const absolute = normalize(resolve(path))
return absolute.length > 1 && absolute.endsWith(SEP) ? absolute.slice(0, -1) : absolute
}
/**
* Read a file.
* @param path - File path.
* @param options - `'utf8'` or `{encoding}` for text; omitted for bytes.
* @returns Text or a copy-free view of the stored bytes.
*/
readFileSync(path: string, options?: VfsReadOptions): string | Uint8Array {
const target = this.key(path)
const node = this.files.get(target)
if (node === undefined) {
if (this.directories.has(target)) fail('EISDIR', 'read', target)
fail('ENOENT', 'open', target)
}
return encodingOf(options) === undefined ? node.bytes : decoder.decode(node.bytes)
}
/**
* Report whether a path exists.
* @param path - Path to test.
* @returns True for files and directories.
*/
existsSync(path: string): boolean {
const target = this.key(path)
return this.files.has(target) || this.directories.has(target)
}
/**
* Stat a path.
* @param path - Path to stat.
* @param options - `bigint` selects the BigInt stats Node returns for it.
* @returns Stats for the file or directory.
*/
statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
const target = this.key(path)
const node = this.files.get(target)
const [size, mtimeMs, directory] = node !== undefined
? [node.bytes.length, node.mtimeMs, false] as const
: this.directories.has(target)
? [0, 0, true] as const
: fail('ENOENT', 'stat', target)
return options?.bigint === true
? bigIntStatsOf(size, mtimeMs, directory, this.identityOf(target))
: statsOf(size, mtimeMs, directory)
}
/** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */
private plainStats(path: string): VfsStats {
return this.statSync(path) as VfsStats
}
/** @returns The stable identity of an existing path, assigning one on first observation. */
private identityOf(target: string): bigint {
const existing = this.identities.get(target)
if (existing !== undefined) return existing
this.lastIdentity += 1n
this.identities.set(target, this.lastIdentity)
return this.lastIdentity
}
/** Forget a removed path's identity, so a recreated path reports a new one. */
private forgetIdentity(target: string): void {
this.identities.delete(target)
const prefix = `${target}${SEP}`
for (const known of [...this.identities.keys()]) {
if (known.startsWith(prefix)) this.identities.delete(known)
}
}
/**
* Modification time for a write, strictly after the entry's previous one.
*
* The clock has millisecond resolution and these writes are in memory, so two
* revisions of one file routinely land in the same millisecond. The filesystem
* service's stale-write guard compares timestamps, so an equal one would let a
* stale overwrite through.
* @param target - Normalized path being written.
* @returns Now, or one millisecond past the entry's current time.
*/
private touch(target: string): number {
const previous = this.files.get(target)?.mtimeMs
const now = Date.now()
return previous === undefined ? now : Math.max(now, previous + 1)
}
/**
* List a directory.
* @param path - Directory path.
* @param options - `withFileTypes` returns {@link VfsDirent} objects instead of names.
* @returns Immediate entry names, or directory entries.
*/
readdirSync(path: string, options?: { withFileTypes?: boolean }): string[] & VfsDirent[] {
const target = this.key(path)
if (!this.directories.has(target)) {
if (this.files.has(target)) fail('ENOTDIR', 'scandir', target)
fail('ENOENT', 'scandir', target)
}
const prefix = target === SEP ? SEP : `${target}${SEP}`
const names = new Set<string>()
for (const candidate of [...this.files.keys(), ...this.directories]) {
if (!candidate.startsWith(prefix) || candidate === target) continue
const rest = candidate.slice(prefix.length)
if (rest === '') continue
const [head = rest] = rest.split(SEP)
names.add(head)
}
const sorted = [...names].sort()
if (options?.withFileTypes !== true) return sorted as string[] & VfsDirent[]
return sorted.map(name => this.direntOf(target, name)) as string[] & VfsDirent[]
}
/** @returns Directory entry for one child of `directory`. */
private direntOf(directory: string, name: string): VfsDirent {
const stats = this.plainStats(join(directory, name))
return {
name,
parentPath: directory,
isFile: () => stats.isFile(),
isDirectory: () => stats.isDirectory(),
isSymbolicLink: () => false,
}
}
/**
* Resolve a path; the VFS has no symlinks, so this only normalizes.
* @param path - Path to resolve.
* @returns Absolute path.
*/
realpathSync(path: string): string {
const target = this.key(path)
if (!this.existsSync(target)) fail('ENOENT', 'realpath', target)
return target
}
/**
* Create a directory.
* @param path - Directory path.
* @param options - `recursive` creates missing parents.
* @returns First created path when recursive, otherwise undefined.
*/
mkdirSync(path: string, options?: { recursive?: boolean }): string | undefined {
const target = this.key(path)
if (this.files.has(target)) fail('EEXIST', 'mkdir', target)
if (this.directories.has(target)) {
if (options?.recursive === true) return undefined
fail('EEXIST', 'mkdir', target)
}
const parent = dirname(target)
if (!this.directories.has(parent)) {
if (options?.recursive !== true) fail('ENOENT', 'mkdir', target)
this.mkdirSync(parent, options)
}
this.directories.add(target)
return target
}
/**
* Write a file, replacing existing contents.
* @param path - File path; its parent directory must exist.
* @param data - Text or bytes.
* @param options - `flag` `wx` refuses an existing file, `a` appends.
*/
writeFileSync(path: string, data: string | Uint8Array, options?: VfsWriteOptions): void {
const target = this.key(path)
if (this.directories.has(target)) fail('EISDIR', 'open', target)
if (!this.directories.has(dirname(target))) fail('ENOENT', 'open', target)
const flag = options?.flag ?? 'w'
if (flag.startsWith('wx') && this.files.has(target)) fail('EEXIST', 'open', target)
if (flag.startsWith('a')) { this.appendFileSync(target, data); return }
this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target) })
}
/**
* Open a directory; consumers enumerate entries or just prove it is one.
* @param path - Directory path.
* @returns Directory handle.
*/
opendir(path: string): VfsDir {
const target = this.key(path)
const names = this.readdirSync(target)
let cursor = 0
const direntOf = (name: string): VfsDirent => this.direntOf(target, name)
return {
path: target,
close: async (): Promise<void> => {},
read: async (): Promise<{ name: string } | null> => {
const name = names[cursor]
cursor += 1
return name === undefined ? null : direntOf(name)
},
async *[Symbol.asyncIterator]() {
for (const name of names) yield direntOf(name)
},
}
}
/**
* Open a file handle.
* @param path - File path.
* @param flags - Node open flags; `r` requires the file, `wx` refuses an existing one.
* @param mode - Accepted and ignored: the VFS has no permission bits.
* @returns File handle.
*/
open(path: string, flags = 'r', mode?: number): VfsFileHandle {
void mode
const target = this.key(path)
// Durable writers fsync the parent directory by opening it read-only.
if (this.directories.has(target)) {
if (!flags.startsWith('r')) fail('EISDIR', 'open', target)
return {
write: async (): Promise<{ bytesWritten: number }> => fail('EISDIR', 'write', target),
writeFile: async (): Promise<void> => fail('EISDIR', 'write', target),
readFile: async (): Promise<string | Uint8Array> => fail('EISDIR', 'read', target),
truncate: async (): Promise<void> => fail('EISDIR', 'ftruncate', target),
...this.handleTail(target),
}
}
const exists = this.files.has(target)
if (flags.startsWith('r') && !exists) fail('ENOENT', 'open', target)
if (flags.startsWith('wx') && exists) fail('EEXIST', 'open', target)
if (!flags.startsWith('r') && !this.directories.has(dirname(target))) fail('ENOENT', 'open', target)
if (flags.startsWith('w') && !flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array())
if (flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), { flag: 'wx' })
if (flags.startsWith('a') && !exists) this.writeFileSync(target, new Uint8Array())
const appending = flags.startsWith('a')
return {
write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => {
const bytes = typeof data === 'string' ? encoder.encode(data) : data
this.appendFileSync(target, bytes)
return { bytesWritten: bytes.length }
},
// A handle opened for append must append here too: session persistence
// opens the log with `a` and writes each batch through this method, so a
// truncating write would replace the whole log with the newest batch.
writeFile: async (data: string | Uint8Array): Promise<void> => {
if (appending) this.appendFileSync(target, data)
else this.writeFileSync(target, data)
},
readFile: async (options?: VfsReadOptions): Promise<string | Uint8Array> => this.readFileSync(target, options),
truncate: async (length = 0): Promise<void> => {
const node = this.files.get(target)
if (node === undefined) fail('ENOENT', 'ftruncate', target)
this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target) })
},
...this.handleTail(target),
}
}
/**
* The handle members that do not depend on how the file was opened.
*
* `sync`/`datasync` have nothing to flush — the bytes are already the stored
* ones — and `close` releases nothing, so both directory and file handles
* share this tail.
* @param target - Normalized path the handle was opened on.
* @returns Metadata plus the no-op durability and release calls.
*/
private handleTail(target: string): Pick<VfsFileHandle, 'stat' | 'sync' | 'datasync' | 'close'> {
return {
stat: async (): Promise<VfsStats> => this.plainStats(target),
sync: async (): Promise<void> => {},
datasync: async (): Promise<void> => {},
close: async (): Promise<void> => {},
}
}
/**
* Append to a file, creating it when absent.
* @param path - File path.
* @param data - Text or bytes.
*/
appendFileSync(path: string, data: string | Uint8Array): void {
const target = this.key(path)
const existing = this.files.get(target)
const addition = typeof data === 'string' ? encoder.encode(data) : data
if (existing === undefined) { this.writeFileSync(target, addition); return }
const merged = new Uint8Array(existing.bytes.length + addition.length)
merged.set(existing.bytes)
merged.set(addition, existing.bytes.length)
this.files.set(target, { bytes: merged, mtimeMs: this.touch(target) })
}
/**
* Move a file or directory subtree.
* @param from - Source path.
* @param to - Destination path.
*/
renameSync(from: string, to: string): void {
const source = this.key(from)
const destination = this.key(to)
const node = this.files.get(source)
if (node !== undefined) {
if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
this.files.delete(source)
this.files.set(destination, node)
this.forgetIdentity(source)
this.forgetIdentity(destination)
return
}
if (!this.directories.has(source)) fail('ENOENT', 'rename', source)
const prefix = `${source}${SEP}`
for (const [candidate, value] of [...this.files]) {
if (!candidate.startsWith(prefix)) continue
this.files.delete(candidate)
this.files.set(join(destination, candidate.slice(prefix.length)), value)
}
for (const candidate of [...this.directories]) {
if (!candidate.startsWith(prefix) && candidate !== source) continue
this.directories.delete(candidate)
this.directories.add(candidate === source ? destination : join(destination, candidate.slice(prefix.length)))
}
this.forgetIdentity(source)
this.forgetIdentity(destination)
}
/**
* Give existing bytes a second name.
*
* There are no inodes here, so the two names share the bytes present at link
* time and diverge on the next write through either name; session persistence
* links a finished file to a stable name, which this satisfies.
* @param existing - Source file path.
* @param next - Additional path; its parent must exist and it must be free.
*/
linkSync(existing: string, next: string): void {
const source = this.key(existing)
const target = this.key(next)
const node = this.files.get(source)
if (node === undefined) fail('ENOENT', 'link', source)
if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target)
if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target)
this.files.set(target, node)
}
/**
* Shorten a file.
* @param path - File path.
* @param length - Byte length to keep; defaults to zero.
*/
truncateSync(path: string, length = 0): void {
const target = this.key(path)
const node = this.files.get(target)
if (node === undefined) fail('ENOENT', 'truncate', target)
this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target) })
}
/**
* Remove a file.
* @param path - File path.
*/
unlinkSync(path: string): void {
const target = this.key(path)
if (!this.files.delete(target)) fail('ENOENT', 'unlink', target)
this.forgetIdentity(target)
}
/**
* Remove a file or directory.
* @param path - Path to remove.
* @param options - `recursive` removes subtrees, `force` ignores absence.
*/
rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void {
const target = this.key(path)
if (this.files.delete(target)) {
this.forgetIdentity(target)
return
}
if (this.directories.has(target)) {
if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target)
const prefix = `${target}${SEP}`
for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.files.delete(candidate)
for (const candidate of [...this.directories]) if (candidate.startsWith(prefix)) this.directories.delete(candidate)
this.directories.delete(target)
this.forgetIdentity(target)
return
}
if (options?.force !== true) fail('ENOENT', 'rm', target)
}
/**
* Create a uniquely named directory beside `prefix`, as `fs.mkdtempSync` does.
* @param prefix - Path prefix; the suffix is appended without a separator.
* @returns The created directory path.
*/
mkdtempSync(prefix: string): string {
this.temporaries += 1
const target = `${prefix}${Date.now().toString(36)}${this.temporaries.toString(36)}`
this.mkdirSync(target, { recursive: true })
return this.key(target)
}
/**
* Seed a file and its parent directories, for image loading and tests.
* @param path - File path.
* @param data - Text or bytes.
*/
seed(path: string, data: string | Uint8Array): void {
const target = this.key(path)
this.mkdirSync(dirname(target), { recursive: true })
this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target) })
}
/**
* Create a directory and its parents.
* @param path - Directory path.
*/
seedDirectory(path: string): void {
this.mkdirSync(this.key(path), { recursive: true })
}
/**
* Report what this filesystem holds, for the host's boot diagnostics.
* @returns File count, directory count, and total byte size.
*/
usage(): { files: number; directories: number; bytes: number } {
let bytes = 0
for (const node of this.files.values()) bytes += node.bytes.length
return { files: this.files.size, directories: this.directories.size, bytes }
}
}
/**
* Mount a tar image produced by the build-time collector.
*
* Entry names are relative to `root` (`node_modules/...`, `config/cordis.yml`);
* an absolute entry name is a collector defect and fails loud. File contents
* stay views into `image` — nothing is copied at mount time.
* @param image - The ustar archive, as `inflateImage` produces it from the fetched image.
* @param root - Virtual root the entries mount under.
* @param vfs - Filesystem to fill; a fresh one by default.
* @returns The filled filesystem.
*/
export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryVfs()): MemoryVfs {
vfs.seedDirectory(root)
for (const entry of parseTar(image)) {
const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
if (relativeName.startsWith(SEP)) {
throw new Error(`webworker vfs: image entry must be relative to ${root}, received "${entry.name}"`)
}
const target = join(root, relativeName)
if (entry.directory) {
vfs.seedDirectory(target)
continue
}
vfs.seed(target, entry.bytes)
}
return vfs
}
@@ -0,0 +1,23 @@
/**
* Virtual root of the worker host's in-memory filesystem. Kept
* in one module so the process shim, the path/os shims, and the VFS image
* collector cannot drift apart.
*/
/** Virtual filesystem root; `process.cwd()` and every absolute path start here. */
export const DSH_ROOT = '/dsh'
/** `$DSH_HOME`: durable-state directory inside the image. */
export const DSH_HOME = `${DSH_ROOT}/home`
/** Flat, symlink-free package tree resolved by the worker module loader. */
export const DSH_NODE_MODULES = `${DSH_ROOT}/node_modules`
/** Directory holding the composed cordis.yml and the agent-preset tree. */
export const DSH_CONFIG = `${DSH_ROOT}/config`
/** Default (empty) workspace directory. */
export const DSH_WORKSPACE = `${DSH_ROOT}/workspace`
/** Temporary directory reported by `os.tmpdir()`. */
export const DSH_TMP = `${DSH_ROOT}/tmp`
@@ -0,0 +1,136 @@
/**
* Uncompressed ustar archive: the VFS image format. One fetch delivers the
* whole tree, and the reader hands out subarray views into the fetched buffer,
* so mounting copies nothing and no inflate step runs inside the worker.
*
* Hand-rolled on purpose: both sides need synchronous in-memory operation and
* the reader ships inside the worker bundle, where the streaming tar packages
* would drag Node stream shims back in. The subset is plain ustar — regular
* files and directories, names up to 255 bytes via the name-prefix split — and
* anything outside it fails loud on either side.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/tar
*/
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const BLOCK = 512
/** One archive entry; a directory carries empty bytes and a trailing-slash name. */
export interface TarEntry {
readonly name: string
readonly bytes: Uint8Array
readonly directory: boolean
}
/** Write an octal field: zero-padded digits with a terminating NUL. */
function writeOctal(header: Uint8Array, offset: number, length: number, value: number): void {
header.set(encoder.encode(value.toString(8).padStart(length - 1, '0')), offset)
}
/**
* Split an entry name into the ustar name and prefix fields.
* @param name - Full entry name.
* @returns The two fields; the prefix is empty when the name fits directly.
* @throws When no slash yields name ≤ 100 and prefix ≤ 155 bytes: the entry
* cannot be archived and a silently truncated name would corrupt the image.
*/
function splitName(name: string): { name: string; prefix: string } {
if (encoder.encode(name).length <= 100) return { name, prefix: '' }
for (let index = name.length - 1; index > 0; index -= 1) {
if (name[index] !== '/') continue
const prefix = name.slice(0, index)
const rest = name.slice(index + 1)
if (encoder.encode(rest).length <= 100 && encoder.encode(prefix).length <= 155) {
return { name: rest, prefix }
}
}
throw new Error(`vfs tar: entry name does not fit the ustar name+prefix split: ${name}`)
}
/**
* Pack entries into one uncompressed ustar archive.
*
* Entries keep their given order; names ending in a slash become directory
* entries. Contents are written verbatim — compression belongs to the HTTP
* transport, not to the archive.
* @param files - Entry name to content bytes.
* @returns The archive bytes.
*/
export function packTar(files: Readonly<Record<string, Uint8Array>>): Uint8Array {
const chunks: Uint8Array[] = []
for (const [entryName, bytes] of Object.entries(files)) {
const directory = entryName.endsWith('/')
const size = directory ? 0 : bytes.length
const { name, prefix } = splitName(entryName)
const header = new Uint8Array(BLOCK)
header.set(encoder.encode(name), 0)
writeOctal(header, 100, 8, directory ? 0o755 : 0o644)
writeOctal(header, 108, 8, 0)
writeOctal(header, 116, 8, 0)
writeOctal(header, 124, 12, size)
writeOctal(header, 136, 12, 0)
header.fill(0x20, 148, 156)
header[156] = directory ? 0x35 : 0x30
header.set(encoder.encode('ustar'), 257)
header.set(encoder.encode('00'), 263)
header.set(encoder.encode(prefix), 345)
let checksum = 0
for (const byte of header) checksum += byte
header.set(encoder.encode(checksum.toString(8).padStart(6, '0')), 148)
header[154] = 0
header[155] = 0x20
chunks.push(header)
if (size > 0) {
chunks.push(bytes)
const padding = size % BLOCK
if (padding !== 0) chunks.push(new Uint8Array(BLOCK - padding))
}
}
chunks.push(new Uint8Array(BLOCK * 2))
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
const archive = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
archive.set(chunk, offset)
offset += chunk.length
}
return archive
}
/** @returns The NUL-terminated string in one header field. */
function readField(header: Uint8Array, offset: number, length: number): string {
let end = offset
while (end < offset + length && header[end] !== 0) end += 1
return decoder.decode(header.subarray(offset, end))
}
/**
* Parse an uncompressed ustar archive.
*
* File bytes are subarray views into `archive`, not copies; callers own the
* aliasing. Entry kinds outside the written subset (links, PAX extensions)
* fail loud instead of being skipped.
* @param archive - Archive bytes.
* @returns Entries in archive order.
*/
export function parseTar(archive: Uint8Array): TarEntry[] {
const entries: TarEntry[] = []
let offset = 0
while (offset + BLOCK <= archive.length) {
const header = archive.subarray(offset, offset + BLOCK)
if (header.every(byte => byte === 0)) break
const short = readField(header, 0, 100)
const prefix = readField(header, 345, 155)
const name = prefix === '' ? short : `${prefix}/${short}`
const size = Number.parseInt(readField(header, 124, 12).trim() || '0', 8)
const typeflag = header[156]
const directory = typeflag === 0x35 || name.endsWith('/')
if (typeflag !== 0x30 && typeflag !== 0 && typeflag !== 0x35) {
throw new Error(`vfs tar: unsupported entry type ${String.fromCharCode(typeflag ?? 0)} for "${name}"`)
}
const dataStart = offset + BLOCK
entries.push({ name, bytes: archive.subarray(dataStart, dataStart + size), directory })
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK
}
return entries
}
@@ -0,0 +1,115 @@
/**
* Filesystem interfaces shared by every VFS backend (in-memory today; a
* browser-persistent backend would implement the same faces). Errors carry
* Node's `code` values because roster plugins branch on them (`ENOENT` for
* optional files, `EACCES` for read-only trees).
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types
*/
/** Encodings the VFS accepts where Node accepts any `BufferEncoding`. */
export type VfsEncoding = 'utf8' | 'utf-8'
/** Read options accepted by both the sync and promise faces. */
export type VfsReadOptions = VfsEncoding | { encoding?: VfsEncoding | null } | null | undefined
/** Node-compatible error with a `code`, as roster plugins expect. */
export interface VfsError extends Error {
code: string
path: string
syscall: string
}
/** Subset of `fs.Stats` the roster reads. */
export interface VfsStats {
readonly size: number
readonly mtimeMs: number
readonly mtime: Date
readonly mode: number
isFile(): boolean
isDirectory(): boolean
isSymbolicLink(): boolean
isFIFO(): boolean
isSocket(): boolean
isBlockDevice(): boolean
isCharacterDevice(): boolean
}
/**
* Stats as Node returns them under `{ bigint: true }`.
*
* The filesystem service (`dsh-fs-local`) stats every target this way and then
* does BigInt arithmetic on `mode` and builds its version token from
* `dev:ino:size:mtimeNs:ctimeNs`, so these fields are load-bearing rather than
* decorative: a number-valued `mode` here fails the whole read as a type error,
* and a constant `ino`/`mtimeNs` would make the service's stale-write guard
* unable to tell two revisions apart.
*/
export interface VfsBigIntStats {
readonly size: bigint
readonly mode: bigint
/** One virtual device holds the whole image. */
readonly dev: bigint
/** Identity of the entry at this path; a removed and recreated path gets a new one. */
readonly ino: bigint
readonly nlink: bigint
readonly mtimeMs: bigint
readonly mtimeNs: bigint
readonly ctimeMs: bigint
readonly ctimeNs: bigint
readonly atimeMs: bigint
readonly atimeNs: bigint
readonly birthtimeMs: bigint
readonly birthtimeNs: bigint
readonly mtime: Date
readonly ctime: Date
readonly atime: Date
readonly birthtime: Date
isFile(): boolean
isDirectory(): boolean
isSymbolicLink(): boolean
isFIFO(): boolean
isSocket(): boolean
isBlockDevice(): boolean
isCharacterDevice(): boolean
}
/** Stat option Node reads; `bigint` selects {@link VfsBigIntStats}. */
export interface VfsStatOptions {
readonly bigint?: boolean
}
/** Write options the roster passes; `flag` decides create and truncate behavior. */
export interface VfsWriteOptions {
readonly encoding?: VfsEncoding | null
readonly mode?: number
readonly flag?: string
}
/** Directory entry as `readdir` with `withFileTypes` reports it. */
export interface VfsDirent {
readonly name: string
readonly parentPath: string
isFile(): boolean
isDirectory(): boolean
isSymbolicLink(): boolean
}
/** Directory handle returned by `opendir`; consumers only enumerate and close. */
export interface VfsDir {
readonly path: string
close(): Promise<void>
read(): Promise<{ name: string } | null>
[Symbol.asyncIterator](): AsyncGenerator<{ name: string; isFile(): boolean; isDirectory(): boolean }>
}
/** File handle returned by `open`; the roster writes, syncs, and closes. */
export interface VfsFileHandle {
write(data: string | Uint8Array): Promise<{ bytesWritten: number }>
writeFile(data: string | Uint8Array): Promise<void>
readFile(options?: VfsReadOptions): Promise<string | Uint8Array>
truncate(length?: number): Promise<void>
stat(): Promise<VfsStats>
sync(): Promise<void>
datasync(): Promise<void>
close(): Promise<void>
}
@@ -0,0 +1,123 @@
/**
* Tunnel frame protocol between the page and the worker host. Frames cross
* `postMessage`, so inbound frames are validated before use.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/transport/frames
*/
/** Request identifier minted by the page. */
export type TunnelRequestId = string | number
/** One request; `body` carries the raw bytes for methods that have one. */
export interface TunnelRequestFrame {
readonly t: 'req'
readonly id: TunnelRequestId
readonly method: string
readonly url: string
readonly headers: Readonly<Record<string, string>>
readonly body?: ArrayBuffer | undefined
}
/** Page-side cancellation of an in-flight request or stream. */
export interface TunnelAbortFrame {
readonly t: 'abort'
readonly id: TunnelRequestId
}
/** Frames the worker accepts. */
/**
* First inbound frame: the image URL, the one input the worker assembly
* takes from outside.
*/
export interface TunnelInitFrame {
readonly t: 'init'
readonly image: string
}
/** Every frame the page sends the worker. */
export type TunnelInboundFrame = TunnelInitFrame | TunnelRequestFrame | TunnelAbortFrame
/** Complete response for unary requests and static files. */
export interface TunnelResponseFrame {
readonly t: 'res'
readonly id: TunnelRequestId
readonly status: number
readonly headers: Record<string, string>
readonly body?: ArrayBuffer | undefined
/** Present when the worker itself refused the request, so the page can surface the reason. */
readonly message?: string | undefined
}
/** Head of a streamed response, followed by chunks and one terminator. */
export interface TunnelResponseHeadFrame {
readonly t: 'res-head'
readonly id: TunnelRequestId
readonly status: number
readonly headers: Record<string, string>
}
/** One body chunk of a streamed response. */
export interface TunnelResponseChunkFrame {
readonly t: 'res-chunk'
readonly id: TunnelRequestId
readonly chunk: ArrayBuffer
}
/** Normal end of a streamed response. */
export interface TunnelResponseEndFrame {
readonly t: 'res-end'
readonly id: TunnelRequestId
}
/** Failure of a streamed response after its head was sent. */
export interface TunnelResponseErrorFrame {
readonly t: 'res-err'
readonly id: TunnelRequestId
readonly message: string
}
/** Frames the worker emits. */
export type TunnelOutboundFrame =
| TunnelResponseFrame
| TunnelResponseHeadFrame
| TunnelResponseChunkFrame
| TunnelResponseEndFrame
| TunnelResponseErrorFrame
/**
* Validate a `postMessage` payload as a tunnel frame.
* @param data - Message data received by the worker.
* @returns The frame.
*/
export function parseInboundFrame(data: unknown): TunnelInboundFrame {
if (typeof data !== 'object' || data === null) {
throw new Error(`webworker tunnel: message is not a frame: ${String(data)}`)
}
const frame = data as Record<string, unknown>
if (frame.t === 'init') {
if (typeof frame.image !== 'string') {
throw new Error('webworker tunnel: init frame needs a string image url')
}
return { t: 'init', image: frame.image }
}
const id = frame.id
if (typeof id !== 'string' && typeof id !== 'number') {
throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`)
}
if (frame.t === 'abort') return { t: 'abort', id }
if (frame.t !== 'req') throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`)
if (typeof frame.method !== 'string' || typeof frame.url !== 'string') {
throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`)
}
if (typeof frame.headers !== 'object' || frame.headers === null) {
throw new Error(`webworker tunnel: request ${String(id)} needs a headers object`)
}
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(frame.headers)) {
if (typeof value === 'string') headers[key.toLowerCase()] = value
}
const body = frame.body
if (body !== undefined && !(body instanceof ArrayBuffer)) {
throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer`)
}
return { t: 'req', id, method: frame.method, url: frame.url, headers, body }
}
@@ -0,0 +1,139 @@
/**
* `IncomingMessage`/`ServerResponse` synthesis for tunnel requests. The app's
* `node:http` proxy reports a successful bind and captures the webserver's
* request listener; the tunnel feeds that listener these pairs, so the real
* route table, its trust fences, and every handler run unchanged.
*
* Synthesized members are exactly the ones the route handlers read (research
* transport.md §5.1/§5.2); anything else is absent on purpose so a new consumer
* fails loud instead of silently reading a stub.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/transport/synthetic-http
*/
import type { TunnelRequestFrame } from './frames.ts'
const encoder = new TextEncoder()
/** Where a synthesized response writes to. */
export interface ResponseSink {
/** Head of a streaming response. */
head(status: number, headers: Record<string, string>): void
/** One body chunk after {@link ResponseSink.head}. */
chunk(bytes: Uint8Array): void
/** Completion; the payload is present only for unary answers. */
end(payload?: { status: number; headers: Record<string, string>; body?: Uint8Array | undefined }): void
/** Failure of the exchange. */
fail(message: string): void
}
/** Request listener shape the app's `createServer` captured. */
export type RequestListener = (req: unknown, res: unknown) => void
/** The pair a route handler consumes, plus abort control for the tunnel. */
export interface SyntheticExchange {
readonly req: unknown
readonly res: unknown
/** Whether the page abandoned the request before it finished. */
readonly aborted: boolean
/** Mark the page as gone: emits `close` and stops further frames. */
abort(): void
}
/**
* Build the request/response pair for one tunnel request.
*
* `res.end()` is the settle point: the captured listener returns void, so the
* response object itself reports completion. `write()` always returns true,
* which skips backpressure waiting the tunnel cannot observe anyway.
* @param frame - Validated request frame.
* @param sink - Frame emitter for the response.
* @returns The pair handed to the captured request listener.
*/
export function createSyntheticExchange(frame: TunnelRequestFrame, sink: ResponseSink): SyntheticExchange {
const listeners = new Map<string, Set<() => void>>()
let status = 200
let headers: Record<string, string> = {}
let streaming = false
let finished = false
let aborted = false
const emit = (event: string): void => {
for (const callback of [...(listeners.get(event) ?? [])]) callback()
}
const req = {
url: frame.url,
method: frame.method,
headers: frame.headers,
destroy: (): void => { aborted = true },
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
if (frame.body === undefined || frame.body.byteLength === 0) return
yield new Uint8Array(frame.body)
},
}
const res: Record<string, unknown> = {
writeHead: (nextStatus: number, nextHeaders?: Record<string, string | number>): unknown => {
status = nextStatus
if (nextHeaders !== undefined) {
headers = {}
for (const [key, value] of Object.entries(nextHeaders)) headers[key.toLowerCase()] = String(value)
}
return res
},
write: (chunk: string | Uint8Array): boolean => {
if (finished || aborted) return false
if (!streaming) {
streaming = true
sink.head(status, headers)
}
sink.chunk(typeof chunk === 'string' ? encoder.encode(chunk) : chunk)
return true
},
end: (body?: string | Uint8Array): unknown => {
if (finished) return res
finished = true
const bytes = body === undefined ? undefined : typeof body === 'string' ? encoder.encode(body) : body
if (streaming) {
if (bytes !== undefined) sink.chunk(bytes)
sink.end()
} else {
sink.end({ status, headers, body: bytes })
}
emit('close')
return res
},
destroy: (): void => {
if (finished) return
finished = true
sink.fail(`response destroyed for ${frame.method} ${frame.url}`)
emit('close')
},
on: (event: string, callback: () => void): unknown => {
const set = listeners.get(event) ?? new Set<() => void>()
set.add(callback)
listeners.set(event, set)
return res
},
off: (event: string, callback: () => void): unknown => {
listeners.get(event)?.delete(callback)
return res
},
}
res.once = res.on
Object.defineProperty(res, 'headersSent', { get: () => streaming })
Object.defineProperty(res, 'writableEnded', { get: () => finished })
return {
req,
res,
get aborted(): boolean {
return aborted
},
abort: (): void => {
if (finished) return
aborted = true
finished = true
emit('close')
},
}
}
@@ -0,0 +1,437 @@
/**
* Worker end of the postMessage tunnel. It owns the dispatch lanes and the queue
* that holds requests until the host tree is serving:
*
* - `GET /__boot__` answers from tunnel glue, never from the host API surface,
* because the page needs the boot payload before its Cordis tree exists.
* - The two event-stream paths go straight to the API fetch handler so they take
* its SSE branch; the `/api` route answers 426 upgrade-required first.
* - Privileged `/api` methods take that same direct entry: the browser strips the
* `host` header from the WHATWG `Request` the route lane rebuilds, so the
* privileged fence would answer 403 for every one of them. The method set is
* not restated here — an unexpected 403 from the route lane is retried on the
* direct lane, which keeps the split honest without a copied list.
* - Everything else is fed into the real webserver route table through the
* request listener the app's fake `node:http` captured, keeping the trust
* fences, byte limits, and status semantics intact.
*
* A boot failure rejects the whole queue with 503 rather than leaving the page
* waiting.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/transport/tunnel
*/
import {
parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId,
} from './frames.ts'
import {
createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange,
} from './synthetic-http.ts'
/** Event-stream routes that must reach the SSE branch of the API fetch handler. */
export const STREAM_PATHS = ['/api/events.mux', '/api/events.host'] as const
/** Prefix owning the API methods. */
export const API_PREFIX = '/api'
/** Host header the synthesized requests carry; the API trust fence requires one. */
export const SYNTHETIC_HOST = '127.0.0.1'
const encoder = new TextEncoder()
/**
* Render a failure with everything nested inside it.
*
* A boot failure is usually an `AggregateError` of per-entry failures, each
* wrapping the plugin's own error as `cause`; only the outermost message names
* "loader entries failed to apply", which says nothing about which row broke.
*
* The page logs the rendered text verbatim for refusals; keep it stable for
* anyone matching boot-failure output.
* @param reason - Thrown value.
* @returns One line per nested failure, indented by depth.
*/
export function describeFailure(reason: unknown): string {
const seen = new Set<unknown>()
const lines: string[] = []
const walk = (value: unknown, depth: number): void => {
if (value === null || value === undefined || seen.has(value) || depth > 6) return
seen.add(value)
const indent = ' '.repeat(depth)
if (!(value instanceof Error)) {
// A thrower may pass anything as a cause; JSON keeps an object readable
// where the default stringification would print `[object Object]`.
const rendered = typeof value === 'string' ? value : JSON.stringify(value) as string | undefined
lines.push(`${indent}${rendered ?? typeof value}`)
return
}
lines.push(`${indent}${value.name}: ${value.message}`)
if (value instanceof AggregateError) for (const inner of value.errors) walk(inner, depth + 1)
walk(value.cause, depth + 1)
}
walk(reason, 0)
return lines.join('\n')
}
/**
* Copy bytes into an exact-size ArrayBuffer so it can be transferred.
*
* Sliced on the ArrayBuffer, not the view: `Uint8Array.prototype.slice` copies,
* but a Node-style Buffer overrides `slice()` with view semantics, and the fs
* bridge hands VFS reads over as Buffer views into the whole mounted image —
* `bytes.slice().buffer` would then post the entire image as the body.
*/
function toTransferable(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/** Message channel the tunnel posts frames on. */
export interface TunnelPort {
postMessage(message: TunnelOutboundFrame, transfer?: Transferable[]): void
}
/** What the tunnel gains once the host tree is up. */
export interface TunnelSeams {
/**
* Direct entry to the API fetch handler: event streams, privileged methods,
* and any unary call the route lane refused with 403.
*/
readonly directFetch: (request: Request) => Promise<Response>
/** Boot payload for `GET /__boot__`: the structured index injection table. */
readonly bootPayload: () => unknown
}
/** Construction inputs for {@link TunnelServer}. */
export interface TunnelServerOptions {
/** Channel back to the page. */
readonly port: TunnelPort
/**
* The webserver's request listener, captured by the app's fake `node:http`.
* Awaited on first use, so requests may arrive before the server binds.
*/
readonly requestListener: () => Promise<RequestListener>
/**
* Methods that skip the route lane outright. Supply the host's own privileged
* set when it is reachable; omitting it leaves the 403 retry as the mechanism.
*/
readonly privilegedMethods?: ReadonlySet<string>
/**
* Escape hatch for the unary `/api` lane. `route` (default) keeps every fence
* and byte limit with a 403 retry on the direct lane; `direct` sends every
* unary `/api` call straight to the fetch handler.
*/
readonly unaryApiLane?: 'route' | 'direct'
}
interface InFlight {
abort(): void
}
/** Recorded response frames, so a 403 from the route lane can be discarded. */
class BufferedSink {
private readonly calls: Array<() => void> = []
private target: ResponseSink | undefined
private settle: ((outcome: { streamed: boolean; status: number }) => void) | undefined
/** Resolves when the exchange either starts streaming or answers in one frame. */
readonly settled = new Promise<{ streamed: boolean; status: number }>((resolve) => { this.settle = resolve })
readonly sink: ResponseSink = {
head: (status, headers) => {
this.record(() => { this.target?.head(status, headers) })
this.settle?.({ streamed: true, status })
},
chunk: (bytes) => { this.record(() => { this.target?.chunk(bytes) }) },
end: (payload) => {
this.record(() => { this.target?.end(payload) })
this.settle?.({ streamed: payload === undefined, status: payload?.status ?? 200 })
},
fail: (message) => {
this.record(() => { this.target?.fail(message) })
this.settle?.({ streamed: true, status: 500 })
},
}
private record(call: () => void): void {
if (this.target === undefined) this.calls.push(call)
else call()
}
/**
* Send everything recorded so far to a real sink and pass later calls through.
* @param target - Sink receiving the frames.
*/
flushTo(target: ResponseSink): void {
this.target = target
for (const call of this.calls.splice(0)) call()
}
}
/** One tunnel per worker; wire {@link TunnelServer.handleMessage} to `onmessage` first. */
export class TunnelServer {
private readonly port: TunnelPort
private readonly requestListener: () => Promise<RequestListener>
private readonly privilegedMethods: ReadonlySet<string> | undefined
private readonly unaryApiLane: 'route' | 'direct'
private readonly queue: TunnelRequestFrame[] = []
private readonly inFlight = new Map<TunnelRequestId, InFlight>()
private seams: TunnelSeams | undefined
private failure: string | undefined
private listener: RequestListener | undefined
constructor(options: TunnelServerOptions) {
this.port = options.port
this.requestListener = options.requestListener
this.privilegedMethods = options.privilegedMethods
this.unaryApiLane = options.unaryApiLane ?? 'route'
}
/**
* Accept one `postMessage` payload.
* @param data - Message data from the page.
*/
handleMessage(data: unknown): void {
const frame = parseInboundFrame(data)
if (frame.t === 'init') {
// The worker entry consumes the opening init before this server exists;
// one reaching a live server is a client double-connect.
throw new Error('webworker tunnel: duplicate init frame; the tunnel is already open')
}
if (frame.t === 'abort') {
this.inFlight.get(frame.id)?.abort()
this.inFlight.delete(frame.id)
// A request still parked in the boot queue must not run after its
// caller gave up; serve() would otherwise execute it post-boot.
const queued = this.queue.findIndex(request => request.id === frame.id)
if (queued !== -1) this.queue.splice(queued, 1)
return
}
if (this.failure !== undefined) { this.refuse(frame, this.failure); return }
if (this.seams === undefined) {
this.queue.push(frame)
return
}
void this.serveRequest(frame)
}
/**
* Start serving: drains everything queued during boot.
* @param seams - Faces that exist only after the host tree is up.
*/
serve(seams: TunnelSeams): void {
this.seams = seams
console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`)
for (const frame of this.queue.splice(0)) void this.serveRequest(frame)
}
/**
* Refuse every queued and future request; the page renders this like a server
* that failed to start.
* @param reason - Boot failure to report.
*/
fail(reason: unknown): void {
const message = describeFailure(reason)
this.failure = message
for (const frame of this.queue.splice(0)) this.refuse(frame, message)
}
private send(frame: TunnelOutboundFrame, transfer?: Transferable[]): void {
this.port.postMessage(frame, transfer)
}
private refuse(frame: TunnelRequestFrame, message: string): void {
const body = toTransferable(encoder.encode(message))
this.send({
t: 'res',
id: frame.id,
status: 503,
headers: { 'content-type': 'text/plain; charset=utf-8' },
body,
message,
}, [body])
}
private sinkFor(id: TunnelRequestId): ResponseSink {
const send = this.send.bind(this)
const inFlight = this.inFlight
return {
head(status, headers) {
send({ t: 'res-head', id, status, headers })
},
chunk(bytes) {
const buffer = toTransferable(bytes)
send({ t: 'res-chunk', id, chunk: buffer }, [buffer])
},
end(payload) {
if (payload === undefined) {
send({ t: 'res-end', id })
} else {
const body = payload.body === undefined ? undefined : toTransferable(payload.body)
send({ t: 'res', id, status: payload.status, headers: payload.headers, body }, body === undefined ? undefined : [body])
}
inFlight.delete(id)
},
fail(message) {
send({ t: 'res-err', id, message })
inFlight.delete(id)
},
}
}
/** The page sends an absolute URL; route handlers read `req.url` as a path. */
private pathFrame(frame: TunnelRequestFrame): { frame: TunnelRequestFrame; path: string } {
const url = new URL(frame.url, `http://${SYNTHETIC_HOST}`)
return {
// The API trust fence reads `host`, which the page cannot set itself.
frame: { ...frame, url: `${url.pathname}${url.search}`, headers: { ...frame.headers, host: SYNTHETIC_HOST } },
path: url.pathname,
}
}
private async serveRequest(frame: TunnelRequestFrame): Promise<void> {
const sink = this.sinkFor(frame.id)
try {
const { frame: routed, path } = this.pathFrame(frame)
if (path === '/__boot__') { this.serveBoot(frame, sink); return }
if ((STREAM_PATHS as readonly string[]).includes(path)) { await this.serveDirect(frame, sink); return }
if (path.startsWith(`${API_PREFIX}/`)) { await this.serveApi(frame, routed, path, sink); return }
this.dispatch(routed, sink)
} catch (reason) {
sink.fail(reason instanceof Error ? reason.message : String(reason))
}
}
/**
* The listener is captured once and reused, so only requests that arrive
* before the web server binds pay an await.
* @returns The webserver request listener.
*/
private async whenListener(): Promise<RequestListener> {
this.listener ??= await this.requestListener()
return this.listener
}
/** Feed the real route table through the captured listener. */
private dispatch(frame: TunnelRequestFrame, sink: ResponseSink, into?: ResponseSink): SyntheticExchange {
const exchange = createSyntheticExchange(frame, into ?? sink)
this.inFlight.set(frame.id, exchange)
const listener = this.listener
if (listener !== undefined) {
listener(exchange.req, exchange.res)
return exchange
}
void this.whenListener().then((resolved) => {
// A page that gave up while the server was still binding has nothing to answer.
if (!exchange.aborted) resolved(exchange.req, exchange.res)
}, (reason: unknown) => {
sink.fail(reason instanceof Error ? reason.message : String(reason))
})
return exchange
}
/**
* Unary `/api`: keep the route lane's fences, but fall back to the direct lane
* when the privileged fence refuses a request the page is entitled to make.
*/
private async serveApi(
original: TunnelRequestFrame,
routed: TunnelRequestFrame,
path: string,
sink: ResponseSink,
): Promise<void> {
const method = path.slice(API_PREFIX.length + 1)
if (this.unaryApiLane === 'direct' || this.privilegedMethods?.has(method) === true) {
await this.serveDirect(original, sink)
return
}
const buffered = new BufferedSink()
const exchange = this.dispatch(routed, sink, buffered.sink)
// An abort must release this wait too: an aborted exchange stops emitting
// frames, so `settled` alone would never resolve when the handler had not
// yet written a head.
let settleAborted = (): void => {}
const aborted = new Promise<'aborted'>((resolve) => { settleAborted = () => { resolve('aborted') } })
this.inFlight.set(routed.id, { abort: () => { exchange.abort(); settleAborted() } })
const outcome = await Promise.race([buffered.settled, aborted])
if (outcome === 'aborted' || exchange.aborted) return
// The decision happens at the first frame, before anything reaches the page:
// the route lane streams its answers, so a refusal can carry a body too.
if (outcome.status === 403) {
// The privileged fence read a Request the browser stripped `host` from.
console.debug(`webworker tunnel: route lane refused ${method} with 403; answering on the direct lane`)
await this.serveDirect(original, sink)
return
}
buffered.flushTo(sink)
}
private serveBoot(frame: TunnelRequestFrame, sink: ResponseSink): void {
if (this.seams === undefined) throw new Error('webworker tunnel: boot payload requested before the host tree is serving')
if (frame.method !== 'GET') {
sink.end({ status: 405, headers: { allow: 'GET' } })
return
}
const body = encoder.encode(JSON.stringify(this.seams.bootPayload()))
sink.end({
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
body,
})
}
private async serveDirect(frame: TunnelRequestFrame, sink: ResponseSink): Promise<void> {
if (this.seams === undefined) throw new Error('webworker tunnel: direct fetch requested before the host tree is serving')
const controller = new AbortController()
this.inFlight.set(frame.id, { abort: () => { controller.abort() } })
const headers = new Headers()
for (const [key, value] of Object.entries(frame.headers)) {
// Forbidden header names throw on a guarded Headers instance.
try {
headers.set(key, value)
} catch {
// The fetch handler reads no forbidden header; the route lane owns those.
}
}
const request = new Request(new URL(frame.url, `http://${SYNTHETIC_HOST}`), {
method: frame.method,
headers,
body: frame.body === undefined || frame.method === 'GET' || frame.method === 'HEAD' ? null : frame.body,
signal: controller.signal,
})
const response = await this.seams.directFetch(request)
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => { responseHeaders[key] = value })
// Event streams are the only responses the page consumes incrementally;
// everything else answers as one frame, as the route lane does.
const streamed = response.body !== null && (responseHeaders['content-type'] ?? '').startsWith('text/event-stream')
if (!streamed) {
const buffer = await response.arrayBuffer()
sink.end({
status: response.status,
headers: responseHeaders,
body: buffer.byteLength === 0 ? undefined : new Uint8Array(buffer),
})
return
}
sink.head(response.status, responseHeaders)
const reader = response.body.getReader()
this.inFlight.set(frame.id, {
abort: () => {
controller.abort()
void reader.cancel().catch(() => {
// Cancelling an already-errored stream has nothing left to release.
})
},
})
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
sink.chunk(value)
}
sink.end()
} catch (reason) {
sink.fail(reason instanceof Error ? reason.message : String(reason))
} finally {
reader.releaseLock()
}
}
}
@@ -0,0 +1,467 @@
/**
* 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 { 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, 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
/** 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 = await readImage(options.image)
const mounted = loadVfsImage(bytes, root)
// 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 apiProxy = ctx.get('apiProxy')
if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as {
toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
}
const shared = ctx.get('connection') !== undefined
const handler = directFetchHandler(ctx, toFetchHandler(apiProxy))
const usage = loader.usage()
console.info(`webworker host: tree active (modules=${String(usage.modules)}, 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})`)
tunnel.serve({
directFetch: (request: Request) => handler.fetch(request),
bootPayload: () => readBootPayload(ctx),
})
} 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`)
}
}
/**
* Build the tunnel's direct API entry.
*
* The core API surface alone is not the whole `/api` channel: Typert RPC
* endpoints (`/api/<service>/<method>`) are served by an interceptor the gateway
* registers on the Connection service, and answer 404 from the core routes. The
* Connection service composes both halves in `createSharedFetchHandler`, whose
* fallback — not the composition — carries the privileged fence, so composing it
* here keeps every interceptor while leaving out the fence the direct lane exists
* to bypass.
* @param ctx - Booted host context.
* @param core - Fetch handler over the API surface.
* @returns Handler covering interceptors and the core surface.
*/
function directFetchHandler(
ctx: HostContext,
core: { fetch(request: Request): Promise<Response> },
): { fetch(request: Request): Promise<Response> } {
const connection = ctx.get('connection') as {
createSharedFetchHandler(
channel: '/api',
fallback: { fetch(request: Request): Promise<Response> },
): { fetch(request: Request): Promise<Response> }
} | undefined
return connection?.createSharedFetchHandler('/api', core) ?? core
}
/**
* 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()
}
@@ -0,0 +1,64 @@
/**
* Dedicated Web Worker entry. The Node-compatibility layer this app owns is
* handed to the host assembly as the module table plus the captured request
* listener; the assembly owns everything else (process global, VFS image,
* Cordis tree, tunnel server).
*
* The assembly needs the image location before it can exist, and it arrives in
* the tunnel's opening `init` frame — this bundle reads nothing from its own
* URL, so the deployment decides where both the bundle and the image live.
* Messages before `init` queue here; requests during boot queue inside the
* host, which attaches its handler before its first await.
*/
// Straight to the assembly, not through the package barrel: the barrel also
// publishes the pack-time transform, whose acorn dependency would then be bundled
// into this worker — which never parses JavaScript.
import { createWorkerHost } from './worker-host.ts'
import './node/builtin_modules/implemented/buffer.ts'
import { alsCausality, runAtAsyncContextRoot } from './node/builtin_modules/implemented/async_hooks.ts'
import { installAsyncContextHooks } from './polyfill/async-context/async-context-hooks.ts'
import { createNodeBuiltins, REPLACED_PREFIXES } from './node/builtins.ts'
import { whenRequestListener } from './node/builtin_modules/implemented/http.ts'
import { installTimerGlobals } from './node/globals/timers.ts'
// Before the timer globals, so the wrappers close over the patched platform.
installAsyncContextHooks()
installTimerGlobals()
let host: { handleMessage(data: unknown): void } | undefined
const pending: unknown[] = []
self.addEventListener('message', (event: MessageEvent) => {
const data = event.data as Record<string, unknown> | null
if (host === undefined && data !== null && typeof data === 'object' && data.t === 'init') {
if (typeof data.image !== 'string') {
throw new Error('webworker: init frame needs a string image url')
}
const created = createWorkerHost({
staticModules: createNodeBuiltins(),
staticModulePrefixes: REPLACED_PREFIXES,
requestListener: whenRequestListener,
alsCausality,
image: data.image,
})
host = created
for (const queued of pending) {
runAtAsyncContextRoot(() => { created.handleMessage(queued) })
}
pending.length = 0
created.start().catch(() => {
// start() already reported the failure to the page through tunnel.fail;
// nothing else can reach this rejection, so only the duplicate
// unhandled-rejection noise is dropped here.
})
return
}
if (host === undefined) {
pending.push(event.data)
return
}
const ready = host
// A tunnel request belongs to no boundary: dispatch it at the context root so
// it cannot inherit whatever ran just before it on this thread.
runAtAsyncContextRoot(() => { ready.handleMessage(event.data) })
})
@@ -0,0 +1,437 @@
/**
* Full-corpus regression for the worker module transform: every built bundle in
* the workspace is transformed, executed through the real wrapper contract, and
* its export shape compared against what Node's own ESM loader produces for the
* same file.
*
* This is the harness that answers "does the transform hold on real output",
* which no hand-written case can: the corpus is whatever the build currently
* emits, so a rolldown upgrade that starts emitting an unseen module form shows
* up here first.
*
* Consolidated from `.artifacts/w0-lexer-probe.ts` (part 2). Two deliberate
* changes for the terminal form:
*
* 1. **No `es-module-lexer`.** The lexer was retired as a runtime dependency
* when the single acorn pass replaced the two-pass pipeline, so the
* statistics it used to contribute are counted from the acorn AST instead.
* The probe's part 1 (lexer field semantics over 20 sample forms) is dropped
* entirely: it documented the behaviour of a component that no longer runs.
* The forms themselves are covered as emitted-code assertions in
* `transform-check.ts`.
* 2. **The baseline exemptions are a pinned list, not a count.** Four files
* cannot be imported by Node in this repository for reasons unrelated to the
* transform; the probe merely counted them, so a fifth would have gone
* unnoticed. Here they are named, and an unexpected member fails the run.
*
* Not consolidated: `.artifacts/v3-oracle.ts`, the byte-for-byte comparison
* against the retired lexer pipeline. It was a **retirement gate** and it has
* been through (`files=228 residualDifferences=0 lineDrift=0`). Keeping it as a
* standing check would mean keeping two abandoned implementations alive
* (`.artifacts/oracle-esm-to-cjs.ts`, `.artifacts/oracle-rewrite-await.ts`)
* forever to compare against. The one real defect it caught that no other signal
* could — `new.target` is also a `MetaProperty` — is preserved as a direct
* assertion (`transform-check.ts`, trap 8), which is where that knowledge
* belongs now.
*
* Cost: this walks the whole build output and imports every bundle, so it takes
* tens of seconds and needs `pnpm run build:lib:host` to have run. It is a
* heavyweight suite, not part of a default aggregator run.
*
* Run: tsx tests/compile/transform-corpus-check.ts [files...]
* With no arguments it discovers the corpus itself.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { parse } from 'acorn'
import { createAlsRuntime } from '../../src/polyfill/async-context/als-runtime.ts'
import { lowerModuleSource } from '../../src/compile/transform.ts'
import { WRAPPER_PARAMS } from '../../src/image-layout.ts'
const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
/**
* Files Node's ESM loader cannot import in this repository, so no baseline
* export shape exists to compare against. None is a transform failure: each is
* checked to still TRANSFORM cleanly, only the comparison is skipped.
*
* Named rather than counted: an unlisted baseline failure is a real finding
* (a bundle that stopped being importable), and it must not hide inside a total.
* A listed file that becomes importable also fails, so the list cannot rot.
*/
const BASELINE_EXEMPT: ReadonlyMap<string, string> = new Map([
['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'],
['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'],
['packages/sandbox/sandbox-windows-acl/lib/index.js', 'koffi type-name collision on a second load'],
['packages/test-support/client-runtime/lib/index.js', "needs vitest's internal state"],
])
/**
* Bundles whose own SOURCE contains the double-lowering sentinels, so the
* transform's guard refuses them by design.
*
* This package is the only such case and the refusal is correct: its bundle
* carries `transform.ts`'s own template literals (`` `__als$${n}` `` from
* `alsTemp`, and the `${ALS}.pause(` fragments), which is exactly the text the
* guard looks for. A self-referential false positive is the right trade: the
* guard exists because a mis-wired image manifest would otherwise show up only
* as "slower", and no roster row transforms this package.
*
* Listed rather than skipped silently, and asserted to keep refusing: if the
* guard stopped tripping here, either the guard or this bundle's contents
* changed, and both are worth knowing about.
*/
const DOUBLE_LOWERING_SENTINEL: ReadonlySet<string> = new Set([
'packages/experimental/webworker-runtime/lib/index.js',
])
let failures = 0
const report: string[] = []
const log = (line: string): void => {
report.push(line)
process.stdout.write(`${line}\n`)
}
const fail = (line: string): void => {
failures += 1
log(line)
}
/** @returns Built bundles under a two-level package directory, in stable order. */
function discover(): string[] {
const found: string[] = []
/** @returns Sorted subdirectory names, or none when the path is not a readable directory. */
const subdirectories = (path: string): string[] => {
try {
return readdirSync(path, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort()
} catch {
return []
}
}
for (const group of ['packages', 'vendor']) {
const groupDirectory = join(repositoryRoot, group)
for (const entry of subdirectories(groupDirectory)) {
// `packages/<group>/<package>/lib/index.js`, `vendor/<package>/lib/index.js`.
const candidates = group === 'vendor'
? [join(groupDirectory, entry, 'lib', 'index.js')]
: subdirectories(join(groupDirectory, entry))
.map(child => join(groupDirectory, entry, child, 'lib', 'index.js'))
for (const candidate of candidates) {
try {
if (statSync(candidate).isFile()) found.push(candidate)
} catch {
// No bundle for this package: it may not build a runtime artifact.
}
}
}
}
return found
}
/**
* @returns Path relative to the repository root, for stable diagnostics.
* Always POSIX-separated: the exemption table and the recorded findings key
* on one form, and a win32 walk would otherwise miss every entry.
*/
const relative = (path: string): string => path.slice(repositoryRoot.length).replaceAll('\\', '/')
/**
* Present a Node ESM namespace the way the worker loader hands one over, so a
* real dependency and a transformed one look the same to the module body.
* @param value - A module namespace, or whatever `require` returned.
* @returns The value, or an `__esModule`-marked projection of a Module namespace.
*/
function asLoaderExports(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value
if ((value as { [Symbol.toStringTag]?: string })[Symbol.toStringTag] !== 'Module') return value
const out: Record<string, unknown> = {}
Object.defineProperty(out, '__esModule', { value: true })
for (const key of Object.keys(value)) {
Object.defineProperty(out, key, { enumerable: true, get: () => (value as Record<string, unknown>)[key] })
}
return out
}
/**
* Specifiers a transformed body will request, read straight out of the emitted
* code. The transform emits every static import as `require(<string literal>)`
* (`transform.ts` builds them with `JSON.stringify`), so a literal scan finds
* exactly the set that must be resolvable before the body runs. A dynamic
* `import(expr)` is not found and does not need to be: it resolves lazily,
* after the body has already produced its exports.
* @param code - Emitted CommonJS body.
* @returns The requested specifiers, deduplicated.
*/
function requestedSpecifiers(code: string): string[] {
const found = new Set<string>()
for (const match of code.matchAll(/require\("((?:[^"\\]|\\.)*)"\)/g)) {
const raw = match[1]
if (raw !== undefined) found.add(JSON.parse(`"${raw}"`) as string)
}
return [...found]
}
/**
* Load a dependency through the same loader that produces this check's baseline.
*
* This matters more than it looks. The baseline every file is compared against is
* `await import(file)` — Node's ESM loader. A dependency fetched with
* `createRequire` instead goes through the CommonJS resolver, which selects the
* `require` condition of a package's `exports` map: for a dual-build package that
* is a DIFFERENT ARTIFACT with a different interop shape. `@deepseek-ai/schemastery`
* is the case that exposed it — `require` yields `lib/index.cjs`, whose
* `module.exports` is the `Schema` function with no `default` and no `__esModule`,
* while `import` yields `lib/index.mjs`, a namespace with `default`. A body
* written against the second shape misbehaves when handed the first.
*
* That divergence also made the whole check runner-dependent: under the `tsx` CLI
* `require` was patched to return the ESM view and all 228 passed, while under
* `node --import tsx/esm` three files failed. A gate whose verdict depends on how
* it was launched is not a gate, so dependencies now come from `import()` and the
* CommonJS path is only a fallback.
* @param specifier - Module specifier as the transformed body requests it.
* @param path - Absolute path of the importing bundle.
* @returns The dependency in loader-facing form, or undefined when neither loader can supply it.
*/
async function loadDependency(specifier: string, path: string): Promise<unknown> {
const real = createRequire(pathToFileURL(path))
try {
// Resolve through the importer so relative and bare specifiers both work, then
// import the resolved file: resolution is CommonJS's, delivery is ESM's.
const resolved = specifier.startsWith('node:') ? specifier : pathToFileURL(real.resolve(specifier)).href
return asLoaderExports(await import(resolved))
} catch {
// Not importable as ESM (a genuine CommonJS-only dependency, or unresolvable).
try {
return asLoaderExports(real(specifier))
} catch {
return undefined
}
}
}
/** A stand-in for a dependency Node cannot load here: every access answers something callable. */
function fakeModule(): unknown {
const target: Record<string, unknown> = {}
return new Proxy(target, {
get: (holder, key) => {
if (key === '__esModule') return true
if (key === 'default') return function fakeDefault() {}
if (typeof key === 'symbol') return undefined
if (!(key in holder)) holder[key] = function fakeNamed() {}
return holder[key]
},
has: () => true,
})
}
const als = createAlsRuntime()
/**
* Execute a transformed body under the real wrapper contract.
*
* Dependencies are loaded BEFORE the body runs, because the body's `require` is
* synchronous while faithful delivery ({@link loadDependency}) is not. A
* dependency neither loader can supply falls back to a permissive stand-in: the
* subject under test is this file's own export shape, not its dependencies'.
* @param code - Emitted CommonJS body.
* @param path - Absolute path of the bundle, used for resolution and diagnostics.
* @returns The populated `exports` object.
*/
async function runTransformed(code: string, path: string): Promise<Record<string, unknown>> {
const exports: Record<string, unknown> = {}
const module = { exports }
const loaded = new Map<string, unknown>()
await Promise.all(requestedSpecifiers(code).map(async (specifier) => {
const delivered = await loadDependency(specifier, path)
if (delivered !== undefined) loaded.set(specifier, delivered)
}))
const fakes = new Map<string, unknown>()
const require = (specifier: string): unknown => {
const delivered = loaded.get(specifier)
if (delivered !== undefined) return delivered
if (!fakes.has(specifier)) fakes.set(specifier, fakeModule())
return fakes.get(specifier)
}
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body
const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void
const metaRequire = createRequire(pathToFileURL(path))
factory(exports, require, module, path, path.replace(/\/[^/]*$/, ''), {
url: pathToFileURL(path).href,
// Path-anchored like the worker loader; an import-only export face falls
// back to this check file's own resolver.
resolve: (specifier: string) => {
try {
return pathToFileURL(metaRequire.resolve(specifier)).href
} catch {
return import.meta.resolve(specifier)
}
},
}, als)
return exports
}
/** Module-syntax counts read from the AST, replacing what the retired lexer reported. */
interface Counts {
staticImports: number
dynamicImports: number
importMeta: number
awaitExpressions: number
}
/** @returns Occurrence counts of the forms the transform rewrites. */
function countForms(source: string, _path: string): Counts {
const counts: Counts = { staticImports: 0, dynamicImports: 0, importMeta: 0, awaitExpressions: 0 }
let program: unknown
try {
program = parse(source, { ecmaVersion: 'latest', sourceType: 'module', allowAwaitOutsideFunction: true })
} catch {
// Counting is reporting only; a parse failure is the transform's to report.
return counts
}
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) walk(child)
return
}
const record = node as Record<string, unknown>
if (typeof record.type !== 'string') return
if (record.type === 'ImportDeclaration') counts.staticImports += 1
if (record.type === 'ImportExpression') counts.dynamicImports += 1
if (record.type === 'AwaitExpression') counts.awaitExpressions += 1
if (record.type === 'MetaProperty' && (record.meta as { name?: string } | undefined)?.name === 'import') {
counts.importMeta += 1
}
for (const [key, value] of Object.entries(record)) {
if (key === 'type' || key === 'start' || key === 'end') continue
walk(value)
}
}
walk(program)
return counts
}
const files = process.argv.slice(2).length > 0
? process.argv.slice(2).map(path => (path.startsWith('/') ? path : join(process.cwd(), path)))
: discover()
if (files.length === 0) {
process.stdout.write('transform-corpus-check: no built bundles found; run `pnpm run build:lib:host` first\n')
process.exitCode = 1
} else {
const verdicts = {
ok: 0, mismatch: 0, transformFailed: 0, execFailed: 0, exempt: 0, unexpectedBaseline: 0, sentinelRefused: 0,
}
const totals = { bytesIn: 0, bytesOut: 0, lowered: 0, unchanged: 0, lineDrift: 0 }
const counts: Counts = { staticImports: 0, dynamicImports: 0, importMeta: 0, awaitExpressions: 0 }
for (const file of files) {
const key = relative(file)
const source = readFileSync(file, 'utf8')
const observed = countForms(source, file)
counts.staticImports += observed.staticImports
counts.dynamicImports += observed.dynamicImports
counts.importMeta += observed.importMeta
counts.awaitExpressions += observed.awaitExpressions
totals.bytesIn += source.length
let code: string
try {
code = lowerModuleSource({ filename: file, source }).code
} catch (reason) {
const message = (reason as Error).message
if (DOUBLE_LOWERING_SENTINEL.has(key)) {
// Expected: this bundle's own text contains the sentinels the guard
// matches. Assert it is really the guard talking, not some other refusal.
if (message.includes('already lowered')) {
verdicts.sentinelRefused += 1
} else {
fail(`- WRONG REFUSAL ${key}: expected the double-lowering guard, got: ${message}`)
}
continue
}
fail(`- TRANSFORM FAILED ${key}: ${message}`)
verdicts.transformFailed += 1
continue
}
if (DOUBLE_LOWERING_SENTINEL.has(key)) {
fail(`- STALE SENTINEL ${key}: the double-lowering guard no longer refuses it; `
+ 'remove it from DOUBLE_LOWERING_SENTINEL or check whether the guard still works')
}
totals.bytesOut += code.length
if (code === source) totals.unchanged += 1
else totals.lowered += 1
// The debugging contract, over the whole corpus: a transformed body has the
// same line count as its source, so a stack frame still points at the right
// line. This is the property the retired oracle measured as "line drift".
const sourceLines = source.split('\n').length
const codeLines = code.split('\n').length
if (sourceLines !== codeLines) {
fail(`- LINE DRIFT ${key}: source ${String(sourceLines)} lines, transformed ${String(codeLines)}`)
totals.lineDrift += 1
}
const exemption = BASELINE_EXEMPT.get(key)
let expected: string[]
try {
expected = Object.keys(await import(pathToFileURL(file).href) as object).sort()
} catch (reason) {
if (exemption === undefined) {
// A bundle that stopped being importable is a real finding, so it fails
// rather than joining a tolerated total.
fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`)
verdicts.unexpectedBaseline += 1
} else {
verdicts.exempt += 1
}
continue
}
if (exemption !== undefined) {
// The exemption list must stay honest in the other direction too: a file
// that became importable should leave the list.
fail(`- STALE EXEMPTION ${key}: imports fine now (${exemption}); remove it from BASELINE_EXEMPT`)
}
let actual: string[]
try {
actual = Object.keys(await runTransformed(code, file)).sort()
} catch (reason) {
fail(`- EXEC FAILED ${key}: ${(reason as Error).message.split('\n')[0]}`)
verdicts.execFailed += 1
continue
}
const missing = expected.filter(name => !actual.includes(name))
const extra = actual.filter(name => !expected.includes(name))
if (missing.length === 0 && extra.length === 0) {
verdicts.ok += 1
continue
}
fail(`- EXPORT MISMATCH ${key}: missing=[${missing.join(',')}] extra=[${extra.join(',')}]`)
verdicts.mismatch += 1
}
const growth = totals.bytesIn === 0 ? 0 : ((totals.bytesOut - totals.bytesIn) / totals.bytesIn) * 100
log('')
log(`files=${String(files.length)} ok=${String(verdicts.ok)} exportMismatch=${String(verdicts.mismatch)} `
+ `transformFailed=${String(verdicts.transformFailed)} execFailed=${String(verdicts.execFailed)} `
+ `lineDrift=${String(totals.lineDrift)} baselineExempt=${String(verdicts.exempt)} `
+ `sentinelRefused=${String(verdicts.sentinelRefused)} `
+ `unexpectedBaselineFailure=${String(verdicts.unexpectedBaseline)}`)
log(`lowered=${String(totals.lowered)} packedAsIs=${String(totals.unchanged)} `
+ `bytes ${String(totals.bytesIn)} -> ${String(totals.bytesOut)} (${growth.toFixed(1)}%)`)
log(`forms: staticImport=${String(counts.staticImports)} dynamicImport=${String(counts.dynamicImports)} `
+ `importMeta=${String(counts.importMeta)} await=${String(counts.awaitExpressions)}`)
process.stdout.write(failures === 0
? `\ntransform-corpus-check: ${String(verdicts.ok)} bundles match their ESM baseline, `
+ `${String(verdicts.exempt)} exempt, ${String(verdicts.sentinelRefused)} sentinel-refused, no drift\n`
: `\ntransform-corpus-check: ${String(failures)} finding(s)\n`)
process.exitCode = failures === 0 ? 0 : 1
}
@@ -0,0 +1,33 @@
/**
* Runs the full-corpus transform gate (`transform-corpus-check.ts`) in the
* launcher it is written for, and reports its findings as this suite's failure.
*
* Spawned rather than imported, because the gate's oracle is NODE's ESM loader:
* every built bundle's transformed export shape is compared against what
* `await import(file)` produces there. Vitest replaces that loader with vite's
* module runner, which imports files Node cannot — a `.css` import resolves, and
* koffi loads a second time — so an in-process corpus run measures the transform
* against a different loader and reports three of the four pinned baseline
* exemptions as stale. The gate's own note applies to itself: a gate whose
* verdict depends on how it was launched is not a gate.
*
* The corpus is the build output, so this skips on a tree that has none.
*/
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { expect, test } from 'vitest'
const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url))
test('every built bundle transforms to the export shape Node loads', (context) => {
const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' })
const output = `${finished.stdout}${finished.stderr}`
if (output.includes('no built bundles found')) {
context.skip('the workspace has no build output to sweep')
return
}
// The runner prefixes every finding with '- ', so a failure reads as the
// findings themselves rather than as a diff of its whole report.
expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('')
expect(finished.status, output).toBe(0)
}, 900_000)
@@ -0,0 +1,710 @@
/**
* Semantic check of the worker module transform (`src/compile/transform.ts`): what the
* emitted CommonJS body looks like for each module form, how suspension points
* are rewritten, that line numbers survive, which forms are refused, and that
* every trap the retired lexer pipeline hit stays fixed.
*
* Scope boundary: this file checks the transform itself; the image collector's
* loop around it is covered by the packer's `transform-image.spec.ts`.
* Emitted-code assertions are deliberately written against substrings
* of the real output rather than whole-file goldens: a golden would fail on every
* helper reordering, which is not the contract. The contract is the observable
* one — the code parses as script, publishes the right bindings, keeps line
* count, and routes suspension through `__als`.
*
* The trap cases come from the two reports the lexer retirement produced
* (`.artifacts/w0-lexer-conclusion.md` §"seven traps", `.artifacts/v3-transform.md`
* §2). Five traps cannot recur under an AST pass, but they are checked anyway:
* they are the forms that actually broke a boot, and a future parser swap would
* reintroduce exactly them.
*/
import { expect, test } from 'vitest'
import { parse } from 'acorn'
import { lowerModuleSource } from '../../src/compile/transform.ts'
import { LOWERING_VERSION, WRAPPER_PARAMS } from '../../src/image-layout.ts'
/**
* Lower one probe module the way the packer does — the transform's only caller.
* @param source - Module source under test.
* @param path - Path the diagnostics name.
* @returns The emitted body.
*/
const transformModule = (source: string, path = 'probe.js'): string =>
lowerModuleSource({ filename: path, source }).code
/** Register one comparison as its own case, serialized at call time. */
const check = (label: string, actual: unknown, expected: unknown): void => {
const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
test(label, () => { expect(seen).toBe(wanted) })
}
/** Assert a substring is present in an emitted body. */
const contains = (label: string, code: string, needle: string): void => {
test(label, () => { expect(code).toContain(needle) })
}
/** Assert a substring is absent (used for "must survive untouched" cases). */
const lacks = (label: string, code: string, needle: string): void => {
test(label, () => { expect(code).not.toContain(needle) })
}
/** @returns The error message of a refused transform, or undefined when it succeeded. */
const refusal = (source: string, path = 'probe.js'): string | undefined => {
try {
transformModule(source, path)
return undefined
} catch (reason) {
return (reason as Error).message
}
}
/** Assert the transform refuses a source and names the reason. */
const refuses = (label: string, source: string, fragment: string): void => {
const message = refusal(source)
test(label, () => { expect(message).toContain(fragment) })
}
/**
* The wrapper contract, applied for real: compile the body with the declared
* parameters and run it. This is the same `new Function` shape the loader uses
* (module-loader.ts), so a body that compiles here compiles there.
* @param code - Emitted CommonJS body.
* @param require - Module resolver the body's `require` calls reach.
* @param als - Suspension runtime bound to `__als`.
* @returns The populated `exports` object.
*/
function runBody(
code: string,
require: (specifier: string) => unknown = () => ({}),
als?: unknown,
): Record<string, unknown> {
const exports: Record<string, unknown> = {}
const module = { exports }
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body
const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void
factory(exports, require, module, '/vfs/probe.js', '/vfs', { url: 'file:///vfs/probe.js' }, als)
return exports
}
/** Every emitted body must parse as a script — the transform's own exit gate, re-checked here. */
const parsesAsScript = (label: string, code: string): void => {
test(label, () => {
expect(() => parse(code, { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: false })).not.toThrow()
})
}
// ---------------------------------------------------------------------------
// 1. The published contract: the three names the packer and loader share.
// ---------------------------------------------------------------------------
check('LOWERING_VERSION is a non-empty string', typeof LOWERING_VERSION === 'string' && LOWERING_VERSION.length > 0, true)
check('WRAPPER_PARAMS is the frozen 7-parameter shape', [...WRAPPER_PARAMS], [
'exports', 'require', 'module', '__filename', '__dirname', '__dsh$meta', '__als',
])
// The wrapper signature is a contract with the loader's `new Function`, so the
// parameters must be valid identifiers in that position.
check(
'every wrapper parameter is a usable identifier',
(() => {
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- proves the parameter names compile where the loader uses them
new Function(...WRAPPER_PARAMS, 'return 0')
return true
} catch {
return false
}
})(),
true,
)
// ---------------------------------------------------------------------------
// 2. lowerModuleSource: the packer face. `lowered` is the pack-time decision.
// ---------------------------------------------------------------------------
{
const esm = lowerModuleSource({ filename: 'node_modules/p/index.js', source: 'export const a = 1\n' })
check('lowered=true for a module that needed rewriting', esm.lowered, true)
check('lowered code differs from source', esm.code !== 'export const a = 1\n', true)
// Plain CommonJS with no suspension point is the "pack as-is" case: the
// collector relies on this to leave 1693-odd entries untouched.
const plain = 'module.exports = 1\n'
const cjs = lowerModuleSource({ filename: 'node_modules/p/legacy.cjs', source: plain })
check('lowered=false for plain CommonJS', cjs.lowered, false)
check('unlowered code is the input verbatim', cjs.code, plain)
// A CommonJS body that still contains a suspension point must be rewritten:
// `await` inside a function is the ALS protocol's business even with no ESM.
const cjsAwait = lowerModuleSource({
filename: 'node_modules/p/async.cjs',
source: 'module.exports = async () => { await 1 }\n',
})
check('lowered=true for CommonJS carrying a suspension point', cjsAwait.lowered, true)
contains('CommonJS await still routes through __als', cjsAwait.code, '__als.pause(')
// `lowered` must agree with the code/source comparison by construction.
check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n')
}
// ---------------------------------------------------------------------------
// 3. Import forms.
// ---------------------------------------------------------------------------
{
// Side-effect import: a bare require, nothing bound.
const code = transformModule("import './side-effect.js'\n", 'probe.js')
contains('side-effect import becomes a bare require', code, 'require("./side-effect.js")')
parsesAsScript('side-effect import', code)
const requested: string[] = []
runBody(code, (specifier) => {
requested.push(specifier)
return {}
})
check('side-effect import actually requires at run time', requested, ['./side-effect.js'])
}
{
// Named imports are snapshots (CommonJS destructuring semantics), which is the
// documented, accepted divergence from ESM live bindings on the import side.
const code = transformModule("import { a, b as c } from 'p'\nexport const out = [a, c]\n", 'probe.js')
parsesAsScript('named imports', code)
const exports = runBody(code, () => ({ a: 1, b: 2 }))
check('named import binds by imported name, honouring the alias', exports.out, [1, 2])
}
{
// Default and namespace imports go through the two interop helpers, which must
// agree with `Loader.unwrapExports` on the `__esModule` convention.
const code = transformModule("import d from 'p'\nimport * as ns from 'q'\nexport const seen = [d, ns.x, ns.default]\n", 'probe.js')
parsesAsScript('default and namespace imports', code)
// An `__esModule` module: default comes from `.default`, namespace passes through.
const esModule = { __esModule: true, default: 'D', x: 'X' }
const withEsm = runBody(code, () => esModule)
check('default import of an __esModule module reads .default', (withEsm.seen as unknown[])[0], 'D')
// A plain CommonJS module: the module object *is* the default, and the
// namespace gains a `default` key pointing at it.
const plain = { x: 'X' }
const withCjs = runBody(code, () => plain)
check('default import of plain CommonJS is the module object', (withCjs.seen as unknown[])[0], plain)
check('namespace of plain CommonJS keeps the named key', (withCjs.seen as unknown[])[1], 'X')
check('namespace of plain CommonJS synthesizes default', (withCjs.seen as unknown[])[2], plain)
}
// ---------------------------------------------------------------------------
// 4. Export forms, including the live-binding contract.
// ---------------------------------------------------------------------------
{
const code = transformModule('export const a = 1\nexport function f() {}\nexport class K {}\n', 'probe.js')
parsesAsScript('exported declarations', code)
contains('module bodies get the __esModule marker', code, '__esModule')
contains('use strict is part of the prologue', code, '"use strict"')
const exports = runBody(code)
check('exported const is published', exports.a, 1)
check('exported function is published', typeof exports.f, 'function')
check('exported class is published', typeof exports.K, 'function')
}
{
// Local exports are getters, so a later assignment is observable through
// `exports` — the ESM live-binding property the report calls out explicitly.
const code = transformModule('export let counter = 0\nexport function bump() { counter += 1 }\n', 'probe.js')
parsesAsScript('live binding', code)
const exports = runBody(code)
check('live binding starts at its initializer', exports.counter, 0)
;(exports.bump as () => void)()
check('live binding observes a later assignment', exports.counter, 1)
// A getter, not a data property: this is what makes the above work.
check(
'exported local is an accessor',
typeof Object.getOwnPropertyDescriptor(exports, 'counter')?.get,
'function',
)
}
{
// Trap 4 in the lexer report: `export const a = 1, b = 2` reported only the
// first declarator, so the AST pass upgraded this from "loud refusal" to
// "correctly supported". Both bindings must appear.
const code = transformModule('export const a = 1, b = 2\n', 'probe.js')
parsesAsScript('multi-declarator export', code)
const exports = runBody(code)
check('multi-declarator export publishes every binding', [exports.a, exports.b], [1, 2])
}
{
// Destructuring exports exercise the pattern walker (object, array, rest,
// default) — every branch of `declaredBindings`.
const code = transformModule(
'export const { p, q: renamed, ...restObj } = { p: 1, q: 2, z: 3 }\n'
+ 'export const [first, , third = 30, ...restArr] = [10, 20, undefined, 40, 50]\n',
'probe.js',
)
parsesAsScript('destructuring exports', code)
const exports = runBody(code)
check('object pattern export', [exports.p, exports.renamed], [1, 2])
check('object rest export', exports.restObj, { z: 3 })
check('array pattern export with hole', [exports.first, exports.third], [10, 30])
check('array rest export', exports.restArr, [40, 50])
// The renamed target is what is published; the source key is not a binding.
check('object pattern publishes the local name, not the source key', 'q' in exports, false)
}
{
const code = transformModule('const x = 1\nexport { x as y }\n', 'probe.js')
parsesAsScript('local export clause', code)
const exports = runBody(code)
check('local export clause publishes under the exported name', exports.y, 1)
check('local export clause does not publish the local name', 'x' in exports, false)
}
{
// Re-export clause: a getter onto the required module, so it also stays live.
const module: Record<string, unknown> = { a: 1 }
const code = transformModule("export { a, a as aliased } from 'p'\n", 'probe.js')
parsesAsScript('re-export clause', code)
const exports = runBody(code, () => module)
check('re-export publishes the name', exports.a, 1)
check('re-export publishes the alias', exports.aliased, 1)
module.a = 2
check('re-export is live against the source module', exports.a, 2)
}
{
// `export *` copies enumerable keys, skips `default`, and must not clobber an
// existing local export.
const code = transformModule("export const own = 'local'\nexport * from 'p'\n", 'probe.js')
parsesAsScript('export all', code)
const exports = runBody(code, () => ({ extra: 'E', default: 'D', own: 'theirs' }))
check('export * copies named keys', exports.extra, 'E')
check('export * skips default', 'default' in exports, false)
check('export * does not overwrite an existing export', exports.own, 'local')
}
{
const code = transformModule("export * as ns from 'p'\n", 'probe.js')
parsesAsScript('export all as namespace', code)
const exports = runBody(code, () => ({ x: 1 }))
check('export * as ns publishes a namespace object', (exports.ns as Record<string, unknown>).x, 1)
}
{
const code = transformModule('export default 42\n', 'probe.js')
parsesAsScript('default export value', code)
check('default export lands on exports.default', runBody(code).default, 42)
}
{
// Documented cost: the function name stops being a module-scope binding, but
// the named function expression can still refer to itself.
const code = transformModule('export default function self(n) { return n <= 0 ? 0 : self(n - 1) }\n', 'probe.js')
parsesAsScript('default export function', code)
const fn = runBody(code).default as (n: number) => number
check('default-exported function keeps self-reference', fn(3), 0)
}
{
const code = transformModule("export { x as default } from 'p'\n", 'probe.js')
parsesAsScript('re-export as default', code)
check('re-export as default publishes default', runBody(code, () => ({ x: 'D' })).default, 'D')
}
// ---------------------------------------------------------------------------
// 5. import.meta and dynamic import.
// ---------------------------------------------------------------------------
{
const code = transformModule('export const here = import.meta.url\n', 'probe.js')
parsesAsScript('import.meta', code)
contains('import.meta becomes the wrapper parameter', code, '__dsh$meta')
check('import.meta.url resolves through the wrapper', runBody(code).here, 'file:///vfs/probe.js')
}
{
// Dynamic import routes through the same require chain (which is what makes
// typert-loader's absolute-path `import()` land on the VFS resolver), and the
// result is namespace-shaped.
const code = transformModule("export const load = () => import('p')\n", 'probe.js')
parsesAsScript('dynamic import', code)
contains('dynamic import becomes the helper call', code, '__dsh$dynImport')
const load = runBody(code, () => ({ x: 1 })).load as () => Promise<Record<string, unknown>>
const namespace = await load()
check('dynamic import resolves to a namespace object', namespace.x, 1)
check('dynamic import namespace has a default', 'default' in namespace, true)
}
// ---------------------------------------------------------------------------
// 6. Suspension points. Behaviour is checked against a recording runtime, so
// these assert the protocol shape rather than re-testing als-runtime.
// ---------------------------------------------------------------------------
/** A recording stand-in for the ALS runtime: proves the emitted calls happen in order. */
function recordingAls(): { als: Record<string, unknown>; calls: string[] } {
const calls: string[] = []
const als = {
pause: (value: unknown) => {
calls.push('pause')
return Promise.resolve(value).then(
settled => ({ ok: true, value: settled, snapshot: 'S' }),
(error: unknown) => ({ ok: false, error, snapshot: 'S' }),
)
},
resume: (token: { ok: boolean; value?: unknown; error?: unknown }) => {
calls.push('resume')
if (token.ok) return token.value
throw token.error
},
snapshot: () => {
calls.push('snapshot')
return 'S'
},
afterYield: (_snapshot: unknown, sent: unknown) => {
calls.push('afterYield')
return sent
},
iterator: (value: unknown) => {
calls.push('iterator')
const source = value as Record<PropertyKey, unknown>
const asyncFactory = source[Symbol.asyncIterator] as (() => AsyncIterator<unknown>) | undefined
if (typeof asyncFactory === 'function') return asyncFactory.call(source)
const syncFactory = source[Symbol.iterator] as () => Iterator<unknown, unknown>
const inner = syncFactory.call(source)
return {
next: async (...args: unknown[]) => {
const step = inner.next(...args as [unknown])
return { done: step.done ?? false, value: await step.value }
},
return: async (sent?: unknown) => {
const step = inner.return?.(sent) ?? { done: true, value: undefined }
return { done: step.done ?? true, value: await step.value }
},
}
},
close: async (iterator: AsyncIterator<unknown>) => {
calls.push('close')
return iterator.return?.(undefined)
},
}
return { als, calls }
}
{
const code = transformModule('export const run = async () => await 7\n', 'probe.js')
parsesAsScript('await rewrite', code)
contains('await is wrapped in resume(await pause(', code, '__als.resume(await __als.pause(')
const { als, calls } = recordingAls()
const run = runBody(code, () => ({}), als).run as () => Promise<number>
check('await still yields its value', await run(), 7)
check('await goes pause-then-resume', calls, ['pause', 'resume'])
}
{
// The rejection path is the half that a naive "snapshot on success" rewrite
// gets wrong, so it is checked as its own case.
const code = transformModule(
"export const run = async () => { try { await Promise.reject(new Error('boom')) } catch (reason) { return `caught:${reason.message}` } }\n",
'probe.js',
)
parsesAsScript('await rejection', code)
const { als, calls } = recordingAls()
const run = runBody(code, () => ({}), als).run as () => Promise<string>
check('rejection surfaces through resume', await run(), 'caught:boom')
check('rejection path also goes pause-then-resume', calls, ['pause', 'resume'])
}
{
// for-await desugars to an explicit loop; `return()` must run only on abrupt
// completion, which is the language rule the report calls out. The two
// completion paths need two different loop bodies, so they are separate cases.
const plain = 'export const run = async (src) => { const seen = []\n'
+ 'for await (const item of src) { seen.push(item) }\n'
+ 'return seen }\n'
const code = transformModule(plain, 'probe.js')
parsesAsScript('for-await', code)
contains('for-await uses the iterator helper', code, '__als.iterator(')
contains('for-await closes on abrupt completion', code, '__als.close(')
/** An async iterable counting up to `n`, rebuilt per case so state cannot leak. */
const counting = (n: number): unknown => ({
[Symbol.asyncIterator]: () => {
let emitted = 0
return {
next: () => Promise.resolve(
emitted < n ? { done: false, value: ++emitted } : { done: true, value: undefined },
),
}
},
})
// Normal completion: the iterator is exhausted, so `return()` must NOT run.
const { als, calls } = recordingAls()
const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise<number[]>
check('for-await over an async source collects values', await run(counting(2)), [1, 2])
check('normal completion does not close the iterator', calls.includes('close'), false)
// Abrupt completion (break), and a sync source whose values are promises
// (async-from-sync): close must run exactly once.
const breaking = 'export const run = async (src) => { const seen = []\n'
+ 'for await (const item of src) { seen.push(item); if (item === 2) break }\n'
+ 'return seen }\n'
const breakingCode = transformModule(breaking, 'probe.js')
parsesAsScript('for-await with break', breakingCode)
const { als: als2, calls: calls2 } = recordingAls()
const run2 = runBody(breakingCode, () => ({}), als2).run as (src: unknown) => Promise<number[]>
const syncSource = {
[Symbol.iterator]: () => [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)][Symbol.iterator](),
}
check('for-await accepts a sync source of promises', await run2(syncSource), [1, 2])
check('break closes the iterator exactly once', calls2.filter(name => name === 'close').length, 1)
}
{
// Destructuring in the loop head goes through the same binding path.
const code = transformModule(
'export const run = async (src) => { const seen = []\nfor await (const { v } of src) seen.push(v)\nreturn seen }\n',
'probe.js',
)
parsesAsScript('for-await destructuring', code)
const { als } = recordingAls()
const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise<number[]>
check('for-await destructures each step', await run([{ v: 1 }, { v: 2 }]), [1, 2])
}
{
// A non-block body must still be wrapped, or the emitted loop would swallow
// the following statement.
const code = transformModule(
'export const run = async (src) => { let sum = 0\nfor await (const n of src) sum += n\nreturn sum }\n',
'probe.js',
)
parsesAsScript('for-await single-statement body', code)
const { als } = recordingAls()
const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise<number>
check('for-await with a non-block body runs correctly', await run([1, 2, 3]), 6)
}
{
// `yield` in an async generator: the snapshot is taken before suspending and
// the consumer's sent value comes back through afterYield.
const code = transformModule(
'export async function* gen() { const got = yield 1\nyield got * 2 }\n',
'probe.js',
)
parsesAsScript('yield rewrite', code)
contains('yield is wrapped in afterYield(snapshot(), yield ...)', code, '__als.afterYield(__als.snapshot(),yield ')
const { als, calls } = recordingAls()
const gen = runBody(code, () => ({}), als).gen as () => AsyncGenerator<number, void, number>
const iterator = gen()
check('first yield produces its value', (await iterator.next(0)).value, 1)
check('sent value returns through afterYield', (await iterator.next(21)).value, 42)
check('yield recorded snapshot and afterYield', calls.filter(name => name === 'afterYield').length >= 1, true)
}
{
// Statement-position `yield*` desugars into a forwarding loop.
const code = transformModule(
'export async function* outer(inner) { yield* inner\nyield "tail" }\n',
'probe.js',
)
parsesAsScript('yield* rewrite', code)
const { als } = recordingAls()
const outer = runBody(code, () => ({}), als).outer as (inner: unknown) => AsyncGenerator<unknown, void, unknown>
const collected: unknown[] = []
for await (const value of outer(['a', 'b'])) collected.push(value)
check('yield* forwards inner values then continues', collected, ['a', 'b', 'tail'])
}
// ---------------------------------------------------------------------------
// 7. Line numbers. The debugging contract: a stack frame in a transformed body
// points at the same line as the artifact it came from.
// ---------------------------------------------------------------------------
/** @returns Line count of a string, counting a trailing newline's line as the last. */
const lineCount = (text: string): number => text.split('\n').length
{
// The prologue is emitted without a trailing newline, so a transformed body
// has exactly as many lines as its source. Anything else is line drift.
const cases: Array<{ readonly label: string; readonly source: string }> = [
{ label: 'imports and exports', source: "import { a } from 'p'\n\nexport const b = a\n\nexport default b\n" },
{ label: 'await in a function', source: 'export const f = async () => {\n const v = await g()\n return v\n}\n' },
{
label: 'for-await (body re-emitted)',
source: 'export const f = async (src) => {\n for await (const x of src) {\n use(x)\n }\n done()\n}\n',
},
{
label: 'yield* (statement desugared)',
source: 'export async function* f(inner) {\n yield* inner\n after()\n}\n',
},
{ label: 'export * with following lines', source: "export * from 'p'\nconst tail = 1\nexport { tail }\n" },
{ label: 'multi-line import clause', source: "import {\n a,\n b,\n} from 'p'\nexport const out = [a, b]\n" },
]
for (const { label, source } of cases) {
const code = transformModule(source, 'probe.js')
check(`line count survives: ${label}`, lineCount(code), lineCount(source))
}
}
// ---------------------------------------------------------------------------
// 8. Refusals. Every one of these is a form the transform must reject loudly
// rather than emit something that breaks later.
// ---------------------------------------------------------------------------
refuses('top-level await is refused', 'export const a = 1\nawait boot()\n', 'top-level await')
refuses('top-level for-await is refused', 'for await (const x of src) use(x)\n', 'top-level for-await')
refuses(
'labeled for-await is refused',
'export const f = async (src) => { outer: for await (const x of src) { break outer } }\n',
'labeled for-await',
)
refuses(
'import attributes are refused',
"import data from './d.json' with { type: 'json' }\n",
'import attributes',
)
refuses(
'value-position yield* is refused',
'export async function* f(inner) { const v = yield* inner\nuse(v) }\n',
'yield* is only supported as a statement',
)
refuses(
'assignment around yield* is refused, never silently dropped',
'export async function* f(inner) { let v\nv = yield* inner\nuse(v) }\n',
'yield* is only supported as the whole statement expression',
)
refuses(
'a call around yield* is refused, never silently dropped',
'export async function* f(inner) { use(yield* inner) }\n',
'yield* is only supported as the whole statement expression',
)
refuses(
'already-lowered source is refused',
'const x = __als.pause(1)\n',
'already lowered',
)
refuses('unparseable source is refused', 'export const = \n', 'parse failed')
{
// A refusal must name the file and the line, which is what makes a build
// failure actionable.
const message = refusal('export const a = 1\n\n\nawait boot()\n', 'node_modules/p/index.js')
check('refusal names the file', message?.includes('node_modules/p/index.js'), true)
check('refusal names the offending line', message?.includes(':4'), true)
}
// ---------------------------------------------------------------------------
// 9. Trap regressions. Each case broke a real boot under the retired lexer
// pipeline; the AST pass must keep them fixed.
// Sources: .artifacts/w0-lexer-conclusion.md, .artifacts/v3-transform.md §2.
// ---------------------------------------------------------------------------
{
// Trap 1: a file with no module syntax can still contain a dynamic import.
// Early-returning on "no module syntax" left it unrewritten and it escaped to
// the host engine's parser.
const code = transformModule("module.exports = () => import('./x.js')\n", 'probe.js')
contains('trap 1: dynamic import in a CommonJS file is still rewritten', code, '__dsh$dynImport')
parsesAsScript('trap 1', code)
}
{
// Trap 2: `export {}` is a bundler module marker. The lexer reported nothing
// for it, so it survived into `new Function` as `Unexpected token 'export'`.
// The needle is the keyword in statement position, since `exports.` in the
// prologue legitimately contains the same letters.
const code = transformModule('export {};\n', 'probe.js')
lacks('trap 2: bare export {} is removed', code, 'export {')
lacks('trap 2: no export keyword survives', code, 'export;')
parsesAsScript('trap 2', code)
check('trap 2: emitted body still marks __esModule', '__esModule' in runBody(code), true)
}
{
// Trap 3/4: `export const a = 1, b = 2` — only the first declarator was
// reported. Now both are published (checked in §4); here the point is that
// the no-initializer form works too.
const code = transformModule('export let x, y\nexport const set = () => { x = 1; y = 2 }\n', 'probe.js')
parsesAsScript('trap 3', code)
const exports = runBody(code)
;(exports.set as () => void)()
check('trap 3: every declarator is exported, initializer or not', [exports.x, exports.y], [1, 2])
}
{
// Trap 6, the most costly one: a block comment before a class member named
// `import` made the lexer report a dynamic import, renaming
// `EntryTree.prototype.import` and breaking the loading chain at
// `Entry._init` with "this.parent.tree.import is not a function".
const source = 'export class A {\n /** doc */ import(name) { return name }\n}\n'
const code = transformModule(source, 'probe.js')
lacks('trap 6: a method named import is not rewritten', code, '__dsh$dynImport')
parsesAsScript('trap 6', code)
const A = runBody(code).A as new () => { import: (name: string) => string }
check('trap 6: the method is still callable under its own name', new A().import('kept'), 'kept')
}
{
// Trap 7: a comment between `export` and the declaration keyword made the
// gap-matching regex miss, refusing zod's `export /*@__NO_SIDE_EFFECTS__*/ function`
// and taking 30-odd roster rows down with it.
const code = transformModule('export /*@__NO_SIDE_EFFECTS__*/ function $constructor(x) { return x }\n', 'probe.js')
parsesAsScript('trap 7', code)
check('trap 7: export with an interposed comment still publishes', typeof runBody(code).$constructor, 'function')
}
{
// The trap the AST pass introduced and the byte-level oracle caught:
// `new.target` is also a MetaProperty. Replacing every MetaProperty made
// `new.target === Cls` permanently false, silently disabling abstract-seam
// guards in `jobs` and `llm`.
const source = 'export class Base {\n constructor() { this.direct = new.target === Base }\n}\n'
const code = transformModule(source, 'probe.js')
contains('trap 8: new.target survives verbatim', code, 'new.target')
lacks('trap 8: new.target is not replaced by the meta parameter', code, '__dsh$meta')
parsesAsScript('trap 8', code)
const Base = runBody(code).Base as new () => { direct: boolean }
class Derived extends Base {}
check('trap 8: new.target compares true for a direct construction', new Base().direct, true)
check('trap 8: new.target compares false for a subclass', new Derived().direct, false)
}
{
// Shebang handling (found while packing `yaml/bin.mjs`): `#!` is only legal at
// offset 0, which the prologue occupies. It is commented out in place so both
// offsets and the line count stay put.
const source = '#!/usr/bin/env node\nexport const main = 1\n'
const code = transformModule(source, 'probe.js')
lacks('shebang is not left in the emitted body', code, '#!')
parsesAsScript('shebang', code)
check('shebang: line count still survives', lineCount(code), lineCount(source))
check('shebang: the module still works', runBody(code).main, 1)
}
{
// The exit gate itself: the transform re-parses its own output as a script.
// Any leftover module syntax or mis-spliced interval fails there, not at load.
// Re-checked here over a source that exercises several edits at once.
const source = "import a from 'p'\nexport * from 'q'\nexport const f = async () => { for await (const x of a) { await x } }\n"
parsesAsScript('exit gate over combined edits', transformModule(source, 'probe.js'))
}
// ---------------------------------------------------------------------------
// 10. Caching: the transform memoizes by source text, and the cache must not
// leak a different file's result.
// ---------------------------------------------------------------------------
{
const source = 'export const cached = 1\n'
const first = transformModule(source, 'a.js')
const second = transformModule(source, 'b.js')
check('identical sources return the identical cached body', first === second, true)
// Distinct sources must not collide.
check(
'distinct sources produce distinct bodies',
transformModule('export const other = 2\n', 'c.js') !== first,
true,
)
}
@@ -0,0 +1,86 @@
/**
* The worker host's log sink: the seam that makes a failing plugin visible.
*
* Cordis's `LoggerService` accepts every message and, with no exporter mounted,
* only fills a ring buffer. No profile in this repository mounts one, so a
* provider that fails and is skipped — the skill registry logs exactly that —
* used to look identical to one that found nothing. That is how an empty skill
* catalog hid a filesystem fault through two rounds of diagnosis, so the sink is
* exercised here rather than trusted: a diagnostic nothing runs is a diagnostic
* that silently stops working.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installLogSink, type LogExporter, type LogMessage } from '../src/worker-host.ts'
/** Capture the exporter the sink registers, and the cordis renderer it asks for. */
function harness(): { register: () => LogExporter; requested: string[] } {
const requested: string[] = []
let registered: LogExporter | undefined
const ctx = {
loader: { internal: undefined },
logger: { exporter: (exporter: LogExporter) => { registered = exporter; return undefined } },
get: () => undefined,
provide: () => {},
fiber: { dispose: async () => {} },
}
const require = (specifier: string): unknown => {
requested.push(specifier)
// Stand in for cordis's printf renderer: the sink's contract is that it
// formats THROUGH it, not that it reimplements the format.
return { Logger: { format: (_exporter: LogExporter, message: LogMessage) => `rendered(${message.args.join('|')})` } }
}
installLogSink(ctx, require)
if (registered === undefined) throw new Error('the sink registered no exporter')
const exporter = registered
return { register: () => exporter, requested }
}
const message = (type: LogMessage['type'], name: string, ...args: unknown[]): LogMessage => ({ name, type, args })
// Console spies are installed on one shared object, so a surviving spy would
// carry the previous case's calls into the counting case below.
afterEach(() => { vi.restoreAllMocks() })
describe('worker host log sink', () => {
it('registers one exporter and renders through cordis', () => {
const { register, requested } = harness()
expect(requested).toEqual(['@deepseek-ai/cordis'])
// Colors off: the page console has no terminal escapes to interpret.
expect(register().colors).toBe(false)
})
it('declares a verbosity gate that admits warnings', () => {
// cordis's scale counts UP with verbosity (ERROR 0, INFO 1, WARN 2, DEBUG 3)
// and it drops a message whose level EXCEEDS the exporter's, so an exporter
// that declares nothing inherits INFO and never sees a warning. This case is
// the one that matters: the sink exists for warnings, and getting the
// comparison backwards makes it silently deliver nothing.
const admits = (exporterLevel: number, messageLevel: number): boolean => exporterLevel >= messageLevel
const gate = harness().register().levels.default
expect(admits(gate, 2), 'warnings must pass the gate').toBe(true)
expect(admits(gate, 0), 'errors must pass the gate').toBe(true)
expect(admits(gate, 3), 'debug must not pass the gate').toBe(false)
})
it('reports a warning with its logger name, the way a skipped provider arrives', () => {
const warned = vi.spyOn(console, 'warn').mockImplementation(() => {})
harness().register().export(message('warn', 'skill', 'provider "local" skipped: FS_IO_ERROR'))
expect(warned).toHaveBeenCalledWith('skill: rendered(provider "local" skipped: FS_IO_ERROR)')
})
it('reports an error on the error channel', () => {
const failed = vi.spyOn(console, 'error').mockImplementation(() => {})
harness().register().export(message('error', 'loader', 'boom'))
expect(failed).toHaveBeenCalledWith('loader: rendered(boom)')
})
it('drops info and debug, which 131 plugin rows would bury the console with', () => {
const logged = vi.spyOn(console, 'log').mockImplementation(() => {})
const warned = vi.spyOn(console, 'warn').mockImplementation(() => {})
const failed = vi.spyOn(console, 'error').mockImplementation(() => {})
const exporter = harness().register()
exporter.export(message('info', 'timer', 'tick'))
exporter.export(message('debug', 'loader', 'resolved'))
expect([logged.mock.calls.length, warned.mock.calls.length, failed.mock.calls.length]).toEqual([0, 0, 0])
})
})
@@ -0,0 +1,87 @@
/**
* The Node-compatibility table and the module identity it owes its consumers.
*
* Two consumers read these specifiers — the worker vite build aliases them for
* statically bundled code, and the module loader answers `require('node:fs')`
* from VFS-loaded modules — and both must land on ONE module instance per
* specifier. Class identity is what depends on it: `instanceof EventEmitter` and
* `Buffer.isBuffer` compare against a specific copy, so a second instance turns
* them into silent false answers rather than an error anyone can trace.
*
* The table holds factories, so what a table entry defers is the table read. The
* namespace objects themselves belong to the static graph the worker bundle
* evaluates at load, which is why nothing here asserts that a factory is
* unevaluated.
*/
import { describe, expect, it } from 'vitest'
import { createNodeBuiltins, REPLACED_PREFIXES } from '../../src/node/builtins.ts'
import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts'
import { MemoryVfs } from '../../src/storage/memory.ts'
/** A loader over an empty image: every specifier below resolves from the table. */
function loaderRequire(): (specifier: string) => unknown {
const vfs = new MemoryVfs()
vfs.seedDirectory('/dsh')
const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() })
return loader.createRequire('/dsh/')
}
describe('the replacement table', () => {
it('holds a factory for every specifier', () => {
const table = createNodeBuiltins()
const notFunctions = Object.entries(table)
.filter(([, value]) => typeof value !== 'function')
.map(([specifier]) => specifier)
// A module object left in the table would be called as a factory and fail at
// the first require of that specifier, not at assembly.
expect(notFunctions).toEqual([])
expect(Object.keys(table).length).toBeGreaterThan(0)
})
it('keys every builtin with and without the node: prefix', () => {
const table = createNodeBuiltins()
expect(Object.keys(table)).toEqual(expect.arrayContaining(['fs', 'node:fs', 'fs/promises', 'node:fs/promises']))
})
it('leaves process out, because the host installs that global itself', () => {
const table = createNodeBuiltins()
expect([table['process'], table['node:process']]).toEqual([undefined, undefined])
})
it('answers path and path/posix from one module: the worker speaks POSIX only', () => {
const table = createNodeBuiltins()
expect(table['path']?.()).toBe(table['path/posix']?.())
})
it('answers a prefixed subpath with the module its exact key answers', () => {
const table = createNodeBuiltins()
expect(REPLACED_PREFIXES['@earendil-works/pi-ai/']?.()).toBe(table['@earendil-works/pi-ai']?.())
})
})
describe('module identity through the loader', () => {
it('hands the same instance to two requires of one specifier', () => {
const require = loaderRequire()
expect(require('node:events')).toBe(require('node:events'))
})
it('hands the same instance to the bare and prefixed specifiers', () => {
const require = loaderRequire()
expect(require('events')).toBe(require('node:events'))
expect(require('fs')).toBe(require('node:fs'))
})
it('keeps class identity across those specifiers', () => {
// The consequence the single-instance rule exists for: a second copy would
// make this comparison answer false with nothing failing.
const require = loaderRequire()
const { EventEmitter } = require('events') as { EventEmitter: new () => unknown }
const prefixed = require('node:events') as { EventEmitter: new () => unknown }
expect(new EventEmitter() instanceof prefixed.EventEmitter).toBe(true)
})
it('refuses a specifier the table does not hold, instead of resolving it empty', () => {
const require = loaderRequire()
expect(() => require('node:dns')).toThrow()
})
})
@@ -0,0 +1,137 @@
/**
* The `node:events` shim's dispatch semantics. Harness code registers on this
* class through the module proxy table and branches on what it returns, so the
* cases below pin the parts a hand-written emitter gets wrong: the boolean
* `emit` reports, the point at which `once` unregisters, and the listener set an
* in-flight emit dispatches to.
*/
import { describe, expect, it } from 'vitest'
import { EventEmitter } from '../../src/node/builtin_modules/implemented/events.ts'
describe('emit', () => {
it('reports whether the event reached a listener', () => {
const emitter = new EventEmitter()
expect(emitter.emit('ready')).toBe(false)
emitter.on('ready', () => {})
expect(emitter.emit('ready')).toBe(true)
emitter.removeAllListeners('ready')
expect(emitter.emit('ready')).toBe(false)
})
it('calls the listeners in registration order with every argument', () => {
const emitter = new EventEmitter()
const seen: string[] = []
emitter.on('data', (...args) => { seen.push(`first:${args.join(',')}`) })
emitter.on('data', (...args) => { seen.push(`second:${args.join(',')}`) })
emitter.emit('data', 'a', 1, true)
expect(seen).toEqual(['first:a,1,true', 'second:a,1,true'])
})
it('puts a prepended listener ahead of the ones already registered', () => {
const emitter = new EventEmitter()
const seen: string[] = []
emitter.on('data', () => { seen.push('registered') })
emitter.prependListener('data', () => { seen.push('prepended') })
emitter.emit('data')
expect(seen).toEqual(['prepended', 'registered'])
})
it('dispatches to the listeners present when the emit began', () => {
// Node dispatches over a copy, so a removal from inside a listener takes
// effect on the NEXT emit; a shim that iterated the live list would skip the
// second listener here.
const emitter = new EventEmitter()
const seen: string[] = []
const second = (): void => { seen.push('second') }
emitter.on('data', () => {
seen.push('first')
emitter.off('data', second)
})
emitter.on('data', second)
emitter.emit('data')
emitter.emit('data')
expect(seen).toEqual(['first', 'second', 'first'])
})
})
describe('once', () => {
it('unregisters before it calls, so a re-entrant emit does not repeat it', () => {
const emitter = new EventEmitter()
let calls = 0
emitter.once('settled', () => {
calls += 1
// A listener that reacts by emitting the same event is the shape that
// re-enters a once listener whose removal happens after the call.
emitter.emit('settled')
})
emitter.emit('settled')
expect(calls).toBe(1)
expect(emitter.listenerCount('settled')).toBe(0)
})
it('passes the emit arguments through the wrapper', () => {
const emitter = new EventEmitter()
const seen: unknown[][] = []
emitter.once('exit', (...args) => { seen.push(args) })
emitter.emit('exit', 3, 'SIGTERM')
expect(seen).toEqual([[3, 'SIGTERM']])
})
it('unregisters through the original listener, not only the wrapper', () => {
// Node reaches the once wrapper by the listener handed to `once`, so a caller
// that never saw the wrapper can still cancel its own registration.
const emitter = new EventEmitter()
let calls = 0
const listener = (): void => { calls += 1 }
emitter.once('ready', listener)
emitter.off('ready', listener)
expect(emitter.listenerCount('ready')).toBe(0)
expect(emitter.emit('ready')).toBe(false)
expect(calls).toBe(0)
})
})
describe('registration bookkeeping', () => {
it('hands out a copy of the listener list', () => {
const emitter = new EventEmitter()
emitter.on('data', () => {})
emitter.listeners('data').length = 0
expect(emitter.listenerCount('data')).toBe(1)
})
it('clears one event by name and every event without one', () => {
const emitter = new EventEmitter()
emitter.on('data', () => {})
emitter.on('error', () => {})
emitter.removeAllListeners('data')
expect([emitter.listenerCount('data'), emitter.listenerCount('error')]).toEqual([0, 1])
emitter.removeAllListeners()
expect(emitter.listenerCount('error')).toBe(0)
})
it('removes only the listener named, and tolerates one that never registered', () => {
const emitter = new EventEmitter()
const kept = (): void => {}
const dropped = (): void => {}
emitter.on('data', kept).on('data', dropped)
emitter.removeListener('data', dropped)
emitter.off('data', (): void => {})
emitter.off('absent', kept)
expect(emitter.listeners('data')).toEqual([kept])
})
it('removes the last registration of a listener added twice', () => {
// Removal searches from the tail and stops at one match, as Node does, so
// the earlier registration is the one that stays — visible here in the order
// the surviving listeners run.
const emitter = new EventEmitter()
const seen: string[] = []
const repeated = (): void => { seen.push('repeated') }
emitter.on('data', repeated)
emitter.on('data', () => { seen.push('other') })
emitter.on('data', repeated)
emitter.off('data', repeated)
emitter.emit('data')
expect(seen).toEqual(['repeated', 'other'])
})
})
@@ -0,0 +1,203 @@
/**
* Behavioural check of this package's `node:fs` bridge over a real MemoryVfs:
* encoding branches, Dirent, file descriptors, FileHandle append/replace semantics,
* and Node's error codes.
*
* Migrated from apps/web-preview/scripts/checks/fs-check.ts.
*
* ONE module instance, and every import says so explicitly. The bridge reaches
* the VFS through a module-level slot (`setActiveVfs`/`requireActiveVfs`), so the
* harness that mounts the VFS and the bridge that reads it must be the same copy
* of `src/storage/memory.ts`. Two copies mean the mount lands in one and the bridge reports
* `no filesystem is mounted` from the other — the incident this check itself
* caused once, when it imported the built `lib/` while the bridge resolved to
* `src/`.
*
* Note the package-name subtlety that made that bug possible: the BARE specifier
* `@deepseek-ai/dsh-experimental-webworker-runtime` resolves to built `lib/index.js`, while
* `…/src/*` resolves to source. Under tsx those two happen to share a `vfs`
* instance today, so a mixed-path version of this file passes — for now. Pinning
* every import to `src/` removes the coincidence instead of depending on it.
*/
import { expect, test } from 'vitest'
import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
import * as fs from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs.ts'
import * as fsp from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts'
import type { VfsBigIntStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts'
const vfs = new MemoryVfs()
setActiveVfs(vfs)
// Precondition, not a behaviour: prove the bridge reads the VFS this file mounted.
// If these ever resolve to two module copies again, the write below would report
// `no filesystem is mounted` — but a bridge that answered from some OTHER mounted
// VFS would pass the whole suite while testing the wrong world, so the identity is
// asserted rather than inferred from things working. The probe creates its own
// directory: the suite's `/dsh` tree does not exist yet at this point.
fs.mkdirSync('/dsh/.probe', { recursive: true })
fs.writeFileSync('/dsh/.probe/instance', 'x')
if (!vfs.existsSync('/dsh/.probe/instance')) {
throw new Error('fs-check: the fs bridge is not reading the VFS this harness mounted '
+ '(two module instances — check that every import resolves through src/)')
}
vfs.rmSync('/dsh/.probe', { recursive: true })
const check = (label: string, actual: unknown, expected: unknown): void => {
const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
test(label, () => { expect(seen).toBe(wanted) })
}
const throws = (label: string, run: () => unknown, code: string): void => {
let outcome: string
try {
run()
outcome = 'did not throw'
} catch (error) {
outcome = (error as { code?: string }).code ?? (error as Error).message
}
test(label, () => { expect(outcome).toContain(code) })
}
fs.mkdirSync('/dsh/config', { recursive: true })
fs.writeFileSync('/dsh/config/cordis.yml', '- id: timer\n')
check('readFileSync utf8', fs.readFileSync('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n')
check('readFileSync options object', fs.readFileSync('/dsh/config/cordis.yml', { encoding: 'utf8' }), '- id: timer\n')
check('readFileSync bytes length', (fs.readFileSync('/dsh/config/cordis.yml') as Uint8Array).byteLength, 12)
check('readFileSync is Buffer', Buffer.isBuffer(fs.readFileSync('/dsh/config/cordis.yml')), true)
check('existsSync true', fs.existsSync('/dsh/config/cordis.yml'), true)
check('existsSync false', fs.existsSync('/dsh/nope'), false)
check('statSync isFile', fs.statSync('/dsh/config/cordis.yml').isFile(), true)
check('statSync size', fs.statSync('/dsh/config/cordis.yml').size, 12)
check('statSync dir', fs.statSync('/dsh/config').isDirectory(), true)
check('realpathSync', fs.realpathSync('/dsh/config/../config/cordis.yml'), '/dsh/config/cordis.yml')
fs.appendFileSync('/dsh/config/cordis.yml', '- id: llm\n')
check('appendFileSync', fs.readFileSync('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n- id: llm\n')
fs.mkdirSync('/dsh/config/agent-presets/standard', { recursive: true })
fs.writeFileSync('/dsh/config/agent-presets/standard/SKILL.md', '# skill\n')
check('readdirSync names', fs.readdirSync('/dsh/config'), ['agent-presets', 'cordis.yml'])
const entries = fs.readdirSync('/dsh/config', { withFileTypes: true }) as fs.Dirent[]
check('readdirSync withFileTypes', entries.map(entry => [entry.name, entry.isFile(), entry.isDirectory()]), [
['agent-presets', false, true],
['cordis.yml', true, false],
])
check('Dirent parentPath', entries[0]!.parentPath, '/dsh/config')
const temporary = fs.mkdtempSync('/dsh/tmp/run-')
check('mkdtempSync creates directory', fs.statSync(temporary).isDirectory(), true)
check('mkdtempSync unique', fs.mkdtempSync('/dsh/tmp/run-') === temporary, false)
throws('readFileSync missing', () => fs.readFileSync('/dsh/missing'), 'ENOENT')
throws('statSync missing', () => fs.statSync('/dsh/missing'), 'ENOENT')
throws('accessSync missing', () =>{ fs.accessSync('/dsh/missing') }, 'ENOENT')
throws('readdirSync missing', () => fs.readdirSync('/dsh/missing'), 'ENOENT')
throws('watchFile is loud', () => fs.watchFile('/dsh/config/cordis.yml'), 'not implemented')
throws('createReadStream is loud', () => fs.createReadStream('/dsh/config/cordis.yml'), 'not implemented')
const appendFd = fs.openSync('/dsh/log.jsonl', 'a')
fs.writeSync(appendFd, '{"a":1}\n')
fs.writeSync(appendFd, '{"a":2}\n')
fs.closeSync(appendFd)
check('append fd writes', fs.readFileSync('/dsh/log.jsonl', 'utf8'), '{"a":1}\n{"a":2}\n')
const readFd = fs.openSync('/dsh/log.jsonl', 'r')
const target = new Uint8Array(8)
check('readSync count', fs.readSync(readFd, target, 0, 8), 8)
check('readSync bytes', new TextDecoder().decode(target), '{"a":1}\n')
check('readSync continues', fs.readSync(readFd, target, 0, 8), 8)
check('readSync second line', new TextDecoder().decode(target), '{"a":2}\n')
check('readSync at eof', fs.readSync(readFd, target, 0, 8), 0)
fs.closeSync(readFd)
throws('closed fd', () => fs.readSync(readFd, target, 0, 8), 'EBADF')
const writeFd = fs.openSync('/dsh/truncated.txt', 'w')
fs.writeSync(writeFd, 'abc')
fs.closeSync(writeFd)
check('write fd truncates', fs.readFileSync('/dsh/truncated.txt', 'utf8'), 'abc')
fs.renameSync('/dsh/truncated.txt', '/dsh/renamed.txt')
check('renameSync moves', [fs.existsSync('/dsh/truncated.txt'), fs.readFileSync('/dsh/renamed.txt', 'utf8')], [false, 'abc'])
fs.rmSync('/dsh/renamed.txt')
check('rmSync removes', fs.existsSync('/dsh/renamed.txt'), false)
// A FileHandle opened for appending must append, not replace: the JSONL session
// log writes its header frame first and every batch after it through this path.
fs.writeFileSync('/dsh/log-handle.jsonl', 'header\n')
const appendHandle = await fsp.open('/dsh/log-handle.jsonl', 'a')
check('append handle sees the existing size', (await appendHandle.stat()).size, 7)
await appendHandle.writeFile('batch-1\n')
await appendHandle.sync()
await appendHandle.close()
const secondHandle = await fsp.open('/dsh/log-handle.jsonl', 'a')
await secondHandle.writeFile('batch-2\n')
await secondHandle.close()
check('handle.writeFile appends in append mode', fs.readFileSync('/dsh/log-handle.jsonl', 'utf8'), 'header\nbatch-1\nbatch-2\n')
const replaceHandle = await fsp.open('/dsh/log-handle.jsonl', 'w')
await replaceHandle.writeFile('replaced\n')
await replaceHandle.close()
check('handle.writeFile replaces without append mode', fs.readFileSync('/dsh/log-handle.jsonl', 'utf8'), 'replaced\n')
const truncHandle = await fsp.open('/dsh/log-handle.jsonl', 'r+')
await truncHandle.truncate(4)
await truncHandle.close()
check('handle.truncate cuts the tail', fs.readFileSync('/dsh/log-handle.jsonl', 'utf8'), 'repl')
check('promises.readFile', await fsp.readFile('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n- id: llm\n')
await fsp.writeFile('/dsh/promise.txt', 'p')
check('promises.writeFile', fs.readFileSync('/dsh/promise.txt', 'utf8'), 'p')
check('promises.stat', (await fsp.stat('/dsh/promise.txt')).isFile(), true)
await fsp.cp('/dsh/config', '/dsh/config-copy')
check('promises.cp tree', await fsp.readFile('/dsh/config-copy/agent-presets/standard/SKILL.md', 'utf8'), '# skill\n')
await fsp.rm('/dsh/config-copy', { recursive: true })
check('promises.rm recursive', fs.existsSync('/dsh/config-copy'), false)
// ---------------------------------------------------------------------------
// The `{ bigint: true }` stats the filesystem service reads.
//
// `dsh-fs-local` stats EVERY target this way before it lists or reads: it masks
// `mode` with a BigInt literal and builds its version token from
// `dev:ino:size:mtimeNs:ctimeNs`. A number-valued `mode` here made that mask
// throw `Cannot mix BigInt and other types`, which the service reported as
// FS_IO_ERROR and skill discovery swallowed as "empty directory" — the worker
// booted with an empty skill catalog and no error anywhere.
// ---------------------------------------------------------------------------
const bigStats = (path: string): VfsBigIntStats => fs.statSync(path, { bigint: true }) as VfsBigIntStats
fs.writeFileSync('/dsh/versioned.txt', 'one')
{
const stats = bigStats('/dsh/versioned.txt')
check('bigint stat reports mode as a BigInt', typeof stats.mode, 'bigint')
check('bigint mode masks to an owner-only file permission', Number(stats.mode & 0o777n), 0o600)
check('bigint stat reports the identity fields the version token needs', [
typeof stats.dev, typeof stats.ino, typeof stats.size, typeof stats.mtimeNs, typeof stats.ctimeNs,
], ['bigint', 'bigint', 'bigint', 'bigint', 'bigint'])
check('bigint nanosecond time scales the millisecond time', stats.mtimeNs === stats.mtimeMs * 1_000_000n, true)
check('bigint stat still answers the type predicates', [stats.isFile(), stats.isDirectory()], [true, false])
check('plain stat keeps its number shape', typeof fs.statSync('/dsh/versioned.txt').mode, 'number')
}
{
// Two writes inside one millisecond must not produce one version: the service's
// stale-write guard compares these tokens.
const token = (path: string): string => {
const stats = bigStats(path)
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`
}
const before = token('/dsh/versioned.txt')
fs.writeFileSync('/dsh/versioned.txt', 'two')
check('a rewrite changes the version token', token('/dsh/versioned.txt') !== before, true)
check('an unchanged file keeps its version token', token('/dsh/versioned.txt'), token('/dsh/versioned.txt'))
}
{
const first = bigStats('/dsh/versioned.txt').ino
fs.writeFileSync('/dsh/versioned.txt', 'three')
check('identity survives a write to the same path', String(bigStats('/dsh/versioned.txt').ino), String(first))
fs.rmSync('/dsh/versioned.txt')
fs.writeFileSync('/dsh/versioned.txt', 'four')
check('a removed and recreated path reports a new identity', bigStats('/dsh/versioned.txt').ino !== first, true)
}
check('a directory reports owner-only directory mode in the bigint shape', Number(bigStats('/dsh/config').mode & 0o777n), 0o700)
check('promises.stat forwards the bigint option', typeof (await fsp.stat('/dsh/config', { bigint: true })).mode, 'bigint')
@@ -0,0 +1,83 @@
/**
* The `node:http` seam the worker's webserver boots through: no socket exists,
* so `createServer` retains the request listener for the tunnel to feed and
* `listen` reports success on its own.
*
* Two failure modes make this worth pinning rather than trusting. A `listen`
* that never invokes its callback leaves the webserver fiber in LOADING with no
* error anywhere, and a capture the tunnel misses leaves every synthesized
* request unanswered — both look like a hang, not a fault.
*
* The capture is module state, so the cases below run in order: the first
* observes the empty slot before anything fills it.
*/
import { describe, expect, it } from 'vitest'
import {
createServer, get, request, requestListener, STATUS_CODES, whenRequestListener,
} from '../../src/node/builtin_modules/implemented/http.ts'
import type { RequestListener } from '../../src/transport/synthetic-http.ts'
const listener: RequestListener = () => {}
describe('request listener capture', () => {
it('keeps a caller waiting until the webserver installs its listener', async () => {
expect(requestListener()).toBeUndefined()
let settled = false
const awaited = whenRequestListener().then((captured) => {
settled = true
return captured
})
await Promise.resolve()
expect(settled).toBe(false)
createServer(listener)
expect(await awaited).toBe(listener)
})
it('answers a later caller from the capture instead of waiting again', async () => {
expect(requestListener()).toBe(listener)
expect(await whenRequestListener()).toBe(listener)
})
it('holds the capture across a server created without a listener', () => {
// The tunnel reads one listener; an unrelated createServer must not blank it.
createServer()
expect(requestListener()).toBe(listener)
})
})
describe('binding', () => {
it('reports the bind through the callback the webserver fiber waits on', async () => {
const server = createServer(listener)
let bound = false
expect(server.listen(3080, () => { bound = true })).toBe(server)
await Promise.resolve()
expect(bound).toBe(true)
})
it('reports the loopback authority the tunnel synthesizes requests against', () => {
expect(createServer(listener).address()).toEqual({ address: '127.0.0.1', family: 'IPv4', port: 3080 })
})
it('completes close without a socket to release', async () => {
const server = createServer(listener)
let closed = false
server.close(() => { closed = true })
server.closeAllConnections()
server.closeIdleConnections()
await Promise.resolve()
expect(closed).toBe(true)
})
})
describe('outbound requests', () => {
it('refuses, naming the carrier the worker does have', () => {
expect(() => request()).toThrow(/node:http\.request is not available.*use fetch/)
expect(() => get()).toThrow(/node:http\.get is not available.*use fetch/)
})
it('publishes the status texts a handler writes by hand', () => {
expect([STATUS_CODES[200], STATUS_CODES[404], STATUS_CODES[503]]).toEqual([
'OK', 'Not Found', 'Service Unavailable',
])
})
})
@@ -0,0 +1,206 @@
/**
* The Node-compatibility layer's refusals and its small answering faces.
*
* Two contracts live here. Every replaced symbol must be PRESENT — a missing
* CommonJS export degrades to `undefined` and fails at call time somewhere
* unrelated — and every symbol the worker cannot honour must refuse while naming
* itself, because these errors are routinely swallowed far from their cause and
* the name is what places them in a worker session's console.
*
* The member lists are the tables the modules are checked against: adding a
* refusing symbol without listing it here leaves it unproven, and listing one
* that starts answering fails.
*/
import { describe, expect, it, vi } from 'vitest'
import { notAvailableError, notImplementedFail } from '../../src/node/notImplementedFail.ts'
import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts'
import * as net from '../../src/node/builtin_modules/mock/net.ts'
import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts'
import * as stream from '../../src/node/builtin_modules/mock/stream.ts'
import * as vm from '../../src/node/builtin_modules/mock/vm.ts'
import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts'
import * as chokidar from '../../src/node/external_packages/chokidar.ts'
import * as landlock from '../../src/node/external_packages/node-addon-landlock-run.ts'
import * as nodePty from '../../src/node/external_packages/node-pty.ts'
import * as piAi from '../../src/node/external_packages/pi-ai.ts'
import * as ripgrep from '../../src/node/external_packages/ripgrep.ts'
import * as ws from '../../src/node/external_packages/ws.ts'
import { REPLACED_EXTERNAL_PACKAGES } from '../../src/node/external_packages/replaced-externals.ts'
import * as fs from '../../src/node/builtin_modules/implemented/fs.ts'
import * as os from '../../src/node/builtin_modules/implemented/os.ts'
import * as perfHooks from '../../src/node/builtin_modules/implemented/perf_hooks.ts'
import { DSH_HOME, DSH_TMP } from '../../src/storage/paths.ts'
/** Every refusal writes its message to the console before throwing; keep the run quiet. */
const quiet = (): void => { vi.spyOn(console, 'error').mockImplementation(() => {}) }
/** Symbols that refuse when called. */
const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
['node:net', net, ['createServer', 'connect']],
['node:sqlite', sqlite, ['backup']],
['node:vm', vm, ['createContext', 'runInContext', 'runInNewContext', 'runInThisContext', 'isContext']],
['node:worker_threads', workerThreads, ['MessageChannel', 'MessagePort', 'markAsUntransferable', 'receiveMessageOnPort']],
// The rest of `node:child_process` runs commands (see child-process.spec.ts);
// these three need a real process, so they stay refusals.
['node:child_process', childProcess, ['execFileSync', 'execSync', 'fork']],
['node:stream', stream, ['Readable', 'Writable', 'Duplex', 'Transform', 'PassThrough', 'pipeline', 'finished']],
['node-pty', nodePty, ['spawn', 'open']],
['@deepseek-ai/node-addon-landlock-run', landlock, ['probe']],
['@deepseek-ai/pi-ai', piAi, [
'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi',
'isContextOverflow', 'getSupportedThinkingLevels',
]],
]
/** Classes that refuse when constructed. */
const CONSTRUCTED: [string, Record<string, unknown>, readonly string[]][] = [
['node:sqlite', sqlite, ['DatabaseSync', 'StatementSync']],
['node:vm', vm, ['Script']],
['node:worker_threads', workerThreads, ['Worker']],
['node:perf_hooks', perfHooks, ['PerformanceObserver']],
['ws', ws, ['WebSocket']],
]
describe('not-implemented stubs', () => {
it('names the module and the symbol, and reports before throwing', () => {
const reported = vi.spyOn(console, 'error').mockImplementation(() => {})
const error = notAvailableError('node:zlib', 'gzipSync')
expect(error.message).toBe('web-preview: node:zlib.gzipSync is not available in the worker host')
expect(reported).toHaveBeenCalledWith(error.message)
const stub = notImplementedFail('node:zlib', 'gzipSync')
expect(() => stub()).toThrow(error.message)
})
for (const [module, namespace, members] of CALLED) {
it(`${module} refuses ${String(members.length)} called symbol(s)`, () => {
quiet()
for (const member of members) {
const value = namespace[member]
expect(typeof value, member).toBe('function')
expect(() => (value as () => unknown)(), member).toThrow(new RegExp(`${member}\\b.*not available in the worker host`))
}
})
}
for (const [module, namespace, members] of CONSTRUCTED) {
it(`${module} refuses ${String(members.length)} constructed symbol(s)`, () => {
quiet()
for (const member of members) {
const value = namespace[member]
expect(typeof value, member).toBe('function')
expect(() => new (value as new () => unknown)(), member).toThrow(/not available in the worker host/)
}
})
}
it('keeps the CommonJS interop marker and a default export on every replaced module', () => {
for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, chokidar, ws, nodePty, piAi, os, perfHooks]) {
const holder = namespace as { __esModule?: unknown; default?: unknown }
expect(holder.__esModule).toBe(true)
expect(holder.default).toBeDefined()
}
})
})
describe('constructible-but-inert fakes', () => {
// These two are constructed in `[Service.init]` bodies and field initializers,
// so construction must succeed; only the members that would move bytes refuse.
it('chokidar watches nothing and says so by never emitting', async () => {
const watcher = chokidar.watch()
expect(watcher).toBeInstanceOf(chokidar.FSWatcher)
expect(watcher.on()).toBe(watcher)
expect(watcher.once()).toBe(watcher)
expect(watcher.add()).toBe(watcher)
expect(watcher.unwatch()).toBe(watcher)
expect(watcher.getWatched()).toEqual({})
await expect(watcher.close()).resolves.toBeUndefined()
})
it('a ws server constructs, accepts listeners, and refuses to carry an upgrade', () => {
quiet()
expect(ws.Server).toBe(ws.WebSocketServer)
const server = new ws.WebSocketServer()
expect(server.clients.size).toBe(0)
expect(server.on()).toBe(server)
expect(() => server.handleUpgrade()).toThrow(/WebSocketServer.handleUpgrade is not available/)
expect(() => server.emit()).toThrow(/WebSocketServer.emit is not available/)
let closed = false
server.close(() => { closed = true })
expect(closed).toBe(true)
})
})
describe('replaced external packages', () => {
it('lists the packages the loader serves from the bundle', () => {
expect(REPLACED_EXTERNAL_PACKAGES).toContain('chokidar')
expect(REPLACED_EXTERNAL_PACKAGES).toContain('ws')
})
it('answers the values callers read without invoking anything', () => {
// The ripgrep binary path and the landlock launcher are read as data by
// consumers that then fail on their own terms.
expect(typeof ripgrep.rgPath).toBe('string')
expect(typeof landlock.LAUNCHER_BIN).toBe('string')
expect(typeof landlock.LAUNCHER_FAILURE_EXIT).toBe('number')
})
})
describe('node:net address predicates', () => {
it('classifies IPv4, IPv6, and neither', () => {
expect([net.isIPv4('127.0.0.1'), net.isIPv4('255.255.255.255')]).toEqual([true, true])
expect([net.isIPv4('256.0.0.1'), net.isIPv4('::1'), net.isIPv4('nope')]).toEqual([false, false, false])
expect([net.isIPv6('::1'), net.isIPv6('fe80::1'), net.isIPv6('127.0.0.1')]).toEqual([true, true, false])
expect([net.isIP('127.0.0.1'), net.isIP('::1'), net.isIP('nope')]).toEqual([4, 6, 0])
})
it('constructs a Socket but refuses to move bytes through it', () => {
const socket = new net.Socket()
expect(() => socket.write()).toThrow(/Socket.write is not available/)
expect(() => socket.end()).toThrow(/Socket.end is not available/)
// Disposal paths run against sockets that were never connected.
expect(() => { socket.destroy() }).not.toThrow()
})
})
describe('node:os', () => {
it('reports the virtual platform identity and the VFS directories', () => {
expect([os.EOL, os.tmpdir(), os.homedir()]).toEqual(['\n', DSH_TMP, DSH_HOME])
expect([os.platform(), os.type(), os.arch()]).toEqual(['linux', 'Linux', 'x64'])
expect([os.release(), os.hostname()]).toEqual(['0.0.0-dsh-worker', 'dsh-worker'])
})
it('reports no per-core facts and no network interfaces', () => {
expect(os.cpus()).toEqual([])
// The worker webserver binds the loopback literal, so a LAN address is never
// derived — and an empty record keeps it out of the trust snapshot.
expect(os.networkInterfaces()).toEqual({})
expect(os.availableParallelism()).toBeGreaterThanOrEqual(1)
})
it('maps the terminal signal names its consumer reads', () => {
expect(os.constants.signals.SIGTERM).toBe(15)
expect(os.constants.signals.SIGKILL).toBe(9)
})
})
describe('node:perf_hooks', () => {
it("hands over the worker's own clock", () => {
expect(perfHooks.performance).toBe(globalThis.performance)
expect(perfHooks.performance.now()).toBeGreaterThan(0)
})
})
describe('watching', () => {
// Watching stays a loud refusal because `skill-filesystem` AWAITS watcher
// progress rather than merely registering a listener; an inert watcher left
// its discovery hanging. `fs.ts` records the experiment and the mechanism.
it('refuses, naming the member, so an awaiting caller fails fast', () => {
quiet()
expect(() => fs.watchFile('/dsh/config/cordis.yml')).toThrow(/watchFile is not implemented in the worker host/)
})
it('accepts the unconditional teardown call, since nothing was watched', () => {
expect(() => { fs.unwatchFile() }).not.toThrow()
})
})
@@ -0,0 +1,73 @@
/**
* Differential check of this package's POSIX path shim against Node's
* `path.posix`.
*
* Differential rather than example-based: the shim's contract is "behaves like
* `node:path/posix`", so Node itself is the oracle and every case is compared
* rather than asserted against a hand-written expectation. The corpus is the
* shapes a VFS path actually takes (absolute image paths, `node_modules`
* specifiers, `.bin` entries) plus the edge forms that historically diverge
* (repeated slashes, trailing dots, `..` past the root).
*
* Migrated from apps/web-preview/scripts/checks/path-diff.ts. Imports go through
* the package name so the harness and the shim resolve to one module instance
* (see `../polyfill/als-shim.spec.ts` for why that matters).
*/
import { expect, test } from 'vitest'
import { posix as nodePosix } from 'node:path'
import * as shim from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/path.ts'
const CASES = [
'', '.', '..', '/', '//', '///', 'a', '/a', 'a/', '/a/', 'a/b', '/a/b/c', 'a//b', '/a//b/',
'./a', '../a', 'a/./b', 'a/../b', '/a/../..', '/../a', 'a/b/../../c', '.hidden', 'a.b.c',
'/a/b/c.txt', 'c.txt', '.txt', 'a/.txt', 'a/b.', '/a/b/.', '/a/b/..', 'foo/bar/../baz/./qux',
'/dsh/node_modules/@deepseek-ai/dsh-session/lib/index.js', 'node_modules/.bin/x',
]
const JOINS: string[][] = [
['a', 'b'], ['/a', 'b'], ['a', '/b'], ['a', '..'], ['a', '../..'], ['', 'b'], ['a', ''],
['/dsh', 'config', 'cordis.yml'], ['/dsh/node_modules', '@scope/pkg', 'lib/index.js'],
['a/', '/b'], ['.', 'a'], ['..', 'a'], ['/', 'a'], [],
]
const RESOLVES: string[][] = [
['a'], ['/a', 'b'], ['/a', '/b'], ['a', '..'], ['/dsh', './config/../config/cordis.yml'],
['/a/b', '../c'], ['/'], ['', 'a'], ['/dsh/node_modules/pkg', './lib/../lib/index.js'],
]
const RELATIVES: [string, string][] = [
['/a/b', '/a/b/c'], ['/a/b/c', '/a/b'], ['/a', '/b'], ['/a/b', '/a/b'], ['/', '/a'],
['/dsh/node_modules/a', '/dsh/node_modules/b/lib/x.js'],
]
const compare = (label: string, actual: unknown, expected: unknown): void => {
const [shimmed, node] = [JSON.stringify(actual), JSON.stringify(expected)]
test(label, () => { expect(shimmed).toBe(node) })
}
// resolve() consults process.cwd() on both sides; pin it so they agree, then put
// it back before any case runs so the rest of the run keeps the repository root.
const originalCwd = process.cwd()
process.chdir('/')
for (const value of CASES) {
for (const fn of ['normalize', 'dirname', 'basename', 'extname', 'isAbsolute', 'parse'] as const) {
try {
compare(`${fn}(${JSON.stringify(value)})`, (shim[fn] as (v: string) => unknown)(value), (nodePosix[fn] as (v: string) => unknown)(value))
} catch (error) {
const thrown = String(error)
test(`${fn}(${JSON.stringify(value)}) does not throw`, () => { expect.unreachable(thrown) })
}
}
compare(`basename(${JSON.stringify(value)}, '.txt')`, shim.basename(value, '.txt'), nodePosix.basename(value, '.txt'))
}
for (const parts of JOINS) compare(`join(${JSON.stringify(parts)})`, shim.join(...parts), nodePosix.join(...parts))
for (const parts of RESOLVES) compare(`resolve(${JSON.stringify(parts)})`, shim.resolve(...parts), nodePosix.resolve(...parts))
for (const [from, to] of RELATIVES) compare(`relative(${from}, ${to})`, shim.relative(from, to), nodePosix.relative(from, to))
for (const value of CASES) {
const parsed = nodePosix.parse(value)
compare(`format(parse(${JSON.stringify(value)}))`, shim.format(parsed), nodePosix.format(parsed))
}
process.chdir(originalCwd)
@@ -0,0 +1,47 @@
/**
* The worker's process shim: the layout-derived environment and the Node 22
* `getBuiltinModule` face, which must answer the loader's module proxies for
* builtin ids and undefined for everything else — never an image resolution.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { installProcessGlobal } from '../../src/node/globals/process.ts'
import { setActiveModuleLoader, WorkerModuleLoader } from '../../src/module-system/module-loader.ts'
import { MemoryVfs } from '../../src/storage/memory.ts'
const realProcess = globalThis.process
afterEach(() => {
;(globalThis as { process: unknown }).process = realProcess
})
describe('process shim', () => {
it('publishes cwd, env, and version zero for the loader probe', () => {
const shim = installProcessGlobal({ cwd: '/dsh', env: { DSH_HOME: '/dsh/home' } })
expect(shim.cwd()).toBe('/dsh')
expect(shim.env.DSH_HOME).toBe('/dsh/home')
// "0.0.0" keeps the vendored Loader off Node internals so the worker owns
// the module seam.
expect(shim.versions.node).toBe('0.0.0')
})
it('answers getBuiltinModule from the module proxies and undefined otherwise', () => {
const fs = { marker: 'fs-proxy' }
// The table holds factories, and a builtin must keep one identity across
// requires (`instanceof`, `Buffer.isBuffer`), so this one answers with the
// same object every time.
const factory = (): unknown => fs
const vfs = new MemoryVfs()
vfs.seedDirectory('/dsh')
const loader = new WorkerModuleLoader({
vfs,
root: '/dsh',
staticModules: { 'node:fs': factory, 'fs': factory },
})
setActiveModuleLoader(loader)
const shim = installProcessGlobal({ cwd: '/dsh', env: {} })
// The shim calls the factory: a caller receives the module, never the thunk.
expect(shim.getBuiltinModule('fs')).toBe(fs)
expect(shim.getBuiltinModule('node:fs')).toBe(fs)
expect(shim.getBuiltinModule('no-such-builtin')).toBeUndefined()
})
})
@@ -0,0 +1,55 @@
/**
* `node:timers/promises` over the worker's timer globals.
*
* The abort paths are the substance. Harness code hands these waits a
* cancellation signal, and an already-aborted signal emits no further `abort`
* event — so a wait that only subscribes runs its full delay before answering,
* which is a cancelled operation that still costs its timeout. The delays below
* are a minute long on purpose: any case that waited would fail by timing out
* rather than pass slowly.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { scheduler, setImmediate, setTimeout } from '../../src/node/builtin_modules/implemented/timers/promises.ts'
afterEach(() => { vi.restoreAllMocks() })
/** The rejection reason, however the wait failed. */
const rejectionOf = async (pending: Promise<unknown>): Promise<unknown> =>
await pending.then(() => undefined, (error: unknown) => error)
describe('setTimeout', () => {
it('resolves with the value after the delay, and undefined without one', async () => {
await expect(setTimeout(1, 'done')).resolves.toBe('done')
await expect(setTimeout(1)).resolves.toBeUndefined()
})
it('rejects an already-aborted signal without arming a timer', async () => {
const armed = vi.spyOn(globalThis, 'setTimeout')
const pending = setTimeout(60_000, 'never', { signal: AbortSignal.abort() })
// Asserted before any await, so nothing else could have armed a timer here.
expect(armed).not.toHaveBeenCalled()
expect(await rejectionOf(pending)).toMatchObject({ name: 'AbortError' })
})
it('rejects and releases the timer when the signal aborts while pending', async () => {
const cleared = vi.spyOn(globalThis, 'clearTimeout')
const controller = new AbortController()
const pending = setTimeout(60_000, 'never', { signal: controller.signal })
controller.abort()
expect(await rejectionOf(pending)).toMatchObject({ name: 'AbortError' })
expect(cleared).toHaveBeenCalled()
})
})
describe('the rest of the face', () => {
it('resolves setImmediate on a later macrotask with its value', async () => {
await expect(setImmediate('now')).resolves.toBe('now')
})
it('settles both scheduler helpers and forwards the signal through wait', async () => {
await expect(scheduler.wait(1)).resolves.toBeUndefined()
await expect(scheduler.yield()).resolves.toBeUndefined()
const aborted = scheduler.wait(60_000, { signal: AbortSignal.abort() })
expect(await rejectionOf(aborted)).toMatchObject({ name: 'AbortError' })
})
})
@@ -0,0 +1,394 @@
/**
* Semantic check of the suspension runtime (`src/polyfill/async-context/als-runtime.ts`): the object the
* transformed modules call at every suspension point.
*
* Scope boundary, and why this file does not need the Node-compatibility layer:
* `als-runtime.ts` owns no state. It moves snapshots through an injected
* {@link AlsCausality} face, and the state itself lives in the
* `node:async_hooks` proxy. So the causality face is stubbed here with a
* recording double, which makes the *ordering* contract — the part transformed
* code depends on — directly observable:
*
* - `pause` captures BEFORE suspending (not after), so the snapshot belongs to
* the frame that suspended;
* - `resume` restores BEFORE returning or rethrowing, so the resumed frame's
* first observable act is already in the right context;
* - both completion paths do this, which is why the token always fulfills.
*
* The shim-backed end of the same contract (does a real AsyncLocalStorage
* actually fold, do the hooks cover timers) is `als-shim.spec.ts`, and the
* cross-session behavioural proof is the browser concurrency probe. This file is
* the middle layer: the protocol, in isolation.
*/
import { expect, test } from 'vitest'
import { createAlsRuntime, type AlsCausality, type AlsToken } from '../../src/polyfill/async-context/als-runtime.ts'
const check = (label: string, actual: unknown, expected: unknown): void => {
const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
test(label, () => { expect(seen).toBe(wanted) })
}
/**
* A causality double standing in for the `node:async_hooks` proxy: one mutable
* "current store" plus a log, so every snapshot/restore is observable in order.
*/
function recordingCausality(): {
readonly causality: AlsCausality
readonly log: string[]
current: string
} {
const state = {
current: 'root',
log: [] as string[],
causality: {
snapshot: (): unknown => {
state.log.push(`snapshot:${state.current}`)
return state.current
},
restore: (snapshot: unknown): void => {
state.current = snapshot as string
state.log.push(`restore:${state.current}`)
},
},
}
return state
}
// ---------------------------------------------------------------------------
// 1. pause: capture before suspending, and always fulfill.
// ---------------------------------------------------------------------------
{
const state = recordingCausality()
const als = createAlsRuntime(state.causality)
state.current = 'session-A'
const pending = als.pause('value')
// The capture is synchronous with the call, before any microtask can run: that
// is what makes the snapshot belong to the suspending frame.
check('pause captures synchronously, before suspending', state.log, ['snapshot:session-A'])
// Something else runs on this thread while the frame is suspended.
state.current = 'session-B'
const token = await pending
check('token reports fulfilment', token.ok, true)
check('token carries the awaited value', token.value, 'value')
check('token carries the snapshot taken at pause time', token.snapshot, 'session-A')
check('pause does not restore by itself', state.current, 'session-B')
}
{
// A rejection must travel INSIDE the token, so the token itself always
// fulfills; otherwise `await __als.pause(x)` would throw before `resume` had a
// chance to restore, and the catch clause would run in the wrong context.
const state = recordingCausality()
const als = createAlsRuntime(state.causality)
state.current = 'session-R'
const failure = new Error('boom')
const token = await als.pause(Promise.reject(failure))
check('a rejection does not reject the token', token.ok, false)
check('the token carries the error', token.error, failure)
check('the rejected token still carries the snapshot', token.snapshot, 'session-R')
}
{
// Non-promise and thenable inputs both work: the rewrite wraps every `await`
// operand, most of which are not promises.
const als = createAlsRuntime(recordingCausality().causality)
check('pause accepts a plain value', (await als.pause(7)).value, 7)
check('pause accepts a thenable', (await als.pause({ then: (resolve: (v: unknown) => void) => { resolve('t') } })).value, 't')
const nested = await als.pause(Promise.resolve(Promise.resolve('deep')))
check('pause unwraps a nested promise', nested.value, 'deep')
}
// ---------------------------------------------------------------------------
// 2. resume: restore before handing control back, on both paths.
// ---------------------------------------------------------------------------
{
const state = recordingCausality()
const als = createAlsRuntime(state.causality)
state.current = 'session-A'
const token = await als.pause('payload')
state.current = 'someone-else'
state.log.length = 0
const value = als.resume(token)
check('resume returns the value', value, 'payload')
check('resume restored the captured snapshot', state.current, 'session-A')
check('resume restores exactly once', state.log, ['restore:session-A'])
}
{
// The rejection path restores too, and only then rethrows: a catch clause must
// observe the caller's store, which is the case the browser probe pinned.
const state = recordingCausality()
const als = createAlsRuntime(state.causality)
state.current = 'session-C'
const failure = new Error('nope')
const token = await als.pause(Promise.reject(failure))
state.current = 'someone-else'
let caught: unknown
try {
als.resume(token)
} catch (reason) {
caught = reason
}
check('resume rethrows the original error', caught, failure)
check('resume restored the context before rethrowing', state.current, 'session-C')
}
{
// Two frames suspended at once must not cross: this is the single-threaded
// shape of the concurrency bug the whole protocol exists to prevent.
const state = recordingCausality()
const als = createAlsRuntime(state.causality)
state.current = 'lane-1'
const first = als.pause('one')
state.current = 'lane-2'
const second = als.pause('two')
const [tokenA, tokenB] = await Promise.all([first, second])
state.current = 'root'
check('interleaved pauses keep their own snapshots', [tokenA.snapshot, tokenB.snapshot], ['lane-1', 'lane-2'])
als.resume(tokenA)
check('resuming the first frame restores lane-1', state.current, 'lane-1')
als.resume(tokenB)
check('resuming the second frame restores lane-2', state.current, 'lane-2')
}
// ---------------------------------------------------------------------------
// 3. snapshot / afterYield: the generator half.
// ---------------------------------------------------------------------------
{
const state = recordingCausality()
const als = createAlsRuntime(state.causality)
state.current = 'gen-A'
const captured = als.snapshot()
check('snapshot returns the current store', captured, 'gen-A')
// While suspended at a `yield`, the consumer may run anything.
state.current = 'consumer'
const sent = als.afterYield(captured, 'sent-value')
check('afterYield passes the consumer value through unchanged', sent, 'sent-value')
check('afterYield restores the generator context', state.current, 'gen-A')
}
{
// afterYield must be transparent to every value shape, including undefined:
// `yield x` with no `next(v)` sends undefined, and swallowing it would change
// the generator's observable behaviour.
const als = createAlsRuntime(recordingCausality().causality)
check('afterYield passes undefined through', als.afterYield('s', undefined), undefined)
check('afterYield passes null through', als.afterYield('s', null), null)
const object = { a: 1 }
check('afterYield passes an object through by identity', als.afterYield('s', object) === object, true)
}
// ---------------------------------------------------------------------------
// 4. iterator: async sources pass through, sync sources are adapted.
// ---------------------------------------------------------------------------
{
const als = createAlsRuntime(recordingCausality().causality)
// An async iterable's own iterator is used directly (no wrapping), so its
// `return`/`throw` stay whatever the source provided.
const inner = { next: () => Promise.resolve({ done: true, value: undefined }) }
const source = { [Symbol.asyncIterator]: () => inner }
check('an async iterable yields its own iterator', als.iterator(source) === inner, true)
}
{
const als = createAlsRuntime(recordingCausality().causality)
// Async-from-sync: a sync iterator whose values are promises must be awaited,
// because `for await` awaits each value.
const source = {
[Symbol.iterator]: () => [Promise.resolve('a'), Promise.resolve('b')][Symbol.iterator](),
}
const iterator = als.iterator(source)
check('sync source step 1 is awaited', await iterator.next(), { done: false, value: 'a' })
check('sync source step 2 is awaited', await iterator.next(), { done: false, value: 'b' })
check('sync source reports completion', (await iterator.next()).done, true)
}
{
const als = createAlsRuntime(recordingCausality().causality)
// `return()` on the adapter must reach the sync iterator's own `return`,
// because that is where a generator's `finally` runs.
let closed = 0
const source = {
[Symbol.iterator]: () => ({
next: () => ({ done: false, value: 1 }),
return: (sent?: unknown) => {
closed += 1
return { done: true, value: sent }
},
}),
}
const iterator = als.iterator(source)
await iterator.next()
const result = await iterator.return?.('bye')
check('adapter forwards return to the sync iterator', closed, 1)
check('adapter reports the forwarded return result', result, { done: true, value: 'bye' })
}
{
const als = createAlsRuntime(recordingCausality().causality)
// A sync iterator with no `return` must not crash the adapter: plain array
// iterators have one, but hand-rolled ones often do not.
const iterator = als.iterator({ [Symbol.iterator]: () => ({ next: () => ({ done: true, value: undefined }) }) })
check('adapter tolerates a sync iterator without return', await iterator.return?.(undefined), { done: true, value: undefined })
}
{
const als = createAlsRuntime(recordingCausality().causality)
// A non-iterable is a programming error in the transformed source, and must be
// a loud TypeError rather than a silent empty loop.
const rejects = (label: string, value: unknown): void => {
let outcome: string
try {
als.iterator(value)
outcome = 'no TypeError'
} catch (reason) {
outcome = reason instanceof TypeError ? 'TypeError' : `no TypeError: ${String(reason)}`
}
test(label, () => { expect(outcome).toBe('TypeError') })
}
rejects('a plain object is not iterable', {})
rejects('a number is not iterable', 7)
rejects('null is not iterable', null)
rejects('undefined is not iterable', undefined)
}
// ---------------------------------------------------------------------------
// 5. close: teardown that cannot itself become the failure.
// ---------------------------------------------------------------------------
{
const als = createAlsRuntime(recordingCausality().causality)
let closed = 0
const iterator = {
next: () => Promise.resolve({ done: true, value: undefined }),
return: (): Promise<IteratorResult<unknown>> => {
closed += 1
return Promise.resolve({ done: true, value: 'closed' })
},
}
check('close forwards the iterator result', await als.close(iterator), { done: true, value: 'closed' })
check('close calls return exactly once', closed, 1)
}
{
const als = createAlsRuntime(recordingCausality().causality)
// An iterator that throws while closing has nothing left to release, and the
// loop is already leaving: swallowing keeps the original failure (or the
// `break`) as the observable outcome instead of masking it with a teardown error.
const throwing = {
next: () => Promise.resolve({ done: true, value: undefined }),
return: (): Promise<IteratorResult<unknown>> => Promise.reject(new Error('teardown exploded')),
}
check('close swallows a failing return', await als.close(throwing), undefined)
const synchronouslyThrowing = {
next: () => Promise.resolve({ done: true, value: undefined }),
return: (): Promise<IteratorResult<unknown>> => { throw new Error('teardown exploded synchronously') },
}
check('close swallows a synchronously throwing return', await als.close(synchronouslyThrowing), undefined)
}
{
const als = createAlsRuntime(recordingCausality().causality)
// No `return` at all: nothing to do, and no crash.
check('close tolerates an iterator without return', await als.close({ next: () => Promise.resolve({ done: true, value: undefined }) }), undefined)
}
// ---------------------------------------------------------------------------
// 6. The inert runtime. `?als=inert` is the browser probe's control arm: the
// rewrite still runs and still hops a microtask, but no state moves. That
// control must be genuinely inert, or the probe loses its discriminating power.
// ---------------------------------------------------------------------------
{
const inert = createAlsRuntime()
check('inert snapshot is undefined', inert.snapshot(), undefined)
const token = await inert.pause('value')
check('inert pause still fulfills with the value', [token.ok, token.value], [true, 'value'])
check('inert pause carries an undefined snapshot', token.snapshot, undefined)
check('inert resume still returns the value', inert.resume(token), 'value')
// Failure semantics must not change with the causality face withheld —
// otherwise the control arm would differ in error handling as well as in
// context propagation, and the comparison would prove nothing.
const failure = new Error('inert boom')
const rejected = await inert.pause(Promise.reject(failure))
check('inert pause reports rejection in the token', rejected.ok, false)
let caught: unknown
try {
inert.resume(rejected)
} catch (reason) {
caught = reason
}
check('inert resume still rethrows', caught, failure)
check('inert afterYield is still transparent', inert.afterYield(undefined, 'sent'), 'sent')
// The iterator and close verbs are pure plumbing and must work identically.
const iterator = inert.iterator({ [Symbol.iterator]: () => ['x'][Symbol.iterator]() })
check('inert iterator still adapts a sync source', await iterator.next(), { done: false, value: 'x' })
check('inert close still resolves', await inert.close({ next: () => Promise.resolve({ done: true, value: undefined }) }), undefined)
}
{
// The one thing the inert arm must NOT do: keep a store alive across a
// suspension. This is the assertion that gives the control arm its meaning.
const inert = createAlsRuntime()
const token = await inert.pause('v')
const before = inert.snapshot()
inert.resume(token)
check('inert resume moves no state', [before, inert.snapshot()], [undefined, undefined])
}
// ---------------------------------------------------------------------------
// 7. The two runtimes are independent instances (the loader builds one per
// boot, and a stray shared closure would couple them).
// ---------------------------------------------------------------------------
{
const first = recordingCausality()
const second = recordingCausality()
const alsA = createAlsRuntime(first.causality)
const alsB = createAlsRuntime(second.causality)
first.current = 'A'
second.current = 'B'
const tokenA = await alsA.pause(1)
const tokenB = await alsB.pause(2)
check('each runtime captures through its own causality face', [tokenA.snapshot, tokenB.snapshot], ['A', 'B'])
first.current = 'moved'
alsA.resume(tokenA)
check('restoring through one runtime does not touch the other', [first.current, second.current], ['A', 'B'])
}
// ---------------------------------------------------------------------------
// 8. The token shape the transform emits against, pinned as a type-level and
// runtime contract (the emitted code reads `.ok`, `.value`, `.error`,
// `.snapshot` directly).
// ---------------------------------------------------------------------------
{
const als = createAlsRuntime(recordingCausality().causality)
const fulfilled: AlsToken = await als.pause('v')
check('a fulfilled token exposes ok/value/snapshot', Object.keys(fulfilled).sort(), ['ok', 'snapshot', 'value'])
const rejected: AlsToken = await als.pause(Promise.reject(new Error('e')))
check('a rejected token exposes ok/error/snapshot', Object.keys(rejected).sort(), ['error', 'ok', 'snapshot'])
}
@@ -0,0 +1,394 @@
/**
* Behavioural check of the folding AsyncLocalStorage shim: the two shapes the
* agent service actually uses (nested instances; a boundary whose operation
* returns a promise), the hook layer that carries a registration context into a
* callback, and the explicit-switch slots the module transform's `await`
* rewriting drives.
*
* Layering, because three mechanisms answer `getStore()` and the cases below
* pick them apart deliberately:
* 1. the **folding stack** — `run()` boundaries, unwound by identity;
* 2. the **hook layer** — patched `then`/timers, so a callback reads the store
* from where it was REGISTERED rather than where it runs;
* 3. the **explicit switch** — `__snapshotAll`/`__restoreAll` and the
* `alsCausality` face, which the transformed modules reach at every
* suspension point. This is the layer that survives true interleaving, and
* case 17 is the one that shows the folding stack alone cannot.
*
* Scope boundary: this file owns the shim (the state). `als-runtime.spec.ts`
* owns the protocol that moves snapshots around, with the causality face stubbed.
* The cross-session end-to-end proof is the browser concurrency probe, whose
* control arm (`?als=inert`) relies on the protocol being genuinely inert.
*
* Migrated from apps/web-preview/scripts/checks/als-check.ts after the Node
* compatibility layer was reorganized into implemented/mock/globals. Every import
* goes through the **package name**, not a relative path: a check that reached
* built `lib/` while the shim resolved by package name to `src/` produced two
* module instances and a shim mounted in the wrong world (the `fs-check`
* incident — "no filesystem is mounted"). One resolution path per module.
*/
import { expect, test } from 'vitest'
import {
AsyncLocalStorage, __restoreAll, __snapshotAll, alsCausality, runAtAsyncContextRoot,
} from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/async_hooks.ts'
import { installAsyncContextHooks } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/polyfill/async-context/async-context-hooks.ts'
import { installTimerGlobals } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/globals/timers.ts'
// Same order the worker entry uses: patch the platform, then wrap the timers over
// the patched platform. The folding cases below must hold with both in place.
installAsyncContextHooks()
installTimerGlobals()
// Both sides are serialized at call time, not inside the case: several blocks
// below reuse a mutable array as the observed value, so a captured reference
// would read a later block's state by the time the case executes.
const check = (label: string, actual: unknown, expected: unknown): void => {
const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)]
test(label, () => { expect(seen).toBe(wanted) })
}
const delay = (ms = 0): Promise<void> => new Promise((resolve) => { setTimeout(resolve, ms) })
// 0. The migration's own precondition: the hook layer really is installed over
// this module instance. If the check and the shim ever resolve to two
// instances again, the patched `then` below belongs to the other copy and
// every hook-layer case would silently test an unpatched platform.
{
const als = new AsyncLocalStorage<string>()
let seen: string | undefined = 'unset'
als.run('installed', () => { void Promise.resolve().then(() => { seen = als.getStore() }) })
await delay()
check('hook layer is installed over this module instance', seen, 'installed')
}
// 1. Synchronous operation: visible inside, gone after.
{
const als = new AsyncLocalStorage<string>()
const seen = als.run('sync', () => als.getStore())
check('sync body sees store', seen, 'sync')
check('sync boundary closes', als.getStore(), undefined)
check('sync return value preserved', als.run('x', () => 42), 42)
}
// 2. The reason for the upgrade: the store survives awaits inside the operation.
{
const als = new AsyncLocalStorage<string>()
const observed: (string | undefined)[] = []
const operation = async (): Promise<void> => {
observed.push(als.getStore())
await delay()
observed.push(als.getStore())
await delay(5)
observed.push(als.getStore())
}
const running = als.run('agent-1', operation)
observed.push(als.getStore())
await running
await delay()
check('store visible across awaits', observed, ['agent-1', 'agent-1', 'agent-1', 'agent-1'])
check('boundary closes after settle', als.getStore(), undefined)
}
// 3. Rejection still closes the boundary, and the caller still sees the rejection.
{
const als = new AsyncLocalStorage<string>()
const failing = als.run('doomed', async () => {
await delay()
throw new Error('operation failed')
})
const message = await failing.then(() => 'resolved', (error: unknown) => (error as Error).message)
check('rejection propagates', message, 'operation failed')
await delay()
check('boundary closes after rejection', als.getStore(), undefined)
}
// 4. A synchronous throw closes the boundary too.
{
const als = new AsyncLocalStorage<string>()
try {
als.run('thrower', () => { throw new Error('sync failure') })
} catch { /* expected */ }
check('boundary closes after sync throw', als.getStore(), undefined)
}
// 5. Nested boundaries on ONE instance unwind by identity, innermost first.
{
const als = new AsyncLocalStorage<string>()
const observed: (string | undefined)[] = []
await als.run('outer', async () => {
observed.push(als.getStore())
await als.run('inner', async () => {
await delay()
observed.push(als.getStore())
})
await delay()
observed.push(als.getStore())
})
await delay()
check('nested unwind', observed, ['outer', 'inner', 'outer'])
check('nested boundaries all closed', als.getStore(), undefined)
}
// 6. The agent service's real shape: two instances, the outer run returning the
// inner run's promise (agent/src/index.ts:649).
{
const runs = new AsyncLocalStorage<{ id: number }>()
const initiators = new AsyncLocalStorage<string>()
const observed: unknown[] = []
const operation = async (): Promise<void> => {
await delay()
// What requireInitiator() does, several awaits below the boundary.
observed.push([initiators.getStore(), runs.getStore()?.id])
}
const parent = runs.getStore()
check('parent chain empty at first boundary', parent, undefined)
await runs.run({ id: 1 }, () => initiators.run('agent-1', operation))
await delay()
check('both instances answered inside', observed, [['agent-1', 1]])
check('runs closed', runs.getStore(), undefined)
check('initiators closed', initiators.getStore(), undefined)
}
// 7. exit()/withoutInitiator hides the inherited store and restores it.
{
const als = new AsyncLocalStorage<string>()
const observed: (string | undefined)[] = []
await als.run('agent-1', async () => {
observed.push(als.getStore())
await als.exit(async () => {
await delay()
observed.push(als.getStore())
})
observed.push(als.getStore())
})
await delay()
check('exit hides then restores', observed, ['agent-1', undefined, 'agent-1'])
}
// 8. Interleaved boundaries: attribution follows the newest entry (the documented
// single-concurrency limit) but nothing throws and the stack fully unwinds.
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
const body = async (_label: string, ms: number): Promise<void> => {
await delay(ms)
seen.push(als.getStore())
}
const first = als.run('A', () => body('A', 20))
const second = als.run('B', () => body('B', 5))
await Promise.all([first, second])
await delay()
check('interleaved reads never crash', seen.length, 2)
check('interleaved reads resolve to an open boundary', seen.every(entry => entry === 'A' || entry === 'B'), true)
check('stack unwinds after interleaving', als.getStore(), undefined)
}
// 9. disable() drops everything, as teardown expects.
{
const als = new AsyncLocalStorage<string>()
const pending = als.run('leaked', async () => { await delay(50) })
als.disable()
check('disable clears the stack', als.getStore(), undefined)
await pending
}
// 10. HOOK LAYER: a `.then` callback reads the store from where it was REGISTERED,
// even though the registering boundary is long closed by the time it runs.
{
const als = new AsyncLocalStorage<string>()
let seen: string | undefined = 'unset'
const promise = new Promise<void>((resolve) => { setTimeout(resolve, 10) })
als.run('registrar', () => { void promise.then(() => { seen = als.getStore() }) })
check('boundary closed before the callback runs', als.getStore(), undefined)
await delay(30)
check('then callback reads the registration store', seen, 'registrar')
}
// 11. HOOK LAYER under interleaving: each callback reads ITS OWN registration
// store, which the folding stack alone could not distinguish.
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
const register = (label: string, ms: number): void => {
als.run(label, () => {
setTimeout(() => { seen.push(`${label}:${String(als.getStore())}`) }, ms)
void Promise.resolve().then(() => { seen.push(`${label}-then:${String(als.getStore())}`) })
queueMicrotask(() => { seen.push(`${label}-micro:${String(als.getStore())}`) })
})
}
register('A', 20)
register('B', 5)
await delay(40)
check('interleaved timers read their own store', seen.filter((entry): entry is string => entry !== undefined && entry.includes(':')).sort(), [
'A-micro:A', 'A-then:A', 'A:A', 'B-micro:B', 'B-then:B', 'B:B',
])
}
// 12. `catch`/`finally` inherit the patched `then` (they invoke it on the receiver).
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
const rejected = Promise.reject(new Error('boom'))
const settled = als.run('handler', () => rejected
.catch(() => { seen.push(als.getStore()) })
.finally(() => { seen.push(als.getStore()) }))
await settled
await delay()
check('catch and finally carry the registration store', seen, ['handler', 'handler'])
}
// 13. An empty handler slot stays empty: a rejection must not be swallowed by a
// wrapper standing in for an absent fulfilled handler.
{
const als = new AsyncLocalStorage<string>()
const outcome = await als.run('slots', () => Promise
.reject(new Error('preserved'))
.then(undefined, (error: unknown) => `caught:${(error as Error).message}`))
check('empty fulfilled slot preserved', outcome, 'caught:preserved')
const passthrough = await als.run('slots', () => Promise.resolve('value').then(undefined, () => 'wrong'))
check('value passes an empty fulfilled slot', passthrough, 'value')
}
// 14. A boundary opened inside a restored callback owns its reads, and the overlay
// comes back afterwards.
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
als.run('outer', () => {
queueMicrotask(() => {
seen.push(als.getStore())
als.run('inner', () => { seen.push(als.getStore()) })
seen.push(als.getStore())
})
})
await delay(10)
check('nested run inside a restored callback', seen, ['outer', 'inner', 'outer'])
}
// 15. The tunnel entry's root context masks whatever was open before it.
{
const als = new AsyncLocalStorage<string>()
let seen: string | undefined = 'unset'
als.run('stale', () => {
runAtAsyncContextRoot(() => { seen = als.getStore() })
})
check('root context masks an open boundary', seen, undefined)
check('root context restores afterwards', als.getStore(), undefined)
}
// 16. Promises stay native: the patch wraps handlers, not the chain.
{
const als = new AsyncLocalStorage<string>()
const chained = als.run('native', () => Promise.resolve(1).then(value => value + 1))
check('then returns a native promise', chained instanceof Promise, true)
check('chained value', await chained, 2)
}
// 17. EXPLICIT SWITCH: a resumed frame reads what its pause point read, even
// while another boundary is open — this is what the loader's await rewriting
// buys over the folding stack.
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
// Frame A pauses inside its boundary…
let paused: ReturnType<typeof __snapshotAll> | undefined
als.run('A', () => { paused = __snapshotAll() })
// …an unrelated boundary opens and stays open…
const other = als.run('B', async () => { await delay(20) })
seen.push(als.getStore())
// …and frame A resumes: the ambient slot answers A, not the open B.
const release = __restoreAll(paused!)
seen.push(als.getStore())
release()
seen.push(als.getStore())
await other
await delay()
check('resume answers the paused context', seen, ['B', 'A', 'B'])
check('all slots empty afterwards', als.getStore(), undefined)
}
// 18. Two frames pausing and resuming alternately keep their own contexts. A
// disposer only ever undoes ITS OWN publish: released while shadowed it is a
// no-op (never clobbers the newer frame), and released on top it restores the
// context it shadowed — the frame that owned that context re-publishes at its
// next await anyway, and any new boundary shadows it.
{
const als = new AsyncLocalStorage<string>()
const seen: string[] = []
const pauseIn = (label: string): ReturnType<typeof __snapshotAll> => {
let snapshot: ReturnType<typeof __snapshotAll> | undefined
als.run(label, () => { snapshot = __snapshotAll() })
return snapshot!
}
const first = pauseIn('one')
const second = pauseIn('two')
const releaseFirst = __restoreAll(first)
seen.push(`first:${String(als.getStore())}`)
const releaseSecond = __restoreAll(second)
seen.push(`second:${String(als.getStore())}`)
releaseFirst() // out of order on purpose
seen.push(`afterFirstRelease:${String(als.getStore())}`)
releaseSecond()
seen.push(`afterSecondRelease:${String(als.getStore())}`)
check('interleaved resumes keep their own context', seen, [
'first:one', 'second:two', 'afterFirstRelease:two', 'afterSecondRelease:one',
])
}
// 19. The ambient slot outranks the folding stack but not a hook overlay: the
// documented slot order.
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
let paused: ReturnType<typeof __snapshotAll> | undefined
als.run('ambient', () => { paused = __snapshotAll() })
als.run('stack', () => {
const release = __restoreAll(paused!)
seen.push(als.getStore())
release()
seen.push(als.getStore())
})
check('ambient outranks the stack, stack returns after release', seen, ['ambient', 'stack'])
}
// 20. The rewriter's face (`AlsCausality`: snapshot + void restore) replaces the
// resumed slot instead of stacking, so repeated resumes cannot leak context.
{
const als = new AsyncLocalStorage<string>()
const pauseIn = (label: string): ReturnType<typeof alsCausality.snapshot> => {
let snapshot: ReturnType<typeof alsCausality.snapshot> | undefined
als.run(label, () => { snapshot = alsCausality.snapshot() })
return snapshot!
}
const one = pauseIn('one')
const two = pauseIn('two')
alsCausality.restore(one)
check('void restore publishes the paused context', als.getStore(), 'one')
alsCausality.restore(two)
check('a later resume replaces it', als.getStore(), 'two')
alsCausality.restore(one)
check('resuming the first frame again republishes its own', als.getStore(), 'one')
// A new boundary shadows the resumed slot, and the slot comes back after it.
als.run('boundary', () => { check('boundary shadows the resumed slot', als.getStore(), 'boundary') })
check('resumed slot returns after the boundary', als.getStore(), 'one')
als.disable()
check('disable clears the resumed slot', als.getStore(), undefined)
}
// 21. A rewritten frame's await round trip (pause → other work interleaves → resume)
// is exactly what `AlsRuntime.pause/resume` does with this face.
{
const als = new AsyncLocalStorage<string>()
const seen: (string | undefined)[] = []
const paused = await als.run('frame', async () => {
const captured = alsCausality.snapshot()
await delay(10)
return captured
})
const other = als.run('interleaved', async () => { await delay(30) })
seen.push(als.getStore())
alsCausality.restore(paused)
seen.push(als.getStore())
await other
await delay()
check('resume wins over an interleaved boundary', seen, ['interleaved', 'frame'])
}
@@ -0,0 +1,82 @@
/**
* Sync-stack AsyncLocalStorage semantics plus the causality faces the module
* loader's `await` rewriting consumes: run boundaries, ambient snapshots, and
* the context root the tunnel dispatches at.
*/
import { describe, expect, it } from 'vitest'
import {
AsyncLocalStorage, alsCausality, captureAsyncContext, runAtAsyncContextRoot, runWithAsyncContext,
} from '../../src/node/builtin_modules/implemented/async_hooks.ts'
describe('AsyncLocalStorage', () => {
it('publishes the store inside run and clears it outside', () => {
const als = new AsyncLocalStorage<string>()
expect(als.getStore()).toBeUndefined()
const returned = als.run('inner', () => {
expect(als.getStore()).toBe('inner')
return 42
})
expect(returned).toBe(42)
expect(als.getStore()).toBeUndefined()
})
it('shadows and restores across nested boundaries', () => {
const als = new AsyncLocalStorage<string>()
als.run('outer', () => {
expect(als.getStore()).toBe('outer')
als.run('inner', () => { expect(als.getStore()).toBe('inner') })
expect(als.getStore()).toBe('outer')
})
})
it('keeps the store visible until a returned promise settles', async () => {
const als = new AsyncLocalStorage<string>()
let during: string | undefined
await als.run('async', async () => {
await Promise.resolve()
during = als.getStore()
})
expect(during).toBe('async')
})
})
describe('causality faces', () => {
it('captures a context and republishes it inside runWithAsyncContext', () => {
const als = new AsyncLocalStorage<string>()
let snapshot: ReturnType<typeof captureAsyncContext>
als.run('captured', () => { snapshot = captureAsyncContext() })
expect(als.getStore()).toBeUndefined()
runWithAsyncContext(snapshot, () => {
expect(als.getStore()).toBe('captured')
})
expect(als.getStore()).toBeUndefined()
})
it('restores every live instance through the alsCausality pair', () => {
const first = new AsyncLocalStorage<string>()
const second = new AsyncLocalStorage<number>()
let paused: ReturnType<typeof alsCausality.snapshot> | undefined
first.run('a', () => {
second.run(7, () => { paused = alsCausality.snapshot() })
})
expect(first.getStore()).toBeUndefined()
expect(second.getStore()).toBeUndefined()
// The rewriting calls restore at a resume point with no place for a
// disposer; the published context answers reads after the await.
alsCausality.restore(paused!)
expect(first.getStore()).toBe('a')
expect(second.getStore()).toBe(7)
// A fresh boundary still wins over the resumed ambient context.
first.run('b', () => { expect(first.getStore()).toBe('b') })
})
it('dispatches at the context root without inheriting the caller boundary', () => {
const als = new AsyncLocalStorage<string>()
als.run('caller', () => {
runAtAsyncContextRoot(() => {
expect(als.getStore()).toBeUndefined()
})
expect(als.getStore()).toBe('caller')
})
})
})

Some files were not shown because too many files have changed in this diff Show More