diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index a00fa133d0..b642e0f606 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/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 => webServerMemo ??= entries: [], }) +let terminalBashMemo: ReturnType | undefined +const packedTerminalBash = (): ReturnType => 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 => @@ -218,6 +229,27 @@ const archive = async (): Promise => 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) diff --git a/packages/experimental/webworker-runtime/src/compile/transform.ts b/packages/experimental/webworker-runtime/src/compile/transform.ts index b3aa229a49..6da482af11 100644 --- a/packages/experimental/webworker-runtime/src/compile/transform.ts +++ b/packages/experimental/webworker-runtime/src/compile/transform.ts @@ -70,6 +70,8 @@ class Transformer { private moduleSyntax = false private readonly moduleRequests = new Set() private readonly metaResolveRequests = new Set() + private readonly createRequireBindings = new Set() + private readonly requireBindings = new Set(['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[] /** diff --git a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts index bf8b74a9bb..b6005d47a2 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts +++ b/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. // ---------------------------------------------------------------------------