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..d42d59b52805 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,8 @@ "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" }, "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..aba58108f21e --- /dev/null +++ b/packages/core/src/npm/resolve.node.ts @@ -0,0 +1,26 @@ +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) { + // 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) + }, +}) +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, ".", { 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 new file mode 100644 index 000000000000..d7c0d81e8cfc --- /dev/null +++ b/packages/core/test/npm.node.ts @@ -0,0 +1,77 @@ +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" +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" } } } }, + 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) }) })