diff --git a/docs/gateway/cloud-workers/setup-and-bundle-installation.md b/docs/gateway/cloud-workers/setup-and-bundle-installation.md index ff6f8a4095e1..f347700cdf1d 100644 --- a/docs/gateway/cloud-workers/setup-and-bundle-installation.md +++ b/docs/gateway/cloud-workers/setup-and-bundle-installation.md @@ -58,6 +58,8 @@ The Gateway reuses its prepared archive for subsequent enrollments with the same While a prepared worker is provisioning, cache cleanup retains the exact worker bundle recorded at admission, including before readiness produces a bootstrap receipt. After the environment reaches a terminal state, normal bundle cleanup can reclaim those bytes when no other environment or placement needs them. +Worker bundles include their JavaScript dependencies, including the WebSocket transport. They target Node.js even when Bun runs the build; the destination still needs a supported Node.js installation. + ### Reuse a node runtime archive after Gateway restart Linux and macOS deployment images can retain an already prepared node runtime archive as `node-runtime.tgz` in the running OpenClaw package root, beside `package.json`. During image preparation, copy the producer's archive there before closing the producer: diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index fbff12810ffc..21bbb6662389 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -221,7 +221,11 @@ without copying the surrounding workspace. Compiled bundled runtime and setup modules share the host's code identity; each inventory still owns its registered callbacks and cleanup. Replacing that compiled code requires a build and Gateway restart. Conditional package aliases retain their package metadata, and native -Node conditions select the target from that captured metadata. Legacy packages +Node conditions, including `module-sync`, select the target from that captured metadata. +Source inspection uses the same synchronous-module condition without evaluating plugin code. +Captured source retains the difference between authored imports and require calls, so Bun's +compiler resolution previews do not acquire a deferred dependency before its first call. +Missing selected targets remain absent for that captured generation. Legacy packages without an exports map also admit their existing main or index entry without executing unselected code. Native entries reuse the recorded admission below. The selected package's remaining body is captured before execution. diff --git a/docs/reference/test/local.md b/docs/reference/test/local.md index 1cc01004aa5d..3e758705706c 100644 --- a/docs/reference/test/local.md +++ b/docs/reference/test/local.md @@ -135,6 +135,11 @@ Use `requireNodeTool("node")` and `stripNodeTypeScriptTypes` from `test/helpers/node-toolchain.ts`, which share that Node-selection owner, while keeping the Vitest worker on the selected test runtime. +Isolated native worker and subprocess fixtures use `mockNativeModuleExports` from +`test/helpers/native-module-mock.ts` for controlled module exports on either runtime. +The mocks live until that child exits. Capture original call-through functions before +registering replacements because Bun updates existing module namespace bindings. + The test toolchain pins stable Vitest `5.0.1`, including its browser and coverage packages. Use `describe(name, { concurrent: false }, callback)` for ordered suites. Await asynchronous assertions, keep `vi.mock`/`vi.hoisted` at module diff --git a/scripts/lib/managed-handoff-build-config.mts b/scripts/lib/managed-handoff-build-config.mts index 18e5e9e1e3c2..b053bad771cb 100644 --- a/scripts/lib/managed-handoff-build-config.mts +++ b/scripts/lib/managed-handoff-build-config.mts @@ -1,4 +1,3 @@ -import { isBuiltin } from "node:module"; import { fileURLToPath } from "node:url"; import type { UserConfig } from "tsdown"; import { packageActivationRuntimeEntrypoint } from "../../src/infra/package-update-activation-runtime-assets.ts"; @@ -47,7 +46,7 @@ function createSealedRecoveryBuildConfig(entry: typeof managedHandoffRuntimeEntr }, }, ], - deps: { alwaysBundle: (id) => !isBuiltin(id), onlyBundle: false }, + deps: { alwaysBundle: () => true, onlyBundle: false }, outExtensions: () => ({ js: ".mjs" }), outputOptions: { codeSplitting: false }, shims: true, diff --git a/src/agents/test-helpers/prepared-model-catalog-inspection.worker.ts b/src/agents/test-helpers/prepared-model-catalog-inspection.worker.ts index 2ad2404e04fd..cab992b3666d 100644 --- a/src/agents/test-helpers/prepared-model-catalog-inspection.worker.ts +++ b/src/agents/test-helpers/prepared-model-catalog-inspection.worker.ts @@ -1,5 +1,5 @@ -import { mock } from "node:test"; import { parentPort } from "node:worker_threads"; +import { mockNativeModuleExports } from "../../../test/helpers/native-module-mock.js"; import type { CatalogInspection, CatalogInspectionTask, @@ -10,40 +10,38 @@ let task: CatalogInspectionTask; let sqliteCopies = 0; let plans: CatalogInspection["plans"] = []; const sqlite = await import("../../infra/sqlite-snapshot-source.js"); -mock.module(new URL("../../infra/sqlite-snapshot-source.ts", import.meta.url).href, { - namedExports: { - ...sqlite, - prepareSqliteReadOnlyLocationSync: ( - ...args: Parameters - ) => { - sqliteCopies += 1; - return sqlite.prepareSqliteReadOnlyLocationSync(...args); - }, +// Bun updates existing namespace bindings when a module is mocked. +const prepareSqliteReadOnlyLocationSync = sqlite.prepareSqliteReadOnlyLocationSync; +mockNativeModuleExports(new URL("../../infra/sqlite-snapshot-source.ts", import.meta.url), { + ...sqlite, + prepareSqliteReadOnlyLocationSync: ( + ...args: Parameters + ) => { + sqliteCopies += 1; + return prepareSqliteReadOnlyLocationSync(...args); }, }); const models = await import("../models-config.js"); -mock.module(new URL("../models-config.ts", import.meta.url).href, { - namedExports: { - ...models, - planOpenClawModelsJsonSource: async ( - ...args: Parameters - ) => { - const plan = await models.planOpenClawModelsJsonSource(...args); - plans.push(plan); - return plan; - }, +const planOpenClawModelsJsonSource = models.planOpenClawModelsJsonSource; +mockNativeModuleExports(new URL("../models-config.ts", import.meta.url), { + ...models, + planOpenClawModelsJsonSource: async ( + ...args: Parameters + ) => { + const plan = await planOpenClawModelsJsonSource(...args); + plans.push(plan); + return plan; }, }); const catalog = await import("../prepared-model-runtime.full-catalog.js"); -mock.module(new URL("../prepared-model-runtime.full-catalog.ts", import.meta.url).href, { - namedExports: { - ...catalog, - prepareFullCatalogFacts: (...args: Parameters) => { - if (task.inspection?.failCatalog) { - throw new Error("synthetic catalog construction failure"); - } - return catalog.prepareFullCatalogFacts(...args); - }, +const prepareFullCatalogFacts = catalog.prepareFullCatalogFacts; +mockNativeModuleExports(new URL("../prepared-model-runtime.full-catalog.ts", import.meta.url), { + ...catalog, + prepareFullCatalogFacts: (...args: Parameters) => { + if (task.inspection?.failCatalog) { + throw new Error("synthetic catalog construction failure"); + } + return prepareFullCatalogFacts(...args); }, }); const { getAuthoredConfigSecretRef, getConfigResolutionFacts, getResolvedConfigEnvSecretRef } = @@ -54,8 +52,7 @@ const { resolveUsableCustomProviderApiKey } = await import("../model-auth-provid const { inspectSharedAuthLegacyRowsReadOnly } = await import("../auth-profiles/shared-store-bootstrap.js"); -// Inspect the exact received clone and completed worker result, not a reconstructed parent copy. -port.on("message", (message: { input: CatalogInspectionTask }) => { +function inspectInput(message: { input: CatalogInspectionTask }) { task = message.input; sqliteCopies = 0; plans = []; @@ -66,7 +63,19 @@ port.on("message", (message: { input: CatalogInspectionTask }) => { env: task.value.input.env, }); } -}); +} +// Observe input with the real handler so initialization cannot consume queued tasks early. +const on = port.on.bind(port); +port.on = (event, listener) => { + if (event === "message") { + port.on = on; + return on(event, (message: { input: CatalogInspectionTask }) => { + inspectInput(message); + Reflect.apply(listener, port, [message]); + }); + } + return on(event, listener); +}; const post = port.postMessage.bind(port); port.postMessage = (message: { status: string; value?: object }, transferList) => { let response = message; diff --git a/src/cli/program/register.backup.product-path.test.ts b/src/cli/program/register.backup.product-path.test.ts index 5ea8060c8b32..dbd16e7f47b6 100644 --- a/src/cli/program/register.backup.product-path.test.ts +++ b/src/cli/program/register.backup.product-path.test.ts @@ -178,6 +178,20 @@ describe("backup create CLI", () => { const { syncBuiltinESMExports } = require("node:module"); const sqlite = process.getBuiltinModule("node:sqlite"); const originalBackup = sqlite.backup.bind(sqlite); + if (process.versions.bun) { + const workerThreads = require("node:worker_threads"); + const OriginalWorker = workerThreads.Worker; + const preload = process.env.OPENCLAW_TEST_SQLITE_WORKER_PRELOAD; + // The CLI's nested Workers do not inherit the Vitest parent's preload spy. + workerThreads.Worker = class Worker extends OriginalWorker { + constructor(filename, options = {}) { + super(filename, { + ...options, + execArgv: [...(options.execArgv ?? process.execArgv), "--preload", preload], + }); + } + }; + } const markerPath = process.env.PROOF_SNAPSHOT_MARKER; const realNow = Date.now.bind(Date); // Acquisition runs in a worker; every isolate must observe the same elapsed time. diff --git a/src/commands/sessions-cleanup.large-labels.test-support.ts b/src/commands/sessions-cleanup.large-labels.test-support.ts index 62d876ecc479..15fe5c374047 100644 --- a/src/commands/sessions-cleanup.large-labels.test-support.ts +++ b/src/commands/sessions-cleanup.large-labels.test-support.ts @@ -1,4 +1,4 @@ -import { mock } from "node:test"; +import { mockNativeModuleExports } from "../../test/helpers/native-module-mock.js"; const extension = import.meta.url.endsWith(".ts") ? "ts" : "js"; const count = 150_000; @@ -16,71 +16,71 @@ let serviceCalls = 0; // Keep the actual command, grid, and label-summary owners. Only fixture the // service and unrelated metadata boundaries; no large database is needed. -mock.module(new URL(`../config/config.${extension}`, import.meta.url), { - namedExports: { getRuntimeConfig: () => ({}) }, +mockNativeModuleExports(new URL(`../config/config.${extension}`, import.meta.url), { + getRuntimeConfig: () => ({}), }); -mock.module(new URL(`./session-store-targets.${extension}`, import.meta.url), { - namedExports: { resolveCommandSessionStoreTargets: () => [{ agentId: "main", storePath }] }, +mockNativeModuleExports(new URL(`./session-store-targets.${extension}`, import.meta.url), { + resolveCommandSessionStoreTargets: () => [{ agentId: "main", storePath }], }); -mock.module(new URL(`../config/sessions.${extension}`, import.meta.url), { - namedExports: { - resolveSessionCleanupAction: () => "keep", - isSessionsCleanupPartialResult: unexpected, - serializeSessionCleanupResult: unexpected, - runSessionsCleanup: async () => { - serviceCalls += 1; - return { - mode: "warn", - appliedSummaries: [], - previewResults: [ - { - summary: { - agentId: "main", - storePath, - mode: "warn", - dryRun: true, - beforeCount: count, - afterCount: count, - missing: 0, - dmScopeRetired: 0, - modelRunPruned: 0, - pruned: 0, - capped: 0, - diskBudget: null, - wouldMutate: false, - }, - beforeStore, - missingKeys: new Set(), - modelRunPrunedKeys: new Set(), - archivedKeys: new Set(), - staleKeys: new Set(), - cappedKeys: new Set(), - dmScopeRetiredKeys: new Set(), +mockNativeModuleExports(new URL(`../config/sessions.${extension}`, import.meta.url), { + resolveSessionCleanupAction: () => "keep", + isSessionsCleanupPartialResult: unexpected, + serializeSessionCleanupResult: unexpected, + runSessionsCleanup: async () => { + serviceCalls += 1; + return { + mode: "warn", + appliedSummaries: [], + previewResults: [ + { + summary: { + agentId: "main", + storePath, + mode: "warn", + dryRun: true, + beforeCount: count, + afterCount: count, + missing: 0, + dmScopeRetired: 0, + modelRunPruned: 0, + pruned: 0, + capped: 0, + diskBudget: null, + wouldMutate: false, }, - ], - }; - }, + beforeStore, + missingKeys: new Set(), + modelRunPrunedKeys: new Set(), + archivedKeys: new Set(), + staleKeys: new Set(), + cappedKeys: new Set(), + dmScopeRetiredKeys: new Set(), + }, + ], + }; }, }); -mock.module(new URL(`../gateway/call.${extension}`, import.meta.url), { - namedExports: { - buildGatewayConnectionDetails: unexpected, - callGateway: unexpected, - isImplicitLocalGatewayTarget: unexpected, +mockNativeModuleExports(new URL(`../gateway/call.${extension}`, import.meta.url), { + buildGatewayConnectionDetails: unexpected, + callGateway: unexpected, + isImplicitLocalGatewayTarget: unexpected, +}); +mockNativeModuleExports( + new URL(`../gateway/call-mutation-fallback.${extension}`, import.meta.url), + { + resolveGatewayMutationFallback: unexpected, }, -}); -mock.module(new URL(`../gateway/call-mutation-fallback.${extension}`, import.meta.url), { - namedExports: { resolveGatewayMutationFallback: unexpected }, -}); -mock.module(new URL(`../config/sessions/session-sqlite-target.${extension}`, import.meta.url), { - namedExports: { resolveSqliteTargetFromSessionStorePath: () => ({ path: storePath }) }, -}); -mock.module(new URL(`./sessions-display-model.${extension}`, import.meta.url), { - namedExports: { - resolveSessionDisplayModelRef: (_cfg: unknown, row: { model: string }) => ({ - model: row.model, - }), +); +mockNativeModuleExports( + new URL(`../config/sessions/session-sqlite-target.${extension}`, import.meta.url), + { + resolveSqliteTargetFromSessionStorePath: () => ({ path: storePath }), }, +); +mockNativeModuleExports(new URL(`./sessions-display-model.${extension}`, import.meta.url), { + resolveSessionDisplayModelRef: (_cfg: unknown, row: { model: string }) => ({ + model: row.model, + }), }); const { sessionsCleanupCommand } = await import("./sessions-cleanup.js"); @@ -111,4 +111,3 @@ await sessionsCleanupCommand( }, ); console.log(JSON.stringify({ serviceCalls, gridPrinted, summaryPrinted, labelRows, total })); -mock.restoreAll(); diff --git a/src/config/sessions/session-accessor.sqlite-reclamation-worker.test.ts b/src/config/sessions/session-accessor.sqlite-reclamation-worker.test.ts index 01d7abc4e564..943eddd2c43e 100644 --- a/src/config/sessions/session-accessor.sqlite-reclamation-worker.test.ts +++ b/src/config/sessions/session-accessor.sqlite-reclamation-worker.test.ts @@ -405,7 +405,7 @@ test("logs a native reclamation Worker throw with its cause, first frame and has error: expect.stringContaining( `synthetic reclamation crash for ${redactIdentifier(scope.sessionId)} | synthetic disk failure`, ), - errorFrame: expect.stringContaining("at MessagePort.failReclamation"), + errorFrame: expect.stringMatching(/^at (?:MessagePort\.)?failReclamation \(/u), }), }), expect.objectContaining({ diff --git a/src/gateway/session-group-mutation-targets.test.ts b/src/gateway/session-group-mutation-targets.test.ts index 3531398ea099..054fa567386c 100644 --- a/src/gateway/session-group-mutation-targets.test.ts +++ b/src/gateway/session-group-mutation-targets.test.ts @@ -1,15 +1,13 @@ import fs from "node:fs"; -import { DatabaseSync } from "node:sqlite"; import { expect, test, vi } from "vitest"; +import { runCliProcessChild } from "../cli/cli-process-child.test-helpers.js"; import { loadSessionEntry, upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveRuntimeWorkerArgv } from "../infra/runtime-worker-url.js"; import * as sqliteIntegrity from "../infra/sqlite-integrity.js"; import * as sqliteWal from "../infra/sqlite-wal.js"; import * as agentDatabaseLeases from "../state/openclaw-agent-db-lease.js"; -import { - closeOpenClawAgentDatabasesAsync, - openOpenClawAgentDatabase, -} from "../state/openclaw-agent-db.js"; +import { closeOpenClawAgentDatabasesAsync } from "../state/openclaw-agent-db.js"; import { listOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.test-support.js"; import { setStateDirEnv, withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { readSessionGroupMembershipInWorker } from "./session-group-catalog.js"; @@ -70,17 +68,25 @@ test.each([false, true])( ["Shared work", [scopes[1]]], ]), ); - const database = openOpenClawAgentDatabase(scopes[0]); - const external = new DatabaseSync(database.path); - try { - external - .prepare( - "UPDATE session_nodes SET entry_json = json_set(entry_json, '$.category', ?) WHERE session_key = ?", - ) - .run("External", scopes[0].sessionKey); - } finally { - external.close(); - } + const entryUrl = new URL("../config/sessions/session-accessor.ts", import.meta.url); + const cleanupUrl = new URL("../test-utils/session-state-cleanup.ts", import.meta.url); + // A foreign canonical writer refreshes metadata without this process's publications. + const external = await runCliProcessChild({ + nodeArgs: [ + ...resolveRuntimeWorkerArgv(entryUrl).slice(0, -1), + "--input-type=module", + "--eval", + `import { upsertSessionEntryCore } from ${JSON.stringify(entryUrl.href)}; + import { cleanupSessionStateForTest } from ${JSON.stringify(cleanupUrl.href)}; + try { + await upsertSessionEntryCore(${JSON.stringify(scopes[0])}, { category: "External" }); + } finally { + await cleanupSessionStateForTest({ stateDir: process.env.OPENCLAW_STATE_DIR }); + }`, + ], + env: { ...process.env }, + }); + expect(external.code, external.stderr).toBe(0); expect(await readTargets()).toEqual( new Map([ ["External", [scopes[0]]], diff --git a/src/infra/sqlite-readonly-worker-session.test.ts b/src/infra/sqlite-readonly-worker-session.test.ts index 93ac268550f2..3f5f7238e583 100644 --- a/src/infra/sqlite-readonly-worker-session.test.ts +++ b/src/infra/sqlite-readonly-worker-session.test.ts @@ -324,33 +324,41 @@ it("carries only its captured read scope and refuses callbacks after owner retir } }); -it("counts admitted read-only session children in node spawn diagnostics", () => { - let now = 0; - const clock = vi.spyOn(performance, "now").mockImplementation(() => now); - const events: unknown[] = []; - const stop = onDiagnosticEvent((event) => { - if (event.type === "diagnostic.child_process.spawn") { - events.push(event); +it.each([ + { execPath: "/fixture/bin/node", family: "node" }, + { execPath: "/fixture/bin/bun", family: "bun" }, + { execPath: "/fixture/bin/custom-runtime", family: "other" }, +])( + "counts admitted read-only session children as $family in spawn diagnostics", + ({ execPath, family }) => { + const originalExecPath = process.execPath; + let now = 0; + const clock = vi.spyOn(performance, "now").mockImplementation(() => now); + const events: unknown[] = []; + const stop = onDiagnosticEvent((event) => { + if (event.type === "diagnostic.child_process.spawn") { + events.push(event); + } + }); + try { + process.execPath = execPath; + setDiagnosticsEnabledForProcess(false); + emitChildProcessSpawnSample(); + setDiagnosticsEnabledForProcess(true); + const { child } = createSession(); + now = 60_000; + emitChildProcessSpawnSample(); + expect(events).toEqual([]); + child.emit("spawn"); + now = 120_000; + emitChildProcessSpawnSample(); + expect(events).toEqual([expect.objectContaining({ family, count: 1 })]); + } finally { + process.execPath = originalExecPath; + stop(); + setDiagnosticsEnabledForProcess(false); + emitChildProcessSpawnSample(); + clock.mockRestore(); } - }); - try { - setDiagnosticsEnabledForProcess(false); - emitChildProcessSpawnSample(); - setDiagnosticsEnabledForProcess(true); - const { child } = createSession(); - now = 60_000; - emitChildProcessSpawnSample(); - expect(events).toEqual([]); - child.emit("spawn"); - now = 120_000; - emitChildProcessSpawnSample(); - expect(events).toEqual([ - expect.objectContaining({ family: process.versions.bun ? "other" : "node", count: 1 }), - ]); - } finally { - stop(); - setDiagnosticsEnabledForProcess(false); - emitChildProcessSpawnSample(); - clock.mockRestore(); - } -}); + }, +); diff --git a/src/plugins/plugin-generation-artifact.ts b/src/plugins/plugin-generation-artifact.ts index 572cd94704ba..2dd8945e2b41 100644 --- a/src/plugins/plugin-generation-artifact.ts +++ b/src/plugins/plugin-generation-artifact.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import { isBuiltin } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { moduleResolve } from "import-meta-resolve"; import type { JitiOptions } from "jiti"; import { isPathInside } from "../infra/path-guards.js"; import { createJiti } from "./jiti-factory.js"; @@ -36,6 +35,8 @@ import { import { isPluginSourceEntry } from "./plugin-source-file.js"; import { capturedPluginModuleUrl, + createPluginPackageMapReferences, + resolvePluginPackageMapTarget, visitPluginSourceReferences, } from "./plugin-source-references.js"; import { verifyPluginSourceInputs } from "./plugin-source-verification.js"; @@ -103,6 +104,7 @@ export function capturePluginGenerationArtifact( } return existing.destination; } + const packageMap = createPluginPackageMapReferences(); const packageId = `package-${packages.size}`; const moduleRoot = path.join(directory, packageId, "node_modules"); const parentName = path.basename(path.dirname(boundary)); @@ -127,6 +129,7 @@ export function capturePluginGenerationArtifact( const source = path.join(boundary, path.relative(capturedBoundary, filename)); if ( !capturedPaths.has(source) && + !packageMap.hasMissingTarget(source) && fs.statSync(source, { throwIfNoEntry: false })?.isFile() && (isPathInside(boundary, fs.realpathSync(source)) || nativeAdmission.isRetainedReference(source)) @@ -292,9 +295,7 @@ export function capturePluginGenerationArtifact( try: true, conditions: conditions ? [...conditions] - : kind === "require" - ? ["node", "require"] - : ["node", "import"], + : ["node", "module-sync", kind === "require" ? "require" : "import"], }); if (!resolved?.startsWith("file:")) { return resolved; @@ -320,7 +321,15 @@ export function capturePluginGenerationArtifact( return undefined; } const name = packageName(value); - const resolved = resolve(value); + const self = scope?.manifest.exports != null && scope.manifest.name === name; + const resolved = + value.startsWith("#") || self + ? packageMap.resolveReference( + value, + source, + conditions ?? ["node", "module-sync", kind], + ) + : resolve(value); const input = resolved?.startsWith("file:") ? fileURLToPath(resolved) : resolved; if ( resolver.options.tsconfigPaths && @@ -341,7 +350,6 @@ export function capturePluginGenerationArtifact( return conditions && execute ? captureExecutableFile(input) : null; } } - const self = scope?.manifest.exports != null && scope.manifest.name === name; if (!value.startsWith("#") && !self) { if (conditions && !resolved) { return undefined; @@ -354,12 +362,15 @@ export function capturePluginGenerationArtifact( } let external = false; if (!self && scope) { - // Jiti selects the condition/target. String leaves identify lookup aliases only; + // Package-map resolution selects the target; string leaves identify lookup aliases only; // preserve every matching alias when several names share one physical package. for (const alias of scope.aliases) { const dependency = resolveDependency(alias, scope.source); if (dependency && isPathInside(dependency.root, input)) { - addDependency(alias, scope.source); + // Package aliases retain metadata now; execution captures the selected body. + if (conditions || !execute) { + addDependency(alias, scope.source); + } external = true; } } @@ -480,30 +491,17 @@ export function capturePluginGenerationArtifact( return dependencyPrepared ? { retryNative: true } : undefined; } if (dependencyPrepared === "package-map") { - let selected: URL; - try { - selected = moduleResolve(specifier, pathToFileURL(target), new Set(conditions)); - } catch (error) { - if ( - !(error instanceof Error) || - !("code" in error) || - error.code !== "ERR_MODULE_NOT_FOUND" - ) { - throw error; - } - if (!("url" in error) || typeof error.url !== "string") { - return undefined; - } - // Node chose this target from immutable metadata; only its body is still uncaptured. - selected = new URL(error.url); - } - if (selected.protocol !== "file:") { + const filename = resolvePluginPackageMapTarget(specifier, target, conditions); + if (!filename) { return undefined; } - const filename = fileURLToPath(selected); if (inPackage(capturedBoundary, filename)) { const original = path.join(boundary, path.relative(capturedBoundary, filename)); + if (packageMap.hasMissingTarget(original)) { + return undefined; + } if (!capturedPaths.has(original) && !fs.existsSync(original)) { + packageMap.recordMissingTarget(original); return undefined; } captureFile(original, resolver.options); @@ -528,13 +526,23 @@ export function capturePluginGenerationArtifact( return { target: capturedPluginModuleUrl(captured, specifier, conditions) }; }; const nativeScope = getNativeScope(source, scope?.manifest); - moduleCaptures.set(target, { prepareDependency, nativeScope, capture: captureModule }); - if (entry && !executableEntry) { - visitPluginSourceReferences( + const moduleCapture: PluginModuleCapture = { + isRequireReference: (specifier) => + observed.has(`require\0${specifier}`) && !observed.has(`import\0${specifier}`), + prepareDependency, + nativeScope, + capture: captureModule, + }; + moduleCaptures.set(target, moduleCapture); + // Only Bun previews need deferred-code facts without acquiring unresolved references. + if ((entry && !executableEntry) || process.versions.bun) { + moduleCapture.staticImports = visitPluginSourceReferences( source, fs.readFileSync(target, "utf8"), resolver, - captureObservedReference, + entry && !executableEntry + ? captureObservedReference + : (reference, kind) => observed.set(`${kind}\0${reference}`, null), ); } }; @@ -619,6 +627,12 @@ export function capturePluginGenerationArtifact( nativeAdmission.reconcileSourceInputs(inputs); }, sourceForCaptured: (file: string) => originalSources.get(path.resolve(file)), + isRequireReference: (importer: string, specifier: string) => + moduleCaptures.get(importer)?.isRequireReference(specifier) ?? false, + isRequirePreview: (importer: string, specifier: string) => { + const imports = moduleCaptures.get(importer)?.staticImports; + return imports !== undefined && !imports.has(specifier); + }, boundaryRoot: directory, // The receipt attests the initial snapshot; first-demand inputs extend only its identity ledger. sourceDigest: initialReceipt.sourceDigest, diff --git a/src/plugins/plugin-generation-conditions.test.ts b/src/plugins/plugin-generation-conditions.test.ts index 44ca93305cc1..92e53a2d7970 100644 --- a/src/plugins/plugin-generation-conditions.test.ts +++ b/src/plugins/plugin-generation-conditions.test.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { runNodeScript } from "../../test/helpers/run-node-script.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { createPluginCache, withPluginCache } from "./plugin-cache.js"; import { bindPluginInstanceModuleLoader } from "./plugin-instance-module-loader.js"; @@ -9,6 +11,9 @@ import { PluginInstance } from "./plugin-instance.js"; const temp = useAutoCleanupTempDirTracker(afterEach); const nativeRequire = createRequire(import.meta.url); +// The CI pin predates openclaw/bun#57. Remove this gate when that pin advances. +const lacksNativeModuleSync = + Reflect.get(process, "revision") === "17c9ecf9eb4aa0b60ae1a2c9f28c1b6dc0f4c8ac"; const instances: PluginInstance[] = []; afterEach(async () => { vi.unstubAllEnvs(); @@ -33,11 +38,13 @@ function load(rootDir: string, entry: string, standalone = false) { } describe("captured module conditions", () => { - it.each( - ["#selected", "condition-owner/selected"].flatMap((specifier) => - ["import", "require"].map((mode) => ({ specifier, mode })), - ), - )( + it + .skipIf(lacksNativeModuleSync) + .each( + ["#selected", "condition-owner/selected"].flatMap((specifier) => + ["import", "require"].map((mode) => ({ specifier, mode })), + ), + )( "preserves native module-sync selection for selective $mode $specifier", ({ specifier, mode }) => { const root = temp.make("plugin-native-conditions-"); @@ -67,75 +74,87 @@ describe("captured module conditions", () => { ); it.each( - ["import", "require"].flatMap((mode) => - ["exports", "main"].map((entryField) => ({ mode, entryField })), + [ + { mode: "import", entry: "index.mjs" }, + { mode: "require", entry: "index.cjs" }, + { mode: "require", entry: "index.js" }, + ].flatMap(({ mode, entry }) => + ["exports", "main"].map((entryField) => ({ mode, entry, entryField })), ), - )("retains conditional external $entryField metadata for $mode", async ({ mode, entryField }) => { - const root = temp.make("plugin-conditional-metadata-"); - const manifest = { - type: "module", - imports: { - "#selected": { - "module-sync": "sync-dependency", - import: "import-dependency", - require: "import-dependency", + )( + "retains conditional external $entryField metadata for $mode in $entry", + async ({ mode, entry, entryField }) => { + const root = temp.make("plugin-conditional-metadata-"); + const manifest = { + type: entry === "index.js" ? "commonjs" : "module", + imports: { + "#selected": { + "module-sync": "sync-dependency", + import: "import-dependency", + require: "import-dependency", + }, + "#unused": "invalid-dependency", }, - "#unused": "invalid-dependency", - }, - }; - fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest)); - const entry = mode === "import" ? "index.mjs" : "index.cjs"; - fs.writeFileSync( - path.join(root, entry), - mode === "import" - ? "export const read = async () => { const loaded = await import('#selected'); return [loaded.value, loaded.body]; };" - : "exports.read = () => { const loaded = require('#selected'); return [loaded.value, loaded.body]; };", - ); - for (const name of ["sync-dependency", "import-dependency", "invalid-dependency"]) { - const directory = path.join(root, "node_modules", name); - fs.mkdirSync(directory, { recursive: true }); + }; + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(manifest)); fs.writeFileSync( - path.join(directory, "package.json"), - name === "invalid-dependency" - ? "invalid unselected manifest" - : JSON.stringify({ [entryField]: "./original.mjs" }), + path.join(root, entry), + mode === "import" + ? "export const read = async () => { const loaded = await import('#selected'); return [loaded.value, loaded.body]; };" + : "exports.read = () => { const loaded = require('#selected'); return [loaded.value, loaded.body]; };", + ); + for (const name of ["sync-dependency", "import-dependency", "invalid-dependency"]) { + const directory = path.join(root, "node_modules", name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, "package.json"), + name === "invalid-dependency" + ? "invalid unselected manifest" + : JSON.stringify({ [entryField]: "./original.mjs" }), + ); + fs.writeFileSync( + path.join(directory, "original.mjs"), + `export const value = '${name}'; export { body } from './body.mjs';`, + ); + fs.writeFileSync( + path.join(directory, "body.mjs"), + "export const body = 'before selection';", + ); + fs.writeFileSync( + path.join(directory, "replacement.mjs"), + "export const value = 'wrong replacement';", + ); + } + const plugin = load(root, entry, true).value as { read(): string[] | Promise }; + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ ...manifest, imports: { "#selected": "import-dependency" } }), + ); + const selected = path.join(root, "node_modules", "sync-dependency"); + fs.writeFileSync( + path.join(selected, "package.json"), + JSON.stringify({ + [entryField]: "./replacement.mjs", + dependencies: { "missing-later-dependency": "1.0.0" }, + }), ); fs.writeFileSync( - path.join(directory, "original.mjs"), - `export const value = '${name}'; export { body } from './body.mjs';`, + path.join(selected, "original.mjs"), + "export const value = 'first demand'; export { body } from './body.mjs';", ); - fs.writeFileSync(path.join(directory, "body.mjs"), "export const body = 'before selection';"); - fs.writeFileSync( - path.join(directory, "replacement.mjs"), - "export const value = 'wrong replacement';", - ); - } - const plugin = load(root, entry, true).value as { read(): string[] | Promise }; - fs.writeFileSync( - path.join(root, "package.json"), - JSON.stringify({ ...manifest, imports: { "#selected": "import-dependency" } }), - ); - const selected = path.join(root, "node_modules", "sync-dependency"); - fs.writeFileSync( - path.join(selected, "package.json"), - JSON.stringify({ - [entryField]: "./replacement.mjs", - dependencies: { "missing-later-dependency": "1.0.0" }, - }), - ); - fs.writeFileSync( - path.join(selected, "original.mjs"), - "export const value = 'first demand'; export { body } from './body.mjs';", - ); - fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'selected body';"); - const expected = [entryField === "main" ? "sync-dependency" : "first demand", "selected body"]; - expect(await plugin.read()).toEqual(expected); - fs.writeFileSync(path.join(selected, "original.mjs"), "export const value = 'later edit';"); - fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'later body';"); - expect(await plugin.read()).toEqual(expected); - }); + fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'selected body';"); + const expected = [ + entryField === "main" ? "sync-dependency" : "first demand", + "selected body", + ]; + expect(await plugin.read()).toEqual(expected); + fs.writeFileSync(path.join(selected, "original.mjs"), "export const value = 'later edit';"); + fs.writeFileSync(path.join(selected, "body.mjs"), "export const body = 'later body';"); + expect(await plugin.read()).toEqual(expected); + }, + ); - it.each(["import", "require"])( + it.skipIf(lacksNativeModuleSync).each(["import", "require"])( "retains a missing selected conditional target for %s", async (mode) => { const root = temp.make("plugin-missing-conditional-target-"); @@ -319,3 +338,220 @@ describe("captured module conditions", () => { expect(plugin.read("#direct")).toBe(42); }); }); + +it("retains the first observed absence of a computed package alias", () => { + const root = temp.make("plugin-computed-missing-target-"); + fs.writeFileSync(path.join(root, "package.json"), '{"imports":{"#selected":"./missing.cjs"}}'); + fs.writeFileSync(path.join(root, "index.cjs"), "exports.read = name => require(name);"); + const plugin = load(root, "index.cjs", true).value as { read(name: string): unknown }; + expect(() => plugin.read("#selected")).toThrow(); + fs.writeFileSync(path.join(root, "missing.cjs"), "module.exports = 42;"); + expect(() => plugin.read("#selected")).toThrow(); + const fresh = load(root, "index.cjs", true).value as { read(name: string): unknown }; + expect(fresh.read("#selected")).toBe(42); +}); + +it("does not acquire the unselected native runtime alias package", () => { + const root = temp.make("plugin-native-runtime-alias-"); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ + imports: { "#selected": { bun: "bun-dependency", default: "node-dependency" } }, + }), + ); + const selected = process.versions.bun ? "bun-dependency" : "node-dependency"; + for (const name of ["bun-dependency", "node-dependency"]) { + const directory = path.join(root, "node_modules", name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, "package.json"), + JSON.stringify({ + main: "index.cjs", + ...(name === selected + ? {} + : { dependencies: { "unselected-missing-dependency": "1.0.0" } }), + }), + ); + fs.writeFileSync(path.join(directory, "index.cjs"), "exports.value = 42;"); + } + fs.writeFileSync(path.join(root, "index.cjs"), "exports.read = name => require(name).value;"); + const plugin = load(root, "index.cjs", true).value as { read(name: string): number }; + expect(plugin.read("#selected")).toBe(42); +}); + +it("scopes observed package-map absences to the target selected by custom conditions", async () => { + const home = temp.make("plugin-custom-condition-absence-"); + const roots = ["import", "require"].map((mode) => { + const root = path.join(home, mode); + fs.mkdirSync(root, { mode: 0o700 }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ + imports: { + "#selected": { "openclaw-custom": "./valid.cjs", default: "./missing.cjs" }, + "#late": "./missing.cjs", + }, + }), + ); + fs.writeFileSync(path.join(root, "valid.cjs"), "exports.value = 42;"); + const entry = path.join(root, mode === "import" ? "index.mjs" : "index.cjs"); + fs.writeFileSync( + entry, + mode === "import" + ? "export const read = async () => (await import('#selected')).default.value; export const readLate = async () => (await import('#late')).default.value;" + : "exports.read = () => require('#selected').value; exports.readLate = () => require('#late').value;", + ); + return { root, entry }; + }); + const moduleUrl = (filename: string) => pathToFileURL(path.resolve("src/plugins", filename)).href; + const probe = path.join(home, "probe.mts"); + fs.writeFileSync( + probe, + `import assert from 'node:assert/strict'; + import fs from 'node:fs'; + import path from 'node:path'; + import { createPluginCache, withPluginCache } from ${JSON.stringify(moduleUrl("plugin-cache.ts"))}; + import { bindPluginInstanceModuleLoader } from ${JSON.stringify(moduleUrl("plugin-instance-module-loader.ts"))}; + import { PluginInstance } from ${JSON.stringify(moduleUrl("plugin-instance.ts"))}; + const values = []; + for (const { root, entry } of ${JSON.stringify(roots)}) { + const instances = []; + const load = () => { + const instance = new PluginInstance('custom-condition-fixture'); + instances.push(instance); + withPluginCache(createPluginCache(), () => bindPluginInstanceModuleLoader({ + instance, origin: 'config', source: entry, rootDir: root, standalone: true, + })); + return instance.loadModule(entry); + }; + try { + const plugin = load(); + fs.writeFileSync(path.join(root, 'missing.cjs'), 'exports.value = 84;'); + values.push(await plugin.read()); + await assert.rejects(async () => await plugin.readLate()); + assert.equal(await load().readLate(), 84); + } finally { + for (const instance of instances.toReversed()) await instance.dispose(); + } + } + console.log(JSON.stringify(values));`, + ); + const state = path.join(home, "state"); + fs.mkdirSync(state, { mode: 0o700 }); + const result = await runNodeScript( + [ + "--conditions=openclaw-custom", + ...(process.versions.bun + ? ["--no-install"] + : ["--import", pathToFileURL(path.resolve("scripts/tsx.mjs")).href]), + probe, + ], + { ...process.env, HOME: home, USERPROFILE: home, TMPDIR: home, OPENCLAW_STATE_DIR: state }, + undefined, + { executable: process.execPath }, + ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([42, 42]); +}); + +// Native erasure keeps specifier-only empty requests; the existing Node/Jiti adapter removes them. +it.each([ + { + name: "import type", + declaration: "import type { Shape } from '#selected';", + expected: "after", + }, + { + name: "type import specifier", + declaration: "import { type Shape } from '#selected';", + expected: process.versions.bun ? "before" : "after", + }, + { + name: "implicit type import", + declaration: "import { Shape } from '#selected'; type Alias = Shape;", + expected: "after", + }, + { + name: "export type", + declaration: "export type { Shape } from '#selected';", + expected: "after", + }, + { + name: "type export specifier", + declaration: "export { type Shape } from '#selected';", + expected: process.versions.bun ? "before" : "after", + }, + { name: "export type all", declaration: "export type * from '#selected';", expected: "after" }, + { name: "side-effect import", declaration: "import '#selected';", expected: "before" }, + { + name: "mixed runtime import", + declaration: "import { type Shape, value } from '#selected'; export const observed = value;", + expected: "before", + }, + { + name: "mixed runtime export", + declaration: "export { type Shape, value } from '#selected';", + expected: "before", + }, +])("acquires the dependency body at runtime after $name", ({ declaration, expected }) => { + const root = temp.make("plugin-type-only-reference-"); + const dependency = path.join(root, "node_modules", "selected-dependency"); + fs.mkdirSync(dependency, { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + '{"imports":{"#selected":"selected-dependency"}}', + ); + fs.writeFileSync(path.join(dependency, "package.json"), '{"exports":"./index.cjs"}'); + fs.writeFileSync( + path.join(dependency, "index.cjs"), + "exports.value = require('./body.cjs').value;", + ); + fs.writeFileSync(path.join(dependency, "body.cjs"), "exports.value = 'before';"); + // A computed import exercises Bun's native TypeScript adapter. + fs.writeFileSync( + path.join(root, "index.ts"), + `${declaration} + export const read = () => require('#selected').value; + export const load = async (name: string) => import(name);`, + ); + const plugin = load(root, "index.ts", true).value as { read(): string }; + fs.writeFileSync(path.join(dependency, "body.cjs"), "exports.value = 'after';"); + expect(plugin.read()).toBe(expected); +}); + +it.each(["declared", "alias", "undeclared"])( + "preserves %s nested dependency capture timing for a deferred entry", + (kind) => { + const root = temp.make("plugin-deferred-entry-facts-"); + const outer = path.join(root, "node_modules", "outer-dependency"); + const inner = path.join(outer, "node_modules", "inner-dependency"); + fs.mkdirSync(inner, { recursive: true }); + fs.writeFileSync(path.join(root, "package.json"), '{"imports":{"#outer":"outer-dependency"}}'); + fs.writeFileSync( + path.join(outer, "package.json"), + JSON.stringify({ + exports: "./index.cjs", + ...(kind === "declared" + ? { dependencies: { "inner-dependency": "1.0.0" } } + : kind === "alias" + ? { imports: { "#inner": "inner-dependency" } } + : {}), + }), + ); + fs.writeFileSync( + path.join(outer, "index.cjs"), + `exports.read = () => require(${JSON.stringify(kind === "alias" ? "#inner" : "inner-dependency")}).value;`, + ); + fs.writeFileSync(path.join(inner, "package.json"), '{"exports":"./index.cjs"}'); + fs.writeFileSync(path.join(inner, "index.cjs"), "exports.value = require('./body.cjs').value;"); + const body = path.join(inner, "body.cjs"); + fs.writeFileSync(body, "exports.value = 'before-load';"); + fs.writeFileSync(path.join(root, "index.cjs"), "exports.select = () => require('#outer');"); + const plugin = load(root, "index.cjs", true).value as { select(): { read(): string } }; + fs.writeFileSync(body, "exports.value = 'before-selection';"); + const selected = plugin.select(); + fs.writeFileSync(body, "exports.value = 'after-selection';"); + expect(selected.read()).toBe(kind === "declared" ? "before-selection" : "after-selection"); + }, +); diff --git a/src/plugins/plugin-generation-source-inspection.test.ts b/src/plugins/plugin-generation-source-inspection.test.ts index 4298623447f4..c3aca3f0efcc 100644 --- a/src/plugins/plugin-generation-source-inspection.test.ts +++ b/src/plugins/plugin-generation-source-inspection.test.ts @@ -144,3 +144,47 @@ it("inspects a captured cyclic dependency graph without following its dependency expect(fs.readdirSync(captures)).toEqual([]); expect(followedLinks.size).toBe(0); }); + +it.for( + ["#selected", "inspection-conditions/selected"].flatMap((specifier) => + ["import", "require"].map((kind) => ({ specifier, kind })), + ), +)( + "inspects the module-sync target for $kind $specifier without evaluation", + ({ specifier, kind }, context) => { + // The CI pin predates openclaw/bun#57; keep import inspection active on that pin. + if ( + kind === "require" && + Reflect.get(process, "revision") === "17c9ecf9eb4aa0b60ae1a2c9f28c1b6dc0f4c8ac" + ) { + context.skip(); + } + const root = temp.make("plugin-inspection-conditions-"); + const entryFile = path.join(root, kind === "import" ? "index.mjs" : "index.cjs"); + const selected = path.join(root, "sync.mjs"); + const selection = { "module-sync": "./sync.mjs", default: "./fallback.mjs" }; + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ + name: "inspection-conditions", + type: "module", + imports: { "#selected": selection }, + exports: { "./selected": selection }, + }), + ); + for (const file of [selected, path.join(root, "fallback.mjs")]) { + fs.writeFileSync(file, "throw new Error('inspection must not evaluate plugin code');"); + } + fs.writeFileSync( + entryFile, + kind === "import" + ? `import ${JSON.stringify(specifier)};` + : `require(${JSON.stringify(specifier)});`, + ); + + const inspection = inspectPluginSourceDependencies([{ rootDir: root, entryFile }]); + expect(inspection.unresolved).toEqual([]); + expect(inspection.references).toEqual([{ source: entryFile, specifier, target: selected }]); + expect(() => inspection.assertSourceCurrent()).not.toThrow(); + }, +); diff --git a/src/plugins/plugin-generation-source-inspection.ts b/src/plugins/plugin-generation-source-inspection.ts index c486574e98be..5699e820a1d5 100644 --- a/src/plugins/plugin-generation-source-inspection.ts +++ b/src/plugins/plugin-generation-source-inspection.ts @@ -59,7 +59,7 @@ export function inspectPluginSourceDependencies( path.isAbsolute(specifier) || specifier.startsWith("file:"); try { - const conditions = ["node", kind]; + const conditions = ["node", "module-sync", kind]; const result = artifact.captureModule(captured, specifier, conditions); const target = result && "target" in result diff --git a/src/plugins/plugin-instance-module-loader.ts b/src/plugins/plugin-instance-module-loader.ts index f8f06ad16cf8..f79239d1fe44 100644 --- a/src/plugins/plugin-instance-module-loader.ts +++ b/src/plugins/plugin-instance-module-loader.ts @@ -133,7 +133,7 @@ export function bindPluginInstanceModuleLoader(params: PluginInstanceModuleLoade : undefined; for (const { specifier } of bunSourceFacts?.staticImports ?? []) { if (path.isAbsolute(specifier) || specifier.startsWith("file:")) { - artifact.captureModule(capturedSource, specifier, ["node", "import"]); + artifact.captureModule(capturedSource, specifier, ["node", "module-sync", "import"]); } } const bunNeedsNativeSource = diff --git a/src/plugins/plugin-native-module-loader.ts b/src/plugins/plugin-native-module-loader.ts index 0ba3732751d7..a46618b38874 100644 --- a/src/plugins/plugin-native-module-loader.ts +++ b/src/plugins/plugin-native-module-loader.ts @@ -15,7 +15,7 @@ import type { PluginOrigin } from "./plugin-origin.types.js"; import { isPluginSdkAliasSpecifier } from "./sdk-alias.js"; function getBunConditions(requireMode: boolean): Set { - const conditions = new Set(["bun", "node", requireMode ? "require" : "import"]); + const conditions = new Set(["bun", "node", "module-sync", requireMode ? "require" : "import"]); if (!process.execArgv.includes("--no-addons")) { conditions.add("node-addons"); } @@ -97,6 +97,11 @@ export function bindNativePluginInstanceModuleLoader( } : {}), prepare(request, parent, kind) { + // Bun previews literal require calls as imports while compiling CommonJS. + // The require-call hook owns acquisition when that operation executes. + if (kind === "import-statement" && artifact.isRequirePreview(parent, request)) { + return undefined; + } // Resolved URLs and built relative imports retain the selected host SDK's identity. const original = artifact.sourceForCaptured(parent); const sdkTarget = hostSdkTarget(request, original); @@ -115,11 +120,17 @@ export function bindNativePluginInstanceModuleLoader( artifact.prepareModule(source); let target: string | undefined; const requireMode = kind === "require-call" || kind === "require-resolve"; - const conditions = ["node", requireMode ? "require" : "import"]; + const conditions = [...getBunConditions(requireMode)]; if (source === parent && request.startsWith(".")) { // Jiti implements computed imports through require.resolve; relative source capture // still follows the authored import graph rather than that internal mechanism. - const captured = artifact.captureModule(parent, request, ["node", "import"]); + const captured = artifact.captureModule( + parent, + request, + kind === "require-resolve" && !artifact.isRequireReference(parent, request) + ? ["node", "module-sync", "import"] + : conditions, + ); if (captured && "target" in captured) { target = captured.target.search || captured.target.hash @@ -130,7 +141,6 @@ export function bindNativePluginInstanceModuleLoader( source === parent && !path.isAbsolute(request) && !request.startsWith("file:") && - !request.startsWith("#") && !isBuiltin(request) ) { const captured = artifact.captureModule(parent, request, conditions); @@ -231,7 +241,11 @@ export function bindNativePluginInstanceModuleLoader( } return captured; } - const captured = artifact.captureModule(parent, request, ["node", "require"]); + const captured = artifact.captureModule(parent, request, [ + "node", + "module-sync", + "require", + ]); return captured && "target" in captured ? fileURLToPath(captured.target) : undefined; }), ); diff --git a/src/plugins/plugin-package-metadata-capture.ts b/src/plugins/plugin-package-metadata-capture.ts index 095c00c9a4a9..7ab164ca0101 100644 --- a/src/plugins/plugin-package-metadata-capture.ts +++ b/src/plugins/plugin-package-metadata-capture.ts @@ -90,6 +90,8 @@ function pluginDependencyNames(manifest: Record | undefined): S type PluginNativeDependencyScope = { prepareDependencies?: () => void }; export type PluginModuleCapture = { + staticImports?: ReadonlySet; + isRequireReference: (specifier: string) => boolean; prepareDependency: ReturnType; nativeScope: PluginNativeDependencyScope; capture: ( diff --git a/src/plugins/plugin-source-references.ts b/src/plugins/plugin-source-references.ts index 051ffd3a567b..30ba1618fc14 100644 --- a/src/plugins/plugin-source-references.ts +++ b/src/plugins/plugin-source-references.ts @@ -1,8 +1,16 @@ import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { NodePath } from "@babel/traverse"; -import type { CallExpression, Node, Program as BabelProgram } from "@babel/types"; +import type { + CallExpression, + ExportAllDeclaration, + ExportNamedDeclaration, + ImportDeclaration, + Node, + Program as BabelProgram, +} from "@babel/types"; import { parse, type AnyNode, type Program } from "acorn"; +import { moduleResolve } from "import-meta-resolve"; import type { createJiti } from "jiti"; export function capturedPluginModuleUrl( @@ -23,6 +31,60 @@ export function capturedPluginModuleUrl( return url; } +/** Package metadata selects a target before its deferred body has been captured. */ +export function resolvePluginPackageMapTarget( + specifier: string, + importer: string, + conditions: readonly string[], +): string | undefined { + let selected: URL; + try { + selected = moduleResolve(specifier, pathToFileURL(importer), new Set(conditions)); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ERR_MODULE_NOT_FOUND") { + throw error; + } + if (!("url" in error) || typeof error.url !== "string") { + return undefined; + } + // Node chose this target from immutable metadata; only its body is still uncaptured. + selected = new URL(error.url); + } + return selected.protocol === "file:" ? fileURLToPath(selected) : undefined; +} + +/** Missing physical inputs stay absent without poisoning another condition's selected target. */ +export function createPluginPackageMapReferences() { + const missingTargets = new Set(); + const recordMissingTarget = (filename: string) => { + missingTargets.add(path.resolve(filename)); + }; + return { + recordMissingTarget, + hasMissingTarget: (filename: string) => missingTargets.has(path.resolve(filename)), + resolveReference(specifier: string, importer: string, conditions: readonly string[]) { + try { + return moduleResolve(specifier, pathToFileURL(importer), new Set(conditions)).href; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ERR_MODULE_NOT_FOUND" && + "url" in error && + typeof error.url === "string" + ) { + const target = new URL(error.url); + if (target.protocol === "file:") { + recordMissingTarget(fileURLToPath(target)); + } + } + // Optional invalid metadata is reported only when its branch is executed. + return undefined; + } + }, + }; +} + type StaticStringNode = { type: string; value?: unknown; @@ -350,7 +412,8 @@ export function visitPluginSourceReferences( sourceText: string, resolver: ReturnType, visitReference: (reference: string, kind: "asset" | "import" | "require") => void, -): void { +): ReadonlySet { + const authoredStaticImports = new Set(); const visitDirectoryAsset = (name: string, parts: readonly (string | undefined)[]) => { if ( (name === "join" || name === "resolve") && @@ -376,6 +439,32 @@ export function visitPluginSourceReferences( { pre(file: { path: NodePath }) { file.path.traverse({ + // Native type erasure retains empty requests from specifier-only type syntax. + ImportDeclaration(declaration) { + if ( + declaration.node.importKind !== "type" && + declaration.node.specifiers.length > 0 && + declaration.node.specifiers.every( + (specifier) => + specifier.type === "ImportSpecifier" && specifier.importKind === "type", + ) + ) { + authoredStaticImports.add(declaration.node.source.value); + } + }, + ExportNamedDeclaration(declaration) { + if ( + declaration.node.source && + declaration.node.exportKind !== "type" && + declaration.node.specifiers.length > 0 && + declaration.node.specifiers.every( + (specifier) => + specifier.type === "ExportSpecifier" && specifier.exportKind === "type", + ) + ) { + authoredStaticImports.add(declaration.node.source.value); + } + }, MemberExpression(member) { // Jiti inlines import.meta.url, dirname and filename as strings, also // where valid code assigns to them. Inspection reads only references, @@ -418,6 +507,20 @@ export function visitPluginSourceReferences( }, }); }, + // Jiti runs these after TypeScript erasure and before lowering module declarations. + visitor: { + ImportDeclaration(declaration: NodePath) { + authoredStaticImports.add(declaration.node.source.value); + }, + ExportNamedDeclaration(declaration: NodePath) { + if (declaration.node.source) { + authoredStaticImports.add(declaration.node.source.value); + } + }, + ExportAllDeclaration(declaration: NodePath) { + authoredStaticImports.add(declaration.node.source.value); + }, + }, }, ], }, @@ -434,6 +537,7 @@ export function visitPluginSourceReferences( const reference = staticString(statement.source); if (reference !== undefined) { staticImports.add(reference); + authoredStaticImports.add(reference); } } } @@ -486,4 +590,5 @@ export function visitPluginSourceReferences( } }; visit(tree); + return authoredStaticImports; } diff --git a/src/plugins/update-npm-metadata.test-support.ts b/src/plugins/update-npm-metadata.test-support.ts index 563c4583e246..a9b505dc6bde 100644 --- a/src/plugins/update-npm-metadata.test-support.ts +++ b/src/plugins/update-npm-metadata.test-support.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core/expect"; import { assert, expect, it, vi, type Mock } from "vitest"; import type { runCommandWithTimeout } from "../process/exec.js"; +import { npmCommandArgs } from "../test-utils/npm-command.js"; import { expectIntegrityDriftRejected, mockNpmViewMetadataResult, @@ -86,9 +87,9 @@ export function registerNpmUpdateMetadataTests({ }); expect( runCommandWithTimeoutMock.mock.calls - .map(([argv]) => argv) - .filter((argv) => argv[1] === "view") - .map((argv) => expectDefined(argv[2], "npm view package spec")) + .map(([argv]) => npmCommandArgs(argv)) + .filter((args): args is string[] => args?.[0] === "view") + .map((args) => expectDefined(args[1], "npm view package spec")) .toSorted((left, right) => left.localeCompare(right)), ).toEqual([`${packageName}@beta`, `${packageName}@latest`]); }); @@ -156,9 +157,9 @@ export function registerNpmUpdateMetadataTests({ JSON.parse(fs.readFileSync(path.join(record.installPath, "package.json"), "utf8")), ).toMatchObject({ name: packageName, version: "2.0.0" }); const metadataCommands = runCommandWithTimeoutMock.mock.calls - .map(([argv]) => argv) - .filter((argv) => argv[1] === "view") - .map((argv) => expectDefined(argv[2], "npm view package spec")); + .map(([argv]) => npmCommandArgs(argv)) + .filter((args): args is string[] => args?.[0] === "view") + .map((args) => expectDefined(args[1], "npm view package spec")); expect(metadataCommands.toSorted((left, right) => left.localeCompare(right))).toEqual( channel === "beta" ? [`${packageName}@beta`, `${packageName}@latest`] : [packageName], ); diff --git a/src/process/supervisor/service-child-relay-host.integration.test.ts b/src/process/supervisor/service-child-relay-host.integration.test.ts index 43a10481ceab..2ea91c9d6f00 100644 --- a/src/process/supervisor/service-child-relay-host.integration.test.ts +++ b/src/process/supervisor/service-child-relay-host.integration.test.ts @@ -55,7 +55,10 @@ async function startSupervisedRelay( options: { timeoutMs?: number; noOutputTimeoutMs?: number } = {}, ) { platformMock = mockProcessPlatform("linux"); - vi.spyOn(process, "kill").mockImplementation(() => { + vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === 0 && signal === 0) { + return true; + } throw Object.assign(new Error("fixture group absent"), { code: "ESRCH" }); }); const stub = createWritableRelayChild(); @@ -106,7 +109,15 @@ async function startSupervisedRelay( stub.disconnectMock(); stub.emitExit(0); }); - return { ...stub, supervisor, closeScope, starting, emit, finish }; + return { + ...stub, + supervisor, + closeScope, + starting, + emit, + finish, + treeOwnership: start.treeOwnership, + }; } it.each(["stdout", "stderr"] as const)( @@ -193,9 +204,13 @@ it.each(["cancel", "overall-timeout"] as const)( const run = await f.starting; expect((await run.wait()).reason).toBe(mode === "cancel" ? "manual-cancel" : mode); expect((await run.wait()).stdout).toBe("before cancellation"); - // Disconnect asks the dedicated native owner to stop and reap descendants. - // Killing that owner during construction would abandon its wait custody. - expect(f.killMock).not.toHaveBeenCalled(); + if (f.treeOwnership === "linux-subreaper") { + // Disconnect preserves the native owner's descendant wait custody. + expect(f.killMock).not.toHaveBeenCalled(); + } else { + // Both construction abort and its failed-ready cleanup stop the ordinary relay. + expect(f.killMock.mock.calls).toEqual([["SIGKILL"], ["SIGKILL"]]); + } expect(f.disconnectMock).toHaveBeenCalled(); expect(f.sendMock.mock.calls.length).toBe(sending); await expect(f.closeScope()).rejects.toThrow("construction aborted"); diff --git a/test/helpers/native-module-mock.ts b/test/helpers/native-module-mock.ts new file mode 100644 index 000000000000..273aa2cfd4a1 --- /dev/null +++ b/test/helpers/native-module-mock.ts @@ -0,0 +1,19 @@ +import { createRequire } from "node:module"; +import { mock } from "node:test"; + +const require = createRequire(import.meta.url); + +/** Install fixture exports for the lifetime of an isolated native child or worker. */ +export function mockNativeModuleExports( + specifier: string | URL, + namedExports: Record, +): void { + if (process.versions.bun) { + const { mock: bunMock } = require("bun:test") as { + mock: { module: (id: string, factory: () => Record) => void }; + }; + bunMock.module(String(specifier), () => namedExports); + } else { + mock.module(specifier, { namedExports }); + } +} diff --git a/test/scripts/ci-git-owner.test.ts b/test/scripts/ci-git-owner.test.ts index e97c24958d41..1a44b7044ece 100644 --- a/test/scripts/ci-git-owner.test.ts +++ b/test/scripts/ci-git-owner.test.ts @@ -48,12 +48,21 @@ type AncestryFixture = { target: string; }; +function ancestryGitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + // Detached Git maintenance can keep writing after the fixture starts cleanup. + GIT_CONFIG_PARAMETERS: + `${process.env.GIT_CONFIG_PARAMETERS ?? ""} 'maintenance.auto=false' 'gc.auto=0'`.trim(), + }; +} + function fixtureGit(cwd: string, args: string[], input?: string) { const result = spawnSync("git", args, { cwd, encoding: "utf8", env: { - ...process.env, + ...ancestryGitEnv(), GIT_AUTHOR_EMAIL: "fixture@example.invalid", GIT_AUTHOR_NAME: "fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid", @@ -215,7 +224,7 @@ function runReleaseAncestry( cwd: checkout, encoding: "utf8", env: { - ...process.env, + ...ancestryGitEnv(), RELEASE_ANCESTRY_MODE: mode, RELEASE_ANCESTRY_TARGET_REF: "refs/heads/main", ...env, diff --git a/test/scripts/ci-platform-checkout.test.ts b/test/scripts/ci-platform-checkout.test.ts index 61a8be79f520..6d2a40b13e7e 100644 --- a/test/scripts/ci-platform-checkout.test.ts +++ b/test/scripts/ci-platform-checkout.test.ts @@ -178,9 +178,6 @@ if (process.argv[2] === "sentinel") { throw error; } }; - // Loaded macOS hosts can drop FSEvents directory notifications entirely. - const watch = fs.watch; - fs.watch = (target, ...args) => (target === root ? { close() {} } : watch(target, ...args)); } syncFixtureBuiltinExports(); ` @@ -1065,6 +1062,14 @@ cp.spawnSync = (command, args, options) => { ''' + "\nrequire(" + json.dumps(sys.argv[5]) + ").syncFixtureBuiltinExports();\n") with subprocess.Popen([sys.executable, "-I", "-S", "-c", "import sys; sys.stdin.read()"], stdin=subprocess.PIPE) as child, contextlib.ExitStack() as cleanup: + lease_owner = cleanup.enter_context(subprocess.Popen([ + sys.argv[1], sys.argv[2], "lease-owner", str(root), "standalone"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)) + def close_lease_owner(): + lease_owner.communicate(timeout=4) + assert lease_owner.returncode == 0, "lease owner failed during retirement" + cleanup.callback(close_lease_owner) + assert lease_owner.stdout.readline().strip() == "ready", "lease owner failed to initialize" if os.name == "nt": broker = cleanup.enter_context(subprocess.Popen([ sys.argv[1], "--input-type=module", "-e", """ @@ -1159,11 +1164,19 @@ with subprocess.Popen([sys.executable, "-I", "-S", "-c", "pass"], start_new_sess assert not group_alive(child.pid, deadline), "zombies are terminated, not checkout writers" group_signal(child.pid, signal.SIGTERM, deadline) group_signal(child.pid, signal.SIGKILL, deadline) - with tempfile.TemporaryDirectory(prefix="checkout-zombie-") as directory: + with tempfile.TemporaryDirectory(prefix="checkout-zombie-") as directory, contextlib.ExitStack() as cleanup: root = pathlib.Path(directory).resolve() (root / "workspace").mkdir() (root / "pids").mkdir() (root / "lease").write_text("owned") + lease_owner = cleanup.enter_context(subprocess.Popen([ + sys.argv[1], sys.argv[2], "lease-owner", str(root), "standalone"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)) + def close_lease_owner(): + lease_owner.communicate(timeout=4) + assert lease_owner.returncode == 0, "lease owner failed during retirement" + cleanup.callback(close_lease_owner) + assert lease_owner.stdout.readline().strip() == "ready", "lease owner failed to initialize" for pid, role, attempt in [(child.pid, "grandchild", 1), (os.getpid(), "sentinel", 0)]: (root / "pids" / f"{pid}.json").write_text(json.dumps(dict(pid=pid, role=role, attempt=attempt, instance=str(pid)))) subprocess.run([sys.argv[1], sys.argv[2], "git", str(root), "early-leader-exit", diff --git a/test/scripts/desktop-resize-proof.test.ts b/test/scripts/desktop-resize-proof.test.ts index 8689ebdfe509..c112827694a5 100644 --- a/test/scripts/desktop-resize-proof.test.ts +++ b/test/scripts/desktop-resize-proof.test.ts @@ -365,17 +365,18 @@ describe("desktop proof identity and public evidence", () => { it("keeps the tap off a port claimed before its listener binds", async () => { const upstream = await acquireTestPortBlock({ offsets: [0] }); - // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply binds each listener. - const listen = net.Server.prototype.listen; + const createServer = net.createServer; // Model the kernel choosing another fixture's claimed but unbound port. - const listenSpy = vi.spyOn(net.Server.prototype, "listen").mockImplementation(function ( - this: net.Server, - ...args - ) { - if (args[0] === 0) { - args[0] = upstream.port; - } - return Reflect.apply(listen, this, args); + const createServerSpy = vi.spyOn(net, "createServer").mockImplementation((...args) => { + const server = createServer(...args); + const listen = server.listen.bind(server); + server.listen = (...listenArgs) => { + if (listenArgs[0] === 0) { + listenArgs[0] = upstream.port; + } + return Reflect.apply(listen, server, listenArgs); + }; + return server; }); let closeTap: (() => Promise) | undefined; await runQaGatewayFixture( @@ -387,7 +388,7 @@ describe("desktop proof identity and public evidence", () => { closeTap = tap.close; expect(tap.port).not.toBe(upstream.port); }, - () => listenSpy.mockRestore(), + () => createServerSpy.mockRestore(), () => closeTap?.(), () => upstream.release(), ); diff --git a/test/scripts/fixtures/ci-platform-checkout.mjs b/test/scripts/fixtures/ci-platform-checkout.mjs index 53943e5f9a8f..8a9c4a5344b0 100644 --- a/test/scripts/fixtures/ci-platform-checkout.mjs +++ b/test/scripts/fixtures/ci-platform-checkout.mjs @@ -1,6 +1,7 @@ import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import net from "node:net"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; @@ -27,6 +28,10 @@ const lease = path.join(root, "lease"); const recordsDir = path.join(root, "pids"); const eventsFile = path.join(root, "events.jsonl"); const commandsFile = path.join(root, "commands.jsonl"); +const leaseChannelFile = path.join(root, "lease-channel.json"); +const leaseConnections = new Set(); +let leaseChannel; +let actorChannel; const optionsFile = path.join(root, "fixture-options.json"); const options = fs.existsSync(optionsFile) ? JSON.parse(fs.readFileSync(optionsFile, "utf8")) : {}; const localGit = options.localGit ?? options.performance; @@ -123,7 +128,64 @@ function recordCommand(tool, cwd, commandArgs, configuration) { ); } +async function startLeaseChannel(onError, token = instance) { + leaseChannel = net.createServer((socket) => { + leaseConnections.add(socket); + socket.once("close", () => leaseConnections.delete(socket)); + socket.on("error", () => socket.destroy()); + let authenticated = false; + let input = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + input += chunk; + if (!authenticated) { + const end = input.indexOf("\n"); + if (end < 0 && input.length <= 128) { + return; + } + if (end < 0 || input.slice(0, end) !== token || !fs.existsSync(lease)) { + socket.destroy(); + return; + } + authenticated = true; + input = input.slice(end + 1); + socket.write("ready\n"); + } + if (input) { + if (!/^\.+$/u.test(input)) { + socket.destroy(); + return; + } + input = ""; + } + }); + }); + await new Promise((resolve, reject) => { + leaseChannel.once("error", reject); + leaseChannel.listen(0, "127.0.0.1", () => { + leaseChannel.off("error", reject); + leaseChannel.on("error", onError); + resolve(); + }); + }); + publish("lease-channel.json", { port: leaseChannel.address().port }); +} + +function retireLeaseChannel() { + for (const socket of leaseConnections) { + socket.destroy(); + } + return new Promise((resolve) => { + if (leaseChannel) { + leaseChannel.close(resolve); + } else { + resolve(); + } + }); +} + function notifyPublication() { + actorChannel?.write("."); if (process.connected && process.send) { // The owner can close IPC during cleanup. Its exit and existing watchdog // still bound readiness; a closed channel must not crash an orphan actor. @@ -135,6 +197,7 @@ function publish(name, value) { const target = path.join(root, name); fs.writeFileSync(`${target}.${process.pid}.tmp`, JSON.stringify(value)); fs.renameSync(`${target}.${process.pid}.tmp`, target); + // Notify only after the authoritative record exists. notifyPublication(); } @@ -376,7 +439,7 @@ function launch(role, attempt) { return child; } -function holdLease() { +async function holdLease() { actorLease = fs.readFileSync(lease, "utf8"); const isLive = () => { try { @@ -394,15 +457,32 @@ function holdLease() { process.exit(0); } }; - // Watch the lease itself before rereading: replacing or retiring it must wake - // actors immediately, including a change during registration. macOS serves - // directory watches through FSEvents, which can drop the unlink under load; - // a file watch is kernel-delivered. A lease already gone fails the reread. - try { - fs.watch(lease, checkLease); - } catch (error) { - if (error.code !== "ENOENT" && error.code !== "EPERM") throw error; - } + // The supervisor owns this connection across intermediate Git parents exiting. + // Its retirement or death closes every actor's channel without filesystem polling. + const { port } = JSON.parse(fs.readFileSync(leaseChannelFile, "utf8")); + actorChannel = net.createConnection({ host: "127.0.0.1", port }); + actorChannel.once("close", () => { + checkLease(); + process.exit(0); + }); + await new Promise((resolve, reject) => { + let reply = ""; + actorChannel.once("error", reject); + actorChannel.once("connect", () => actorChannel.write(`${actorLease}\n`)); + actorChannel.setEncoding("utf8"); + actorChannel.on("data", (chunk) => { + reply += chunk; + if (!reply.includes("\n") && reply.length <= 128) { + return; + } + if (reply !== "ready\n") { + reject(new Error("Fixture lease channel rejected its actor")); + return; + } + checkLease(); + resolve(); + }); + }); setTimeout(checkLease, Math.max(0, deadline - Date.now())); checkLease(); return deadline; @@ -434,7 +514,7 @@ function writeConsumer(target, tool) { } async function command() { - operationDeadline = holdLease(); + operationDeadline = await holdLease(); const descendant = mode === "child" || mode === "grandchild"; // Descendants publish their actual attempt below. Replacing a provisional PID // record can race a Windows reader and fail before readiness with EPERM. @@ -1168,6 +1248,7 @@ async function supervise() { : undefined; try { fs.rmSync(lease, { force: true }); + const channelClosed = retireLeaseChannel(); sentinel?.kill("SIGKILL"); if (shell && shell.exitCode === null && shell.signalCode === null) { // Only this fixture's still-owned detached shell group may be signaled. @@ -1192,7 +1273,7 @@ async function supervise() { let closeCutoff; try { await Promise.race([ - Promise.all(pendingChildren.values()), + Promise.all([...pendingChildren.values(), channelClosed]), new Promise((_, reject) => { closeCutoff = setTimeout( () => reject(new Error("Timed out waiting for direct child close")), @@ -1294,6 +1375,11 @@ async function supervise() { }); operationDeadline = Date.now() + lifetimeCeilingMs; try { + await startLeaseChannel((error) => void stop(error)); + if (stopping) { + await stopping; + return; + } if (process.platform === "win32") { census = createWindowsProcessCensus({ root, @@ -1458,6 +1544,22 @@ source "$2"`, if (mode === "supervise") { await supervise(); +} else if (mode === "lease-owner") { + try { + await startLeaseChannel( + (error) => { + throw error; + }, + fs.readFileSync(lease, "utf8"), + ); + process.stdout.write("ready\n"); + await new Promise((resolve) => { + process.stdin.once("end", resolve); + process.stdin.resume(); + }); + } finally { + await retireLeaseChannel(); + } } else { await command(); } diff --git a/test/scripts/tsdown-config.test.ts b/test/scripts/tsdown-config.test.ts index b38479a9e536..da56e17824a8 100644 --- a/test/scripts/tsdown-config.test.ts +++ b/test/scripts/tsdown-config.test.ts @@ -1327,7 +1327,7 @@ console.log("relocated Bash parser works without native grammar package"); throw new Error("worker deploy config must define dependency bundling"); } expect(alwaysBundle("json5", undefined)).toBe(true); - expect(alwaysBundle("node:fs", undefined)).toBe(false); + expect(config?.platform).toBe("node"); expect(config?.outExtensions?.(context)).toEqual({ js: ".mjs", dts: ".d.ts" }); } }); diff --git a/test/scripts/worker-deploy-build-plugin.test.ts b/test/scripts/worker-deploy-build-plugin.test.ts index 0c27e1775968..8fa61953bac1 100644 --- a/test/scripts/worker-deploy-build-plugin.test.ts +++ b/test/scripts/worker-deploy-build-plugin.test.ts @@ -161,6 +161,13 @@ export { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";`; expect(bundles.flatMap((bundle) => bundle.chunks.map((chunk) => chunk.fileName))).toEqual([ "worker/worker.mjs", ]); + expect( + bundles.flatMap((bundle) => + bundle.chunks.flatMap((chunk) => + chunk.type === "chunk" ? [...chunk.imports, ...chunk.dynamicImports] : [], + ), + ), + ).not.toContain("ws"); const { collectWorkerDeployArtifactErrors } = await import("../../scripts/check-cli-bootstrap-imports.mts"); expect( @@ -393,6 +400,7 @@ console.log("relocated worker facade activation follows the shared config snapsh const result = await promisify(execFile)( process.execPath, [ + ...(process.versions.bun ? ["--no-install"] : []), "--input-type=module", "--eval", ` diff --git a/tsdown.config.ts b/tsdown.config.ts index c2a0179cd45a..9c2919c70ac4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,6 @@ // tsdown config defines package build entrypoints and output options. import fs from "node:fs"; -import { createRequire, isBuiltin } from "node:module"; +import { createRequire } from "node:module"; import path from "node:path"; import type { DtsOptions, TsdownPlugin, UserConfig } from "tsdown"; import { @@ -243,6 +243,7 @@ function workerDeployBuildConfig(entry: Record): UserConfig { name: TSDOWN_UNIFIED_CONFIG_GROUP, entry, outDir: "dist", + platform: "node", dts: false, env, define: { @@ -260,7 +261,8 @@ function workerDeployBuildConfig(entry: Record): UserConfig { "utf-8-validate": WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID, }, deps: { - alwaysBundle: (id) => !isBuiltin(id), + // Rolldown's Node target owns builtin resolution, independently of the build host. + alwaysBundle: () => true, onlyBundle: false, }, fixedExtension: false, @@ -282,11 +284,12 @@ function workerHelperBuildConfig( name: TSDOWN_UNIFIED_CONFIG_GROUP, entry, outDir: "dist", + platform: "node", dts: false, env, define, deps: { - alwaysBundle: (id) => !isBuiltin(id), + alwaysBundle: () => true, onlyBundle: false, }, fixedExtension: false,