From cd3f9d94ae7a7407ec1053d8e55d9b4b0dcf1751 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Thu, 27 Aug 2026 18:03:23 +0000 Subject: [PATCH 1/2] fix(core): resolve npm provider entrypoints on Node --- bun.lock | 3 ++ packages/core/package.json | 7 +++ packages/core/src/npm.ts | 3 +- packages/core/src/npm/resolve.bun.ts | 3 ++ packages/core/src/npm/resolve.node.ts | 25 ++++++++++ packages/core/test/npm.node.ts | 71 +++++++++++++++++++++++++++ packages/core/test/npm.test.ts | 1 + 7 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/npm/resolve.bun.ts create mode 100644 packages/core/src/npm/resolve.node.ts create mode 100644 packages/core/test/npm.node.ts diff --git a/bun.lock b/bun.lock index 740abb79909b..4f64137224c5 100644 --- a/bun.lock +++ b/bun.lock @@ -351,6 +351,7 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", + "resolve.exports": "2.0.3", "semver": "^7.6.3", "turndown": "7.2.0", "venice-ai-sdk-provider": "2.1.1", @@ -4957,6 +4958,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], + "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], diff --git a/packages/core/package.json b/packages/core/package.json index ba3653df8ae1..576d60fc4d08 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,7 @@ "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "test": "bun test --only-failures", + "test:node-npm": "node --conditions=fixture-provider --test test/npm.node.ts", "typecheck": "tsgo --noEmit" }, "bin": { @@ -23,6 +24,11 @@ "./*": "./src/*.ts" }, "imports": { + "#npm-resolve": { + "bun": "./src/npm/resolve.bun.ts", + "node": "./src/npm/resolve.node.ts", + "default": "./src/npm/resolve.bun.ts" + }, "#sqlite": { "bun": "./src/database/sqlite.bun.ts", "node": "./src/database/sqlite.node.ts", @@ -119,6 +125,7 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", + "resolve.exports": "2.0.3", "semver": "^7.6.3", "turndown": "7.2.0", "venice-ai-sdk-provider": "2.1.1", diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 30e12cff1206..20fa13bfd75a 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -12,6 +12,7 @@ import { filesystem } from "./effect/app-node-platform" import { LayerNode } from "./effect/layer-node" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" +import { resolveModule } from "#npm-resolve" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), @@ -50,7 +51,7 @@ export function sanitize(pkg: string) { const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: string | undefined try { - entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) + entrypoint = resolveModule(name, dir) } catch { entrypoint = undefined } diff --git a/packages/core/src/npm/resolve.bun.ts b/packages/core/src/npm/resolve.bun.ts new file mode 100644 index 000000000000..db27b9528c74 --- /dev/null +++ b/packages/core/src/npm/resolve.bun.ts @@ -0,0 +1,3 @@ +export function resolveModule(name: string, directory: string) { + return import.meta.resolve(name, directory) +} diff --git a/packages/core/src/npm/resolve.node.ts b/packages/core/src/npm/resolve.node.ts new file mode 100644 index 000000000000..19dca3d7ed93 --- /dev/null +++ b/packages/core/src/npm/resolve.node.ts @@ -0,0 +1,25 @@ +import { Script, constants } from "node:vm" +import { createRequire, registerHooks } from "node:module" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { resolve, type Package } from "resolve.exports" + +// Capture import conditions from the runtime, including custom conditions and module-sync. +let conditions: readonly string[] = [] +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + conditions = context.conditions + return nextResolve(specifier, context) + }, +}) +await new Script('import("node:module")', { + importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, +}).runInThisContext() +hooks.deregister() + +export function resolveModule(name: string, directory: string) { + const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package + const target = resolve(pkg, name, { conditions, unsafe: true })?.[0] + if (target) return pathToFileURL(path.resolve(directory, target)).href + return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(directory)).href +} diff --git a/packages/core/test/npm.node.ts b/packages/core/test/npm.node.ts new file mode 100644 index 000000000000..53a80c50a909 --- /dev/null +++ b/packages/core/test/npm.node.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict" +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { test } from "node:test" +import { pathToFileURL } from "node:url" +import { resolveModule } from "#npm-resolve" + +const cases = [ + { title: "string exports", manifest: { exports: "./entry.mjs" }, file: "entry.mjs" }, + { + title: "nested import conditions", + manifest: { exports: { ".": { node: { import: "./entry.mjs", require: "./wrong.cjs" } } } }, + file: "entry.mjs", + }, + { + title: "export condition key order", + manifest: { exports: { ".": { default: "./entry.mjs", import: "./wrong.mjs" } } }, + file: "entry.mjs", + }, + { + title: "runtime module-sync condition", + manifest: { exports: { ".": { "module-sync": "./entry.mjs", import: "./wrong.mjs" } } }, + file: "entry.mjs", + }, + { + title: "custom runtime condition", + manifest: { exports: { ".": { "fixture-provider": "./entry.mjs", default: "./wrong.mjs" } } }, + file: "entry.mjs", + }, + { title: "legacy main", manifest: { main: "./entry.cjs" }, file: "entry.cjs" }, + { title: "legacy index", manifest: {}, file: "index.js" }, +] + +for (const fixture of cases) { + test(fixture.title, async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "opencode-npm-node-")) + t.after(() => rm(root, { recursive: true, force: true })) + const directory = path.join(root, "node_modules", "@fixture", "provider") + await mkdir(directory, { recursive: true }) + await writeFile( + path.join(directory, "package.json"), + JSON.stringify({ name: "@fixture/provider", ...fixture.manifest }), + ) + await writeFile( + path.join(directory, fixture.file), + fixture.file.endsWith(".mjs") + ? 'export const createFixture = () => "loaded"' + : 'exports.createFixture = () => "loaded"', + ) + + // The previous Node branch returned this directory URL instead of an entry file. + await assert.rejects(import(pathToFileURL(directory).href), { code: "ERR_UNSUPPORTED_DIR_IMPORT" }) + const entrypoint = resolveModule("@fixture/provider", directory) + assert.equal(entrypoint, pathToFileURL(path.join(directory, fixture.file)).href) + assert.equal((await import(entrypoint)).createFixture(), "loaded") + }) +} + +for (const exports of [{ ".": { require: "./index.js" } }, { "./other": "./index.js" }, { ".": null }]) { + test(`does not bypass unavailable root exports: ${JSON.stringify(exports)}`, async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "opencode-npm-node-")) + t.after(() => rm(directory, { recursive: true, force: true })) + await writeFile( + path.join(directory, "package.json"), + JSON.stringify({ name: "fixture", exports, main: "index.js" }), + ) + await writeFile(path.join(directory, "index.js"), "exports.fixture = true") + assert.throws(() => resolveModule("fixture", directory)) + }) +} diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 7e4a5763bf08..d808149c1dbf 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -54,6 +54,7 @@ describe("Npm.add", () => { }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) expect(entry.entrypoint).toBeDefined() + expect((await import(entry.entrypoint!)).fixture).toBe(true) }) }) From 2d788b87ab4cfbb6c90f8cc96d3efa6ff5c58c34 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Thu, 27 Aug 2026 18:13:43 +0000 Subject: [PATCH 2/2] fix(core): isolate npm import condition capture --- packages/core/package.json | 2 +- packages/core/src/npm/resolve.node.ts | 7 ++++--- packages/core/test/fixture/npm-require.node.ts | 4 ++++ packages/core/test/npm.node.ts | 6 ++++++ 4 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/fixture/npm-require.node.ts diff --git a/packages/core/package.json b/packages/core/package.json index 576d60fc4d08..d42d59b52805 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test --only-failures", + "test": "bun test --only-failures && bun run test:node-npm", "test:node-npm": "node --conditions=fixture-provider --test test/npm.node.ts", "typecheck": "tsgo --noEmit" }, diff --git a/packages/core/src/npm/resolve.node.ts b/packages/core/src/npm/resolve.node.ts index 19dca3d7ed93..aba58108f21e 100644 --- a/packages/core/src/npm/resolve.node.ts +++ b/packages/core/src/npm/resolve.node.ts @@ -8,7 +8,8 @@ import { resolve, type Package } from "resolve.exports" let conditions: readonly string[] = [] const hooks = registerHooks({ resolve(specifier, context, nextResolve) { - conditions = context.conditions + // Concurrent startup requires must not overwrite the import probe's conditions. + if (specifier === "node:module" && context.conditions.includes("import")) conditions = context.conditions return nextResolve(specifier, context) }, }) @@ -17,9 +18,9 @@ await new Script('import("node:module")', { }).runInThisContext() hooks.deregister() -export function resolveModule(name: string, directory: string) { +export function resolveModule(_name: string, directory: string) { const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package - const target = resolve(pkg, name, { conditions, unsafe: true })?.[0] + const target = resolve(pkg, ".", { conditions, unsafe: true })?.[0] if (target) return pathToFileURL(path.resolve(directory, target)).href return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(directory)).href } diff --git a/packages/core/test/fixture/npm-require.node.ts b/packages/core/test/fixture/npm-require.node.ts new file mode 100644 index 000000000000..af064b2e2625 --- /dev/null +++ b/packages/core/test/fixture/npm-require.node.ts @@ -0,0 +1,4 @@ +import { createRequire } from "node:module" + +// Other modules can require dependencies while the resolver awaits its import probe. +createRequire(import.meta.url)("node:path") diff --git a/packages/core/test/npm.node.ts b/packages/core/test/npm.node.ts index 53a80c50a909..d7c0d81e8cfc 100644 --- a/packages/core/test/npm.node.ts +++ b/packages/core/test/npm.node.ts @@ -5,9 +5,15 @@ import path from "node:path" import { test } from "node:test" import { pathToFileURL } from "node:url" import { resolveModule } from "#npm-resolve" +import "./fixture/npm-require.node.ts" const cases = [ { title: "string exports", manifest: { exports: "./entry.mjs" }, file: "entry.mjs" }, + { + title: "npm alias with a different manifest name", + manifest: { name: "@fixture/original", exports: "./entry.mjs" }, + file: "entry.mjs", + }, { title: "nested import conditions", manifest: { exports: { ".": { node: { import: "./entry.mjs", require: "./wrong.cjs" } } } },