Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/npm/resolve.bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function resolveModule(name: string, directory: string) {
return import.meta.resolve(name, directory)
}
26 changes: 26 additions & 0 deletions packages/core/src/npm/resolve.node.ts
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions packages/core/test/fixture/npm-require.node.ts
Original file line number Diff line number Diff line change
@@ -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")
77 changes: 77 additions & 0 deletions packages/core/test/npm.node.ts
Original file line number Diff line number Diff line change
@@ -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))
})
}
1 change: 1 addition & 0 deletions packages/core/test/npm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
Loading