/** * Minimal npm-compat registry server. * * This stand-in exists so the e2e harness can exercise install.sh's * `bun add -g @celilo/cli @celilo/event-bus @celilo/e2e` step without * reaching real npmjs.org. It serves the two endpoints `bun add` actually * hits: * * GET /:scope/:name → packument JSON (versions + dist URLs) * GET /:scope/:name/-/:file → the tarball bytes * * Plus the unscoped equivalents (no `@scope` segment) for completeness. * * Tarballs are read from PACKUMENT_DIR (default /var/lib/npm) at startup * and held in memory. Adjust the env var if you want to point this at a * different staging directory in tests. */ import { type TarballMeta, indexTarballs, loadTarballsFromDir } from './packument'; import { buildPackument } from './packument'; interface ServerOptions { packumentDir: string; port: number; /** URL clients use to reach this server, advertised in tarball URLs. */ registryUrl: string; } export function startServer(options: ServerOptions): ReturnType { const tarballs = loadTarballsFromDir(options.packumentDir); const byName = indexTarballs(tarballs); const byFilename = new Map(); for (const t of tarballs) byFilename.set(t.filename, t); console.log( `[npm-registry] loaded ${tarballs.length} tarball(s) covering ${byName.size} package(s):`, ); for (const [name, versions] of byName) { console.log(` ${name} (${versions.map((v) => v.pkg.version).join(', ')})`); } /** * Parse a request path into a package name and optional tarball filename. * Honors both scoped (`@scope/name`) and unscoped (`name`) routes, and * accepts both path-style and URL-encoded scope separators: * /@celilo/cli → { name: '@celilo/cli' } * /@celilo%2Fcli → { name: '@celilo/cli' } (bun's form) * /@celilo/cli/-/celilo-cli-0.1.tgz → { name: '@celilo/cli', file: '...' } * /lodash → { name: 'lodash' } * * Returns null on unrecognized paths. */ function parsePath(pathname: string): { name: string; file?: string } | null { // bun (and the npm CLI) URL-encodes the `/` in scoped names — a // request for `@celilo/cli` arrives as `/@celilo%2fcli`. Decode // before splitting so both forms resolve identically. let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { return null; } const parts = decoded.replace(/^\/+/, '').split('/').filter(Boolean); if (parts.length === 0) return null; if (parts[0].startsWith('@')) { // Scoped: @scope/name [ /-/ filename ] if (parts.length < 2) return null; const name = `${parts[0]}/${parts[1]}`; if (parts.length === 2) return { name }; if (parts.length === 4 && parts[2] === '-') return { name, file: parts[3] }; return null; } // Unscoped: name [ /-/ filename ] if (parts.length === 1) return { name: parts[0] }; if (parts.length === 3 && parts[1] === '-') return { name: parts[0], file: parts[2] }; return null; } return Bun.serve({ port: options.port, fetch(req: Request): Response | Promise { const url = new URL(req.url); const parsed = parsePath(url.pathname); if (!parsed) return new Response('not found', { status: 404 }); // Tarball download if (parsed.file) { const t = byFilename.get(parsed.file); if (!t) return new Response('not found', { status: 404 }); // Convert Node Buffer to a fresh Uint8Array so the Response body type // is unambiguous (Bun's Response rejects raw Buffer in strict mode). const body = new Uint8Array(t.bytes); return new Response(body, { headers: { 'content-type': 'application/octet-stream' }, }); } // Packument const versions = byName.get(parsed.name); if (!versions || versions.length === 0) { return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'content-type': 'application/json' }, }); } const packument = buildPackument(parsed.name, versions, { registryUrl: options.registryUrl, }); return new Response(JSON.stringify(packument), { headers: { 'content-type': 'application/json' }, }); }, }); } if (import.meta.main) { const packumentDir = process.env.PACKUMENT_DIR ?? '/var/lib/npm'; const port = Number(process.env.PORT ?? 80); const registryUrl = process.env.REGISTRY_URL ?? 'http://npm-registry.lab'; const server = startServer({ packumentDir, port, registryUrl }); console.log(`[npm-registry] listening on :${server.port} (registryUrl=${registryUrl})`); }