diff --git a/packages/kernel/src/kernel/npm/install.ts b/packages/kernel/src/kernel/npm/install.ts index 0ceb00c..d921910 100644 --- a/packages/kernel/src/kernel/npm/install.ts +++ b/packages/kernel/src/kernel/npm/install.ts @@ -16,7 +16,7 @@ import { fetchLockMeta, hiddenLockfile, jsonFormat, normalizeRoot, packageLock, import { createRegistry, registryTarball, type LockMeta, type Registry } from "./registry"; import { resolve } from "./resolve"; import { addDeps, packageJsonText, parseRequested, rmDeps, savedSpec, setSaved } from "./save"; -import { parseSpec } from "./spec"; +import { parseSpec, type Version } from "./spec"; import { fetchPackage, settleStore } from "./store"; import type { PackageFile } from "./unpacked"; import { loadTree, optionalSet, type Node, type Source, type TarballNode, type TarballRef, type Tree } from "./tree"; @@ -219,8 +219,9 @@ export async function install(fs: MemoryFS, request: InstallRequest, changed: () const skip = (node: Node): void => { for (const n of optionalSet(node)) skipped.add(n); }; + const libc = await resolvedLibc(nodes, registry); for (const node of nodes) { - if (node.inert || node.inBundle || platformAllows(node, PLATFORM, ARCH, LIBC)) continue; + if (node.inert || node.inBundle || platformAllows({ ...node, libc: node.libc ?? libc.get(node) }, PLATFORM, ARCH, LIBC)) continue; if (!node.optional) throw badPlatform(node); skip(node); } @@ -398,6 +399,15 @@ function noPackageJson(path: string): Error { ); } +// A resolved platform package's libc, which its abbreviated packument left out: npm +// resolves from full packuments, so a -musl binding it would skip is skipped here too. +// A lockfile's entries carry no libc, and npm ci installs both bindings from them. +async function resolvedLibc(nodes: readonly Node[], registry: Registry): Promise> { + const unknown = nodes.filter((n) => !n.fromLockfile && !n.inert && !n.inBundle && !n.libc && (n.os || n.cpu) && platformAllows(n, PLATFORM, ARCH, LIBC)); + const found = await Promise.all(unknown.map((n) => registry.libc(n.packageName, n.version as Version))); + return new Map(unknown.flatMap((n, i) => (found[i] ? [[n, found[i]] as const] : []))); +} + // npm-install-checks' checkPlatform, which arborist runs on every non-optional node. function badPlatform(node: Node): Error { const wanted = { os: node.os?.join(",") ?? "any", cpu: node.cpu?.join(",") ?? "any", ...(node.libc && { libc: node.libc.join(",") }) }; diff --git a/packages/kernel/src/kernel/npm/registry.ts b/packages/kernel/src/kernel/npm/registry.ts index bf16779..ddf9dfa 100644 --- a/packages/kernel/src/kernel/npm/registry.ts +++ b/packages/kernel/src/kernel/npm/registry.ts @@ -46,6 +46,9 @@ export interface Registry { manifest(spec: RegistrySpec): Promise; /** `GET //`, stored without expiry: a published version never changes. */ lockMeta(name: string, version: Version): Promise; + /** The version's `libc`, from the same document: the abbreviated packument drops it, + * and npm reads it from the full packument (build-ideal-tree's fullMetadata). */ + libc(name: string, version: Version): Promise; readonly counts: MetadataCounts; /** A stale packument that answered because the registry could not be reached. */ readonly warnings: readonly string[]; @@ -90,7 +93,7 @@ export function createRegistry(nodeVersion: string = NODE_VERSION): Registry { const counts = { cached: 0, fetched: 0 }; const warnings: string[] = []; const packuments = new Map>(); - const lockMetas = new Map>(); + const versionDocs = new Map>(); // `stale` is the stored copy past its max-age, which answers if the registry cannot. const fetchPackument = async (name: string, revalidate: boolean, stale?: StoredMeta): Promise => { @@ -134,6 +137,27 @@ export function createRegistry(nodeVersion: string = NODE_VERSION): Registry { return packuments.get(name)!; }; + const versionDoc = (name: string, version: Version): Promise => { + const key = `${name}@${version}`; + let doc = versionDocs.get(key); + if (!doc) { + doc = (async () => { + const url = `${packumentUrl(name)}/${version}`; + const stored = await storedMeta(url, JSON_ACCEPT); + if (stored !== undefined) { + counts.cached++; + return stored.json; + } + const res = await fetchMeta(url, JSON_ACCEPT, false); + counts.fetched++; + if (!res.ok) throw fetchFailed(url, res); + return res.json; + })(); + versionDocs.set(key, doc); + } + return doc; + }; + const pick = (loaded: Loaded, spec: RegistrySpec) => pickManifest(loaded.packument, spec, { nodeVersion }); return { @@ -154,25 +178,11 @@ export function createRegistry(nodeVersion: string = NODE_VERSION): Registry { if (!mani) throw new RegistryError("ETARGET", `No matching version found for ${spec.name}@${spec.raw}.`); return mani; }, - lockMeta(name, version) { - const key = `${name}@${version}`; - let meta = lockMetas.get(key); - if (!meta) { - meta = (async () => { - const url = `${packumentUrl(name)}/${version}`; - const stored = await storedMeta(url, JSON_ACCEPT); - if (stored !== undefined) { - counts.cached++; - return parseLockMeta(stored.json); - } - const res = await fetchMeta(url, JSON_ACCEPT, false); - counts.fetched++; - if (!res.ok) throw fetchFailed(url, res); - return parseLockMeta(res.json); - })(); - lockMetas.set(key, meta); - } - return meta; + lockMeta: async (name, version) => parseLockMeta(await versionDoc(name, version)), + async libc(name, version) { + const doc = await versionDoc(name, version); + const libc = isRecord(doc) ? doc.libc : undefined; + return Array.isArray(libc) && libc.every((x) => typeof x === "string") ? libc : undefined; }, }; } diff --git a/packages/kernel/tests/install.spec.ts b/packages/kernel/tests/install.spec.ts index 7ac56d3..a1876af 100644 --- a/packages/kernel/tests/install.spec.ts +++ b/packages/kernel/tests/install.spec.ts @@ -245,6 +245,42 @@ test("the runtime is a glibc linux: a -musl binding is skipped and the -gnu one ]); }); +// Resolved from package.json alone, the registry's abbreviated packument has no libc; +// npm resolves from full packuments, so it skips a binding for the other libc. These +// docs drop `cpu` so that libc alone decides; the version documents carry it. +test("resolving from package.json, a -musl binding is skipped by the libc its version document names", async ({ page, context }) => { + const docs = Object.fromEntries((["gnu", "musl"] as const).map((libc) => { + const name = `@napi-rs/lzma-linux-x64-${libc}`; + const { optional: _, cpu: ___, resolved, integrity, ...entry } = LZMA(libc, JSON.parse(LIBC_LOCK).packages[`node_modules/${name}`].integrity); + const full = { name, ...entry, dist: { tarball: resolved, integrity } }; + const { libc: __, ...abbreviated } = full; + return [name, { full, packument: { name, "dist-tags": { latest: "1.5.1" }, versions: { "1.5.1": abbreviated } } }]; + })); + const log: string[] = []; + await context.route("https://registry.npmjs.org/**", async (route) => { + const path = decodeURIComponent(new URL(route.request().url()).pathname.slice(1)); + if (path.includes("/-/")) return route.fallback(); + log.push(`/${path}`); + const [scope, name, version] = path.split("/"); + const doc = docs[`${scope}/${name}`]; + const body = version ? doc?.full : doc?.packument; + const cors = { "access-control-allow-origin": "*" }; + if (!body) return route.fulfill({ status: 404, headers: cors, contentType: "application/json", body: '{"error":"Not found"}' }); + return route.fulfill({ headers: cors, contentType: "application/json", body: JSON.stringify(body) }); + }); + const { report, error } = await installFiles(page, { "/app/package.json": JSON.stringify(JSON.parse(LIBC_LOCK).packages[""]) }); + expect(error).toBeNull(); + expect([report!.lockfile, report!.packages, report!.skipped]).toEqual(["written", 1, ["node_modules/@napi-rs/lzma-linux-x64-musl"]]); + expect(log.sort()).toEqual([ + "/@napi-rs/lzma-linux-x64-gnu", "/@napi-rs/lzma-linux-x64-gnu/1.5.1", + "/@napi-rs/lzma-linux-x64-musl", "/@napi-rs/lzma-linux-x64-musl/1.5.1", + ]); + expect(Object.keys((await listModules(page)).files).sort()).toEqual([ + "/app/node_modules/.package-lock.json", + "/app/node_modules/@napi-rs/lzma-linux-x64-gnu/package.json", + ]); +}); + // fsevents 1.2.13 is darwin-only and depends on bindings and nan. The root depends on // bindings too, and on fsevents optionally. The lockfile is what npm 11.7 wrote for // this package.json; `npm ci --os linux --cpu x64` from it skips nan with fsevents, diff --git a/packages/kernel/tests/npm-registry.ts b/packages/kernel/tests/npm-registry.ts index c263fee..ad9c3e2 100644 --- a/packages/kernel/tests/npm-registry.ts +++ b/packages/kernel/tests/npm-registry.ts @@ -43,6 +43,10 @@ function registryOver(packument: (name: string) => Packument | undefined): TestR if (!doc) throw new RegistryError("E404", `404 Not Found - GET https://registry.npmjs.org/${name.replace("/", "%2f")}/${version}`); return parseLockMeta(doc); }, + async libc(name, version) { + const libc = (packument(name)?.versions.get(version)?.doc as { libc?: unknown } | undefined)?.libc; + return Array.isArray(libc) ? libc : undefined; + }, }; }