diff --git a/apps/web/package.json b/apps/web/package.json index 23e15a5821..5bdfe8ebd7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "build": "vite build", "dev": "vite", "watch": "vite build --watch --no-emptyOutDir", - "build:preview": "vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz", + "build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz", "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1" }, "license": "MIT", diff --git a/packages/experimental/webworker-packer/src/pack.ts b/packages/experimental/webworker-packer/src/pack.ts index 2f71716e4d..a5dcb00156 100644 --- a/packages/experimental/webworker-packer/src/pack.ts +++ b/packages/experimental/webworker-packer/src/pack.ts @@ -105,7 +105,7 @@ export interface PackResult { readonly missing: readonly string[] /** Executable scripts dropped from the image. */ readonly executables: readonly string[] - /** Page bundles left verbatim, and so out of the transform. */ + /** 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 @@ -282,6 +282,53 @@ interface SweepOutcome { * @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///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, 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, @@ -317,7 +364,7 @@ function sweepImage( continue } // Every non-wildcard face is a root; a face resolving onto a page asset is - // kept verbatim below rather than excluded here. + // kept untransformed below rather than excluded here. const subpaths = manifest.exports === undefined ? ['.'] : Object.keys(manifest.exports).filter(key => key.startsWith('.') && !key.includes('*')) @@ -379,12 +426,13 @@ function sweepImage( } 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] = bytes + swept[name] = isJs ? nameForDebugger(bytes, debuggerName(name), decoder, encoder) : bytes if (isJs) javascriptEntries += 1 continue } @@ -393,7 +441,7 @@ function sweepImage( dropped += 1 continue } - swept[name] = kept + swept[name] = nameForDebugger(kept, debuggerName(name), decoder, encoder) javascriptEntries += 1 } return { diff --git a/packages/experimental/webworker-packer/src/rules.ts b/packages/experimental/webworker-packer/src/rules.ts index 96c1fa0265..3e7321f842 100644 --- a/packages/experimental/webworker-packer/src/rules.ts +++ b/packages/experimental/webworker-packer/src/rules.ts @@ -44,9 +44,10 @@ export const EXCLUDE_WORKSPACE: readonly string[] = [ * 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). + * inject calls the page cannot resolve, so they ship untransformed — their + * only change is the trailing debugger-name line every JavaScript entry + * gains — 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', diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 07ccb0986f..688b21c279 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -75,6 +75,27 @@ const archive = async (): Promise => expect(result.transform.rewritten).toBeGreaterThan(0) }) + it('names every JavaScript entry for the debugger, workspace files by repository path', () => { + const result = packed() + const decoder = new TextDecoder() + const entries = Object.keys(result.files).filter(name => /\.[cm]?js$/.test(name)) + expect(entries.length).toBeGreaterThan(0) + for (const name of entries) { + const lines = decoder.decode(result.files[name]).split('\n') + // V8 stacks and DevTools read the trailing comment, so worker + // `new Function` bodies and page blobs alike show under a stable name + // instead of as anonymous VM or blob entries. + expect(lines.at(-1)).toMatch(/^\/\/# sourceURL=\S+$/) + // A dangling map reference would make the debugger report one load + // failure per named script; the packer ships no `.map` files. + expect(lines.at(-2) ?? '').not.toContain('sourceMappingURL') + } + // A workspace entry is named by the path a reader navigates in this + // repository, not by its image mount. + const subject = decoder.decode(result.files[`node_modules/${SUBJECT}/lib/index.js`]) + expect(subject.endsWith('\n//# sourceURL=packages/util/timeout/lib/index.js')).toBe(true) + }) + 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 diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index cd8dbcb7dc..e03fb6e9c9 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -167,6 +167,9 @@ export class WorkerTunnel { /** * `loadBundle` seam: take one client bundle through the tunnel and execute it * as a classic script, exactly like the shell's same-origin `