mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-30 04:40:37 +00:00
674 lines
29 KiB
TypeScript
674 lines
29 KiB
TypeScript
/**
|
|
* 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,
|
|
IMAGE_OVERLAY_DIRECTORIES,
|
|
} 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 into the image at a caller-selected mount. */
|
|
export interface ImageTree {
|
|
/** Image path to mount it at, relative to the virtual root. */
|
|
readonly mount: string
|
|
/** Absolute source directory. */
|
|
readonly directory: string
|
|
}
|
|
|
|
/** One configuration tree whose plugin rows may extend the package roster. */
|
|
export interface ConfigTree extends ImageTree {
|
|
/**
|
|
* 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 out of the transform; like every JavaScript entry they carry the trailing debugger name. */
|
|
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
|
|
}
|
|
|
|
/** One deterministic data-overlay archive and its uncompressed entries. */
|
|
export interface PackOverlayResult {
|
|
/** Gzip-compressed ustar bytes consumed by the Worker host. */
|
|
readonly image: Uint8Array
|
|
/** Every path in the overlay before compression. */
|
|
readonly files: ImageFiles
|
|
}
|
|
|
|
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
|
|
* package/config collection flattens nested `node_modules` and prunes dot
|
|
* directories, while seed collection preserves every directory. Every file
|
|
* judgement comes in through `keep` (the {@link EXCLUDE} tables and the npm
|
|
* publish view, or an unconditional seed predicate).
|
|
* @param root - Source directory.
|
|
* @param into - Image entries to add to.
|
|
* @param prefix - Image path prefix.
|
|
* @param keep - Filter over root-relative paths.
|
|
* @param preserveDirectories - Whether dot directories and nested `node_modules`
|
|
* are ordinary fixture content rather than package-manager residue.
|
|
*/
|
|
function collectTree(
|
|
root: string,
|
|
into: ImageFiles,
|
|
prefix: string,
|
|
keep: (relativePath: string) => boolean,
|
|
preserveDirectories = false,
|
|
): void {
|
|
const walk = (directory: string): void => {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
if (!preserveDirectories && (entry.name === 'node_modules' || 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.
|
|
*/
|
|
/** Trailing `sourceMappingURL` comment; the image carries no `.map` files. */
|
|
const DANGLING_SOURCE_MAP = /\n\/\/# sourceMappingURL=\S+\s*$/
|
|
|
|
/**
|
|
* Name one JavaScript entry for the debugger: append the `sourceURL` magic
|
|
* comment V8 stacks and DevTools read, so the entry shows under its
|
|
* repository path instead of as an anonymous VM script (worker `new Function`
|
|
* bodies) or blob entry (page bundles). A trailing `sourceMappingURL` comment
|
|
* is stripped first — its `.map` never ships, and once the script has a name
|
|
* the debugger would resolve the reference against it and report a load
|
|
* failure per script. Only the final line is touched, so every other line
|
|
* keeps its number; evaluation cost stays at pack time, where the names are
|
|
* already deterministic.
|
|
* @param bytes - Entry body as the image would otherwise hold it.
|
|
* @param name - Debugger name for the entry.
|
|
* @param decoder - Shared UTF-8 decoder.
|
|
* @param encoder - Shared UTF-8 encoder.
|
|
* @returns The named body.
|
|
*/
|
|
function nameForDebugger(bytes: Uint8Array, name: string, decoder: TextDecoder, encoder: TextEncoder): Uint8Array {
|
|
const source = decoder.decode(bytes).replace(DANGLING_SOURCE_MAP, '\n')
|
|
return encoder.encode(`${source}\n//# sourceURL=${name}`)
|
|
}
|
|
|
|
/**
|
|
* Debugger names for image entries: a workspace or vendored package file is
|
|
* named by its repository path (`packages/<group>/<pkg>/lib/index.js`), the
|
|
* shape a reader navigates; an external package file keeps its image key —
|
|
* it has no repository path, and its pnpm store path would name a hash.
|
|
* @param workspaces - Package name → absolute repository directory.
|
|
* @param resolveFrom - Repository root the names are relative to.
|
|
* @returns Mapper from an image key to the entry's debugger name.
|
|
*/
|
|
function debuggerNamer(workspaces: ReadonlyMap<string, string>, resolveFrom: string): (key: string) => string {
|
|
const repoDirs = new Map(
|
|
[...workspaces].map(([name, directory]) => [name, relative(resolveFrom, directory).replaceAll('\\', '/')]),
|
|
)
|
|
return (key: string): string => {
|
|
if (!key.startsWith('node_modules/')) return key
|
|
const rest = key.slice('node_modules/'.length)
|
|
const segments = rest.split('/')
|
|
const packageName = segments[0]?.startsWith('@') === true ? segments.slice(0, 2).join('/') : segments[0] ?? ''
|
|
const directory = repoDirs.get(packageName)
|
|
return directory === undefined ? key : `${directory}${rest.slice(packageName.length)}`
|
|
}
|
|
}
|
|
|
|
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 untransformed 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 = {}
|
|
const debuggerName = debuggerNamer(options.workspaces, options.resolveFrom)
|
|
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] = isJs ? nameForDebugger(bytes, debuggerName(name), decoder, encoder) : bytes
|
|
if (isJs) javascriptEntries += 1
|
|
continue
|
|
}
|
|
const kept = reached.get(name)
|
|
if (kept === undefined) {
|
|
dropped += 1
|
|
continue
|
|
}
|
|
swept[name] = nameForDebugger(kept, debuggerName(name), decoder, encoder)
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pack opaque data trees into one ordered VFS overlay.
|
|
*
|
|
* Overlay mounts are restricted to the runtime-owned data directories, so an
|
|
* overlay cannot replace configuration, the lowering manifest, or modules.
|
|
* Files bypass package excludes and module reachability processing; later
|
|
* trees replace earlier files at the same path.
|
|
* @param trees - Absolute source directories and their data-directory mounts.
|
|
* @returns Deterministic compressed archive plus its uncompressed entries.
|
|
*/
|
|
export function packVfsOverlay(trees: readonly ImageTree[]): PackOverlayResult {
|
|
const files: ImageFiles = {}
|
|
for (const tree of trees) {
|
|
if (!existsSync(tree.directory)) {
|
|
throw new Error(`vfs overlay: tree ${tree.mount} is missing at ${tree.directory}`)
|
|
}
|
|
const mount = tree.mount.replace(/^\.\//, '').replace(/\/$/, '')
|
|
const first = mount.split('/')[0]
|
|
if (mount === '' || first === undefined || !IMAGE_OVERLAY_DIRECTORIES.includes(first)
|
|
|| mount.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
|
|
throw new Error(
|
|
`vfs overlay: mount ${JSON.stringify(tree.mount)} must stay under ${IMAGE_OVERLAY_DIRECTORIES.join(' or ')}`,
|
|
)
|
|
}
|
|
collectTree(tree.directory, files, mount, () => true, true)
|
|
}
|
|
return { image: compressImage(packTar(files)), files }
|
|
}
|