690 lines
23 KiB
TypeScript
690 lines
23 KiB
TypeScript
import { builtinModules } from "node:module";
|
|
import { posix } from "node:path";
|
|
|
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
|
import {
|
|
admitsCanonicalPathSyntax,
|
|
hasScheme,
|
|
htmlUrlText,
|
|
looksExternal,
|
|
percentDecodeUrlPath,
|
|
stripQueryAndFragment,
|
|
} from "../../domain/artifactPathSyntax.js";
|
|
|
|
type ArtifactPathResolutionContext =
|
|
| "package-entrypoint"
|
|
| "filesystem-expression"
|
|
| "module-specifier"
|
|
| "html-reference"
|
|
| "url-reference";
|
|
|
|
type UnresolvedArtifactPathStatus =
|
|
| "not-found"
|
|
| "unavailable"
|
|
| "external"
|
|
| "rejected";
|
|
|
|
interface ArtifactPathResolutionBase {
|
|
readonly declared_path: string;
|
|
readonly resolution_context: ArtifactPathResolutionContext;
|
|
readonly limitations: readonly string[];
|
|
}
|
|
|
|
/** Explicit outcome of artifact-confined path resolution. */
|
|
export type ArtifactPathResolution = ArtifactPathResolutionBase &
|
|
(
|
|
| {
|
|
readonly resolved_path: string;
|
|
readonly resolution_status: "resolved";
|
|
}
|
|
| {
|
|
readonly resolved_path: null;
|
|
readonly resolution_status: UnresolvedArtifactPathStatus;
|
|
}
|
|
);
|
|
|
|
/** Values needed to resolve a declaration without filesystem access. */
|
|
export interface ResolveArtifactPathInput {
|
|
readonly declaredPath: string;
|
|
readonly sourcePath: string;
|
|
readonly context: ArtifactPathResolutionContext;
|
|
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
|
|
readonly htmlBaseHref?: string | null;
|
|
readonly moduleKind?: "import" | "require";
|
|
}
|
|
|
|
const EXTENSIONS = [
|
|
".js",
|
|
".cjs",
|
|
".mjs",
|
|
".ts",
|
|
".tsx",
|
|
".json",
|
|
".html",
|
|
".node",
|
|
];
|
|
const NODE_BUILTINS = new Set(
|
|
builtinModules.map((name) => name.replace(/^node:/u, "")),
|
|
);
|
|
|
|
type CandidateResolution =
|
|
| {
|
|
readonly resolvedPath: string;
|
|
readonly status: "resolved";
|
|
readonly limitations: readonly string[];
|
|
}
|
|
| {
|
|
readonly resolvedPath: null;
|
|
readonly status: UnresolvedArtifactPathStatus;
|
|
readonly limitations: readonly string[];
|
|
};
|
|
|
|
/** Resolve one declaration under its exact syntax/metadata context. */
|
|
export const resolveArtifactPathByContext = (
|
|
input: ResolveArtifactPathInput,
|
|
): ArtifactPathResolution => resolvePackagePath(input, new Set());
|
|
|
|
const resolvePackagePath = (
|
|
input: ResolveArtifactPathInput,
|
|
packageChain: ReadonlySet<string>,
|
|
mode: "full" | "files-and-index" = "full",
|
|
exportsTargetUrl = false,
|
|
): ArtifactPathResolution => {
|
|
const rejected = rejectDeclaration(input);
|
|
if (rejected !== null) return rejected;
|
|
const candidate = contextualCandidate(input, exportsTargetUrl);
|
|
if (typeof candidate !== "string") return candidate;
|
|
const confined = confineCandidate(input, candidate);
|
|
if (typeof confined !== "string") return confined;
|
|
if (input.context === "html-reference")
|
|
return outcome(
|
|
input,
|
|
resolveFileCandidates(input, [confined]) ?? {
|
|
resolvedPath: null,
|
|
status: "not-found",
|
|
limitations: [
|
|
`The exact HTML resource ${confined} was not found among the selected application files.`,
|
|
],
|
|
},
|
|
);
|
|
// An exports target names one exact file; Node tries no extension or index.
|
|
const resolved = exportsTargetUrl
|
|
? (resolveFileCandidates(input, [confined]) ??
|
|
exactExportsTargetNotFound(confined))
|
|
: mode === "files-and-index"
|
|
? (resolveFileCandidates(input, [
|
|
...fileCandidates(confined),
|
|
...indexCandidates(confined),
|
|
]) ?? notFoundCandidate())
|
|
: resolveCandidate(input, confined, packageChain);
|
|
return outcome(input, resolved);
|
|
};
|
|
|
|
const rejectDeclaration = (
|
|
input: ResolveArtifactPathInput,
|
|
): ArtifactPathResolution | null => {
|
|
const declared = input.declaredPath;
|
|
if (declared.length === 0)
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The declared path is empty.",
|
|
]);
|
|
if (declared.includes("\0") || declared.includes("\\"))
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"NUL and backslash path syntax are not admitted for canonical artifact paths.",
|
|
]);
|
|
if (!admitsCanonicalPathSyntax(declared))
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"Encoded dot or separator bytes are rejected before artifact path resolution.",
|
|
]);
|
|
return null;
|
|
};
|
|
|
|
const contextualCandidate = (
|
|
input: ResolveArtifactPathInput,
|
|
exportsTargetUrl: boolean,
|
|
): string | ArtifactPathResolution => {
|
|
const { context } = input;
|
|
let declared =
|
|
context === "html-reference" ||
|
|
context === "url-reference" ||
|
|
exportsTargetUrl ||
|
|
(context === "module-specifier" && input.moduleKind !== "require")
|
|
? stripQueryAndFragment(input.declaredPath)
|
|
: input.declaredPath;
|
|
if (context === "html-reference") return htmlCandidate(input);
|
|
if (context === "module-specifier") {
|
|
const fileUrl = fileUrlPath(declared);
|
|
if (fileUrl !== undefined) return fileUrl;
|
|
if (hasScheme(declared))
|
|
return unresolvedOutcome(input, "external", [
|
|
"URL and Node builtin schemes are outside static artifact module resolution.",
|
|
]);
|
|
if (!declared.startsWith(".") && !declared.startsWith("/"))
|
|
return bareModuleCandidate(input, declared);
|
|
} else if (looksExternal(declared))
|
|
return unresolvedOutcome(input, "external", [
|
|
"URL schemes and protocol-relative URLs are outside this local artifact path context.",
|
|
]);
|
|
// Exports targets use URL paths for both loaders; legacy main fields remain literal.
|
|
if (
|
|
exportsTargetUrl ||
|
|
(context === "module-specifier" && input.moduleKind !== "require")
|
|
) {
|
|
try {
|
|
declared = decodeURIComponent(declared);
|
|
} catch (cause: unknown) {
|
|
void cause;
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The module URL path contains malformed percent encoding.",
|
|
]);
|
|
}
|
|
if (declared.includes("\0"))
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The decoded module URL path contains NUL.",
|
|
]);
|
|
}
|
|
const relative = declared.startsWith("/") ? declared.slice(1) : declared;
|
|
return declared.startsWith("/")
|
|
? relative
|
|
: posix.join(posix.dirname(input.sourcePath), relative);
|
|
};
|
|
|
|
const bareModuleCandidate = (
|
|
input: ResolveArtifactPathInput,
|
|
declared: string,
|
|
): string | ArtifactPathResolution => {
|
|
const packageName = barePackageName(declared);
|
|
if (
|
|
packageName === null ||
|
|
NODE_BUILTINS.has(packageName) ||
|
|
declared.startsWith("#")
|
|
)
|
|
return unresolvedOutcome(input, "external", [
|
|
"The bare specifier is a Node builtin, package import map, or invalid package name.",
|
|
]);
|
|
if (declared !== packageName)
|
|
return unresolvedOutcome(input, "external", [
|
|
"Bare package subpaths remain unresolved unless an exact package-exports subpath model is available.",
|
|
]);
|
|
const source = input.files.get(input.sourcePath);
|
|
let directory = posix.dirname(input.sourcePath);
|
|
while (true) {
|
|
const candidate = posix.join(directory, "node_modules", declared);
|
|
if (hasContainerCandidate(input.files, candidate, source?.container_sha256))
|
|
return candidate;
|
|
if (directory === "." || directory === "") break;
|
|
directory = posix.dirname(directory);
|
|
}
|
|
return unresolvedOutcome(input, "external", [
|
|
"No matching bare package was inventoried in an enclosing node_modules directory.",
|
|
]);
|
|
};
|
|
|
|
const barePackageName = (specifier: string): string | null => {
|
|
const segments = specifier.split("/");
|
|
if (specifier.startsWith("@"))
|
|
return segments.length >= 2 && segments[0] !== "" && segments[1] !== ""
|
|
? `${segments[0]}/${segments[1]}`
|
|
: null;
|
|
return segments[0] === "" ? null : (segments[0] ?? null);
|
|
};
|
|
|
|
const hasContainerCandidate = (
|
|
files: ReadonlyMap<string, JavaScriptArtifactFile>,
|
|
candidate: string,
|
|
containerSha256: string | undefined,
|
|
): boolean =>
|
|
[
|
|
...fileCandidates(candidate),
|
|
...indexCandidates(candidate),
|
|
posix.join(candidate, "package.json"),
|
|
].some((path) => {
|
|
const file = files.get(path);
|
|
return (
|
|
file !== undefined &&
|
|
(containerSha256 === undefined ||
|
|
file.container_sha256 === containerSha256)
|
|
);
|
|
});
|
|
|
|
const htmlCandidate = (
|
|
input: ResolveArtifactPathInput,
|
|
): string | ArtifactPathResolution => {
|
|
const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath));
|
|
if (looksExternal(declared))
|
|
return unresolvedOutcome(input, "external", [
|
|
"External HTML references are not mapped to local artifact assets.",
|
|
]);
|
|
const rawBase = input.htmlBaseHref;
|
|
const base =
|
|
rawBase === undefined || rawBase === null
|
|
? rawBase
|
|
: stripQueryAndFragment(htmlUrlText(rawBase));
|
|
if (base !== undefined && base !== null && looksExternal(base))
|
|
return unresolvedOutcome(input, "external", [
|
|
"The document base href is external, so its script reference is not a local artifact path.",
|
|
]);
|
|
// Removing URL tabs and newlines can join an encoded dot or separator that
|
|
// the raw declaration split, so admit the parsed URL text again.
|
|
if (!admitsCanonicalPathSyntax(declared))
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"Encoded dot or separator bytes are rejected before artifact path resolution.",
|
|
]);
|
|
const declaredPath = percentDecodeUrlPath(declared);
|
|
if (declaredPath === null)
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.",
|
|
]);
|
|
if (declaredPath.startsWith("/")) return declaredPath.slice(1);
|
|
if (base === undefined || base === null || base === "")
|
|
return posix.join(posix.dirname(input.sourcePath), declaredPath);
|
|
// A local base href is a second untrusted path input; apply the same
|
|
// admission rules a declared path gets so it cannot smuggle traversal or
|
|
// separator syntax past canonicalization.
|
|
if (!admitsCanonicalPathSyntax(base))
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The document base href uses NUL, backslash, or encoded dot and separator bytes that are not admitted for canonical artifact paths.",
|
|
]);
|
|
const decodedBase = percentDecodeUrlPath(base);
|
|
if (decodedBase === null)
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The document base href path percent-decodes to NUL or to bytes that are not UTF-8.",
|
|
]);
|
|
const basePath = decodedBase.startsWith("/")
|
|
? decodedBase.slice(1)
|
|
: posix.join(posix.dirname(input.sourcePath), decodedBase);
|
|
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
|
|
};
|
|
|
|
/**
|
|
* The directory a relative HTML reference resolves against. A base path
|
|
* ending in "/" or in a "." or ".." segment is already a directory, because a
|
|
* relative reference resolves against the base URL's directory and those
|
|
* segments are dropped rather than stepped through.
|
|
*/
|
|
const htmlBaseDirectory = (base: string, basePath: string): string =>
|
|
base.endsWith("/") ||
|
|
base.endsWith("/.") ||
|
|
base.endsWith("/..") ||
|
|
base === "." ||
|
|
base === ".."
|
|
? basePath
|
|
: posix.dirname(basePath);
|
|
|
|
const confineCandidate = (
|
|
input: ResolveArtifactPathInput,
|
|
candidate: string,
|
|
): string | ArtifactPathResolution => {
|
|
const normalized = posix.normalize(candidate);
|
|
if (
|
|
normalized === ".." ||
|
|
normalized.startsWith("../") ||
|
|
normalized.startsWith("/")
|
|
)
|
|
return unresolvedOutcome(input, "rejected", [
|
|
"The resolved candidate escapes the canonical artifact root.",
|
|
]);
|
|
return normalized === "." ? "" : normalized;
|
|
};
|
|
|
|
const resolveCandidate = (
|
|
input: ResolveArtifactPathInput,
|
|
candidate: string,
|
|
packageChain: ReadonlySet<string>,
|
|
): CandidateResolution => {
|
|
const direct = resolveFileCandidates(input, fileCandidates(candidate));
|
|
if (direct !== null) return direct;
|
|
const source = input.files.get(input.sourcePath);
|
|
const packagePath = posix.join(candidate, "package.json");
|
|
const packageFile = input.files.get(packagePath);
|
|
if (
|
|
packageFile === undefined ||
|
|
(source !== undefined &&
|
|
packageFile.container_sha256 !== source.container_sha256)
|
|
)
|
|
return (
|
|
resolveFileCandidates(input, indexCandidates(candidate)) ??
|
|
notFoundCandidate()
|
|
);
|
|
if (!packageFile.text.included)
|
|
return {
|
|
resolvedPath: null,
|
|
status: "unavailable",
|
|
limitations: [
|
|
`Directory package metadata ${packagePath} was inventoried but its text is unavailable: ${packageFile.text.reason}.`,
|
|
],
|
|
};
|
|
const main = packageEntry(
|
|
packageFile.text.value,
|
|
input.moduleKind,
|
|
input.context === "module-specifier" &&
|
|
!input.declaredPath.startsWith(".") &&
|
|
!input.declaredPath.startsWith("/") &&
|
|
!hasScheme(input.declaredPath),
|
|
);
|
|
if (main.status === "invalid")
|
|
return {
|
|
resolvedPath: null,
|
|
status: "unavailable",
|
|
limitations: [
|
|
`Directory package metadata ${packagePath} is not valid package JSON.`,
|
|
],
|
|
};
|
|
if (main.status === "rejected")
|
|
return {
|
|
resolvedPath: null,
|
|
status: "rejected",
|
|
limitations: [`Package metadata ${packagePath}: ${main.limitation}`],
|
|
};
|
|
if (main.status === "unmatched")
|
|
return {
|
|
resolvedPath: null,
|
|
status: "external",
|
|
limitations: [
|
|
`Directory package metadata ${packagePath} declares no exports target for the active conditions; it declares ${main.declared.join(", ")}.`,
|
|
],
|
|
};
|
|
if (packageChain.has(packagePath)) {
|
|
return {
|
|
resolvedPath: null,
|
|
status: "unavailable",
|
|
limitations: [
|
|
`Directory package entrypoint cycle includes ${packagePath}.`,
|
|
],
|
|
};
|
|
}
|
|
if (main.status === "missing")
|
|
return (
|
|
resolveFileCandidates(input, indexCandidates(candidate)) ??
|
|
notFoundCandidate()
|
|
);
|
|
const entryInput: ResolveArtifactPathInput = {
|
|
declaredPath: main.value,
|
|
sourcePath: packagePath,
|
|
context: "package-entrypoint",
|
|
files: input.files,
|
|
};
|
|
const chain = new Set([...packageChain, packagePath]);
|
|
if (main.source === "legacy") {
|
|
const entry = resolvePackagePath(entryInput, chain, "files-and-index");
|
|
if (entry.resolution_status !== "not-found") return candidateOutcome(entry);
|
|
const index = resolveFileCandidates(input, indexCandidates(candidate));
|
|
if (index !== null) return index;
|
|
}
|
|
// Preserve recursive artifact package lookup only after legacy file/index fallbacks.
|
|
return candidateOutcome(
|
|
resolvePackagePath(entryInput, chain, "full", main.source === "exports"),
|
|
);
|
|
};
|
|
|
|
const candidateOutcome = (
|
|
nested: ArtifactPathResolution,
|
|
): CandidateResolution =>
|
|
nested.resolution_status === "resolved"
|
|
? {
|
|
resolvedPath: nested.resolved_path,
|
|
status: nested.resolution_status,
|
|
limitations: nested.limitations,
|
|
}
|
|
: {
|
|
resolvedPath: null,
|
|
status: nested.resolution_status,
|
|
limitations: nested.limitations,
|
|
};
|
|
|
|
const resolveFileCandidates = (
|
|
input: ResolveArtifactPathInput,
|
|
candidates: readonly string[],
|
|
): CandidateResolution | null => {
|
|
const source = input.files.get(input.sourcePath);
|
|
for (const path of candidates) {
|
|
const target = input.files.get(path);
|
|
if (
|
|
target !== undefined &&
|
|
(source === undefined ||
|
|
target.container_sha256 === source.container_sha256)
|
|
)
|
|
return { resolvedPath: path, status: "resolved", limitations: [] };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const fileCandidates = (candidate: string): readonly string[] => [
|
|
candidate,
|
|
...EXTENSIONS.map((extension) => `${candidate}${extension}`),
|
|
];
|
|
|
|
const indexCandidates = (candidate: string): readonly string[] =>
|
|
EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`));
|
|
|
|
const packageEntry = (
|
|
text: string,
|
|
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
|
useExports: boolean,
|
|
):
|
|
| {
|
|
readonly status: "value";
|
|
readonly value: string;
|
|
readonly source: "legacy" | "exports";
|
|
}
|
|
| { readonly status: "missing" }
|
|
| { readonly status: "invalid" }
|
|
| { readonly status: "rejected"; readonly limitation: string }
|
|
| { readonly status: "unmatched"; readonly declared: readonly string[] } => {
|
|
try {
|
|
const value: unknown = JSON.parse(text);
|
|
if (typeof value !== "object" || value === null)
|
|
return { status: "invalid" };
|
|
const rawExports = Reflect.get(value, "exports");
|
|
if (useExports && rawExports !== undefined && rawExports !== null) {
|
|
const exported = packageExport(rawExports, moduleKind);
|
|
return exported.status === "value"
|
|
? { ...exported, source: "exports" }
|
|
: exported;
|
|
}
|
|
// Node imports and requires ignore the bundler-only module field. Keep
|
|
// that fallback for callers without an explicit module-loading kind.
|
|
const preferred = [
|
|
Reflect.get(value, "main"),
|
|
...(moduleKind === undefined ? [Reflect.get(value, "module")] : []),
|
|
];
|
|
const entry = preferred.find((candidate) => candidate !== undefined);
|
|
if (entry === undefined) return { status: "missing" };
|
|
const legacy = packagePathValue(entry);
|
|
return legacy.status === "value" ? { ...legacy, source: "legacy" } : legacy;
|
|
} catch (cause: unknown) {
|
|
// Reflect-based manifest reads fail closed as invalid.
|
|
void cause;
|
|
return { status: "invalid" };
|
|
}
|
|
};
|
|
|
|
const packageExport = (
|
|
value: unknown,
|
|
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
|
): PackageExportOutcome => {
|
|
const root =
|
|
typeof value === "object" && value !== null && Object.hasOwn(value, ".")
|
|
? Reflect.get(value, ".")
|
|
: value;
|
|
const flattened = exportTargets(root, packageExportConditions(moduleKind));
|
|
if (flattened.kind === "invalid")
|
|
return { status: "rejected", limitation: flattened.limitation };
|
|
const first =
|
|
flattened.kind === "unmatched" ? undefined : flattened.values[0];
|
|
return first === undefined
|
|
? {
|
|
status: "unmatched",
|
|
declared:
|
|
typeof root === "object" && root !== null ? Object.keys(root) : [],
|
|
}
|
|
: { status: "value", value: first };
|
|
};
|
|
|
|
type ExportTargets =
|
|
| { readonly kind: "targets"; readonly values: readonly string[] }
|
|
| { readonly kind: "unmatched" }
|
|
| { readonly kind: "invalid"; readonly limitation: string };
|
|
|
|
const invalidExportTarget = (
|
|
value: unknown,
|
|
reason: string,
|
|
): ExportTargets => ({
|
|
kind: "invalid",
|
|
limitation: `The package exports target ${JSON.stringify(value) ?? String(value)} ${reason}.`,
|
|
});
|
|
|
|
// Node checks raw target segments before URL normalization. Compare encoded
|
|
// segment names here; full URL decoding follows array target selection.
|
|
const forbiddenExportSegment = (value: string): string | undefined =>
|
|
value
|
|
.slice(2)
|
|
.split(/[\\/]/u)
|
|
.find((raw) => {
|
|
const segment = raw.replace(/%[0-9a-f]{2}/giu, (encoded) =>
|
|
String.fromCharCode(Number.parseInt(encoded.slice(1), 16)),
|
|
);
|
|
return (
|
|
segment === "." ||
|
|
segment === ".." ||
|
|
segment.toLowerCase() === "node_modules"
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 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") {
|
|
if (!value.startsWith("./"))
|
|
return invalidExportTarget(value, 'must start with "./"');
|
|
const forbidden = forbiddenExportSegment(value);
|
|
return forbidden === undefined
|
|
? { kind: "targets", values: [value] }
|
|
: invalidExportTarget(
|
|
value,
|
|
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
|
|
);
|
|
}
|
|
if (value === null) return { kind: "targets", values: [] };
|
|
if (Array.isArray(value)) {
|
|
let invalid: string | null = null;
|
|
for (const entry of value) {
|
|
const nested = exportTargets(entry, conditions);
|
|
if (nested.kind === "invalid") {
|
|
invalid = nested.limitation;
|
|
continue;
|
|
}
|
|
if (nested.kind === "unmatched") continue;
|
|
invalid = null;
|
|
if (nested.values.length > 0) return nested;
|
|
}
|
|
return invalid === null
|
|
? { kind: "targets", values: [] }
|
|
: { kind: "invalid", limitation: invalid };
|
|
}
|
|
if (typeof value !== "object")
|
|
return invalidExportTarget(
|
|
value,
|
|
"must be a relative string, object, array, or null",
|
|
);
|
|
for (const [condition, target] of Object.entries(value)) {
|
|
if (!conditions.has(condition)) continue;
|
|
const nested = exportTargets(target, conditions);
|
|
if (nested.kind !== "unmatched") return nested;
|
|
}
|
|
return { kind: "unmatched" };
|
|
};
|
|
|
|
/**
|
|
* Node selects an exports target by walking the declared keys in order and
|
|
* taking the first whose condition is active for the calling resolver.
|
|
* "default" is always active, and "node" plus "node-addons" are active for the
|
|
* built-in resolver that owns installed-package imports and requires.
|
|
*/
|
|
const packageExportConditions = (
|
|
moduleKind: ResolveArtifactPathInput["moduleKind"],
|
|
): ReadonlySet<string> =>
|
|
new Set([
|
|
"node",
|
|
"node-addons",
|
|
...(moduleKind === undefined ? [] : [moduleKind]),
|
|
"default",
|
|
]);
|
|
|
|
type PackageExportOutcome =
|
|
| { readonly status: "value"; readonly value: string }
|
|
| { readonly status: "rejected"; readonly limitation: string }
|
|
| { readonly status: "unmatched"; readonly declared: readonly string[] };
|
|
|
|
const packagePathValue = (
|
|
value: unknown,
|
|
):
|
|
| { readonly status: "value"; readonly value: string }
|
|
| { readonly status: "invalid" } =>
|
|
typeof value === "string" && value.length > 0
|
|
? { status: "value", value }
|
|
: { status: "invalid" };
|
|
|
|
const notFoundCandidate = (): CandidateResolution => ({
|
|
resolvedPath: null,
|
|
status: "not-found",
|
|
limitations: [
|
|
"No extension, directory package, or index candidate exists in the inventoried artifact container.",
|
|
],
|
|
});
|
|
|
|
const exactExportsTargetNotFound = (path: string): CandidateResolution => ({
|
|
resolvedPath: null,
|
|
status: "not-found",
|
|
limitations: [
|
|
`The selected package exports target ${path} does not exist in the inventoried artifact container; exports targets are exact files, so no extension or directory index was tried.`,
|
|
],
|
|
});
|
|
|
|
const fileUrlPath = (value: string): string | undefined => {
|
|
if (!value.startsWith("file://")) return undefined;
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "file:" || url.hostname !== "") return undefined;
|
|
return decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
|
} catch (cause: unknown) {
|
|
// Unparseable file URLs have no path to resolve.
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const outcome = (
|
|
input: ResolveArtifactPathInput,
|
|
resolution: CandidateResolution,
|
|
): ArtifactPathResolution =>
|
|
resolution.status === "resolved"
|
|
? {
|
|
declared_path: input.declaredPath,
|
|
resolution_context: input.context,
|
|
resolved_path: resolution.resolvedPath,
|
|
resolution_status: resolution.status,
|
|
limitations: resolution.limitations,
|
|
}
|
|
: {
|
|
declared_path: input.declaredPath,
|
|
resolution_context: input.context,
|
|
resolved_path: null,
|
|
resolution_status: resolution.status,
|
|
limitations: resolution.limitations,
|
|
};
|
|
|
|
const unresolvedOutcome = (
|
|
input: ResolveArtifactPathInput,
|
|
status: UnresolvedArtifactPathStatus,
|
|
limitations: readonly string[],
|
|
): ArtifactPathResolution =>
|
|
outcome(input, { resolvedPath: null, status, limitations });
|