Files
openclaw/scripts/e2e/lib/plugins/npm-registry-server.mjs
Peter Steinberger 509f5d3409 fix: bound npm fixture upstream response bodies (#108960)
* test: bound npm fixture upstream responses

* test: auto-clean npm fixture temp dirs

* style: format npm fixture assertion test

* test: simplify plugin fixture temp cleanup
2026-07-16 04:53:43 -07:00

226 lines
7.2 KiB
JavaScript

import { execFileSync } from "node:child_process";
// Fixture npm registry server for plugin E2E scenarios.
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { readBoundedResponseBytes } from "../bounded-response-text.mjs";
const [portFile, ...packageArgs] = process.argv.slice(2);
function normalizeUpstreamRegistry(raw) {
if (!raw) {
return undefined;
}
const url = new URL(raw);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error("OPENCLAW_NPM_REGISTRY_UPSTREAM must be an HTTP(S) origin");
}
return url.origin;
}
const upstreamRegistry = normalizeUpstreamRegistry(process.env.OPENCLAW_NPM_REGISTRY_UPSTREAM);
// Match other E2E package-download budgets while keeping public-registry hops
// inside the install deadline and decoded bodies inside a fixed memory budget.
const UPSTREAM_REQUEST_TIMEOUT_MS = 120_000;
const UPSTREAM_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) {
console.error(
"usage: npm-registry-server.mjs <port-file> <package-name> <version> <tarball-path> [...]",
);
process.exit(1);
}
const packages = new Map();
function readPackageManifest(tarballPath, packageName) {
try {
const packageJson = JSON.parse(
execFileSync("tar", ["-xOf", tarballPath, "package/package.json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}),
);
return packageJson && typeof packageJson === "object" && !Array.isArray(packageJson)
? packageJson
: {};
} catch {
return packageName === "@openclaw/demo-plugin-npm"
? { dependencies: { "is-number": "7.0.0" } }
: {};
}
}
for (let index = 0; index < packageArgs.length; index += 3) {
const packageName = packageArgs[index];
const version = packageArgs[index + 1];
const tarballPath = packageArgs[index + 2];
const archive = fs.readFileSync(tarballPath);
const existing = packages.get(packageName) ?? {
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
packageName,
latestVersion: version,
versions: new Map(),
};
existing.latestVersion = version;
existing.versions.set(version, {
archive,
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
manifest: readPackageManifest(tarballPath, packageName),
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName: path.basename(tarballPath),
version,
});
packages.set(packageName, existing);
}
const metadataFor = (entry, baseUrl) => ({
name: entry.packageName,
"dist-tags": { latest: entry.latestVersion },
versions: Object.fromEntries(
[...entry.versions.entries()].map(([version, versionEntry]) => [
version,
{
...versionEntry.manifest,
name: entry.packageName,
version,
dist: {
integrity: versionEntry.integrity,
shasum: versionEntry.shasum,
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${versionEntry.tarballName}`,
},
},
]),
),
});
function decodePackagePath(pathname) {
try {
return decodeURIComponent(pathname.slice(1));
} catch {
return undefined;
}
}
function findPackageForPath(pathname) {
const packageName = decodePackagePath(pathname);
return packageName === undefined ? undefined : packages.get(packageName);
}
function findTarballForPath(pathname) {
for (const entry of packages.values()) {
const prefix = `/${entry.encodedPackageName}/-/`;
if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
continue;
}
for (const versionEntry of entry.versions.values()) {
if (pathname.endsWith(`/${versionEntry.tarballName}`)) {
return versionEntry;
}
}
}
return undefined;
}
function resolveUpstreamRequestUrl(rawRequestUrl) {
const raw = rawRequestUrl || "/";
if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
throw new Error(`refusing non-origin registry request URL: ${JSON.stringify(raw)}`);
}
const requestUrl = new URL(raw, "http://127.0.0.1");
return `${upstreamRegistry}${requestUrl.pathname}${requestUrl.search}`;
}
async function proxyUpstream(rawRequestUrl, response) {
if (!upstreamRegistry) {
return false;
}
try {
const upstreamUrl = resolveUpstreamRequestUrl(rawRequestUrl);
const upstreamResponse = await fetch(upstreamUrl, {
redirect: "manual",
signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS),
});
const body = await readBoundedResponseBytes(
upstreamResponse,
"npm registry upstream",
UPSTREAM_RESPONSE_MAX_BYTES,
);
// Fetch decodes compressed bodies but preserves upstream length metadata.
// Emit the decoded size so npm clients do not truncate proxied responses.
const headers = { "content-length": String(body.length) };
for (const name of ["content-type", "location"]) {
const value = upstreamResponse.headers.get(name);
if (value) {
headers[name] = value;
}
}
response.writeHead(upstreamResponse.status, headers);
response.end(body);
} catch (error) {
response.writeHead(502, { "content-type": "text/plain" });
response.end(`upstream registry request failed: ${String(error)}`);
}
return true;
}
async function handleRequest(request, response) {
const fallbackHost = `127.0.0.1:${server.address().port}`;
const requestHost = request.headers.host || fallbackHost;
const url = new URL(request.url ?? "/", `http://${requestHost}`);
const baseUrl = url.origin;
if (request.method !== "GET") {
response.writeHead(405, { "content-type": "text/plain" });
response.end("method not allowed");
return;
}
const packageEntry = findPackageForPath(url.pathname);
if (packageEntry) {
response.writeHead(200, { "content-type": "application/json" });
response.end(`${JSON.stringify(metadataFor(packageEntry, baseUrl))}\n`);
return;
}
const tarballEntry = findTarballForPath(url.pathname);
if (tarballEntry) {
response.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": String(tarballEntry.archive.length),
});
response.end(tarballEntry.archive);
return;
}
if (await proxyUpstream(request.url, response)) {
return;
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
}
const server = http.createServer((request, response) => {
void handleRequest(request, response).catch((/** @type {unknown} */ error) => {
if (!response.headersSent) {
response.writeHead(500, { "content-type": "text/plain" });
response.end(`registry request failed: ${String(error)}`);
return;
}
response.destroy(error instanceof Error ? error : new Error(String(error)));
});
});
const bindHost = process.env.OPENCLAW_NPM_REGISTRY_BIND_HOST || "127.0.0.1";
const requestedPort = Number(process.env.OPENCLAW_NPM_REGISTRY_PORT || 0);
server.listen(requestedPort, bindHost, () => {
fs.writeFileSync(portFile, String(server.address().port));
});