Procházet zdrojové kódy

fix(webworker): retain createRequire dependencies

imccyu před 1 měsícem
rodič
revize
d54c2795cc

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

@@ -37,7 +37,9 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
 const SUBJECT = '@deepseek-ai/dsh-timeout'
 const LANDLOCK = '@deepseek-ai/node-addon-landlock-run'
 const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
+const TERMINAL_BASH = '@deepseek-ai/dsh-terminal-bash'
 const WEB_SERVER = '@deepseek-ai/dsh-host-webserver'
+const XTERM_HEADLESS = '@xterm/headless'
 
 const workspaces = indexWorkspacePackages(repoRoot)
 
@@ -114,6 +116,15 @@ const packedWebServer = (): ReturnType<typeof packVfsImage> => webServerMemo ??=
   entries: [],
 })
 
+let terminalBashMemo: ReturnType<typeof packVfsImage> | undefined
+const packedTerminalBash = (): ReturnType<typeof packVfsImage> => terminalBashMemo ??= packVfsImage({
+  config: `- id: subject\n  name: '${TERMINAL_BASH}'\n`,
+  profile: 'terminal-bash-dependency-check',
+  workspaces,
+  resolveFrom: repoRoot,
+  entries: [],
+})
+
 /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */
 let archiveMemo: Uint8Array | undefined
 const archive = async (): Promise<Uint8Array> =>
@@ -218,6 +229,27 @@ const archive = async (): Promise<Uint8Array> =>
     expect(typeof webserver.WebServer).toBe('function')
   })
 
+  it('loads a third-party dependency requested through createRequire', async () => {
+    const result = packedTerminalBash()
+    expect(result.missing).toEqual([])
+    expect(result.packages.get(XTERM_HEADLESS)).toBeGreaterThan(0)
+    expect(Object.hasOwn(result.files, `node_modules/${XTERM_HEADLESS}/lib-headless/xterm-headless.js`)).toBe(true)
+
+    const vfs = loadVfsImage(await inflateImage(result.image, 'the packed terminal backend'), DEFAULT_ROOT)
+    const loader = new WorkerModuleLoader({
+      vfs,
+      root: DEFAULT_ROOT,
+      staticModules: createNodeBuiltins(),
+      staticModulePrefixes: REPLACED_PREFIXES,
+    })
+    setActiveVfs(vfs)
+    setActiveModuleLoader(loader)
+    const terminal = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(TERMINAL_BASH) as {
+      BashTerminalBackend?: unknown
+    }
+    expect(typeof terminal.BashTerminalBackend).toBe('function')
+  })
+
   it('runs the unchanged Landlock entry package over the Worker platform executable', async () => {
     const result = packedLandlock()
     expect(workspaces.has(LANDLOCK)).toBe(true)

+ 36 - 6
packages/experimental/webworker-runtime/src/compile/transform.ts

@@ -70,6 +70,8 @@ class Transformer {
   private moduleSyntax = false
   private readonly moduleRequests = new Set<string>()
   private readonly metaResolveRequests = new Set<string>()
+  private readonly createRequireBindings = new Set<string>()
+  private readonly requireBindings = new Set<string>(['require'])
 
   constructor(source: string, private readonly path: string) {
     // A `#!` line is only legal at offset zero, and the prologue takes that spot;
@@ -154,7 +156,14 @@ class Transformer {
     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 source = node.source as Node
+    if (source.value === 'node:module' || source.value === 'module') {
+      for (const specifier of node.specifiers as Node[]) {
+        if (specifier.type !== 'ImportSpecifier' || nameOf(specifier.imported as Node) !== 'createRequire') continue
+        this.createRequireBindings.add(nameOf(specifier.local as Node))
+      }
+    }
+    const request = `require(${this.literal(source)})`
     const specifiers = node.specifiers as Node[]
     if (specifiers.length === 0) {
       this.replace(node.start, node.end, `${request};`)
@@ -337,12 +346,21 @@ class Transformer {
         if (argument !== undefined && typeof argument.value === 'string') this.moduleRequests.add(argument.value)
         break
       }
+      case 'VariableDeclarator': {
+        const id = record.id as Node
+        const init = record.init as Node | null
+        if (id.type === 'Identifier' && init !== null && this.isCreateRequireCall(init)) {
+          this.requireBindings.add(nameOf(id))
+        }
+        break
+      }
       case 'CallExpression': {
-        // CommonJS bodies pass through untransformed, but their literal
-        // `require()` calls are module requests all the same.
+        // CommonJS bodies pass through untransformed, but literal calls through
+        // the wrapper's `require` or Node's `createRequire` 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
+        if (this.isRequireCall(callee) && callArguments.length === 1
           && typeof callArguments[0]?.value === 'string') {
           this.moduleRequests.add(callArguments[0].value)
         }
@@ -410,6 +428,17 @@ class Transformer {
     }
   }
 
+  private isCreateRequireCall(node: Node): boolean {
+    if (node.type !== 'CallExpression') return false
+    const callee = node.callee as Node
+    return callee.type === 'Identifier' && this.createRequireBindings.has(nameOf(callee))
+  }
+
+  private isRequireCall(callee: Node): boolean {
+    return (callee.type === 'Identifier' && this.requireBindings.has(nameOf(callee)))
+      || this.isCreateRequireCall(callee)
+  }
+
   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
@@ -539,8 +568,9 @@ export interface LoweredModule {
   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.
+   * literal dynamic imports, and literal calls through `require` or an imported
+   * `createRequire`. Computed requests are absent — they resolve (and fail loud)
+   * at runtime only.
    */
   readonly moduleRequests: readonly string[]
   /**

+ 14 - 0
packages/experimental/webworker-runtime/tests/compile/transform.spec.ts

@@ -144,6 +144,20 @@ check(
   check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n')
 }
 
+{
+  const direct = lowerModuleSource({
+    filename: 'node_modules/p/direct.js',
+    source: "import { createRequire } from 'node:module'\ncreateRequire(import.meta.url)('@xterm/headless')\n",
+  })
+  check('literal createRequire call is a module request', direct.moduleRequests, ['node:module', '@xterm/headless'])
+
+  const assigned = lowerModuleSource({
+    filename: 'node_modules/p/assigned.js',
+    source: "import { createRequire as makeRequire } from 'node:module'\nconst localRequire = makeRequire(import.meta.url)\nlocalRequire('p')\n",
+  })
+  check('literal call through a createRequire binding is a module request', assigned.moduleRequests, ['node:module', 'p'])
+}
+
 // ---------------------------------------------------------------------------
 // 3. Import forms.
 // ---------------------------------------------------------------------------