pack.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. /**
  2. * VFS image packer: turns one composed profile plus a package index into the single
  3. * gzip-compressed tar the browser runtime inflates and mounts as its filesystem.
  4. *
  5. * Nothing is compiled here. The image carries the repository's real build products,
  6. * so a preview deployment debugs exactly what the served deployment ships. What the
  7. * pass does add is the pack-time module transform and the manifest that records the
  8. * wrapper contract it was transformed against.
  9. *
  10. * This module holds no repository knowledge: paths, globs, and the composition come
  11. * in as parameters, so the same library packs a different tree by being called
  12. * differently. Locating those inputs is the CLI's job.
  13. * @module @deepseek-ai/dsh-experimental-webworker-packer/src/pack
  14. */
  15. import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
  16. import { dirname, join, relative } from 'node:path'
  17. import { gzipSync } from 'node:zlib'
  18. import {
  19. lowerModuleSource, MemoryVfs, packTar, WorkerModuleLoader,
  20. DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_MANIFEST_PATH,
  21. IMAGE_OVERLAY_DIRECTORIES,
  22. } from '@deepseek-ai/dsh-experimental-webworker-runtime'
  23. import picomatch from 'picomatch'
  24. import yaml from 'js-yaml'
  25. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  26. import { REPLACED_EXTERNAL_PACKAGES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/external_packages/replaced-externals.ts'
  27. import { MODULE_PROXIES, MODULE_PROXY_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies.ts'
  28. import { WRAPPER_CONTRACT, type ImageFiles, type TransformOutcome } from './transform-image.ts'
  29. import { EXCLUDE, EXCLUDE_WORKSPACE, IMAGE_ENTRY_SEEDS, PAGE_ASSETS } from './rules.ts'
  30. export { DEFAULT_ROOT } from '@deepseek-ai/dsh-experimental-webworker-runtime'
  31. /** Image path of the manifest; the layout contract's name, re-exported for callers. */
  32. export const MANIFEST_PATH: string = IMAGE_MANIFEST_PATH
  33. /** Image path of the composed profile; the layout contract's name, re-exported for callers. */
  34. export const CONFIG_PATH: string = IMAGE_CONFIG_PATH
  35. /**
  36. * Manifest field the runtime judges the image by: the wrapper contract every packed
  37. * body was emitted against. The runtime refuses an image whose value is not its own
  38. * contract, because those bodies assume different wrapper semantics.
  39. */
  40. const CONTRACT_FIELD = 'lowered'
  41. /** Exclude matcher over tree-root-relative paths ({@link EXCLUDE}). */
  42. const excluded = picomatch([...EXCLUDE], { dot: true })
  43. /** Workspace exclude matcher: {@link EXCLUDE} plus {@link EXCLUDE_WORKSPACE}. */
  44. const workspaceExcluded = picomatch([...EXCLUDE, ...EXCLUDE_WORKSPACE], { dot: true })
  45. /** Page-asset matcher over image paths ({@link PAGE_ASSETS}). */
  46. const pageAsset = picomatch([...PAGE_ASSETS], { dot: true })
  47. /** One directory tree to copy into the image at a caller-selected mount. */
  48. export interface ImageTree {
  49. /** Image path to mount it at, relative to the virtual root. */
  50. readonly mount: string
  51. /** Absolute source directory. */
  52. readonly directory: string
  53. }
  54. /** One configuration tree whose plugin rows may extend the package roster. */
  55. export interface ConfigTree extends ImageTree {
  56. /**
  57. * Whether plugin names inside its `.yml` files join the materialization closure.
  58. * An agent preset mounts plugins the base composition never lists, and creating a
  59. * session fails if any of them is missing from the image.
  60. */
  61. readonly scanRoster?: boolean
  62. }
  63. /** Everything the packer needs that it cannot know by itself. */
  64. export interface PackOptions {
  65. /** Composed profile, `!!js` intact, as the CLI's `--dump-default-config` produced it. */
  66. readonly config: string
  67. /** Profile name, recorded in the manifest. */
  68. readonly profile: string
  69. /** Virtual root the image mounts under; defaults to {@link DEFAULT_ROOT}. */
  70. readonly root?: string
  71. /** Package name to absolute directory, for workspace and vendored packages. */
  72. readonly workspaces: ReadonlyMap<string, string>
  73. /** Directory Node-style dependency resolution walks up from for the roster. */
  74. readonly resolveFrom: string
  75. /** Config trees to copy in beside the composition. */
  76. readonly configTrees?: readonly ConfigTree[]
  77. /** Empty directories to create; defaults to `home/`, `workspace/`, `tmp/`. */
  78. readonly emptyDirectories?: readonly string[]
  79. /**
  80. * Extra sweep roots: image specifiers requested by code outside the image.
  81. * Defaults to the worker assembly's own entries.
  82. */
  83. readonly entries?: readonly string[]
  84. }
  85. /** What one pack produced, for the caller to report or assert on. */
  86. export interface PackResult {
  87. /** The gzip-compressed tar archive to write; the runtime inflates it at mount. */
  88. readonly image: Uint8Array
  89. /** Every entry, before zipping; the manifest is already among them. */
  90. readonly files: ImageFiles
  91. /** Package name to how many files it contributed, in materialization order. */
  92. readonly packages: ReadonlyMap<string, number>
  93. /** How many of them came from the workspace rather than from `node_modules`. */
  94. readonly workspacePackages: number
  95. /** Roster package names the closure started from. */
  96. readonly roster: readonly string[]
  97. /** Dependencies that did not resolve; a non-empty list means an incomplete image. */
  98. readonly missing: readonly string[]
  99. /** Executable scripts dropped from the image. */
  100. readonly executables: readonly string[]
  101. /** Page bundles left out of the transform; like every JavaScript entry they carry the trailing debugger name. */
  102. readonly pageBundles: readonly string[]
  103. /** JavaScript entries the image carries. */
  104. readonly javascriptEntries: number
  105. /** JavaScript candidates no root reaches, dropped from the image. */
  106. readonly droppedJavascriptEntries: number
  107. /** Third-party requests that resolve nowhere; loud at require time if hit. */
  108. readonly unresolvedExternalRequests: readonly string[]
  109. /** What the pack-time transform did. */
  110. readonly transform: TransformOutcome
  111. /** Wrapper contract recorded in the manifest; every packed body meets it. */
  112. readonly contract: string
  113. }
  114. /** One deterministic data-overlay archive and its uncompressed entries. */
  115. export interface PackOverlayResult {
  116. /** Gzip-compressed ustar bytes consumed by the Worker host. */
  117. readonly image: Uint8Array
  118. /** Every path in the overlay before compression. */
  119. readonly files: ImageFiles
  120. }
  121. const readJson = (file: string): Record<string, unknown> =>
  122. JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
  123. /**
  124. * Package name of a module specifier.
  125. * @param specifier - Module specifier, possibly with a subpath.
  126. * @returns The package name (`@scope/pkg/sub` → `@scope/pkg`).
  127. */
  128. function packageNameOf(specifier: string): string {
  129. const [first = specifier, second = ''] = specifier.split('/')
  130. return first.startsWith('@') ? `${first}/${second}` : first
  131. }
  132. /**
  133. * Collect module-specifier `name` fields from parsed entry rows, recursively
  134. * through nested `config` row lists (groups). Builtin rows (`cordis:group`)
  135. * and preset metadata documents carry names that are not module specifiers;
  136. * only names with a scope or a path separator count.
  137. * @param rows - Parsed YAML value; anything but an entry array is ignored.
  138. * @param names - Package names collected so far.
  139. */
  140. function moduleNamesOf(rows: unknown, names: Set<string>): void {
  141. if (!Array.isArray(rows)) return
  142. for (const row of rows) {
  143. if (typeof row !== 'object' || row === null) continue
  144. const { name, config } = row as { name?: unknown; config?: unknown }
  145. if (typeof name === 'string' && (name.startsWith('@') || name.includes('/'))) {
  146. names.add(packageNameOf(name))
  147. }
  148. moduleNamesOf(config, names)
  149. }
  150. }
  151. /**
  152. * Package names the composition names.
  153. * @param config - Composed profile; `!!js` scalars parse under Include's dialect.
  154. * @returns Package names, deduplicated.
  155. */
  156. function rosterOf(config: string): string[] {
  157. const names = new Set<string>()
  158. moduleNamesOf(yaml.load(config, { schema: entryListSchema }), names)
  159. return [...names]
  160. }
  161. /**
  162. * Package names the compositions under one config tree name.
  163. * @param root - Directory to walk.
  164. * @returns Package names, deduplicated.
  165. */
  166. function treeRosterOf(root: string): string[] {
  167. const names = new Set<string>()
  168. const walk = (directory: string): void => {
  169. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  170. const absolute = join(directory, entry.name)
  171. if (entry.isDirectory()) {
  172. walk(absolute)
  173. continue
  174. }
  175. if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue
  176. moduleNamesOf(yaml.load(readFileSync(absolute, 'utf8'), { schema: entryListSchema }), names)
  177. }
  178. }
  179. walk(root)
  180. return [...names]
  181. }
  182. /**
  183. * Resolve one dependency the way Node does: walk up from the importer.
  184. * @param fromDirectory - Directory to start at.
  185. * @param name - Package name.
  186. * @returns The real path of the package directory, or undefined.
  187. */
  188. function resolveDependency(fromDirectory: string, name: string): string | undefined {
  189. let directory = fromDirectory
  190. for (;;) {
  191. const candidate = join(directory, 'node_modules', name)
  192. if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate)
  193. const parent = dirname(directory)
  194. if (parent === directory) return undefined
  195. directory = parent
  196. }
  197. }
  198. /**
  199. * Collect files under one directory. Traversal mechanics live here — nested
  200. * package/config collection flattens nested `node_modules` and prunes dot
  201. * directories, while seed collection preserves every directory. Every file
  202. * judgement comes in through `keep` (the {@link EXCLUDE} tables and the npm
  203. * publish view, or an unconditional seed predicate).
  204. * @param root - Source directory.
  205. * @param into - Image entries to add to.
  206. * @param prefix - Image path prefix.
  207. * @param keep - Filter over root-relative paths.
  208. * @param preserveDirectories - Whether dot directories and nested `node_modules`
  209. * are ordinary fixture content rather than package-manager residue.
  210. */
  211. function collectTree(
  212. root: string,
  213. into: ImageFiles,
  214. prefix: string,
  215. keep: (relativePath: string) => boolean,
  216. preserveDirectories = false,
  217. ): void {
  218. const walk = (directory: string): void => {
  219. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  220. if (entry.isDirectory()) {
  221. if (!preserveDirectories && (entry.name === 'node_modules' || entry.name.startsWith('.'))) continue
  222. walk(join(directory, entry.name))
  223. continue
  224. }
  225. if (!entry.isFile()) continue
  226. const absolute = join(directory, entry.name)
  227. const relativePath = relative(root, absolute).replaceAll('\\', '/')
  228. if (!keep(relativePath)) continue
  229. into[`${prefix}/${relativePath}`] = readFileSync(absolute)
  230. }
  231. }
  232. walk(root)
  233. }
  234. /**
  235. * Predicate for npm's `files` allowlist, with standard glob semantics
  236. * (picomatch). A pattern admits the path itself and everything under it, so a
  237. * bare directory name publishes its whole tree; `!` patterns subtract from the
  238. * admitted set; package.json is always published.
  239. * @param patterns - The package.json `files` array.
  240. * @returns Predicate over package-root-relative paths.
  241. */
  242. function publishedFilter(patterns: readonly unknown[]): (path: string) => boolean {
  243. const strings = patterns.filter((pattern): pattern is string => typeof pattern === 'string')
  244. const normalize = (pattern: string): string => pattern.replace(/^\.\//, '').replace(/\/+$/, '')
  245. const widen = (pattern: string): string[] => [pattern, `${pattern}/**`]
  246. const positive = strings.filter(pattern => !pattern.startsWith('!')).map(normalize).flatMap(widen)
  247. const negative = strings.filter(pattern => pattern.startsWith('!')).map(pattern => normalize(pattern.slice(1))).flatMap(widen)
  248. const admits = picomatch(positive, { dot: true })
  249. const denies = negative.length > 0 ? picomatch(negative, { dot: true }) : (): boolean => false
  250. return path => path === 'package.json' || (admits(path) && !denies(path))
  251. }
  252. /** What the reachability sweep kept, transformed, and dropped. */
  253. interface SweepOutcome {
  254. readonly swept: ImageFiles
  255. readonly transform: TransformOutcome
  256. readonly javascriptEntries: number
  257. readonly droppedJavascriptEntries: number
  258. /** Third-party requests that resolve nowhere; loud at require time if hit. */
  259. readonly unresolvedExternalRequests: readonly string[]
  260. }
  261. /**
  262. * Keep only the JavaScript the worker can reach, transforming it on the way.
  263. *
  264. * Roots are the export faces of every materialized workspace and vendored
  265. * package — the harness addresses them by constructed name at runtime (Loader
  266. * rows, typert faces, delegating providers such as `-auto` pickers), so the
  267. * sweep prunes files only inside third-party packages — plus the worker
  268. * assembly's own image entries. Resolution runs the runtime loader's own
  269. * algorithm over the candidate set, so pack-time reachability and boot-time
  270. * resolution cannot drift, and a request that resolves nowhere — an undeclared
  271. * or missing dependency — fails the pack rather than the boot.
  272. *
  273. * Two entry classes stay out of the walk by rule: page assets
  274. * ({@link PAGE_ASSETS}) are evaluated by the page's module system, and
  275. * non-JavaScript entries always stay because data reads go through fs paths
  276. * this pass cannot see.
  277. * @param files - Candidate entries after the publish-view filter.
  278. * @param options - Pack options carrying the sweep roots.
  279. * @param rootPackages - Roster package names from the workspace.
  280. * @param root - Virtual root the candidates mount under.
  281. * @returns The final entries plus the sweep's counts.
  282. */
  283. /** Trailing `sourceMappingURL` comment; the image carries no `.map` files. */
  284. const DANGLING_SOURCE_MAP = /\n\/\/# sourceMappingURL=\S+\s*$/
  285. /**
  286. * Name one JavaScript entry for the debugger: append the `sourceURL` magic
  287. * comment V8 stacks and DevTools read, so the entry shows under its
  288. * repository path instead of as an anonymous VM script (worker `new Function`
  289. * bodies) or blob entry (page bundles). A trailing `sourceMappingURL` comment
  290. * is stripped first — its `.map` never ships, and once the script has a name
  291. * the debugger would resolve the reference against it and report a load
  292. * failure per script. Only the final line is touched, so every other line
  293. * keeps its number; evaluation cost stays at pack time, where the names are
  294. * already deterministic.
  295. * @param bytes - Entry body as the image would otherwise hold it.
  296. * @param name - Debugger name for the entry.
  297. * @param decoder - Shared UTF-8 decoder.
  298. * @param encoder - Shared UTF-8 encoder.
  299. * @returns The named body.
  300. */
  301. function nameForDebugger(bytes: Uint8Array, name: string, decoder: TextDecoder, encoder: TextEncoder): Uint8Array {
  302. const source = decoder.decode(bytes).replace(DANGLING_SOURCE_MAP, '\n')
  303. return encoder.encode(`${source}\n//# sourceURL=${name}`)
  304. }
  305. /**
  306. * Debugger names for image entries: a workspace or vendored package file is
  307. * named by its repository path (`packages/<group>/<pkg>/lib/index.js`), the
  308. * shape a reader navigates; an external package file keeps its image key —
  309. * it has no repository path, and its pnpm store path would name a hash.
  310. * @param workspaces - Package name → absolute repository directory.
  311. * @param resolveFrom - Repository root the names are relative to.
  312. * @returns Mapper from an image key to the entry's debugger name.
  313. */
  314. function debuggerNamer(workspaces: ReadonlyMap<string, string>, resolveFrom: string): (key: string) => string {
  315. const repoDirs = new Map(
  316. [...workspaces].map(([name, directory]) => [name, relative(resolveFrom, directory).replaceAll('\\', '/')]),
  317. )
  318. return (key: string): string => {
  319. if (!key.startsWith('node_modules/')) return key
  320. const rest = key.slice('node_modules/'.length)
  321. const segments = rest.split('/')
  322. const packageName = segments[0]?.startsWith('@') === true ? segments.slice(0, 2).join('/') : segments[0] ?? ''
  323. const directory = repoDirs.get(packageName)
  324. return directory === undefined ? key : `${directory}${rest.slice(packageName.length)}`
  325. }
  326. }
  327. function sweepImage(
  328. files: ImageFiles,
  329. options: PackOptions,
  330. rootPackages: readonly string[],
  331. root: string,
  332. ): SweepOutcome {
  333. const decoder = new TextDecoder()
  334. const encoder = new TextEncoder()
  335. const vfs = new MemoryVfs()
  336. for (const [name, bytes] of Object.entries(files)) {
  337. if (name.endsWith('/')) vfs.seedDirectory(`${root}/${name}`)
  338. else vfs.seed(`${root}/${name}`, bytes)
  339. }
  340. // The walk resolves static specifiers and never loads them, so one shared
  341. // factory stands for every replaced module.
  342. const stub = (): unknown => ({})
  343. const loader = new WorkerModuleLoader({
  344. vfs,
  345. root,
  346. staticModules: Object.fromEntries(Object.keys(MODULE_PROXIES).map(name => [name, stub])),
  347. staticModulePrefixes: Object.fromEntries(Object.keys(MODULE_PROXY_PREFIXES).map(name => [name, stub])),
  348. })
  349. const queue: { specifier: string; from: string; importer: string; meta?: boolean }[] = (options.entries ?? IMAGE_ENTRY_SEEDS)
  350. .map(specifier => ({ specifier, from: root, importer: 'worker assembly entry' }))
  351. for (const name of rootPackages) {
  352. const manifestBytes = files[`node_modules/${name}/package.json`]
  353. if (manifestBytes === undefined) continue // materialize already reported it under `missing`
  354. let manifest: { exports?: Record<string, unknown> }
  355. try {
  356. manifest = JSON.parse(decoder.decode(manifestBytes)) as typeof manifest
  357. } catch {
  358. continue
  359. }
  360. // Every non-wildcard face is a root; a face resolving onto a page asset is
  361. // kept untransformed below rather than excluded here.
  362. const subpaths = manifest.exports === undefined
  363. ? ['.']
  364. : Object.keys(manifest.exports).filter(key => key.startsWith('.') && !key.includes('*'))
  365. for (const subpath of subpaths) {
  366. queue.push({ specifier: subpath === '.' ? name : `${name}/${subpath.slice(2)}`, from: root, importer: `workspace face ${name}` })
  367. }
  368. }
  369. const reached = new Map<string, Uint8Array>()
  370. const seen = new Set<string>()
  371. const failures: string[] = []
  372. const tolerated = new Set<string>()
  373. let visited = 0
  374. let rewritten = 0
  375. for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
  376. const { specifier, from, importer } = entry
  377. let resolution
  378. try {
  379. resolution = loader.resolve(specifier, from)
  380. } catch (reason) {
  381. // Our own packages must declare what they request: an unresolvable
  382. // request from a workspace or vendored file, a roster face, or the
  383. // assembly entries is a pack defect. Third-party files keep the runtime
  384. // philosophy instead — platform-dispatch branches the worker never
  385. // evaluates may request node-only modules, and such a request fails loud
  386. // at require time if it ever runs.
  387. const external = importer.startsWith('node_modules/') && !importer.startsWith('node_modules/@deepseek-ai/')
  388. // A meta-resolve request is a URL mapping, not a load: a missing target
  389. // is tolerable from any importer — the call throws if it ever runs.
  390. if (external || entry.meta === true) tolerated.add(`${importer}: "${specifier}"`)
  391. else failures.push(`${importer}: "${specifier}" — ${(reason as Error).message}`)
  392. continue
  393. }
  394. if (resolution.kind === 'static') continue
  395. const path = resolution.path
  396. if (seen.has(path)) continue
  397. seen.add(path)
  398. const key = path.slice(root.length + 1)
  399. const bytes = files[key]
  400. if (bytes === undefined) continue
  401. if (!/\.[cm]?js$/.test(key) || pageAsset(key)) {
  402. reached.set(key, bytes)
  403. continue
  404. }
  405. visited += 1
  406. const { code, lowered, moduleRequests, metaResolveRequests } = lowerModuleSource({ filename: `/${key}`, source: decoder.decode(bytes) })
  407. if (lowered) rewritten += 1
  408. reached.set(key, lowered ? encoder.encode(code) : bytes)
  409. const directory = path.slice(0, path.lastIndexOf('/'))
  410. for (const request of moduleRequests) queue.push({ specifier: request, from: directory, importer: key })
  411. for (const request of metaResolveRequests) queue.push({ specifier: request, from: directory, importer: key, meta: true })
  412. }
  413. if (failures.length > 0) {
  414. throw new Error(
  415. `vfs image: ${String(failures.length)} unresolvable module request(s); `
  416. + 'an undeclared or missing dependency fails the pack rather than the boot:\n '
  417. + failures.join('\n '),
  418. )
  419. }
  420. const swept: ImageFiles = {}
  421. const debuggerName = debuggerNamer(options.workspaces, options.resolveFrom)
  422. let javascriptEntries = 0
  423. let dropped = 0
  424. for (const [name, bytes] of Object.entries(files)) {
  425. const isJs = /\.[cm]?js$/.test(name)
  426. if (!isJs || pageAsset(name)) {
  427. swept[name] = isJs ? nameForDebugger(bytes, debuggerName(name), decoder, encoder) : bytes
  428. if (isJs) javascriptEntries += 1
  429. continue
  430. }
  431. const kept = reached.get(name)
  432. if (kept === undefined) {
  433. dropped += 1
  434. continue
  435. }
  436. swept[name] = nameForDebugger(kept, debuggerName(name), decoder, encoder)
  437. javascriptEntries += 1
  438. }
  439. return {
  440. swept,
  441. transform: { visited, rewritten },
  442. javascriptEntries,
  443. droppedJavascriptEntries: dropped,
  444. unresolvedExternalRequests: [...tolerated],
  445. }
  446. }
  447. /**
  448. * Drop executable scripts from the image.
  449. *
  450. * A shebang says "program", not "module": nothing in a browser can spawn one and no
  451. * consumer reads their bytes (the packages that expose a launcher path are replaced
  452. * by stubs that answer with a string). They are also the one place top-level `await`
  453. * appears in the closure, which a CommonJS body cannot express.
  454. * @param files - Image entries, mutated.
  455. * @returns The dropped entry names.
  456. */
  457. function dropExecutables(files: ImageFiles): string[] {
  458. const decoder = new TextDecoder()
  459. const dropped: string[] = []
  460. for (const [name, bytes] of Object.entries(files)) {
  461. if (!/\.[cm]?js$/.test(name)) continue
  462. if (decoder.decode(bytes.subarray(0, 2)) !== '#!') continue
  463. dropped.push(name)
  464. // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- the image is a plain path map
  465. delete files[name]
  466. }
  467. return dropped
  468. }
  469. /**
  470. * Materialize the dependency closure of every roster package into the image.
  471. * @param roster - Package names to start from.
  472. * @param options - Pack options carrying the workspace index and resolution root.
  473. * @returns Image entries, per-package file counts, and unresolved dependencies.
  474. */
  475. function materialize(
  476. roster: readonly string[],
  477. options: PackOptions,
  478. ): { files: ImageFiles; packages: Map<string, number>; missing: string[] } {
  479. const files: ImageFiles = {}
  480. const packages = new Map<string, number>()
  481. const missing: string[] = []
  482. const replaced = new Set(REPLACED_EXTERNAL_PACKAGES)
  483. const queue: { name: string; from: string }[] = roster.map(name => ({ name, from: options.resolveFrom }))
  484. for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
  485. const { name, from } = entry
  486. if (packages.has(name) || replaced.has(name)) continue
  487. const directory = options.workspaces.get(name) ?? resolveDependency(from, name)
  488. if (directory === undefined) {
  489. missing.push(`${name} (from ${relative(options.resolveFrom, from) || '.'})`)
  490. continue
  491. }
  492. const manifest = readJson(join(directory, 'package.json'))
  493. const prefix = `node_modules/${name}`
  494. const before = Object.keys(files).length
  495. if (options.workspaces.has(name)) {
  496. // A workspace package ships the slice npm would publish — `files`
  497. // filters out build residue like the tsc mirror under lib/types/ —
  498. // minus the workspace exclude table (no sources, no dist: the page
  499. // serves its own assets).
  500. const published = Array.isArray(manifest.files) ? publishedFilter(manifest.files) : undefined
  501. collectTree(directory, files, prefix, relativePath =>
  502. !workspaceExcluded(relativePath) && (published === undefined || published(relativePath)))
  503. } else {
  504. collectTree(directory, files, prefix, relativePath => !excluded(relativePath))
  505. }
  506. packages.set(name, Object.keys(files).length - before)
  507. for (const field of ['dependencies', 'peerDependencies'] as const) {
  508. // npm semantics: a peer is provided by the consumer. For an external
  509. // package the consumer is the page (react behind the prebuilt client
  510. // bundles), so its peer edges never bind the worker. Workspace and
  511. // vendored packages declare real runtime seams as peers
  512. // (@deepseek-ai/cordis is a peerDependency of every harness package),
  513. // so their peer edges stay on the chain.
  514. if (field === 'peerDependencies' && !options.workspaces.has(name)) continue
  515. const dependencies = manifest[field]
  516. if (typeof dependencies !== 'object' || dependencies === null) continue
  517. for (const dependency of Object.keys(dependencies)) queue.push({ name: dependency, from: directory })
  518. }
  519. }
  520. return { files, packages, missing }
  521. }
  522. /** Gzip header byte that records the packing platform; RFC 1952 §2.3.1 spells 255 "unknown". */
  523. const GZIP_OS_UNKNOWN = 255
  524. /** Offset of that byte in the gzip member header. */
  525. const GZIP_OS_OFFSET = 9
  526. /**
  527. * Compress the archive into one gzip member the same tree always produces
  528. * byte for byte.
  529. *
  530. * Two header fields would otherwise carry build facts: zlib writes no
  531. * modification time and no original file name for a buffer (`gzipSync` is handed
  532. * neither), and it fills the operating-system byte from the platform it was built
  533. * for, which would make the same tree pack differently on Linux and macOS. That
  534. * byte is overwritten with "unknown" — every gzip reader ignores it, and the
  535. * artifact stops depending on where it was packed.
  536. * @param archive - the ustar archive.
  537. * @returns the compressed image bytes.
  538. */
  539. function compressImage(archive: Uint8Array): Uint8Array {
  540. const compressed = gzipSync(archive, { level: 9 })
  541. compressed[GZIP_OS_OFFSET] = GZIP_OS_UNKNOWN
  542. return compressed
  543. }
  544. /**
  545. * Pack one VFS image.
  546. *
  547. * The manifest's claim is all-or-nothing: it names the one contract every packed body
  548. * was emitted against. A module the transform cannot express therefore fails the pack
  549. * rather than downgrading the image, because a mostly-transformed image boots into
  550. * errors far from their cause.
  551. * @param options - Composition, package index, and paths.
  552. * @returns The compressed image plus what went into it.
  553. * @throws When a config tree or workspace directory named in the options is missing,
  554. * because a silently thinner image fails much later and much less clearly.
  555. */
  556. export function packVfsImage(options: PackOptions): PackResult {
  557. const root = options.root ?? DEFAULT_ROOT
  558. const encoder = new TextEncoder()
  559. const configTrees = options.configTrees ?? []
  560. for (const tree of configTrees) {
  561. if (!existsSync(tree.directory)) {
  562. throw new Error(`vfs image: config tree ${tree.mount} is missing at ${tree.directory}`)
  563. }
  564. }
  565. const roster = [...new Set([
  566. ...rosterOf(options.config),
  567. ...configTrees.filter(tree => tree.scanRoster === true).flatMap(tree => treeRosterOf(tree.directory)),
  568. ])]
  569. const { files, packages, missing } = materialize(roster, options)
  570. files[CONFIG_PATH] = encoder.encode(options.config)
  571. for (const tree of configTrees) collectTree(tree.directory, files, tree.mount, relativePath => !excluded(relativePath))
  572. const executables = dropExecutables(files)
  573. const rootPackages = [...packages.keys()].filter(name => options.workspaces.has(name))
  574. const { swept, transform, javascriptEntries, droppedJavascriptEntries, unresolvedExternalRequests } =
  575. sweepImage(files, options, rootPackages, root)
  576. swept[MANIFEST_PATH] = encoder.encode(`${JSON.stringify({
  577. root,
  578. profile: options.profile,
  579. [CONTRACT_FIELD]: WRAPPER_CONTRACT,
  580. javascriptEntries,
  581. visitedEntries: transform.visited,
  582. rewrittenEntries: transform.rewritten,
  583. }, null, 2)}\n`)
  584. for (const directory of options.emptyDirectories ?? IMAGE_EMPTY_DIRECTORIES) {
  585. swept[directory] = new Uint8Array(0)
  586. }
  587. return {
  588. image: compressImage(packTar(swept)),
  589. files: swept,
  590. packages,
  591. workspacePackages: [...packages.keys()].filter(name => options.workspaces.has(name)).length,
  592. roster,
  593. missing,
  594. executables,
  595. pageBundles: Object.keys(swept).filter(name => pageAsset(name)),
  596. javascriptEntries,
  597. droppedJavascriptEntries,
  598. unresolvedExternalRequests,
  599. transform,
  600. contract: WRAPPER_CONTRACT,
  601. }
  602. }
  603. /**
  604. * Pack opaque data trees into one ordered VFS overlay.
  605. *
  606. * Overlay mounts are restricted to the runtime-owned data directories, so an
  607. * overlay cannot replace configuration, the lowering manifest, or modules.
  608. * Files bypass package excludes and module reachability processing; later
  609. * trees replace earlier files at the same path.
  610. * @param trees - Absolute source directories and their data-directory mounts.
  611. * @returns Deterministic compressed archive plus its uncompressed entries.
  612. */
  613. export function packVfsOverlay(trees: readonly ImageTree[]): PackOverlayResult {
  614. const files: ImageFiles = {}
  615. for (const tree of trees) {
  616. if (!existsSync(tree.directory)) {
  617. throw new Error(`vfs overlay: tree ${tree.mount} is missing at ${tree.directory}`)
  618. }
  619. const mount = tree.mount.replace(/^\.\//, '').replace(/\/$/, '')
  620. const first = mount.split('/')[0]
  621. if (mount === '' || first === undefined || !IMAGE_OVERLAY_DIRECTORIES.includes(first)
  622. || mount.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
  623. throw new Error(
  624. `vfs overlay: mount ${JSON.stringify(tree.mount)} must stay under ${IMAGE_OVERLAY_DIRECTORIES.join(' or ')}`,
  625. )
  626. }
  627. collectTree(tree.directory, files, mount, () => true, true)
  628. }
  629. return { image: compressImage(packTar(files)), files }
  630. }