Ver código fonte

feat(webworker): name packed modules and client bundles for the debugger

imccyu 1 mês atrás
pai
commit
99db143e37

+ 1 - 1
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",

+ 52 - 4
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/<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,
@@ -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 {

+ 4 - 3
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',

+ 21 - 0
packages/experimental/webworker-packer/tests/image-loadable.spec.ts

@@ -75,6 +75,27 @@ const archive = async (): Promise<Uint8Array> =>
     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

+ 3 - 0
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 `<script src>`.
+   * The image packs each bundle with a trailing `sourceURL` naming its image
+   * path, so the blob shows under that name in the debugger instead of as an
+   * anonymous blob entry.
    * @param url - graph row url (`/plugins/<id>/client.js?rev=...`).
    */
   async loadBundle(url: string): Promise<void> {