fix(javascript): preserve Node exports fallback stop reasons (#606)

* fix(javascript): preserve Node exports fallback stop reasons

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>

* test(browser): preserve unknown with-scope loader bindings

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>

---------

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
This commit is contained in:
Rudy Mizrahi Celekli
2026-10-05 14:09:12 -04:00
committed by GitHub
parent 0ce529af4e
commit adc07f5eaa
2 changed files with 86 additions and 36 deletions
@@ -448,7 +448,8 @@ const packageExport = (
return { status: "invalid" };
const flattened = exportTargets(root, packageExportConditions(moduleKind));
if (flattened.kind === "invalid") return { status: "invalid" };
const first = flattened.values[0];
const first =
flattened.kind === "unmatched" ? undefined : flattened.values[0];
return first === undefined
? { status: "unmatched", declared: Object.keys(root) }
: { status: "value", value: first };
@@ -456,38 +457,45 @@ const packageExport = (
type ExportTargets =
| { readonly kind: "targets"; readonly values: readonly string[] }
| { readonly kind: "unmatched" }
| { readonly kind: "invalid" };
/**
* Flatten one exports target into the ordered strings Node would consider.
* A condition object stops at its first active key, so a null or unmatched
* nested target ends the search rather than falling through to a later key. An
* array instead skips the entries it cannot resolve and concatenates the rest.
* Resolve a target in Node's declared order. Explicit null blocks an active
* condition, whereas an unmatched nested object permits the next condition.
* An array skips unmatched and invalid entries, stops at its first string, and
* preserves the final invalid-versus-null refusal if no string is selected.
*/
const exportTargets = (
value: unknown,
conditions: ReadonlySet<string>,
): ExportTargets => {
if (typeof value === "string")
return value.length > 0
return value.startsWith("./")
? { kind: "targets", values: [value] }
: { kind: "invalid" };
if (value === null) return { kind: "targets", values: [] };
if (Array.isArray(value)) {
const values: string[] = [];
let invalid = false;
for (const entry of value) {
const nested = exportTargets(entry, conditions);
if (nested.kind === "invalid") return nested;
values.push(...nested.values);
if (nested.kind === "invalid") {
invalid = true;
continue;
}
if (nested.kind === "unmatched") continue;
invalid = false;
if (nested.values.length > 0) return nested;
}
return { kind: "targets", values };
return invalid ? { kind: "invalid" } : { kind: "targets", values: [] };
}
if (typeof value !== "object") return { kind: "invalid" };
for (const [condition, target] of Object.entries(value)) {
if (!conditions.has(condition)) continue;
return exportTargets(target, conditions);
const nested = exportTargets(target, conditions);
if (nested.kind !== "unmatched") return nested;
}
return { kind: "targets", values: [] };
return { kind: "unmatched" };
};
/**
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
resolveArtifactPathByContext,
type ArtifactPathResolution,
@@ -18,29 +19,34 @@ const TARGETS = [
"array.cjs",
"nested.cjs",
];
const SCRIPT =
'import { createRequire } from "node:module"; console.log((await import("example")).default ?? createRequire(import.meta.url)("example"))';
/** Resolve the bare `example` specifier through the Node resolver that owns it. */
const nodeTarget = async (
cwd: string,
moduleKind: "import" | "require",
): Promise<string | null> => {
try {
const { stdout } = await execute(
process.execPath,
[
"--input-type=module",
"-e",
moduleKind === "import"
? 'console.log((await import("example")).default)'
: 'import { createRequire } from "node:module"; console.log(createRequire(import.meta.url)("example"))',
],
{ cwd },
);
return stdout.trim();
} catch {
return null;
}
const nodeExportOutcomeSchema = z.strictObject({
target: z.string().nullable(),
error_code: z.string().nullable(),
});
/** Resolve through the real Node resolver, retaining its refusal reason. */
const nodeTarget = async (cwd: string, moduleKind: "import" | "require") => {
const expression =
moduleKind === "import"
? '(await import("example")).default'
: 'createRequire(import.meta.url)("example")';
const { stdout } = await execute(
process.execPath,
[
"--input-type=module",
"-e",
`
import { createRequire } from "node:module";
try {
console.log(JSON.stringify({target: ${expression}, error_code: null}));
} catch (error) {
console.log(JSON.stringify({target: null, error_code: error.code}));
}
`,
],
{ cwd },
);
return nodeExportOutcomeSchema.parse(JSON.parse(stdout));
};
/**
* Install one real package on disk and mirror it into an artifact inventory,
@@ -52,6 +58,7 @@ const compareWithNode = async (
present: readonly string[] = TARGETS,
): Promise<{
readonly node: string | null;
readonly node_error: string | null;
readonly outcome: ArtifactPathResolution;
}> => {
const root = await createTestTempDirectory("rea-exports-");
@@ -83,8 +90,10 @@ const compareWithNode = async (
},
]),
);
const node = await nodeTarget(root, moduleKind);
return {
node: await nodeTarget(root, moduleKind),
node: node.target,
node_error: node.error_code,
outcome: resolveArtifactPathByContext({
declaredPath: "example",
sourcePath: "main.js",
@@ -171,6 +180,13 @@ describe("nested and array package export targets", () => {
"nested.cjs",
],
[{ import: { default: "./default.cjs" } }, "default.cjs"],
[
{ import: { browser: "./specific.cjs" }, default: "./default.cjs" },
"default.cjs",
],
[{ import: [42, "./array.cjs"] }, "array.cjs"],
[{ import: ["./specific.cjs", 42] }, "specific.cjs"],
[{ import: ["../outside.cjs", "./array.cjs"] }, "array.cjs"],
[{ import: { node: { default: "./default.cjs" } } }, "default.cjs"],
[{ import: [{ node: "./nested.cjs" }, "./array.cjs"] }, "nested.cjs"],
[{ import: [{ browser: "./x.cjs" }, "./array.cjs"] }, "array.cjs"],
@@ -226,3 +242,29 @@ describe("unmatched package export conditions", () => {
expect(outcome.limitations[0]).toContain("not valid package JSON");
});
});
describe("package exports fallback refusal reasons", () => {
it.each([
[{ import: [42, 7] }, "ERR_INVALID_PACKAGE_TARGET", "unavailable"],
[{ import: [null, null] }, "ERR_PACKAGE_PATH_NOT_EXPORTED", "external"],
[{ import: [42, null] }, "ERR_PACKAGE_PATH_NOT_EXPORTED", "external"],
[{ import: [null, 42] }, "ERR_INVALID_PACKAGE_TARGET", "unavailable"],
[
{ import: { node: null }, default: "./default.cjs" },
"ERR_PACKAGE_PATH_NOT_EXPORTED",
"external",
],
] as const)(
"matches Node refusal for %j",
async (exportsMap, code, status) => {
const { node, node_error, outcome } = await compareWithNode(
exportsMap,
"import",
);
expect(node).toBeNull();
expect(node_error).toBe(code);
expect(outcome.resolved_path).toBeNull();
expect(outcome.resolution_status).toBe(status);
},
);
});