diff --git a/compress/_repl.ts b/packages/compress/_repl.ts similarity index 100% rename from compress/_repl.ts rename to packages/compress/_repl.ts diff --git a/compress/compress.ts b/packages/compress/compress.ts similarity index 100% rename from compress/compress.ts rename to packages/compress/compress.ts diff --git a/compress/config.ts b/packages/compress/config.ts similarity index 100% rename from compress/config.ts rename to packages/compress/config.ts diff --git a/compress/decompress.ts b/packages/compress/decompress.ts similarity index 97% rename from compress/decompress.ts rename to packages/compress/decompress.ts index f1c9b6a..1e69cc4 100644 --- a/compress/decompress.ts +++ b/packages/compress/decompress.ts @@ -76,7 +76,7 @@ export async function decompress(inputs: Uint8Array[] | string[] = [], opts: Com // Compress all binary contents according to the compression map const compressedContent = await Promise.all( - contents.map((content) => compressionMap(content)) + contents.map((content) => compressionMap(content as BufferSource)) ); // Convert sizes to human readable formats, e.g. 10000 bytes to 10MB diff --git a/compress/deno.jsonc b/packages/compress/deno.jsonc similarity index 100% rename from compress/deno.jsonc rename to packages/compress/deno.jsonc diff --git a/compress/deno/brotli/brotli.ts b/packages/compress/deno/brotli/brotli.ts similarity index 100% rename from compress/deno/brotli/brotli.ts rename to packages/compress/deno/brotli/brotli.ts diff --git a/compress/deno/brotli/deno_brotli_bg.wasm b/packages/compress/deno/brotli/deno_brotli_bg.wasm similarity index 100% rename from compress/deno/brotli/deno_brotli_bg.wasm rename to packages/compress/deno/brotli/deno_brotli_bg.wasm diff --git a/compress/deno/brotli/mod.ts b/packages/compress/deno/brotli/mod.ts similarity index 100% rename from compress/deno/brotli/mod.ts rename to packages/compress/deno/brotli/mod.ts diff --git a/compress/deno/brotli/wasm.ts b/packages/compress/deno/brotli/wasm.ts similarity index 100% rename from compress/deno/brotli/wasm.ts rename to packages/compress/deno/brotli/wasm.ts diff --git a/compress/deno/gzip/mod.ts b/packages/compress/deno/gzip/mod.ts similarity index 100% rename from compress/deno/gzip/mod.ts rename to packages/compress/deno/gzip/mod.ts diff --git a/compress/deno/lz4/deno_lz4_bg.wasm b/packages/compress/deno/lz4/deno_lz4_bg.wasm similarity index 100% rename from compress/deno/lz4/deno_lz4_bg.wasm rename to packages/compress/deno/lz4/deno_lz4_bg.wasm diff --git a/compress/deno/lz4/lz4.ts b/packages/compress/deno/lz4/lz4.ts similarity index 100% rename from compress/deno/lz4/lz4.ts rename to packages/compress/deno/lz4/lz4.ts diff --git a/compress/deno/lz4/mod.ts b/packages/compress/deno/lz4/mod.ts similarity index 100% rename from compress/deno/lz4/mod.ts rename to packages/compress/deno/lz4/mod.ts diff --git a/compress/deno/lz4/wasm.ts b/packages/compress/deno/lz4/wasm.ts similarity index 100% rename from compress/deno/lz4/wasm.ts rename to packages/compress/deno/lz4/wasm.ts diff --git a/compress/deno/zstd/mod.ts b/packages/compress/deno/zstd/mod.ts similarity index 100% rename from compress/deno/zstd/mod.ts rename to packages/compress/deno/zstd/mod.ts diff --git a/compress/deno/zstd/zstd.deno.js b/packages/compress/deno/zstd/zstd.deno.js similarity index 100% rename from compress/deno/zstd/zstd.deno.js rename to packages/compress/deno/zstd/zstd.deno.js diff --git a/compress/deno/zstd/zstd.encoded.wasm.ts b/packages/compress/deno/zstd/zstd.encoded.wasm.ts similarity index 100% rename from compress/deno/zstd/zstd.encoded.wasm.ts rename to packages/compress/deno/zstd/zstd.encoded.wasm.ts diff --git a/compress/deno/zstd/zstd.wasm b/packages/compress/deno/zstd/zstd.wasm similarity index 100% rename from compress/deno/zstd/zstd.wasm rename to packages/compress/deno/zstd/zstd.wasm diff --git a/compress/mod.ts b/packages/compress/mod.ts similarity index 100% rename from compress/mod.ts rename to packages/compress/mod.ts diff --git a/compress/package.json b/packages/compress/package.json similarity index 100% rename from compress/package.json rename to packages/compress/package.json diff --git a/compress/scripts/build-wasm.ts b/packages/compress/scripts/build-wasm.ts similarity index 100% rename from compress/scripts/build-wasm.ts rename to packages/compress/scripts/build-wasm.ts diff --git a/compress/types.ts b/packages/compress/types.ts similarity index 100% rename from compress/types.ts rename to packages/compress/types.ts diff --git a/core/_demo.ts b/packages/core/_demo.ts similarity index 100% rename from core/_demo.ts rename to packages/core/_demo.ts diff --git a/core/_init_bench.ts b/packages/core/_init_bench.ts similarity index 100% rename from core/_init_bench.ts rename to packages/core/_init_bench.ts diff --git a/core/_race_bench.ts b/packages/core/_race_bench.ts similarity index 100% rename from core/_race_bench.ts rename to packages/core/_race_bench.ts diff --git a/core/_repl.ts b/packages/core/_repl.ts similarity index 100% rename from core/_repl.ts rename to packages/core/_repl.ts diff --git a/core/build.ts b/packages/core/build.ts similarity index 100% rename from core/build.ts rename to packages/core/build.ts diff --git a/core/configs/config.ts b/packages/core/configs/config.ts similarity index 100% rename from core/configs/config.ts rename to packages/core/configs/config.ts diff --git a/core/configs/events.ts b/packages/core/configs/events.ts similarity index 100% rename from core/configs/events.ts rename to packages/core/configs/events.ts diff --git a/core/configs/platform.ts b/packages/core/configs/platform.ts similarity index 100% rename from core/configs/platform.ts rename to packages/core/configs/platform.ts diff --git a/core/context.ts b/packages/core/context.ts similarity index 100% rename from core/context.ts rename to packages/core/context.ts diff --git a/core/context/context.ts b/packages/core/context/context.ts similarity index 100% rename from core/context/context.ts rename to packages/core/context/context.ts diff --git a/core/deno.jsonc b/packages/core/deno.jsonc similarity index 100% rename from core/deno.jsonc rename to packages/core/deno.jsonc diff --git a/core/esbuild.wasm b/packages/core/esbuild.wasm similarity index 100% rename from core/esbuild.wasm rename to packages/core/esbuild.wasm diff --git a/core/index.ts b/packages/core/index.ts similarity index 100% rename from core/index.ts rename to packages/core/index.ts diff --git a/core/init.ts b/packages/core/init.ts similarity index 100% rename from core/init.ts rename to packages/core/init.ts diff --git a/core/mod.ts b/packages/core/mod.ts similarity index 100% rename from core/mod.ts rename to packages/core/mod.ts diff --git a/core/package.json b/packages/core/package.json similarity index 100% rename from core/package.json rename to packages/core/package.json diff --git a/core/plugins/alias.ts b/packages/core/plugins/alias.ts similarity index 100% rename from core/plugins/alias.ts rename to packages/core/plugins/alias.ts diff --git a/core/plugins/cdn.ts b/packages/core/plugins/cdn.ts similarity index 100% rename from core/plugins/cdn.ts rename to packages/core/plugins/cdn.ts diff --git a/core/plugins/external.ts b/packages/core/plugins/external.ts similarity index 100% rename from core/plugins/external.ts rename to packages/core/plugins/external.ts diff --git a/core/plugins/fs.ts b/packages/core/plugins/fs.ts similarity index 100% rename from core/plugins/fs.ts rename to packages/core/plugins/fs.ts diff --git a/core/plugins/http.ts b/packages/core/plugins/http.ts similarity index 100% rename from core/plugins/http.ts rename to packages/core/plugins/http.ts diff --git a/core/plugins/package.ts b/packages/core/plugins/package.ts similarity index 100% rename from core/plugins/package.ts rename to packages/core/plugins/package.ts diff --git a/core/plugins/tar.ts b/packages/core/plugins/tar.ts similarity index 100% rename from core/plugins/tar.ts rename to packages/core/plugins/tar.ts diff --git a/core/scripts/build-wasm.ts b/packages/core/scripts/build-wasm.ts similarity index 100% rename from core/scripts/build-wasm.ts rename to packages/core/scripts/build-wasm.ts diff --git a/core/tests/01-conditional-exports.test.ts b/packages/core/tests/01-conditional-exports.test.ts similarity index 100% rename from core/tests/01-conditional-exports.test.ts rename to packages/core/tests/01-conditional-exports.test.ts diff --git a/core/tests/02-subpath-patterns.test.ts b/packages/core/tests/02-subpath-patterns.test.ts similarity index 100% rename from core/tests/02-subpath-patterns.test.ts rename to packages/core/tests/02-subpath-patterns.test.ts diff --git a/core/tests/03-legacy-fields.test.ts b/packages/core/tests/03-legacy-fields.test.ts similarity index 100% rename from core/tests/03-legacy-fields.test.ts rename to packages/core/tests/03-legacy-fields.test.ts diff --git a/core/tests/04-browser-remapping.test.ts b/packages/core/tests/04-browser-remapping.test.ts similarity index 100% rename from core/tests/04-browser-remapping.test.ts rename to packages/core/tests/04-browser-remapping.test.ts diff --git a/core/tests/05-platform-remapping.test.ts b/packages/core/tests/05-platform-remapping.test.ts similarity index 100% rename from core/tests/05-platform-remapping.test.ts rename to packages/core/tests/05-platform-remapping.test.ts diff --git a/core/tests/06-relative-imports.test.ts b/packages/core/tests/06-relative-imports.test.ts similarity index 100% rename from core/tests/06-relative-imports.test.ts rename to packages/core/tests/06-relative-imports.test.ts diff --git a/core/tests/07-tree-shaking.test.ts b/packages/core/tests/07-tree-shaking.test.ts similarity index 100% rename from core/tests/07-tree-shaking.test.ts rename to packages/core/tests/07-tree-shaking.test.ts diff --git a/core/tests/08-non-npm-resolution.test.ts b/packages/core/tests/08-non-npm-resolution.test.ts similarity index 100% rename from core/tests/08-non-npm-resolution.test.ts rename to packages/core/tests/08-non-npm-resolution.test.ts diff --git a/core/tests/09-builtins-polyfills.test.ts b/packages/core/tests/09-builtins-polyfills.test.ts similarity index 100% rename from core/tests/09-builtins-polyfills.test.ts rename to packages/core/tests/09-builtins-polyfills.test.ts diff --git a/core/tests/10-dual-package.test.ts b/packages/core/tests/10-dual-package.test.ts similarity index 100% rename from core/tests/10-dual-package.test.ts rename to packages/core/tests/10-dual-package.test.ts diff --git a/core/tests/11-edge-cases.test.ts b/packages/core/tests/11-edge-cases.test.ts similarity index 100% rename from core/tests/11-edge-cases.test.ts rename to packages/core/tests/11-edge-cases.test.ts diff --git a/core/tests/12-runtime-conditions.test.ts b/packages/core/tests/12-runtime-conditions.test.ts similarity index 100% rename from core/tests/12-runtime-conditions.test.ts rename to packages/core/tests/12-runtime-conditions.test.ts diff --git a/core/tests/13-utils-correctness.test.ts b/packages/core/tests/13-utils-correctness.test.ts similarity index 100% rename from core/tests/13-utils-correctness.test.ts rename to packages/core/tests/13-utils-correctness.test.ts diff --git a/core/tests/14-cdn-format-loader-fs.test.ts b/packages/core/tests/14-cdn-format-loader-fs.test.ts similarity index 100% rename from core/tests/14-cdn-format-loader-fs.test.ts rename to packages/core/tests/14-cdn-format-loader-fs.test.ts diff --git a/core/tests/15-plugin-pipeline.test.ts b/packages/core/tests/15-plugin-pipeline.test.ts similarity index 100% rename from core/tests/15-plugin-pipeline.test.ts rename to packages/core/tests/15-plugin-pipeline.test.ts diff --git a/core/tests/16-github-issue-regression.test.ts b/packages/core/tests/16-github-issue-regression.test.ts similarity index 100% rename from core/tests/16-github-issue-regression.test.ts rename to packages/core/tests/16-github-issue-regression.test.ts diff --git a/core/tests/17-system-audit.test.ts b/packages/core/tests/17-system-audit.test.ts similarity index 100% rename from core/tests/17-system-audit.test.ts rename to packages/core/tests/17-system-audit.test.ts diff --git a/core/tests/18-jsx-in-js-files.test.ts b/packages/core/tests/18-jsx-in-js-files.test.ts similarity index 100% rename from core/tests/18-jsx-in-js-files.test.ts rename to packages/core/tests/18-jsx-in-js-files.test.ts diff --git a/core/tests/19-registry-tarballs.test.ts b/packages/core/tests/19-registry-tarballs.test.ts similarity index 100% rename from core/tests/19-registry-tarballs.test.ts rename to packages/core/tests/19-registry-tarballs.test.ts diff --git a/core/tests/21-concurrent-init.test.ts b/packages/core/tests/21-concurrent-init.test.ts similarity index 100% rename from core/tests/21-concurrent-init.test.ts rename to packages/core/tests/21-concurrent-init.test.ts diff --git a/core/tests/helpers.ts b/packages/core/tests/helpers.ts similarity index 100% rename from core/tests/helpers.ts rename to packages/core/tests/helpers.ts diff --git a/core/transform.ts b/packages/core/transform.ts similarity index 100% rename from core/transform.ts rename to packages/core/transform.ts diff --git a/core/types.ts b/packages/core/types.ts similarity index 100% rename from core/types.ts rename to packages/core/types.ts diff --git a/core/utils/cdn-format.ts b/packages/core/utils/cdn-format.ts similarity index 100% rename from core/utils/cdn-format.ts rename to packages/core/utils/cdn-format.ts diff --git a/core/utils/cdn-resolution.ts b/packages/core/utils/cdn-resolution.ts similarity index 100% rename from core/utils/cdn-resolution.ts rename to packages/core/utils/cdn-resolution.ts diff --git a/core/utils/create-notice.ts b/packages/core/utils/create-notice.ts similarity index 100% rename from core/utils/create-notice.ts rename to packages/core/utils/create-notice.ts diff --git a/core/utils/filesystem.ts b/packages/core/utils/filesystem.ts similarity index 100% rename from core/utils/filesystem.ts rename to packages/core/utils/filesystem.ts diff --git a/core/utils/flow-strip.ts b/packages/core/utils/flow-strip.ts similarity index 100% rename from core/utils/flow-strip.ts rename to packages/core/utils/flow-strip.ts diff --git a/core/utils/get-esbuild.ts b/packages/core/utils/get-esbuild.ts similarity index 100% rename from core/utils/get-esbuild.ts rename to packages/core/utils/get-esbuild.ts diff --git a/core/utils/index.ts b/packages/core/utils/index.ts similarity index 100% rename from core/utils/index.ts rename to packages/core/utils/index.ts diff --git a/core/utils/loader.ts b/packages/core/utils/loader.ts similarity index 100% rename from core/utils/loader.ts rename to packages/core/utils/loader.ts diff --git a/core/utils/side-effects.ts b/packages/core/utils/side-effects.ts similarity index 100% rename from core/utils/side-effects.ts rename to packages/core/utils/side-effects.ts diff --git a/core/utils/types.ts b/packages/core/utils/types.ts similarity index 100% rename from core/utils/types.ts rename to packages/core/utils/types.ts diff --git a/core/wasm.ts b/packages/core/wasm.ts similarity index 100% rename from core/wasm.ts rename to packages/core/wasm.ts diff --git a/packages/utils/_repl.ts b/packages/utils/_repl.ts new file mode 100644 index 0000000..714513a --- /dev/null +++ b/packages/utils/_repl.ts @@ -0,0 +1,25 @@ +// import { parseQuery, stringifyQuery } from "./src/utils/url.ts"; + +// console.log({ +// "repl.ts": "Repl.ts - Cool", +// query: stringifyQuery({ +// obj: { +// cool: "this" +// } +// }) +// }) + + +import { getPackageOfVersion, getPackageVersions, getPackages, getRegistryURL } from "./src/utils/npm-search.ts"; + +const input = "@okikio/animate"; +let { version: range } = getRegistryURL(input); +const versionsAndTags = await getPackageOfVersion(input); +const pkg = await getPackages(input); + +console.log({ + input, + range, + versionsAndTags, + info: pkg.info, +}) \ No newline at end of file diff --git a/packages/utils/ansi.ts b/packages/utils/ansi.ts new file mode 100644 index 0000000..f3198f0 --- /dev/null +++ b/packages/utils/ansi.ts @@ -0,0 +1,116 @@ +// Based off of @hyrious esbuild-repl https://github.com/hyrious/esbuild-repl/blob/main/src/helpers/ansi.ts +import { escape as htmlEscape } from "./html.ts"; + +// https://github.com/evanw/esbuild/blob/master/internal/logger/logger.go +export const ESCAPE_TO_COLOR = { + "37": "dim", + "31": "red", + "32": "green", + "34": "blue", + "36": "cyan", + "35": "magenta", + "33": "yellow", + "41;31": "red-bg-red", + "41;97": "red-bg-white", + "42;32": "green-bg-green", + "42;97": "green-bg-white", + "44;34": "blue-bg-blue", + "44;97": "blue-bg-white", + "46;36": "cyan-bg-cyan", + "46;30": "cyan-bg-black", + "45;35": "magenta-bg-magenta", + "45;30": "magenta-bg-black", + "43;33": "yellow-bg-yellow", + "43;30": "yellow-bg-black", +} as const; + +export type Escape = "0" | "1" | "4" | keyof typeof ESCAPE_TO_COLOR; +export type Color = typeof ESCAPE_TO_COLOR[keyof typeof ESCAPE_TO_COLOR]; + +export class AnsiBuffer { + result = ""; + _stack: string[] = []; + _bold = false; + _underline = false; + _link = false; + text(text: string) { + this.result += htmlEscape(text); + } + reset() { + let close: string | undefined; + while ((close = this._stack.pop())) { + this.result += close; + } + } + bold() { + if (!this._bold) { + this._bold = true; + this.result += ""; + this._stack.push(""); + } + } + underline() { + if (!this._underline) { + this._underline = true; + this.result += ""; + this._stack.push(""); + } + } + last() { + return this._stack[this._stack.length - 1]; + } + color(color: Color) { + let close: string | undefined; + while ((close = this.last()) === "") { + this._stack.pop(); + this.result += close; + } + this.result += ``; + this._stack.push(""); + } + done() { + this.reset(); + return this.result; + } +} + +export function render(ansi: string) { + ansi = ansi.trimEnd(); + + let i = 0; + const buffer = new AnsiBuffer(); + for (const m of ansi.matchAll(/\x1B\[([\d;]+)m/g)) { + const escape = m[1] as Escape; + buffer.text(ansi.slice(i, m.index)); + i = m.index! + m[0].length; + /* */ if (escape === "0") { + buffer.reset(); + } else if (escape === "1") { + buffer.bold(); + } else if (escape === "4") { + buffer.underline(); + } else if (ESCAPE_TO_COLOR[escape]) { + buffer.color(ESCAPE_TO_COLOR[escape]); + } + } + if (i < ansi.length) { + buffer.text(ansi.slice(i)); + } + + /** + * Based on https://stackoverflow.com/questions/6038061/regular-expression-to-find-urls-within-a-string + * Based on http://www.regexguru.com/2008/11/detecting-urls-in-a-block-of-text/ + */ + const str = buffer.done() + .replace(/:/g, ":") + .replace(/./g, ".") + .replace(///g, "/") + .replace(/@/g, "@"); + return str.replace( + /\b(?:(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)[-A-Z0-9+&@#/%?=~_|$!:,.;]*[-A-Z0-9+&@#/%=~_|$]|((?:mailto:)?[A-Z0-9._%+-]+@[A-Z0-9._%-]+\.[A-Z]{2,4})\b)|"(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)[^"\r\n]+"?|'(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)[^'\r\n]+'?/gi, + (match) => `${match}` + ); +} + +export { render as ansi }; +export default render; \ No newline at end of file diff --git a/packages/utils/archive-detect.ts b/packages/utils/archive-detect.ts new file mode 100644 index 0000000..2a60017 --- /dev/null +++ b/packages/utils/archive-detect.ts @@ -0,0 +1,1203 @@ +// core/utils/archive-detect.ts +// +// Resolver-grade archive detection. +// +// This file is not an “archiver” and it is not trying to unpack anything by itself. +// It exists to answer one question as cheaply and as reliably as possible: +// +// “Given a URL and a Response, is this payload an archive, and if so, what kind?” +// +// In a Node resolution pipeline, this question is a gate: +// +// - If we think it’s a tarball, we might mount/extract it into a virtual filesystem. +// - If we think it’s a JS/TS module, we might parse it as code and continue resolving imports. +// - If we think it’s HTML or something else, we should stop and emit a meaningful diagnostic. +// +// The trick is that the network often lies: +// +// - Content-Type is frequently "application/octet-stream" even when the bytes are gzip. +// - Content-Encoding might be absent even when the payload is compressed. +// - A “tarball URL” might redirect to an HTML page or a JS entry file. +// - Some servers set Content-Disposition filename but omit everything else. +// +// So we treat detection like a courtroom: +// +// - URL/path extensions are testimony (helpful, but can be wrong). +// - Headers are sworn testimony (more formal, still can be wrong). +// - Magic bytes are physical evidence (usually the most trustworthy). +// +// We structure the code so the “physical evidence” can override hints, +// and we always keep the chain-of-reasoning in `reasons` so diagnostics can explain +// what happened without guessing. +// +// The central mental model: container vs wrapper. +// +// - A *container* is the thing that holds files: tar, zip. +// - A *compression wrapper* is what shrinks bytes for transport: gzip, zstd, xz, etc. +// +// Examples people recognize: +// +// - .tar.gz => tar container wrapped in gzip +// - .tgz => same thing, different shorthand +// - .tar.zst => tar container wrapped in zstd +// - .zip => zip container (zip is both container and has its own internal compression) +// - .gz => ambiguous: could be “a single gzipped file”, or “a gzipped tar” +// +// The detection policy is layered: +// +// 1) Path hint (URL or filename) -> cheap, medium-signal. +// 2) Header hint -> spec-defined, still not authoritative. +// 3) Byte sniff (wire prefix) -> authoritative for wrapper/container signatures. +// 4) Tar confirmation (ustar) -> authoritative for tar specifically. +// +// We keep this file “policy-level”. Strict parsing lives in archive-spec.ts. +// This file consumes those parsers and turns them into practical routing decisions. + +import { basename } from "@std/path"; + +import { + extractFilenameFromContentDisposition, + getPrimaryContentCoding, + parseContentEncodingStrict, + parseContentTypeStrict, + type ParseIssue, +} from "./archive-spec.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Public types + * ------------------------------------------------------------------------------------------------- */ + +/** + * Compression wrapper applied to a payload. + * + * Resolver context: + * - You can detect more formats than you can currently decompress. + * - Detection is about routing and diagnostics, not necessarily about “supporting extraction”. + * + * That’s why the union includes formats that your runtime may not be able to decode yet. + * The resolver can still surface “this is .tar.zst” rather than “unknown blob”. + */ +export type ArchiveCompression = + | "none" + | "gzip" + | "bzip2" + | "xz" + | "zstd" + | "brotli" + | "lz4" + | "lzma" + | "lzip" + | "compress" + | "deflate" + | "deflate-raw" + | "unknown"; + +/** + * Container format after any outer compression wrapper is removed. + */ +export type ArchiveContainer = + | "tar" + | "zip" + | "unknown"; + +/** + * Each reason has a signal source so you can understand “why we believe this”. + * This is the difference between “works” and “debuggable”. + */ +export type ArchiveSignal = + | "url" + | "content-type" + | "content-encoding" + | "content-disposition" + | "magic-bytes" + | "tar-ustar"; + +/** + * Small structured breadcrumb for diagnostics. + */ +export type ArchiveReason = { + signal: ArchiveSignal; + detail: string; +}; + +/** + * Final detection result. + * + * `confidence` is intentionally conservative: + * - high: we have strong byte-based confirmation (e.g. tar ustar header) + * - medium: magic bytes indicate wrapper/container, or strong filename hints + * - low: mostly hints; caller should be prepared for “it’s actually HTML/JS” + */ +export type ArchiveDetection = { + container: ArchiveContainer; + compression: ArchiveCompression; + isTarballLike: boolean; + confidence: "high" | "medium" | "low"; + reasons: Iterable; + + // Evidence (inputs) captured for debugging and future refactors. + filenameHint?: string; + urlHint?: string; + + contentType?: string | null; + contentEncoding?: string | null; + + // Content-Disposition parsing is strict-first with controlled lenient fallback. + // We store the mode and issues so we can diagnose “why strict failed”. + contentDispositionMode?: "strict" | "lenient"; + contentDispositionIssues?: readonly ParseIssue[]; +}; + +/** + * Debug output meant for logs. + * The guiding principle is: if someone pastes this into an issue, you can triage quickly. + */ +export type ArchiveDiagnostic = { + summary: string; + details: string; +}; + +/* ------------------------------------------------------------------------------------------------- + * Public constants + * ------------------------------------------------------------------------------------------------- */ + +/** + * Multi-extensions for tarballs, sorted by descending specificity. + * + * Why we prefer multi-extension matches over single-extension matches: + * - `.tar.zst` means “tar container” with “zstd wrapper”. + * - If you only look at `.zst`, you lose the container identity. + * + * Spec note: + * - File extensions are conventions, not HTTP standards. + * - Still useful as early hints because publishers encode intent into filenames. + */ +export const TAR_MULTI_EXTENSIONS: readonly string[] = [ + ".tar.zstd", + ".tar.zst", + ".tar.gz", + ".tar.bz2", + ".tar.xz", + ".tar.br", + ".tar.lz4", + ".tar.lzip", + ".tar.lzma", + ".tar.lz", + ".tar.z", + ".tar.Z", +] as const; + +/** + * Short tarball extensions (publisher conventions). + */ +export const TAR_SHORT_EXTENSIONS: readonly string[] = [ + ".tgz", + ".tbz2", + ".tbz", + ".txz", + ".tzst", + ".tbr", + ".tlz4", +] as const; + +/** + * Content-Types that show up in tarball delivery endpoints. + * + * Policy note: + * - This is never a hard gate. + * - It’s a “raise your eyebrows” hint, not a conviction. + */ +export const TAR_CONTENT_TYPES: readonly string[] = [ + "application/tar", + "application/x-tar", + "application/gzip", + "application/x-gzip", + "application/zstd", + "application/xz", + "application/octet-stream", + "application/tar+gzip", +] as const; + +/* ------------------------------------------------------------------------------------------------- + * Path / URL hint detection + * ------------------------------------------------------------------------------------------------- */ + +/** + * Detect archive hints from a URL or filename. + * + * This is the earliest and cheapest stage: it does not touch the network and it does not read bytes. + * It is also the least reliable stage, because URLs can lie and CDNs can rewrite paths. + * + * The “why” of this function: + * - In resolvers, we often want to decide early whether to even attempt a tarball pipeline. + * - If the response body is missing (HEAD requests, errors), this might be all we have. + * + * The “how” in plain terms: + * - We look for “most specific” multi-extensions first (.tar.zst beats .zst). + * - Then we look for short-hand tar extensions (.tgz, .txz). + * - Then we fall back to single extensions (.tar, .gz) with lower confidence. + * + * @example + * detectArchiveFromPathHint("pkg.tar.zst") + * // container="tar", compression="zstd", confidence="medium" + * + * @example + * detectArchiveFromPathHint("pkg.gz") + * // compression="gzip", container="unknown" (ambiguous), confidence="low" + */ +export function detectArchiveFromPathHint( + input: string | URL, +): Pick< + ArchiveDetection, + | "container" + | "compression" + | "reasons" + | "filenameHint" + | "urlHint" + | "isTarballLike" + | "confidence" +> { + const urlHint = typeof input === "string" ? input : input.toString(); + + // basename() gives us the last path segment which is the best extension signal we can get. + // If URL parsing fails for some reason, we fall back to a best-effort basename on the whole string. + const filenameHint = (() => { + try { + if (typeof input === "string") return basename(input); + return basename(input.pathname); + } catch { + return basename(urlHint); + } + })(); + + const lower = filenameHint.toLowerCase(); + const reasons: ArchiveReason[] = []; + + let container: ArchiveContainer = "unknown"; + let compression: ArchiveCompression = "unknown"; + let confidence: ArchiveDetection["confidence"] = "low"; + let isTarballLike = false; + + // The most common “hard to follow” bug in archive detection is checking ".zst" before ".tar.zst". + // That makes `.tar.zst` look like “some zstd blob” instead of “a tar container wrapped in zstd”. + // So we always check the multi-extensions first. + for (const ext of TAR_MULTI_EXTENSIONS) { + if (lower.endsWith(ext.toLowerCase())) { + container = "tar"; + compression = mapTarMultiExtensionToCompression(ext); + confidence = "medium"; + isTarballLike = true; + + reasons.push({ + signal: "url", + detail: `Filename ends with ${ext} (publisher intent hint: tar container + wrapper)`, + }); + + return { container, compression, isTarballLike, confidence, reasons, filenameHint, urlHint }; + } + } + + // Next: short tarball conventions. + for (const ext of TAR_SHORT_EXTENSIONS) { + if (lower.endsWith(ext)) { + container = "tar"; + compression = mapTarShortExtensionToCompression(ext); + confidence = "medium"; + isTarballLike = true; + + reasons.push({ + signal: "url", + detail: `Filename ends with ${ext} (short tarball convention hint)`, + }); + + return { container, compression, isTarballLike, confidence, reasons, filenameHint, urlHint }; + } + } + + // Finally: single-extension hints. + // These are lower-confidence because they do not uniquely identify a “tarball”. + if (lower.endsWith(".tar")) { + container = "tar"; + compression = "none"; + confidence = "medium"; + isTarballLike = true; + + reasons.push({ signal: "url", detail: "Filename ends with .tar" }); + } else if (lower.endsWith(".gz")) { + compression = "gzip"; + confidence = "low"; + + reasons.push({ + signal: "url", + detail: "Filename ends with .gz (ambiguous: could be a gzipped tar or a single gzipped file)", + }); + } else if (lower.endsWith(".zst") || lower.endsWith(".zstd")) { + compression = "zstd"; + confidence = "low"; + + reasons.push({ + signal: "url", + detail: "Filename ends with .zst/.zstd (ambiguous without an explicit tar hint)", + }); + } + + return { container, compression, isTarballLike, confidence, reasons, filenameHint, urlHint }; +} + +/** + * Map known tar multi-extensions to wrapper types. + * + * We keep this mapping explicit because it becomes a long-lived “convention dictionary”. + * When new extensions appear in the wild, you add them here, and diagnostics automatically improve. + */ +export function mapTarMultiExtensionToCompression(ext: string): ArchiveCompression { + const lower = ext.toLowerCase(); + + if (lower === ".tar.gz") return "gzip"; + if (lower === ".tar.bz2") return "bzip2"; + if (lower === ".tar.xz") return "xz"; + if (lower === ".tar.zst" || lower === ".tar.zstd") return "zstd"; + if (lower === ".tar.br") return "brotli"; + if (lower === ".tar.lz4") return "lz4"; + if (lower === ".tar.lzma") return "lzma"; + if (lower === ".tar.lzip") return "lzip"; + + // “compress” is the classic Unix .Z encoding; it still appears occasionally. + if (lower === ".tar.z") return "compress"; + + // ".tar.lz" is not consistently defined across ecosystems; treat as unknown wrapper. + if (lower === ".tar.lz") return "unknown"; + + return "unknown"; +} + +/** + * Map known tar short extensions to wrapper types. + */ +export function mapTarShortExtensionToCompression(ext: string): ArchiveCompression { + switch (ext) { + case ".tgz": + return "gzip"; + case ".tbz": + case ".tbz2": + return "bzip2"; + case ".txz": + return "xz"; + case ".tzst": + return "zstd"; + case ".tbr": + return "brotli"; + case ".tlz4": + return "lz4"; + default: + return "unknown"; + } +} + +/* ------------------------------------------------------------------------------------------------- + * Header hint detection (spec-first via archive-spec.ts) + * ------------------------------------------------------------------------------------------------- */ + +/** + * Detect archive hints from headers. + * + * There are three useful header sources: + * + * - Content-Type: + * “What the server claims the representation is.” + * Helpful, but often generic or wrong. + * + * - Content-Encoding: + * “What transformations were applied *in transit*.” + * This can indicate gzip/brotli even if the URL says nothing. + * + * - Content-Disposition: + * “Suggested filename for a download.” + * Surprisingly helpful because CDNs sometimes include a real filename even when the URL is opaque. + * + * We parse headers strictly in archive-spec.ts (source of truth), + * and only do controlled recovery for Content-Disposition filename extraction. + */ +export function detectArchiveFromHeaders( + headers: Headers, +): Pick< + ArchiveDetection, + | "container" + | "compression" + | "reasons" + | "filenameHint" + | "contentType" + | "contentEncoding" + | "isTarballLike" + | "confidence" + | "contentDispositionMode" + | "contentDispositionIssues" +> { + const reasons: ArchiveReason[] = []; + + const contentTypeRaw = headers.get("content-type"); + const contentEncodingRaw = headers.get("content-encoding"); + const contentDispositionRaw = headers.get("content-disposition"); + + // Content-Type parsing is spec-level and delegated to @std/media-types by archive-spec.ts. + // We store both the raw and the parsed base type because: + // - raw is what you print in logs, + // - parsed base type is what you compare in policy decisions. + const contentTypeParsed = parseContentTypeStrict(contentTypeRaw); + let contentTypeBase: string | null = null; + + if (contentTypeParsed.ok) { + contentTypeBase = contentTypeParsed.value.type; + reasons.push({ + signal: "content-type", + detail: `Content-Type parsed as ${contentTypeBase}`, + }); + } else if (contentTypeRaw) { + reasons.push({ + signal: "content-type", + detail: `Content-Type present but unparseable: ${contentTypeRaw}`, + }); + } + + // Content-Encoding parsing is strict: a comma-separated list of tokens. + // We keep the entire list in the spec layer, but here we mostly care about the “outermost” + // encoding (the first coding), because that’s what you would need to undo first. + const encodingParsed = parseContentEncodingStrict(contentEncodingRaw); + const codingList = encodingParsed.ok ? encodingParsed.value : []; + const primaryCoding = getPrimaryContentCoding(codingList); + + if (contentEncodingRaw) { + reasons.push({ + signal: "content-encoding", + detail: `Content-Encoding raw=${contentEncodingRaw}`, + }); + } + + // Content-Disposition filename extraction: strict first, then controlled lenient. + // We track the mode and issues because “why did strict fail?” matters in resolver debugging. + const cd = extractFilenameFromContentDisposition(contentDispositionRaw); + let filenameHint: string | undefined; + + if (cd.filename) { + filenameHint = cd.filename; + + reasons.push({ + signal: "content-disposition", + detail: `Content-Disposition filename=${cd.filename} (mode=${cd.mode})`, + }); + } else if (contentDispositionRaw) { + reasons.push({ + signal: "content-disposition", + detail: `Content-Disposition present but no filename extracted (mode=${cd.mode})`, + }); + } + + let container: ArchiveContainer = "unknown"; + let compression: ArchiveCompression = "unknown"; + let confidence: ArchiveDetection["confidence"] = "low"; + let isTarballLike = false; + + // Header hints are intentionally not “authoritative”. They move suspicion up or down, + // but the byte sniff stage gets the final say when available. + if (contentTypeBase) { + // This is a “tar-ish payload might be here” hint. We do not assert tar. + if (TAR_CONTENT_TYPES.includes(contentTypeBase)) { + isTarballLike = true; + confidence = "low"; + } + + // When the media type is explicitly tar, that’s a stronger claim. + if (contentTypeBase === "application/tar" || contentTypeBase === "application/x-tar") { + container = "tar"; + isTarballLike = true; + confidence = "medium"; + } + + // Some servers label the wrapper type instead of the container. + if (contentTypeBase === "application/gzip" || contentTypeBase === "application/x-gzip") { + compression = "gzip"; + isTarballLike = true; + } + + if (contentTypeBase === "application/zstd") { + compression = "zstd"; + isTarballLike = true; + } + + if (contentTypeBase === "application/xz") { + compression = "xz"; + isTarballLike = true; + } + } + + // Content-Encoding is about the transport wrapper. + // We normalize to our union so later code doesn’t have to reason about strings. + if (primaryCoding) { + const normalized = normalizeContentEncodingToken(primaryCoding); + if (normalized !== "unknown") { + compression = normalized; + isTarballLike = true; + } + } + + // If Content-Disposition gave us a filename, treat it like a “path hint” + // and merge it using the exact same path-hint logic. + // + // This avoids a subtle inconsistency where URL hints and filename hints drift apart over time. + if (filenameHint) { + const byName = detectArchiveFromPathHint(filenameHint); + + if (byName.container !== "unknown") container = byName.container; + if (byName.compression !== "unknown") compression = byName.compression; + + if (byName.isTarballLike) { + isTarballLike = true; + confidence = upgradeConfidence(confidence, byName.confidence); + } + } + + return { + container, + compression, + isTarballLike, + confidence, + reasons, + filenameHint, + contentType: contentTypeRaw, + contentEncoding: contentEncodingRaw, + contentDispositionMode: cd.mode, + contentDispositionIssues: cd.issues, + }; +} + +/** + * Normalize a Content-Encoding token into our wrapper union. + * + * Content-Encoding is a token list; tokens are case-insensitive in practice. + * We normalize to lowercase and then map known values. + * + * Note: + * - If you later add support for zstd via Content-Encoding (some servers do), + * this is the function you’d extend. + */ +export function normalizeContentEncodingToken(token: string): ArchiveCompression { + const lower = token.trim().toLowerCase(); + + switch (lower) { + case "gzip": + return "gzip"; + case "br": + return "brotli"; + case "deflate": + return "deflate"; + case "deflate-raw": + return "deflate-raw"; + case "zstd": + return "zstd"; + default: + return "unknown"; + } +} + +/** + * Confidence merging stays intentionally tiny. + * + * When confidence merging becomes complicated, it usually means: + * - we’re encoding a scoring system implicitly + * - and we should introduce an explicit scoring function instead. + */ +export function upgradeConfidence( + current: ArchiveDetection["confidence"], + next: ArchiveDetection["confidence"], +): ArchiveDetection["confidence"] { + if (current === "high" || next === "high") return "high"; + if (current === "medium" || next === "medium") return "medium"; + return "low"; +} + +/* ------------------------------------------------------------------------------------------------- + * Byte sniffing (“physical evidence”) + * ------------------------------------------------------------------------------------------------- */ + +/** + * Sniff compression/container using magic bytes at the beginning of the wire payload. + * + * Why magic bytes matter: + * - For wrapper formats like gzip and zstd, the signature is near byte 0. + * - Headers and filenames can be wrong, but bytes rarely lie. + * + * What we return: + * - compression wrapper, if recognized + * - container hint for zip, because zip has a strong signature at byte 0 + * + * What we do not claim: + * - we do not confirm tar here, because tar has no signature at byte 0. + * Tar confirmation happens later via the ustar marker at offset 257. + */ +export function sniffCompressionMagic(bytes: Uint8Array): { + compression: ArchiveCompression; + containerHint?: ArchiveContainer; + reason?: ArchiveReason; +} { + // gzip: 1F 8B + if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { + return { + compression: "gzip", + reason: { signal: "magic-bytes", detail: "gzip magic bytes (1F 8B)" }, + }; + } + + // bzip2: "BZh" -> 42 5A 68 + if (bytes.length >= 3 && bytes[0] === 0x42 && bytes[1] === 0x5a && bytes[2] === 0x68) { + return { + compression: "bzip2", + reason: { signal: "magic-bytes", detail: 'bzip2 magic bytes ("BZh")' }, + }; + } + + // xz: FD 37 7A 58 5A 00 + if ( + bytes.length >= 6 && + bytes[0] === 0xfd && + bytes[1] === 0x37 && + bytes[2] === 0x7a && + bytes[3] === 0x58 && + bytes[4] === 0x5a && + bytes[5] === 0x00 + ) { + return { + compression: "xz", + reason: { signal: "magic-bytes", detail: "xz magic bytes (FD 37 7A 58 5A 00)" }, + }; + } + + // zstd: 28 B5 2F FD + if (bytes.length >= 4 && bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd) { + return { + compression: "zstd", + reason: { signal: "magic-bytes", detail: "zstd magic bytes (28 B5 2F FD)" }, + }; + } + + // lz4 frame: 04 22 4D 18 + if (bytes.length >= 4 && bytes[0] === 0x04 && bytes[1] === 0x22 && bytes[2] === 0x4d && bytes[3] === 0x18) { + return { + compression: "lz4", + reason: { signal: "magic-bytes", detail: "lz4 frame magic bytes (04 22 4D 18)" }, + }; + } + + // lzip: "LZIP" -> 4C 5A 49 50 + if (bytes.length >= 4 && bytes[0] === 0x4c && bytes[1] === 0x5a && bytes[2] === 0x49 && bytes[3] === 0x50) { + return { + compression: "lzip", + reason: { signal: "magic-bytes", detail: 'lzip magic bytes ("LZIP")' }, + }; + } + + // Unix compress (.Z): 1F 9D or 1F A0 + if (bytes.length >= 2 && bytes[0] === 0x1f && (bytes[1] === 0x9d || bytes[1] === 0xa0)) { + return { + compression: "compress", + reason: { signal: "magic-bytes", detail: "Unix compress magic bytes (1F 9D/1F A0)" }, + }; + } + + // zip container magic: PK.. + if (isZipMagic(bytes)) { + return { + compression: "none", + containerHint: "zip", + reason: { signal: "magic-bytes", detail: "zip magic bytes (PK..)" }, + }; + } + + return { compression: "unknown" }; +} + +/** + * Zip is easier to confirm than tar because it has a signature at byte 0. + */ +export function isZipMagic(bytes: Uint8Array): boolean { + if (bytes.length < 4) return false; + + const b0 = bytes[0], b1 = bytes[1], b2 = bytes[2], b3 = bytes[3]; + + // PK\x03\x04 (local file header) + // PK\x05\x06 (empty archive) + // PK\x07\x08 (spanned archive) + if (b0 === 0x50 && b1 === 0x4b) { + if (b2 === 0x03 && b3 === 0x04) return true; + if (b2 === 0x05 && b3 === 0x06) return true; + if (b2 === 0x07 && b3 === 0x08) return true; + } + + return false; +} + +/** + * Confirm tar via the POSIX “ustar” marker. + * + * This is where tar differs from most formats: + * - There is no “magic number” at the beginning of the file. + * - Instead, the first file header is 512 bytes and includes the ustar marker at offset 257. + * + * Why offset 257? + * - That’s defined by the tar header layout (POSIX ustar). + * - So to confirm tar, we must have at least 262 bytes of the *tar stream* (not the gz stream). + * + * This function is a pure check: + * - it does not try to validate checksums or parse headers, + * - it only answers “does this look like a ustar tar header?” + */ +export function sniffTarUstar(bytes: Uint8Array): { isTar: boolean; reason?: ArchiveReason } { + if (bytes.length < 262) return { isTar: false }; + + const off = 257; + const isUstar = + bytes[off + 0] === 0x75 && // u + bytes[off + 1] === 0x73 && // s + bytes[off + 2] === 0x74 && // t + bytes[off + 3] === 0x61 && // a + bytes[off + 4] === 0x72; // r + + if (!isUstar) return { isTar: false }; + + return { + isTar: true, + reason: { signal: "tar-ustar", detail: 'tar signature "ustar" at offset 257 (POSIX tar header)' }, + }; +} + +/* ------------------------------------------------------------------------------------------------- + * Stream peeking helpers + * ------------------------------------------------------------------------------------------------- */ + +/** + * Read up to `limit` bytes from a stream and return them as a single Uint8Array. + * + * Resolver context: + * - For sniffing, we only need a prefix (e.g., first 1KB). + * - We want to avoid buffering entire responses just to detect gzip vs zstd vs HTML. + * + * Important workflow note: + * - This function assumes you already split the stream via `tee()`. + * - That’s the “resolver trick” that keeps sniffing cheap: + * + * const [sniffBranch, consumeBranch] = response.body.tee(); + * const prefix = await peekStreamBytes(sniffBranch, 1024); + * // consumeBranch continues to stream full payload for extraction or compilation + * + * We do not “put bytes back” into the stream because the tee() split avoids that need. + */ +export async function peekStreamBytes( + stream: ReadableStream>, + limit: number, +): Promise> { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + try { + while (total < limit) { + const { done, value } = await reader.read(); + if (done || !value) break; + + if (total + value.length <= limit) { + chunks.push(value); + total += value.length; + continue; + } + + // Only take the prefix we need from the last chunk. + const needed = limit - total; + chunks.push(value.subarray(0, needed)); + total += needed; + break; + } + } finally { + reader.releaseLock(); + } + + if (chunks.length === 0) return new Uint8Array(0); + if (chunks.length === 1) return chunks[0]; + + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + +/** + * Inflate a small prefix when gzip is detected. + * + * Why we do this: + * - For .tar.gz, ustar confirmation requires looking into the tar stream. + * - The tar stream is inside gzip, so we must inflate at least a small window first. + * + * Why we only do gzip here: + * - Web/Deno provides DecompressionStream("gzip") in many runtimes. + * - zstd/xz/bzip2 are not generally available as built-ins. + * + * Policy implication: + * - gzip tarballs can become “high confidence” (ustar confirmed). + * - zstd tarballs may remain “medium confidence” unless you add a zstd inflater later. + */ +export async function gunzipPrefixIfPossible( + gzipBytes: Uint8Array, + limit: number, +): Promise | null> { + if (!("DecompressionStream" in globalThis)) return null; + + try { + const ds = new DecompressionStream("gzip"); + const decompressed = new Blob([gzipBytes]).stream().pipeThrough(ds); + return await peekStreamBytes(decompressed, limit); + } catch { + return null; + } +} + +/* ------------------------------------------------------------------------------------------------- + * End-to-end response detection + * ------------------------------------------------------------------------------------------------- */ + +/** + * Detect archive/container characteristics from a fetch Response. + * + * This function is designed to plug directly into a tarball “mount” step in a resolver. + * It returns: + * + * - `detection`: what we believe, with confidence and reasons. + * - `diagnostic`: human-readable summary/details for logs. + * - `bodyForConsumption`: a stream branch that still contains the full payload. + * + * The core idea is to split the response body: + * - One branch is used for sniffing a small prefix. + * - The other branch is preserved for the actual consumer (extractor/compiler). + * + * The slightly tricky part is the precedence order: + * - We start with hints (URL + headers). + * - Magic bytes override wrapper guesses. + * - Tar ustar confirmation overrides container guesses. + * - Confidence is computed last based on what we actually confirmed. + * + * @example + * const { detection, bodyForConsumption } = await detectArchiveFromResponse(url, res); + * if (detection.container === "tar") { + * // route into tarball mount/extract path using bodyForConsumption + * } else { + * // treat as module/html/etc and handle accordingly + * } + */ +export async function detectArchiveFromResponse( + url: string | URL, + response: Response, + opts: { peekWireBytes?: number; peekTarBytes?: number } = {}, +): Promise<{ + detection: ArchiveDetection; + diagnostic: ArchiveDiagnostic; + bodyForConsumption: ReadableStream> | null; +}> { + const peekWireBytes = opts.peekWireBytes ?? 1024; + const peekTarBytes = opts.peekTarBytes ?? 512; + + const urlHint = detectArchiveFromPathHint(url); + const headerHint = detectArchiveFromHeaders(response.headers); + + const reasons: ArchiveReason[] = []; + for (const r of urlHint.reasons) reasons.push(r); + for (const r of headerHint.reasons) reasons.push(r); + + // Start with the best hints we have. + // Later stages (magic bytes / ustar) can override these. + let container = pickContainer(urlHint.container, headerHint.container); + let compression = pickCompression(urlHint.compression, headerHint.compression); + + const body = response.body; + if (!body) { + const detection = finalizeDetection({ + container, + compression, + urlHint, + headerHint, + reasons, + magicKnown: false, + tarConfirmed: false, + }); + + return { + detection, + diagnostic: { + summary: "No response body available for archive detection.", + details: formatDetectionDetails(detection, new Uint8Array(0), null), + }, + bodyForConsumption: null, + }; + } + + const [sniffBranch, consumeBranch] = body.tee(); + const wirePrefix = await peekStreamBytes(sniffBranch, peekWireBytes); + + // Magic bytes can conclusively identify wrapper formats (gzip, zstd, xz, etc). + // If magic disagrees with hints, we trust magic. + const magic = sniffCompressionMagic(wirePrefix); + const magicKnown = magic.compression !== "unknown" || Boolean(magic.containerHint); + + if (magic.compression !== "unknown") { + compression = magic.compression; + if (magic.reason) reasons.push(magic.reason); + } + + if (magic.containerHint && container === "unknown") { + container = magic.containerHint; + } + + // Tar confirmation requires tar bytes, not wrapper bytes. + // + // For plain tar (no wrapper), wirePrefix might already be tar bytes. + // For gzip-wrapped tar, we can inflate a prefix and check ustar. + // For other wrappers, we currently cannot inflate without extra dependencies, so we stop at hints. + let tarPrefix: Uint8Array | null = null; + + if (compression === "none" || compression === "unknown") { + tarPrefix = wirePrefix; + } else if (compression === "gzip") { + tarPrefix = await gunzipPrefixIfPossible(wirePrefix, peekTarBytes); + if (tarPrefix) { + reasons.push({ + signal: "magic-bytes", + detail: `Inflated first ${tarPrefix.length} bytes via gzip to check tar header`, + }); + } + } + + let tarConfirmed = false; + + if (tarPrefix) { + const ustar = sniffTarUstar(tarPrefix); + if (ustar.isTar) { + container = "tar"; + tarConfirmed = true; + if (ustar.reason) reasons.push(ustar.reason); + } + } + + const detection = finalizeDetection({ + container, + compression, + urlHint, + headerHint, + reasons, + magicKnown, + tarConfirmed, + }); + + return { + detection, + diagnostic: { + summary: formatDetectionSummary(detection), + details: formatDetectionDetails(detection, wirePrefix, tarPrefix), + }, + bodyForConsumption: consumeBranch, + }; +} + +/** + * Container merging rule: prefer known over unknown. + * + * This looks trivial, and that’s a feature: + * - you want this kind of merge logic to be stable, boring, and hard to break. + */ +export function pickContainer(a: ArchiveContainer, b: ArchiveContainer): ArchiveContainer { + if (a !== "unknown") return a; + if (b !== "unknown") return b; + return "unknown"; +} + +/** + * Compression merging rule: prefer known over unknown. + */ +export function pickCompression(a: ArchiveCompression, b: ArchiveCompression): ArchiveCompression { + if (a !== "unknown") return a; + if (b !== "unknown") return b; + return "unknown"; +} + +/** + * Finalize a detection into a stable output shape. + * + * This is separated out because the end-to-end function is already “branchy”. + * Branchy logic is where future bugs hide, so we keep the final assembly step pure and obvious. + * + * @internal + */ +export function finalizeDetection(input: { + container: ArchiveContainer; + compression: ArchiveCompression; + urlHint: Pick; + headerHint: Pick< + ArchiveDetection, + | "isTarballLike" + | "filenameHint" + | "confidence" + | "contentType" + | "contentEncoding" + | "contentDispositionMode" + | "contentDispositionIssues" + >; + reasons: ArchiveReason[]; + magicKnown: boolean; + tarConfirmed: boolean; +}): ArchiveDetection { + const isTarballLike = + input.container === "tar" || + input.urlHint.isTarballLike || + input.headerHint.isTarballLike; + + const confidence = computeConfidence({ + urlHint: input.urlHint.confidence, + headerHint: input.headerHint.confidence, + hasKnownMagic: input.magicKnown, + tarConfirmed: input.tarConfirmed, + }); + + return { + container: input.container, + compression: input.compression, + isTarballLike, + confidence, + reasons: input.reasons, + filenameHint: input.headerHint.filenameHint ?? input.urlHint.filenameHint, + urlHint: input.urlHint.urlHint, + contentType: input.headerHint.contentType, + contentEncoding: input.headerHint.contentEncoding, + contentDispositionMode: input.headerHint.contentDispositionMode, + contentDispositionIssues: input.headerHint.contentDispositionIssues, + }; +} + +/** + * Confidence rules are intentionally conservative and explainable. + * + * Think of confidence like a “how surprised would we be if we were wrong?” meter: + * + * - Tar ustar confirmation: we would be very surprised -> high. + * - Known magic bytes: we would be somewhat surprised -> medium. + * - Only hints: we would not be surprised -> low. + * + * This function is tiny on purpose. If you need more nuance later, + * add an explicit scoring system rather than letting a pile of if-statements grow here. + */ +export function computeConfidence(input: { + urlHint: ArchiveDetection["confidence"]; + headerHint: ArchiveDetection["confidence"]; + hasKnownMagic: boolean; + tarConfirmed: boolean; +}): ArchiveDetection["confidence"] { + if (input.tarConfirmed) return "high"; + if (input.hasKnownMagic) return "medium"; + if (input.urlHint === "medium" || input.headerHint === "medium") return "medium"; + return "low"; +} + +/* ------------------------------------------------------------------------------------------------- + * Diagnostics formatting + * ------------------------------------------------------------------------------------------------- */ + +/** + * Produce a one-line summary for logs. + */ +export function formatDetectionSummary(det: ArchiveDetection): string { + const wrap = det.compression === "none" ? "" : `+${det.compression}`; + const kind = `${det.container}${wrap}`; + return `Detected: ${kind} (confidence=${det.confidence}, tarballLike=${det.isTarballLike})`; +} + +/** + * Produce a multi-line debug report. + * + * The goal is not to dump everything; it’s to show the evidence that drives decisions: + * - what we saw in URL/filename + * - what headers said + * - what the first bytes looked like + * - which reasons were used to reach the conclusion + */ +export function formatDetectionDetails( + det: ArchiveDetection, + wirePrefix: Uint8Array, + tarPrefix: Uint8Array | null, +): string { + const lines: string[] = []; + + lines.push(`urlHint: ${det.urlHint ?? "(none)"}`); + lines.push(`filenameHint: ${det.filenameHint ?? "(none)"}`); + lines.push(`contentType: ${det.contentType ?? "(none)"}`); + lines.push(`contentEncoding: ${det.contentEncoding ?? "(none)"}`); + + if (det.contentDispositionMode) { + lines.push(`contentDispositionMode: ${det.contentDispositionMode}`); + } + + if (det.contentDispositionIssues && det.contentDispositionIssues.length > 0) { + lines.push(`contentDispositionIssues: ${det.contentDispositionIssues.length}`); + for (const issue of det.contentDispositionIssues) { + lines.push(` - ${issue.code}: ${issue.message}`); + } + } + + // Hex previews are the most practical way to debug magic-byte sniffing: + // - gzip is 1f 8b + // - zstd is 28 b5 2f fd + // - zip starts with 50 4b + lines.push(`wirePrefix(${wirePrefix.length}): ${formatHexPreview(wirePrefix)}`); + + if (tarPrefix) { + lines.push(`tarPrefix(${tarPrefix.length}): ${formatHexPreview(tarPrefix)}`); + } + + lines.push("reasons:"); + for (const r of det.reasons) { + lines.push(`- [${r.signal}] ${r.detail}`); + } + + return lines.join("\n"); +} + +/** + * Format a short hex preview of bytes. + * + * This looks like a tiny helper, but it’s one of the best “future maintainer gifts”: + * when someone is diagnosing a tarball failure, they can recognize signatures instantly. + * + * Why `.toString(16)`? + * - Bytes are numbers 0..255. + * - Magic signatures are universally documented in hexadecimal. + * - Converting each byte to hex makes the output match specs, docs, and tooling. + * + * Why `padStart(2, "0")`? + * - A byte in hex is conventionally shown as two characters. + * - 0x0a should be “0a”, not “a”, otherwise the preview becomes hard to scan. + * + * Why we cap at 16 bytes? + * - We want a fingerprint, not a full hexdump. + * - Logs should stay readable. + * + * @example + * formatHexPreview(new Uint8Array([0x1f, 0x8b, 0x08])) + * // "1f 8b 08" + */ +export function formatHexPreview(bytes: Uint8Array): string { + const max = Math.min(bytes.length, 16); + const parts: string[] = []; + + for (let i = 0; i < max; i++) { + parts.push(bytes[i].toString(16).padStart(2, "0")); + } + + const suffix = bytes.length > max ? " …" : ""; + return parts.join(" ") + suffix; +} diff --git a/packages/utils/archive-spec.ts b/packages/utils/archive-spec.ts new file mode 100644 index 0000000..c8a63bf --- /dev/null +++ b/packages/utils/archive-spec.ts @@ -0,0 +1,684 @@ +// utils/archive-spec.ts +// +// Spec-level parsers and canonicalizers used by resolver-grade archive detection. +// The intent is to separate “what the spec says” from “what the network does”. +// The policy layer can then choose strict vs lenient behavior explicitly. +// +// We use @std/media-types for Content-Type because it’s exactly the kind of +// spec-aligned parsing logic we should not re-implement by hand. +// +// Content-Disposition is not handled by @std/media-types and has its own RFC grammar, +// so we implement it here in a way that is both strict (source of truth) and +// optionally lenient (reality mode) without mixing those concerns. + +import { formatMediaType, parseMediaType } from "./media-types.ts"; + +/* ------------------------------------------------------------------------------------------------- + * Shared error model + * ------------------------------------------------------------------------------------------------- */ + +export type ParseIssueCode = + | "EMPTY" + | "INVALID_TOKEN" + | "INVALID_QUOTED_STRING" + | "INVALID_PARAMETER" + | "INVALID_EXT_VALUE" + | "UNSUPPORTED_CHARSET" + | "INVALID_PERCENT_ENCODING" + | "TRAILING_DATA"; + +export type ParseIssue = { + code: ParseIssueCode; + message: string; + index?: number; + input?: string; +}; + +export type ParseResult = + | { ok: true; value: T } + | { ok: false; issues: readonly ParseIssue[] }; + +/* ------------------------------------------------------------------------------------------------- + * Content-Type (RFC media type) — delegated to @std/media-types + * ------------------------------------------------------------------------------------------------- */ + +/** + * Canonical media type: "type/subtype" plus a parameters map. + * + * `@std/media-types.parseMediaType()` returns a tuple [type, params], + * where type is normalized to lowercase and param keys are lowercased. + * We keep a dedicated type so calling code stays explicit. + */ +export type MediaType = { + type: string; + parameters: Record; +}; + +/** + * Parse a Content-Type header value using @std/media-types. + * + * Why this exists even though parseMediaType exists: + * - The spec-layer should expose an explicit success/error shape (ParseResult). + * - Callers can then decide strict vs lenient policy without exceptions. + * + * Note: + * - `@std/media-types` returns `undefined` when parsing fails. + * - We translate that into a structured issue. + */ +export function parseContentTypeStrict( + value: string | null, +): ParseResult { + if (value == null) { + return { ok: false, issues: [{ code: "EMPTY", message: "Content-Type is missing." }] }; + } + + const parsed = parseMediaType(value); + if (!parsed) { + return { + ok: false, + issues: [{ code: "INVALID_TOKEN", message: "Invalid Content-Type media type syntax.", input: value }], + }; + } + + const [type, params] = parsed; + return { ok: true, value: { type, parameters: Object.assign({}, params) } }; +} + +/** + * Canonicalize a Content-Type header value. + * + * This is handy for diagnostics and stable comparisons. + * It round-trips through parse+format so you do not accidentally compare + * semantically identical types that differ only in whitespace or casing. + * + * @example + * canonicalizeContentType("Text/HTML; Charset=UTF-8") + * // -> { ok: true, value: "text/html; charset=UTF-8" } + */ +export function canonicalizeContentType( + value: string | null, +): ParseResult { + const parsed = parseContentTypeStrict(value); + if (!parsed.ok) return parsed; + + const normalized = formatMediaType(parsed.value.type, parsed.value.parameters); + return { ok: true, value: normalized }; +} + +/* ------------------------------------------------------------------------------------------------- + * Content-Encoding (RFC 9110 token list) — spec-level list parsing + * ------------------------------------------------------------------------------------------------- */ + +/** + * Content codings are a comma-separated list of tokens (ordered), + * where the order represents the sequence of encodings applied to the representation. + * + * Resolver intent: + * - callers commonly want the *outermost* coding (the first token). + * - we return the full list because it’s more truthful and more debuggable. + */ +export type ContentCodingList = readonly string[]; + +export function parseContentEncodingStrict( + value: string | null, +): ParseResult { + if (value == null || value.trim().length === 0) { + return { ok: true, value: [] }; + } + + // Spec model: a list of `token` separated by commas with optional whitespace. + // We do not allow empty items. + const rawItems = value.split(","); + const codings: string[] = []; + + for (let i = 0; i < rawItems.length; i++) { + const item = rawItems[i].trim(); + if (item.length === 0) { + return { + ok: false, + issues: [{ + code: "INVALID_TOKEN", + message: "Invalid Content-Encoding list: empty item.", + index: i, + input: value, + }], + }; + } + + if (!isToken(item)) { + return { + ok: false, + issues: [{ + code: "INVALID_TOKEN", + message: `Invalid Content-Encoding token: ${item}`, + index: i, + input: value, + }], + }; + } + + codings.push(item.toLowerCase()); + } + + return { ok: true, value: codings }; +} + +/** + * Convenience: return the outermost coding, or null when absent. + */ +export function getPrimaryContentCoding( + codings: ContentCodingList, +): string | null { + return codings.length > 0 ? codings[0] : null; +} + +/* ------------------------------------------------------------------------------------------------- + * Content-Disposition (RFC 6266 + RFC 8187) — spec-level strict parser + * ------------------------------------------------------------------------------------------------- */ + +/** + * The parsed shape of Content-Disposition. + * + * We keep both raw and decoded values so policy code can choose: + * - strictly prefer filename* per RFC, + * - fall back to filename when filename* is missing or invalid, + * - or record both for diagnostics. + */ +export type ContentDisposition = { + dispositionType: string; // e.g. "attachment" | "inline" | token + parameters: Record; // decoded, best-effort for strict path + rawParameters: Record; // raw un-decoded parameter values + filename?: string; // chosen (strict preference), already decoded + filenameStar?: string; // decoded value of filename*, if present and valid +}; + +/** + * Parse Content-Disposition strictly (spec-first). + * + * Key spec points encoded here: + * - disposition-type is a token + * - parameters are `;` separated + * - parameter values are token or quoted-string + * - filename* uses RFC 8187 encoding: charset'lang'%xx... + * + * If parsing fails, you get structured issues so policy can decide whether to: + * - hard-fail, + * - or try lenient parsing. + */ +export function parseContentDispositionStrict( + value: string | null, +): ParseResult { + if (value == null) { + return { ok: false, issues: [{ code: "EMPTY", message: "Content-Disposition is missing." }] }; + } + + const input = value.trim(); + if (input.length === 0) { + return { ok: false, issues: [{ code: "EMPTY", message: "Content-Disposition is empty.", input: value }] }; + } + + const state: ParserState = { input, index: 0 }; + const issues: ParseIssue[] = []; + + skipOWS(state); + + const dispositionType = readToken(state, issues); + if (!dispositionType) return { ok: false, issues }; + + skipOWS(state); + + const rawParameters: Record = Object.create(null); + const parameters: Record = Object.create(null); + + while (state.index < state.input.length) { + // Expect ; param=... + if (!consumeChar(state, ";")) { + issues.push({ + code: "TRAILING_DATA", + message: "Unexpected trailing data in Content-Disposition.", + index: state.index, + input: state.input, + }); + break; + } + + skipOWS(state); + + const name = readToken(state, issues); + if (!name) break; + + skipOWS(state); + + if (!consumeChar(state, "=")) { + issues.push({ + code: "INVALID_PARAMETER", + message: `Expected '=' after parameter name: ${name}`, + index: state.index, + input: state.input, + }); + break; + } + + skipOWS(state); + + const rawValue = readTokenOrQuotedString(state, issues); + if (rawValue == null) break; + + rawParameters[name.toLowerCase()] = rawValue; + + // Spec-level decoding: + // - For filename*, decode RFC 8187. + // - For other params, keep the raw value (already unquoted/unescaped for quoted-string). + if (name.toLowerCase() === "filename*") { + const decoded = decodeRfc8187Value(rawValue); + if (!decoded.ok) { + for (const issue of decoded.issues) issues.push(issue); + } else { + parameters[name.toLowerCase()] = decoded.value; + } + } else { + parameters[name.toLowerCase()] = rawValue; + } + + skipOWS(state); + } + + const filenameStar = parameters["filename*"]; + const filename = filenameStar ?? parameters["filename"]; + + if (issues.length > 0) return { ok: false, issues }; + + return { + ok: true, + value: { + dispositionType: dispositionType.toLowerCase(), + parameters, + rawParameters, + filename: filename ?? undefined, + filenameStar: filenameStar ?? undefined, + }, + }; +} + +/** + * Lenient Content-Disposition parsing. + * + * Policy intent: + * - strict path is the source of truth + * - lenient path is a recovery mechanism for real servers + * + * This method only tries a small set of deliberate “reality repairs”: + * - trim outer whitespace + * - tolerate missing disposition-type by assuming "attachment" + * - tolerate unquoted filename with stray semicolons by scanning for filename keys + */ +export function parseContentDispositionLenient( + value: string | null, +): ParseResult { + const strict = parseContentDispositionStrict(value); + if (strict.ok) return strict; + + if (value == null) return strict; + + const input = value.trim(); + if (input.length === 0) return strict; + + // Minimal salvage: scan for filename* and filename patterns. + // This is intentionally limited so the behavior remains predictable. + const rawParameters: Record = Object.create(null); + const parameters: Record = Object.create(null); + + const filenameStarRaw = scanForParamValue(input, "filename*"); + const filenameRaw = scanForParamValue(input, "filename"); + + if (filenameStarRaw != null) { + rawParameters["filename*"] = filenameStarRaw; + const decoded = decodeRfc8187Value(filenameStarRaw); + if (decoded.ok) parameters["filename*"] = decoded.value; + } + + if (filenameRaw != null) { + rawParameters["filename"] = filenameRaw; + parameters["filename"] = stripQuotes(filenameRaw); + } + + const filenameStar = parameters["filename*"]; + const filename = filenameStar ?? parameters["filename"]; + + return { + ok: true, + value: { + dispositionType: "attachment", + parameters, + rawParameters, + filename: filename ?? undefined, + filenameStar: filenameStar ?? undefined, + }, + }; +} + +/** + * Extract a filename from Content-Disposition using strict parsing by default. + * If strict fails, lenient parsing is attempted. + * + * This gives your detection policy a single call site while preserving the + * strict-vs-lenient separation in code and diagnostics. + */ +export function extractFilenameFromContentDisposition( + headerValue: string | null, +): { filename: string | null; mode: "strict" | "lenient"; issues?: readonly ParseIssue[] } { + const strict = parseContentDispositionStrict(headerValue); + if (strict.ok) { + return { filename: strict.value.filename ?? null, mode: "strict" }; + } + + const lenient = parseContentDispositionLenient(headerValue); + if (lenient.ok) { + return { filename: lenient.value.filename ?? null, mode: "lenient", issues: strict.issues }; + } + + return { filename: null, mode: "strict", issues: strict.issues }; +} + +/* ------------------------------------------------------------------------------------------------- + * RFC 8187 value decoding (charset'lang'percent-encoded) + * ------------------------------------------------------------------------------------------------- */ + +export function decodeRfc8187Value( + value: string, +): ParseResult { + // The value is expected to be: charset'lang'%XX%YY... + // We treat missing parts as invalid in strict mode. + const firstQuote = value.indexOf("'"); + const secondQuote = firstQuote === -1 ? -1 : value.indexOf("'", firstQuote + 1); + + if (firstQuote === -1 || secondQuote === -1) { + return { + ok: false, + issues: [{ + code: "INVALID_EXT_VALUE", + message: "Invalid extended parameter value (expected charset'lang'value).", + input: value, + }], + }; + } + + const charset = value.slice(0, firstQuote).trim().toLowerCase(); + // lang is informational; we parse but do not enforce. + const _lang = value.slice(firstQuote + 1, secondQuote).trim(); + const encoded = value.slice(secondQuote + 1); + + if (charset !== "utf-8" && charset !== "iso-8859-1") { + return { + ok: false, + issues: [{ + code: "UNSUPPORTED_CHARSET", + message: `Unsupported extended parameter charset: ${charset}`, + input: value, + }], + }; + } + + // Percent-decoding: decodeURIComponent handles UTF-8 percent sequences. + // For iso-8859-1, decodeURIComponent is not correct for all bytes, but many servers + // use UTF-8 in practice. If you need true ISO-8859-1 fidelity, implement byte mapping. + try { + return { ok: true, value: decodeURIComponent(encoded) }; + } catch { + return { + ok: false, + issues: [{ + code: "INVALID_PERCENT_ENCODING", + message: "Invalid percent-encoding in extended parameter value.", + input: value, + }], + }; + } +} + +/* ------------------------------------------------------------------------------------------------- + * Internal parsing primitives + * ------------------------------------------------------------------------------------------------- */ + +export type ParserState = { + input: string; + index: number; +}; + +/** + * @internal + * + * OWS = optional whitespace. In HTTP header ABNF, this typically means SP / HTAB. + */ +export function skipOWS(state: ParserState): void { + const s = state.input; + while (state.index < s.length) { + const c = s.charCodeAt(state.index); + if (c === 0x20 || c === 0x09) { + state.index++; + continue; + } + break; + } +} + +/** + * @internal + * + * token per RFC “tchar” set. We keep this strict because it’s the source of truth layer. + */ +export function isToken(value: string): boolean { + if (value.length === 0) return false; + + for (let i = 0; i < value.length; i++) { + const ch = value.charCodeAt(i); + if (!isTchar(ch)) return false; + } + + return true; +} + +/** + * @internal + * + * tchar set based on RFC 7230 / RFC 9110 token grammar: + * "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + * "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + */ +export function isTchar(ch: number): boolean { + // DIGIT + if (ch >= 0x30 && ch <= 0x39) return true; + // ALPHA + if (ch >= 0x41 && ch <= 0x5a) return true; + if (ch >= 0x61 && ch <= 0x7a) return true; + + switch (ch) { + case 0x21: // ! + case 0x23: // # + case 0x24: // $ + case 0x25: // % + case 0x26: // & + case 0x27: // ' + case 0x2a: // * + case 0x2b: // + + case 0x2d: // - + case 0x2e: // . + case 0x5e: // ^ + case 0x5f: // _ + case 0x60: // ` + case 0x7c: // | + case 0x7e: // ~ + return true; + default: + return false; + } +} + +/** + * @internal + * + * Consume a specific character if present. + */ +export function consumeChar(state: ParserState, ch: string): boolean { + if (state.input[state.index] === ch) { + state.index++; + return true; + } + return false; +} + +/** + * @internal + * + * Read a token starting at the current position. + */ +export function readToken( + state: ParserState, + issues: ParseIssue[], +): string | null { + const start = state.index; + const s = state.input; + + while (state.index < s.length) { + const ch = s.charCodeAt(state.index); + if (!isTchar(ch)) break; + state.index++; + } + + const out = s.slice(start, state.index); + if (out.length === 0) { + issues.push({ + code: "INVALID_TOKEN", + message: "Expected token.", + index: state.index, + input: state.input, + }); + return null; + } + + return out; +} + +/** + * @internal + * + * Read either a token or a quoted-string. + * + * Quoted-string rules: + * - starts with " + * - supports backslash escaping for quotes and backslash itself + * + * We keep this strict and predictable. Policy can choose lenient mode elsewhere. + */ +export function readTokenOrQuotedString( + state: ParserState, + issues: ParseIssue[], +): string | null { + const s = state.input; + + if (s[state.index] === "\"") { + return readQuotedString(state, issues); + } + + const token = readToken(state, issues); + return token; +} + +/** + * @internal + */ +export function readQuotedString( + state: ParserState, + issues: ParseIssue[], +): string | null { + const s = state.input; + + if (!consumeChar(state, "\"")) { + issues.push({ + code: "INVALID_QUOTED_STRING", + message: "Expected opening quote for quoted-string.", + index: state.index, + input: s, + }); + return null; + } + + let out = ""; + + while (state.index < s.length) { + const ch = s[state.index]; + + if (ch === "\"") { + state.index++; + return out; + } + + if (ch === "\\") { + state.index++; + if (state.index >= s.length) { + issues.push({ + code: "INVALID_QUOTED_STRING", + message: "Backslash escape at end of quoted-string.", + index: state.index, + input: s, + }); + return null; + } + + // Accept the escaped char verbatim. + out += s[state.index]; + state.index++; + continue; + } + + out += ch; + state.index++; + } + + issues.push({ + code: "INVALID_QUOTED_STRING", + message: "Unterminated quoted-string.", + index: state.index, + input: s, + }); + + return null; +} + +/* ------------------------------------------------------------------------------------------------- + * Lenient helpers (deliberately limited) + * ------------------------------------------------------------------------------------------------- */ + +/** + * @internal + * + * Scan for a parameter in a semi-colon-ish string. This is not a full parser. + * It exists only to provide a controlled fallback in lenient mode. + */ +export function scanForParamValue(input: string, paramName: string): string | null { + const lower = input.toLowerCase(); + const key = paramName.toLowerCase() + "="; + + const idx = lower.indexOf(key); + if (idx === -1) return null; + + const start = idx + key.length; + const rest = input.slice(start).trimStart(); + + // If it starts with a quote, read until next quote. Otherwise read until semicolon. + if (rest.startsWith("\"")) { + const end = rest.indexOf("\"", 1); + if (end === -1) return rest.slice(1); + return rest.slice(1, end); + } + + const semi = rest.indexOf(";"); + return semi === -1 ? rest.trim() : rest.slice(0, semi).trim(); +} + +/** + * @internal + */ +export function stripQuotes(value: string): string { + return value.replace(/^"(.*)"$/, "$1"); +} diff --git a/packages/utils/assert.ts b/packages/utils/assert.ts new file mode 100644 index 0000000..1cd9842 --- /dev/null +++ b/packages/utils/assert.ts @@ -0,0 +1 @@ +export * from "@std/assert"; \ No newline at end of file diff --git a/packages/utils/async.ts b/packages/utils/async.ts new file mode 100644 index 0000000..8bc0050 --- /dev/null +++ b/packages/utils/async.ts @@ -0,0 +1 @@ +export * from "@std/async"; \ No newline at end of file diff --git a/packages/utils/bytes.ts b/packages/utils/bytes.ts new file mode 100644 index 0000000..55a867f --- /dev/null +++ b/packages/utils/bytes.ts @@ -0,0 +1 @@ +export * from "@std/bytes" \ No newline at end of file diff --git a/packages/utils/deep-object.ts b/packages/utils/deep-object.ts new file mode 100644 index 0000000..1d97296 --- /dev/null +++ b/packages/utils/deep-object.ts @@ -0,0 +1,42 @@ +import { diff, patch } from "@opentf/obj-diff"; +import { deepMerge } from "@std/collections"; +import { equal } from "@std/assert"; + +export * from "@opentf/obj-diff"; + +export const isObject = (obj: unknown) => typeof obj === "object" && obj != null; +export const isPrimitive = (val: unknown) => (typeof val === "object" ? val === null : typeof val !== "function"); +export const isValidKey = (key: unknown) => { + return key !== "__proto__" && key !== "constructor" && key !== "prototype"; +}; + +// Based on https://gist.github.com/egardner/efd34f270cc33db67c0246e837689cb9 +// Deep Equality comparison example +// +// This is an example of how to implement an object-comparison function in +// JavaScript (ES5+). A few points of interest here: +// +// * You can get an array of all an object's properties in ES5+ by calling +// the class method Object.keys(obj). +// * The function recursively calls itself in the for / in loop when it +// compares the contents of each property +// * You can hide a "private" function inside a function of this kind by +// placing one function declaration inside of another. The inner function +// is not hoisted out into the global scope, so it is only visible inside +// of the parent function. +// * The reason this nested helper function is necessary is that +// `typeof null` is still "object" in JS, a major "gotcha" to watch out for. +// +export function deepEqual(obj1: any, obj2: any) { + return equal(obj1, obj2); +}; + +/** Compares 2 objects and only keep the keys that are different in both objects */ +export const deepDiff = (obj1: any, obj2: any) => { + return patch({}, diff(obj1, obj2)); +}; + +export const deepAssign = deepMerge; +export { deepMerge }; + +export type { DeepMerge, DeepMergeOptions } from "@std/collections"; \ No newline at end of file diff --git a/packages/utils/deno.jsonc b/packages/utils/deno.jsonc new file mode 100644 index 0000000..528a351 --- /dev/null +++ b/packages/utils/deno.jsonc @@ -0,0 +1,65 @@ +{ + "name": "@bundle/utils", + "version": "0.0.0", + "tasks": { + "dev": "deno run -A --watch _repl.ts" + }, + "exports": { + ".": "./mod.ts", + "./ansi": "./ansi.ts", + "./assert": "./assert.ts", + "./async": "./async.ts", + "./deep-object": "./deep-object.ts", + "./encode-decode": "./encode-decode.ts", + "./encoding": "./encoding.ts", + "./fetch-and-cache": "./fetch-and-cache.ts", + "./fmt": "./fmt.ts", + "./html": "./html.ts", + "./json5": "./json5.ts", + "./json-stream": "./json-stream.ts", + "./bytes": "./bytes.ts", + "./tar": "./tar.ts", + "./lru": "./lru.ts", + "./archive-spec": "./archive-spec.ts", + "./archive-detect": "./archive-detect.ts", + "./npm-spec": "./npm-spec.ts", + "./npm-search": "./npm-search.ts", + "./npmrc": "./npmrc.ts", + "./jsr-spec": "./jsr-spec.ts", + "./lz-string": "./lz-string.ts", + "./media-types": "./media-types.ts", + "./outdent": "./outdent.ts", + "./parse-package-name": "./parse-package-name.ts", + "./resolve-exports-imports": "./resolve-exports-imports.ts", + "./resolve-conditions": "./resolve-conditions.ts", + "./resolve-import-map": "./resolve-import-map.ts", + "./runtime-builtins": "./runtime-builtins.ts", + "./validate-package-name": "./validate-package-name.ts", + "./dispose": "./dispose.ts", + "./path": "./path.ts", + "./semver": "./semver.ts", + "./types": "./types.ts", + "./url": "./url.ts" + }, + "imports": { + "@opentf/obj-diff": "jsr:@opentf/obj-diff@^0", + "@nick/dispose": "jsr:@nick/dispose@^1", + "@std/assert": "jsr:@std/assert@^1", + "@std/async": "jsr:@std/async@^1", + "@std/bytes": "jsr:@std/bytes@^1", + "@std/cache": "jsr:@std/cache@^0", + "@std/collections": "jsr:@std/collections@^1", + "@std/encoding": "jsr:@std/encoding@^1", + "@std/fmt": "jsr:@std/fmt@^1", + "@std/html": "jsr:@std/html@^1", + "@std/json": "jsr:@std/json@^1", + "@std/jsonc": "jsr:@std/jsonc@^1", + "@std/media-types": "jsr:@std/media-types@^1", + "@std/path": "jsr:@std/path@^1", + "@std/semver": "jsr:@std/semver@^1", + "@std/tar": "jsr:@std/tar@^0", + "@okikio/undent": "jsr:@okikio/undent@^0", + "resolve.exports": "npm:resolve.exports@^2", + "json5": "npm:json5@^2" + } +} \ No newline at end of file diff --git a/packages/utils/dispose.ts b/packages/utils/dispose.ts new file mode 100644 index 0000000..40e5fef --- /dev/null +++ b/packages/utils/dispose.ts @@ -0,0 +1 @@ +export * from "@nick/dispose"; \ No newline at end of file diff --git a/packages/utils/encode-decode.ts b/packages/utils/encode-decode.ts new file mode 100644 index 0000000..74cd907 --- /dev/null +++ b/packages/utils/encode-decode.ts @@ -0,0 +1,5 @@ +// export const { encode } = new TextEncoder(); +// export const { decode } = new TextDecoder(); + +export const encode = (str: string) => new TextEncoder().encode(str); +export const decode = (buf: BufferSource) => new TextDecoder().decode(buf); \ No newline at end of file diff --git a/packages/utils/encoding.ts b/packages/utils/encoding.ts new file mode 100644 index 0000000..8ed1b9d --- /dev/null +++ b/packages/utils/encoding.ts @@ -0,0 +1,15 @@ +export * from "@std/encoding/ascii85"; +export * from "@std/encoding/base32"; +export * from "@std/encoding/base58"; +export * from "@std/encoding/base64"; +export * from "@std/encoding/base64url"; +export * from "@std/encoding/hex"; +export * from "@std/encoding/varint"; + +export * as ascii85 from "@std/encoding/ascii85"; +export * as base32 from "@std/encoding/base32"; +export * as base58 from "@std/encoding/base58"; +export * as base64 from "@std/encoding/base64"; +export * as base64url from "@std/encoding/base64url"; +export * as hex from "@std/encoding/hex"; +export * as varint from "@std/encoding/varint"; diff --git a/packages/utils/fetch-and-cache.ts b/packages/utils/fetch-and-cache.ts new file mode 100644 index 0000000..46f8fe4 --- /dev/null +++ b/packages/utils/fetch-and-cache.ts @@ -0,0 +1,673 @@ +import type { AsyncDisposableStack } from "@bundle/utils/dispose"; +import { LruCache } from "./lru.ts"; + +/** + * Fetch-and-cache module with proper redirect handling. + * + * **Key design decision**: Always cache under the *final* URL (`response.url`), + * not the original request URL. This ensures: + * + * 1. Relative imports resolve correctly (bundlers need the final URL as base) + * 2. No stale redirect targets when CDN aliases like `@latest` change + * 3. Direct requests to final URLs hit cache + * + * For efficiency, we also maintain a redirect map so requests to aliased URLs + * can find cached content without re-fetching. + * + * @example How Redirects Now Work + * ``` + * Request: https://esm.sh/lodash@latest + * ↓ + * fetch-and-cache: fetch with redirect:'follow' + * ↓ + * Response.url: https://esm.sh/lodash@4.17.21 + * ↓ + * Cache: stores under final URL + records redirect mapping + * ↓ + * http.ts: receives { url: "https://esm.sh/lodash@4.17.21", content, ... } + * ↓ + * pluginData.url = final URL + * ↓ + * Relative import "./debounce" resolves against final URL ✓ + * ``` + */ + +// ============================================================================ +// Configuration +// ============================================================================ + +/** LRU cache capacity for responses */ +export const CACHE_CAPACITY = 300; + +/** LRU cache capacity for redirect mappings */ +export const REDIRECT_MAP_CAPACITY = 500; + +/** Default retry attempts for failed requests */ +export const DEFAULT_RETRIES = 1; + +// ============================================================================ +// Caches +// ============================================================================ + +/** In-memory LRU cache for responses, keyed by final URL */ +export const responseCache = new LruCache(CACHE_CAPACITY); + +/** + * Maps original URLs to their final redirect targets. + * Enables cache hits when requesting aliased URLs (e.g., `lodash@latest` → `lodash@4.17.21`) + */ +export const redirectMap = new LruCache(REDIRECT_MAP_CAPACITY); + +/** Cache API storage name */ +export const CACHE_NAME = "EXTERNAL_FETCHES"; + +/** Feature detection */ +export const SUPPORTS_CACHE_API = "caches" in globalThis; +export const SUPPORTS_REQUEST_API = "Request" in globalThis; + +// ============================================================================ +// Types +// ============================================================================ + +export interface FetchResult { + /** The final URL after any redirects */ + url: string; + /** The response (cloned if requested) */ + response: Response; + /** Whether this came from cache */ + fromCache: boolean; + /** Whether a redirect occurred */ + redirected: boolean; + /** + * Background stale-while-revalidate refresh promise, if one was fired. + * + * When a cached response triggers a background refresh, this promise + * tracks that work. Callers can await it for deterministic cleanup, + * or ignore it if the `scope` option already adopted the promise. + */ + pending?: Promise; +} + +export interface FetchOptions { + /** Additional fetch options (headers, method, etc.) */ + init?: RequestInit; + /** Number of retry attempts on failure. Default: 2 */ + retries?: number; + /** + * Cache behavior: + * - 'normal': Use cache, update in background (stale-while-revalidate) + * - 'force': Always use cache if available, never revalidate + * - 'reload': Bypass cache, fetch fresh, update cache + * - 'no-store': Bypass cache entirely, don't store response + * Default: 'normal' + */ + cacheMode?: 'normal' | 'force' | 'reload' | 'no-store'; + /** + * Abort signal scoped to the build's lifetime. + * + * When provided, background stale-while-revalidate refreshes carry this + * signal so they are cancelled when the build finishes. This prevents + * Deno's test-runner sanitizer from detecting leaked `fetchCancelHandle` + * or `op_cache_put` resources. + * + * The signal is **only** applied to background refreshes — primary + * (awaited) fetches are not affected, so in-progress builds won't be + * interrupted. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal + */ + signal?: AbortSignal; + /** + * Per-build disposal scope (`AsyncDisposableStack`). + * + * When provided, background stale-while-revalidate refresh promises are + * **adopted** into this scope via `scope.adopt(bgPromise, awaiter)`. This + * ensures `disposeAsync()` awaits settlement of every in-flight background + * operation — including `cache.put()` calls that `AbortSignal` alone + * cannot cancel — before the build boundary closes. + * + * Without this, fire-and-forget `backgroundRefresh` promises can outlive + * the build and leak Deno runtime ops (`op_cache_put`, `fetchCancelHandle`). + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack/adopt + */ + scope?: AsyncDisposableStack; +} + +// ============================================================================ +// Cache API wrapper +// ============================================================================ + +let openCachePromise: Promise | null = null; + +/** + * Opens the Cache API storage (singleton pattern) + */ +export function openCache(): Promise { + if (!SUPPORTS_CACHE_API) { + throw new Error("Cache API not supported in this environment"); + } + if (!openCachePromise) { + openCachePromise = caches.open(CACHE_NAME); + } + return openCachePromise; +} + +// ============================================================================ +// In-flight request deduplication +// ============================================================================ + +/** + * Capacity for the in-flight request deduplication map. + * + * Limits memory when many unique URLs are fetched concurrently. + * Once full, the oldest entry is evicted (LRU) so a duplicate request + * will simply start a fresh fetch — correctness is preserved, only the + * dedup optimisation is lost. + */ +export const INFLIGHT_CAPACITY = 200; + +/** + * Maps a URL to its **in-progress** network fetch promise. + * + * When two callers request the same URL before the first fetch settles, + * the second caller joins the existing promise instead of issuing a + * duplicate HTTP request. The entry is removed as soon as the promise + * settles so subsequent requests go to the normal cache-or-fetch path. + * + * This also bounds the number of concurrent fire-and-forget + * `backgroundRefresh` calls: if a background refresh for URL X is already + * in progress, the next stale-while-revalidate hit simply joins the + * existing refresh rather than spawning a second one. + */ +export const inflight = new LruCache>(INFLIGHT_CAPACITY); + +// ============================================================================ +// Core fetch implementation +// ============================================================================ + +/** + * Resolves a URL through the redirect map to find the final cached URL. + * Returns the original URL if no redirect mapping exists. + */ +function resolveRedirect(url: string): string { + return redirectMap.get(url) ?? url; +} + +/** + * Looks up a URL in cache, checking both the direct URL and any redirect mappings. + */ +async function lookupCache(url: string, cacheApi?: Cache): Promise { + const finalUrl = resolveRedirect(url); + + if (SUPPORTS_CACHE_API && cacheApi) { + const cached = await cacheApi.match(finalUrl); + if (cached) return cached; + } + + return responseCache.get(finalUrl); +} + +/** + * Stores a response in cache under the final URL. + * If a redirect occurred, also stores the mapping from original → final URL. + */ +async function storeInCache( + originalUrl: string, + finalUrl: string, + response: Response, + cacheApi?: Cache, + signal?: AbortSignal, +): Promise { + // Only cache successful GET responses + // Note: response.ok is false for 3xx, but fetch with redirect:'follow' + // resolves with the final 2xx response, so we're caching the final response + if (!response.ok) { + // Cancel unconsumed body to prevent resource leak. + // Must be awaited so `CacheResponseResource` handles are released + // before the caller's scope closes. + await response.body?.cancel(); + return; + } + + // If the build finished (signal aborted) between fetch-complete and + // cache-write, skip the write to avoid starting an `op_cache_put` that + // would outlive the caller. + if (signal?.aborted) { + await response.body?.cancel(); + return; + } + + try { + if (SUPPORTS_CACHE_API && cacheApi) { + // Cache API: store under final URL + await cacheApi.put(new Request(finalUrl), response); + } else { + // In-memory fallback: store under final URL + responseCache.set(finalUrl, response); + } + + // Track redirect mapping if URLs differ + if (originalUrl !== finalUrl) { + redirectMap.set(originalUrl, finalUrl); + } + } catch (err) { + // Swallow errors that occur because the signal fired mid-write + if (signal?.aborted) return; + console.error(`[cache] Failed to store response for ${finalUrl}:`, err); + } +} + +/** + * Performs the actual network fetch with retry logic. + */ +function doFetch( + url: string, + init: RequestInit = {}, + _retries: number +): Promise { + const fetchWithRedirect = async () => { + const response = await fetch(url, { + redirect: "follow", + ...init, + }); + + if (!response.ok) { + // Cancel the body before throwing — prevents leaked response streams + // when the caller never reads the body of a failed request. + // Awaited to ensure `CacheResponseResource` is fully released. + await response.body?.cancel(); + throw new Error(`HTTP ${response.status}: ${response.statusText} for ${url}`); + } + + return response; + }; + + // TODO: re-enable retry logic + // if (_retries > 0) { + // return retry(fetchWithRedirect, { maxAttempts: _retries + 1 }); + // } + + return fetchWithRedirect(); +} + +/** + * Background refresh with smart fallback for stale-while-revalidate. + * + * Tries the original URL first to discover version updates (e.g., @latest → @4.17.22). + * Falls back to the final URL if original returns 404 (e.g., extensionless URLs). + * + * This handles two cases: + * 1. Version aliases (@latest): original URL works, may resolve to newer version + * 2. Extension probing (dbcs-codec → dbcs-codec.js): original 404s, use final URL + */ +async function backgroundRefresh( + originalUrl: string, + finalUrl: string, + init: RequestInit, + retries: number, + cacheApi?: Cache +): Promise { + // Bail early if already aborted (e.g. build finished before refresh started) + if (init.signal?.aborted) return; + + try { + // Try original URL first — this allows version discovery for @latest etc. + const response = await doFetch(originalUrl, init, retries); + + // Check abort *after* fetch completes but *before* the cache write. + // Avoids starting an `op_cache_put` that would outlive the caller. + if (init.signal?.aborted) { + await response.body?.cancel(); + return; + } + + const resolvedUrl = response.url || originalUrl; + await storeInCache(originalUrl, resolvedUrl, response, cacheApi, init.signal ?? undefined); + } catch (err) { + // Silently swallow abort errors — expected during build cleanup + if (err instanceof DOMException && err.name === "AbortError") return; + if (init.signal?.aborted) return; + + // If original URL failed with 404 and we have a different final URL, + // try the final URL (handles extension probing case) + const is404 = err instanceof Error && err.message.includes("404"); + + if (is404 && originalUrl !== finalUrl) { + try { + if (init.signal?.aborted) return; + + const response = await doFetch(finalUrl, init, retries); + + if (init.signal?.aborted) { + await response.body?.cancel(); + return; + } + + const resolvedUrl = response.url || finalUrl; + await storeInCache(finalUrl, resolvedUrl, response, cacheApi, init.signal ?? undefined); + } catch (fallbackErr) { + if (fallbackErr instanceof DOMException && fallbackErr.name === "AbortError") return; + if (init.signal?.aborted) return; + + // Both failed — log but don't throw (background operation) + console.error(`[cache] Background refresh failed for ${finalUrl}:`, fallbackErr); + } + } else { + // Not a 404 or no fallback available — log the original error + console.error(`[cache] Background refresh failed for ${originalUrl}:`, err); + } + } +} + +/** + * Main fetch function with caching and proper redirect handling. + * + * **Important**: The returned `url` is the *final* URL after redirects. + * Use this for resolving relative imports in fetched content. + * + * @example + * ```ts + * // Basic usage + * const { url, response } = await fetchWithCache('https://esm.sh/lodash@latest'); + * // url is now 'https://esm.sh/lodash@4.17.21' (the final URL) + * + * // The final URL is critical for relative imports: + * // If lodash/index.js imports './debounce', resolve against the final URL + * const debounceUrl = new URL('./debounce', url).toString(); + * ``` + */ +export async function fetchWithCache( + url: string, + options: FetchOptions = {} +): Promise { + const { + init = {}, + retries = DEFAULT_RETRIES, + cacheMode = 'normal', + } = options; + + // Only cache GET requests (not HEAD, POST, etc.) + const method = init.method?.toUpperCase() ?? 'GET'; + const canCache = method === 'GET' && cacheMode !== 'no-store'; + + const cacheApi = SUPPORTS_CACHE_API ? await openCache() : undefined; + + // Check cache first (unless reload mode) + if (canCache && cacheMode !== 'reload') { + const cached = await lookupCache(url, cacheApi); + + if (cached) { + const finalUrl = resolveRedirect(url); + + // Stale-while-revalidate: return cached, refresh in background. + // + // When an AbortSignal is provided (per-build lifecycle), the signal + // is threaded into the background fetch so it is cancelled when the + // build finishes — preventing Deno's sanitizer from detecting + // leaked `fetchCancelHandle` / `op_cache_put` resources. + // + // When no signal is provided, the refresh is plain fire-and-forget + // — fine for standalone / REPL usage where leak detection isn't a + // concern. + // Track the background refresh promise (if any) so callers and the + // disposal scope can await its settlement. + let pending: Promise | undefined; + + if (cacheMode === 'normal' && options.signal) { + // Background refresh requires a signal for lifecycle management. + // Without a signal the fetch cannot be cancelled on disposal, + // causing `fetchCancelHandle` resources to leak across test/build + // boundaries. Callers that don't provide a signal (e.g. REPL) + // simply skip the refresh — stale cached data is returned instead. + const refreshInit: RequestInit = { ...init, signal: options.signal }; + + pending = backgroundRefresh(url, finalUrl, refreshInit, retries, cacheApi); + + // Adopt the background promise into the build's disposal scope. + // When `disposeAsync()` runs, the abort signal fires first (LIFO: + // abort was registered last → runs first), cancelling in-flight + // fetches. Then the adopt awaiter drains the settling promise, + // ensuring `cache.put()` ops that were already in progress + // complete before the test/build boundary closes. + if (options.scope && !options.scope.disposed) { + options.scope.adopt( + pending, + async (p) => { try { await p; } catch { /* swallow — abort errors are expected */ } }, + ); + } + } + + // Consume-and-reconstruct: read the full body from the cache response, + // then build a fresh Response from the consumed bytes. + // + // Why not clone()? + // Response.clone() uses ReadableStream.tee() internally. In Deno, + // cancelling one tee branch of a CacheResponseResource-backed stream + // can deadlock when the other branch hasn't been consumed yet. + // By fully consuming the body here we release the native + // CacheResponseResource handle immediately — no tee, no cancel, + // no deadlock. + // + // Memory cost is negligible: these are source-code files that would + // be buffered by tee() anyway, and the consumer reads the full body + // shortly after. + const body = await cached.arrayBuffer(); + const returnResponse = new Response(body, { + headers: cached.headers, + status: cached.status, + statusText: cached.statusText, + }); + + return { + url: finalUrl, + response: returnResponse, + fromCache: true, + redirected: url !== finalUrl, + pending, + }; + } + } + + // ----------------------------------------------------------------- + // Network fetch with request deduplication + // ----------------------------------------------------------------- + // If another caller is already fetching the same URL, join that + // promise instead of issuing a duplicate HTTP request. + // When the shared fetch settles we read the result from cache so + // every caller gets its own clone and the response body isn't + // consumed twice. + // ----------------------------------------------------------------- + + const existing = inflight.get(url); + if (existing) { + // Join the in-flight request, then read the result from cache. + try { await existing; } catch { /* original caller handles the error */ } + + const deduped = await lookupCache(url, cacheApi); + if (deduped) { + const finalUrl = resolveRedirect(url); + + // Consume-and-reconstruct (same rationale as the primary cache-hit + // path above): fully read the body so the CacheResponseResource + // handle is released, then return a fresh Response. + const body = await deduped.arrayBuffer(); + const returnResponse = new Response(body, { + headers: deduped.headers, + status: deduped.status, + statusText: deduped.statusText, + }); + + return { + url: finalUrl, + response: returnResponse, + fromCache: true, + redirected: url !== finalUrl, + }; + } + // Cache miss after dedup — fall through to a fresh fetch below. + } + + // Start the authoritative fetch and register it in the inflight map + // so concurrent callers can join. + const fetchPromise = doFetch(url, init, retries); + inflight.set(url, fetchPromise); + + let response: Response; + try { + response = await fetchPromise; + } finally { + inflight.delete(url); + } + + const finalUrl = response.url || url; + const redirected = response.redirected || url !== finalUrl; + + // Make ONE clone for cache (because cache.put consumes the body) + if (canCache) { + // Pass the clone into caching, so storeInCache DOES NOT clone again. + await storeInCache(url, finalUrl, response.clone(), cacheApi); + } + + // Return the original response. + // This keeps exactly two branches total: (1) cache clone, (2) original return. + return { + url: finalUrl, + response, + fromCache: false, + redirected, + }; +} + +// ============================================================================ +// Convenience functions +// ============================================================================ + +/** + * Fetches content and returns it as a Uint8Array along with metadata. + * This is the most common pattern for bundlers. + */ +export async function fetchContent( + url: string, + options: FetchOptions = {} +): Promise<{ + url: string; + content: Uint8Array; + contentType: string | null; + fromCache: boolean; + redirected: boolean; +}> { + const { url: finalUrl, response, fromCache, redirected } = await fetchWithCache(url, options); + const contentType = response.headers.get("content-type"); + const content = new Uint8Array(await response.arrayBuffer()); + + return { url: finalUrl, content, contentType, fromCache, redirected }; +} + +/** + * Fetches only headers (useful for probing URLs without downloading content). + * Uses HEAD request with GET fallback for servers that don't support HEAD. + */ +export async function fetchHeaders( + url: string, + options: Omit = {} +): Promise<{ + url: string; + contentType: string | null; + fromCache: boolean; +}> { + const { init = {}, retries = 0, cacheMode = 'normal', signal } = options; + + // Try HEAD first + try { + const { url: finalUrl, response, fromCache } = await fetchWithCache(url, { + init: { ...init, method: 'HEAD' }, + retries: 0, + cacheMode, + signal, + }); + + const contentType = response.headers.get("content-type"); + + return { url: finalUrl, contentType, fromCache }; + } catch (_) { + // HEAD failed, try GET with immediate body cancellation + const { url: finalUrl, response, fromCache } = await fetchWithCache(url, { + init: { ...init, method: 'GET' }, + retries, + cacheMode: 'no-store', // Don't cache partial responses + signal, + }); + + const contentType = response.headers.get("content-type"); + + // Cancel body immediately - we only wanted headers + try { await response.body?.cancel(); } catch { /* ignore cancel errors */ } + + if (contentType && /text\/html/i.test(contentType)) { + throw new Error(`Received HTML instead of expected content for ${finalUrl}`); + } + + return { url: finalUrl, contentType, fromCache }; + } +} + +// ============================================================================ +// Cache management +// ============================================================================ + +/** + * Clears all cached data (both in-memory and Cache API) + */ +export async function clearCache(): Promise { + responseCache.clear(); + redirectMap.clear(); + inflight.clear(); + + if (SUPPORTS_CACHE_API) { + try { + await caches.delete(CACHE_NAME); + } catch (e) { console.error(e) } + openCachePromise = null; + } +} + +/** + * Removes a specific URL from cache (both original and final URL mappings) + */ +export async function invalidate(url: string): Promise { + const finalUrl = resolveRedirect(url); + + responseCache.delete(finalUrl); + redirectMap.delete(url); + inflight.delete(url); + + if (SUPPORTS_CACHE_API) { + try { + const cache = await openCache(); + await cache.delete(finalUrl); + } catch (e) { console.error(e) } + } +} + +// ============================================================================ +// Legacy API compatibility (deprecated, use new functions) +// ============================================================================ + +/** + * @deprecated Use `fetchWithCache` instead + */ +export async function getRequest( + url: RequestInfo | URL, + opts: { cacheMode?: FetchOptions['cacheMode']; retry?: number; fetchOpts?: RequestInit } = {} +): Promise { + const urlString = url.toString(); + + const { response } = await fetchWithCache(urlString, { + init: opts.fetchOpts, + retries: opts.retry, + cacheMode: opts.cacheMode, + }); + + return response; +} \ No newline at end of file diff --git a/packages/utils/fmt.ts b/packages/utils/fmt.ts new file mode 100644 index 0000000..bae9c72 --- /dev/null +++ b/packages/utils/fmt.ts @@ -0,0 +1,4 @@ +export * as bytes from "@std/fmt/bytes"; +export * as duration from "@std/fmt/duration"; +export * from "@std/fmt/colors"; +export * from "@std/fmt/printf"; \ No newline at end of file diff --git a/packages/utils/html.ts b/packages/utils/html.ts new file mode 100644 index 0000000..48b9135 --- /dev/null +++ b/packages/utils/html.ts @@ -0,0 +1 @@ +export * from "@std/html"; \ No newline at end of file diff --git a/packages/utils/json-streams.ts b/packages/utils/json-streams.ts new file mode 100644 index 0000000..39f51d2 --- /dev/null +++ b/packages/utils/json-streams.ts @@ -0,0 +1,2 @@ +export * from "@std/json"; +export * from "@std/jsonc"; \ No newline at end of file diff --git a/packages/utils/json5.ts b/packages/utils/json5.ts new file mode 100644 index 0000000..2d8688f --- /dev/null +++ b/packages/utils/json5.ts @@ -0,0 +1,28 @@ +/** + * Based on https://deno.land/x/json5@v1.0.0/mod.ts + */ +import JSON5_MOD from "json5"; + +/** + * Converts a JSON5 string into an object. + * @template T Type of return value. + * @param text A valid JSON string. + * @param reviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is. + */ +export function parse(text: string, reviver?: ((this: any, key: string, value: any) => any) | null): T { + return JSON5_MOD.parse(text, reviver); +} + +/** + * Converts a JavaScript value to a JSON5 string. + * @param value A JavaScript value, usually an object or array, to be converted. + * @param replacer A function that transforms the results. + * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. + */ +export function stringify(value: any, replacer?: ((this: any, key: string, value: any) => any) | null, space?: string | number | undefined): string { + return JSON5_MOD.stringify(value, replacer, parseInt(space + '') || undefined); +} + +// defaults +const JSON5 = { parse, stringify }; +export default JSON5; \ No newline at end of file diff --git a/packages/utils/jsr-spec.ts b/packages/utils/jsr-spec.ts new file mode 100644 index 0000000..e24a0e8 --- /dev/null +++ b/packages/utils/jsr-spec.ts @@ -0,0 +1,1035 @@ +/** + * JSR (jsr.io) Registry Utilities + * + * Complete utilities for working with the JSR (JavaScript Registry): + * - Package specifier parsing with validation + * - Direct registry API access (no esm.sh proxy needed) + * - Package metadata and version resolution + * - Search functionality + * - npm compatibility layer support + * + * JSR is a modern TypeScript-first registry that works with Deno, Node.js, + * Bun, browsers, and more. Unlike npm, JSR packages are always scoped. + * + * ## API Summary + * + * JSR has three distinct APIs: + * 1. **Registry API** (jsr.io) - For downloading modules and package metadata + * - Module: `https://jsr.io/@///` + * - Package meta: `https://jsr.io/@//meta.json` + * - Version meta: `https://jsr.io/@//_meta.json` + * + * 2. **npm Compatibility API** (npm.jsr.io) - For npm-compatible package managers + * - Package: `https://npm.jsr.io/@jsr/__` + * + * 3. **Management API** (api.jsr.io) - For publishing and search (NOT for registry operations) + * - Search: `https://api.jsr.io/packages?query=...` + * + * @module + * + * @example Basic usage + * ```ts + * import { + * parseJSRSpec, + * isJSRSpec, + * getJSRModuleUrl, + * resolveJSRVersion, + * } from "./jsr.ts"; + * + * // Parse a JSR specifier + * const spec = parseJSRSpec("jsr:@std/path@^1.0.0/posix"); + * // { kind: "jsr", scope: "std", name: "path", version: "^1.0.0", subpath: "/posix" } + * + * // Check if a string is a JSR specifier + * isJSRSpec("jsr:@std/path") // true + * isJSRSpec("npm:lodash") // false + * + * // Get direct module URL (no proxy needed!) + * getJSRModuleUrl("std", "path", "1.0.0", "/mod.ts") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * + * // Resolve version ranges + * await resolveJSRVersion({ scope: "std", name: "path", version: "^1.0.0" }) + * // "1.0.8" (or whatever the latest matching version is) + * ``` + * + * @see https://jsr.io/docs/api + * @see https://jsr.io/docs/packages + */ + +import type { FetchOptions } from "./fetch-and-cache.ts"; + +import { compare, maxSatisfying, parse, parseRange, format } from "./semver.ts"; +import { fetchWithCache } from "./fetch-and-cache.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed JSR package specifier. + * + * @example + * ```ts + * const spec: JSRSpec = { + * kind: "jsr", + * raw: "jsr:@std/path@1.0.0/posix", + * scope: "std", // WITHOUT @ prefix (matches registry API) + * name: "path", + * fullName: "@std/path", // WITH @ prefix for display/import maps + * version: "1.0.0", + * subpath: "/posix" + * }; + * ``` + */ +export interface JSRSpec { + /** Always "jsr" for JSR specifiers */ + kind: "jsr"; + /** Original raw specifier string */ + raw: string; + /** Package scope WITHOUT @ prefix (e.g., "std" from "@std/path") */ + scope: string; + /** Package name without scope (e.g., "path" from "@std/path") */ + name: string; + /** Full package name with scope (e.g., "@std/path") */ + fullName: string; + /** Version or version range, null if not specified */ + version: string | null; + /** Subpath import (e.g., "/posix"), empty string if none */ + subpath: string; +} + +/** + * JSR package metadata from the registry API. + * + * Fetched from: `https://jsr.io/@//meta.json` + * + * @example API Response + * ```json + * { + * "scope": "std", + * "name": "path", + * "versions": { + * "1.0.0": {}, + * "1.0.1": { "yanked": true }, + * "1.0.2": {} + * } + * } + * ``` + */ +export interface JSRPackageMeta { + /** Scope name without @ */ + scope: string; + /** Package name */ + name: string; + /** Map of version strings to version metadata */ + versions: Record; +} + +/** + * Version summary in package metadata. + */ +export interface JSRVersionSummary { + /** Whether this version has been yanked */ + yanked?: boolean; +} + +/** + * JSR version metadata from the registry API. + * + * Fetched from: `https://jsr.io/@//_meta.json` + * + * @example API Response + * ```json + * { + * "manifest": { + * "/mod.ts": { + * "size": 2989, + * "checksum": "sha256-a41796ceb..." + * } + * }, + * "exports": { ".": "./mod.ts" }, + * "moduleGraph1": { "/mod.ts": {} } + * } + * ``` + */ +export interface JSRVersionMeta { + /** File manifest with sizes and checksums */ + manifest: Record; + /** Module graph for preloading (version 1 format) */ + moduleGraph1?: Record>; + /** Module graph for preloading (version 2 format) */ + moduleGraph2?: Record>; + /** Package exports mapping */ + exports: Record; +} + +/** + * File information in version manifest. + */ +export interface JSRFileInfo { + /** File size in bytes */ + size: number; + /** SHA-256 checksum with algorithm prefix */ + checksum: string; +} + +/** + * npm compatibility metadata from npm.jsr.io. + * + * Fetched from: `https://npm.jsr.io/@jsr/__` + */ +export interface JSRNpmMeta { + /** npm compatibility name (@jsr/scope__name) */ + name: string; + /** Package description */ + description?: string; + /** Distribution tags (e.g., latest) */ + "dist-tags": Record; + /** Version metadata map */ + versions: Record; + /** Publication timestamps */ + time: Record; +} + +/** + * npm compatibility version metadata. + */ +export interface JSRNpmVersionMeta { + /** npm compatibility name */ + name: string; + /** Version string */ + version: string; + /** Package description */ + description?: string; + /** Distribution info */ + dist: { + tarball: string; + integrity?: string; + shasum?: string; + }; + /** Dependencies */ + dependencies?: Record; +} + +/** + * Package name validation result. + */ +export interface JSRNameValidation { + /** Whether the name is valid */ + valid: boolean; + /** Validation errors (name cannot be used) */ + errors: string[]; + /** Validation warnings (name works but has issues) */ + warnings: string[]; +} + +/** + * Search result from JSR management API. + * + * Note: Search uses api.jsr.io, which is separate from the registry API. + */ +export interface JSRSearchResult { + /** Search result items */ + items: JSRPackageInfo[]; + /** Total number of matching packages */ + total: number; +} + +/** + * Package info from search results. + */ +export interface JSRPackageInfo { + /** Scope name (without @) */ + scope: string; + /** Package name */ + name: string; + /** Package description */ + description?: string; + /** Latest version */ + latestVersion?: string; + /** Runtime compatibility flags */ + runtimeCompat?: { + deno?: boolean; + node?: boolean; + browsers?: boolean; + bun?: boolean; + workerd?: boolean; + }; + /** JSR score (0-100) */ + score?: number; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** JSR registry base URL for direct module access */ +export const JSR_REGISTRY = "https://jsr.io"; + +/** JSR management API base URL (for search, NOT for registry operations) */ +export const JSR_API = "https://api.jsr.io"; + +/** JSR npm compatibility registry URL */ +export const JSR_NPM_REGISTRY = "https://npm.jsr.io"; + +/** + * JSR scope naming constraints. + * + * From https://jsr.io/docs/scopes: + * - 2-20 characters + * - Lowercase letters, numbers, hyphens + * - Cannot start with a hyphen + */ +export const JSR_SCOPE_RULES = { + minLength: 2, + maxLength: 20, + pattern: /^[a-z][a-z0-9-]*$/, +} as const; + +/** + * JSR package naming constraints. + * + * From https://jsr.io/docs/packages: + * - 2-58 characters + * - Lowercase letters, numbers, hyphens + * - Cannot start with a hyphen + */ +export const JSR_PACKAGE_RULES = { + minLength: 2, + maxLength: 58, + pattern: /^[a-z][a-z0-9-]*$/, +} as const; + +/** + * Pattern for parsing JSR specifiers. + * + * Matches: `jsr:@scope/name@version/subpath` + * - Scope is required (starts with @) + * - Version is optional + * - Subpath is optional + * + * Uses strict lowercase validation for scope and name. + */ +const JSR_SPEC_PATTERN = /^jsr:@([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)(?:@([^\/]+))?(\/.*)?$/; + +// ============================================================================= +// Parsing +// ============================================================================= + +/** + * Parse a JSR specifier string. + * + * JSR specifiers follow the format: `jsr:@scope/name@version/subpath` + * + * @param input The specifier string + * @returns Parsed spec or null if invalid + * + * @example Valid specifiers + * ```ts + * parseJSRSpec("jsr:@std/path") + * // { + * // kind: "jsr", + * // raw: "jsr:@std/path", + * // scope: "std", // Note: WITHOUT @ prefix + * // name: "path", + * // fullName: "@std/path", + * // version: null, + * // subpath: "" + * // } + * + * parseJSRSpec("jsr:@std/path@1.0.0") + * // { ..., version: "1.0.0", subpath: "" } + * + * parseJSRSpec("jsr:@std/path@^1.0.0/posix") + * // { ..., version: "^1.0.0", subpath: "/posix" } + * ``` + * + * @example Invalid specifiers + * ```ts + * parseJSRSpec("npm:lodash") // null (wrong protocol) + * parseJSRSpec("jsr:lodash") // null (missing scope) + * parseJSRSpec("@std/path") // null (missing jsr: prefix) + * parseJSRSpec("jsr:@STD/path") // null (uppercase not allowed) + * ``` + */ +export function parseJSRSpec(input: string): JSRSpec | null { + if (!input || typeof input !== "string") { + return null; + } + + const trimmed = input.trim(); + + // Must start with jsr: + if (!trimmed.startsWith("jsr:")) { + return null; + } + + const match = JSR_SPEC_PATTERN.exec(trimmed); + if (!match) { + return null; + } + + const [, scope, name, version, subpath] = match; + + return { + kind: "jsr", + raw: input, + scope, + name, + fullName: `@${scope}/${name}`, + version: version ?? null, + subpath: subpath ?? "", + }; +} + +/** + * Check if a string is a JSR specifier. + * + * @param input String to check + * @returns True if it's a valid jsr: specifier + * + * @example + * ```ts + * isJSRSpec("jsr:@std/path") // true + * isJSRSpec("npm:lodash") // false + * isJSRSpec("@std/path") // false (missing prefix) + * ``` + */ +export function isJSRSpec(input: string): boolean { + return parseJSRSpec(input) !== null; +} + +/** + * Check if a string looks like a JSR specifier (quick check). + * + * Faster than parseJSRSpec for filtering. + */ +export function looksLikeJSRSpec(input: string): boolean { + return typeof input === "string" && input.startsWith("jsr:"); +} + +// ============================================================================= +// Name Validation +// ============================================================================= + +/** + * Validate a JSR scope name. + * + * @param scope Scope name (with or without @ prefix) + * @returns Validation result + * + * @example + * ```ts + * validateJSRScope("std") + * // { valid: true, errors: [], warnings: [] } + * + * validateJSRScope("a") + * // { valid: false, errors: ["Scope must be 2-20 characters (got 1)"], warnings: [] } + * ``` + */ +export function validateJSRScope(scope: string): JSRNameValidation { + const errors: string[] = []; + const warnings: string[] = []; + const name = scope.startsWith("@") ? scope.slice(1) : scope; + + if (name.length < JSR_SCOPE_RULES.minLength || name.length > JSR_SCOPE_RULES.maxLength) { + errors.push(`Scope must be ${JSR_SCOPE_RULES.minLength}-${JSR_SCOPE_RULES.maxLength} characters (got ${name.length})`); + } + + if (name.startsWith("-")) { + errors.push("Scope cannot start with a hyphen"); + } + + if (!JSR_SCOPE_RULES.pattern.test(name)) { + errors.push("Scope must contain only lowercase letters, numbers, and hyphens"); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * Validate a JSR package name. + * + * @param name Package name (without scope) + * @returns Validation result + */ +export function validateJSRPackageName(name: string): JSRNameValidation { + const errors: string[] = []; + const warnings: string[] = []; + + if (name.length < JSR_PACKAGE_RULES.minLength || name.length > JSR_PACKAGE_RULES.maxLength) { + errors.push(`Package name must be ${JSR_PACKAGE_RULES.minLength}-${JSR_PACKAGE_RULES.maxLength} characters (got ${name.length})`); + } + + if (name.startsWith("-")) { + errors.push("Package name cannot start with a hyphen"); + } + + if (!JSR_PACKAGE_RULES.pattern.test(name)) { + errors.push("Package name must contain only lowercase letters, numbers, and hyphens"); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** + * Validate a full JSR package identifier. + * + * @param fullName Full package name (e.g., "@std/path") + * @returns Validation result combining scope and name checks + */ +export function validateJSRFullName(fullName: string): JSRNameValidation { + const errors: string[] = []; + const warnings: string[] = []; + + if (!fullName.startsWith("@")) { + errors.push("JSR package names must start with @ (all packages are scoped)"); + return { valid: false, errors, warnings }; + } + + const slashCount = (fullName.match(/\//g) || []).length; + if (slashCount !== 1) { + errors.push("JSR package names must be in format @scope/name"); + return { valid: false, errors, warnings }; + } + + const [scope, name] = fullName.slice(1).split("/"); + + const scopeResult = validateJSRScope(scope); + errors.push(...scopeResult.errors.map(e => `Scope: ${e}`)); + warnings.push(...scopeResult.warnings.map(w => `Scope: ${w}`)); + + const nameResult = validateJSRPackageName(name); + errors.push(...nameResult.errors.map(e => `Name: ${e}`)); + warnings.push(...nameResult.warnings.map(w => `Name: ${w}`)); + + return { valid: errors.length === 0, errors, warnings }; +} + +// ============================================================================= +// URL Generation +// ============================================================================= + +/** + * Get the package metadata URL. + * + * @param scope Package scope (with or without @) + * @param name Package name + * @returns Metadata URL + * + * @example + * ```ts + * getJSRPackageMetaUrl("std", "path") + * // "https://jsr.io/@std/path/meta.json" + * ``` + */ +export function getJSRPackageMetaUrl(scope: string, name: string): string { + const cleanScope = scope.startsWith("@") ? scope : `@${scope}`; + return `${JSR_REGISTRY}/${cleanScope}/${name}/meta.json`; +} + +/** + * Get the version metadata URL. + * + * @param scope Package scope + * @param name Package name + * @param version Version string + * @returns Version metadata URL + * + * @example + * ```ts + * getJSRVersionMetaUrl("std", "path", "1.0.0") + * // "https://jsr.io/@std/path/1.0.0_meta.json" + * ``` + */ +export function getJSRVersionMetaUrl(scope: string, name: string, version: string): string { + const cleanScope = scope.startsWith("@") ? scope : `@${scope}`; + return `${JSR_REGISTRY}/${cleanScope}/${name}/${version}_meta.json`; +} + +/** + * Get the direct module URL for ESM import. + * + * This URL can be used directly in import statements - no esm.sh needed! + * + * @param scope Package scope + * @param name Package name + * @param version Version string + * @param path File path within the package + * @returns Direct module URL + * + * @example + * ```ts + * getJSRModuleUrl("std", "path", "1.0.0", "/mod.ts") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * + * // Can be used directly in Deno: + * import { join } from "https://jsr.io/@std/path/1.0.0/mod.ts"; + * ``` + */ +export function getJSRModuleUrl( + scope: string, + name: string, + version: string, + path: string = "/mod.ts" +): string { + const cleanScope = scope.startsWith("@") ? scope : `@${scope}`; + const cleanPath = path.startsWith("/") ? path : `/${path}`; + return `${JSR_REGISTRY}/${cleanScope}/${name}/${version}${cleanPath}`; +} + +/** + * Get the npm compatibility package URL. + * + * @param scope Package scope + * @param name Package name + * @returns npm registry URL + * + * @example + * ```ts + * getJSRNpmUrl("std", "path") + * // "https://npm.jsr.io/@jsr/std__path" + * ``` + */ +export function getJSRNpmUrl(scope: string, name: string): string { + const cleanScope = scope.startsWith("@") ? scope.slice(1) : scope; + return `${JSR_NPM_REGISTRY}/@jsr/${cleanScope}__${name}`; +} + +/** + * Get the tarball URL for npm compatibility. + */ +export function getJSRTarballUrl(scope: string, name: string, version: string): string { + const cleanScope = scope.startsWith("@") ? scope.slice(1) : scope; + return `${JSR_NPM_REGISTRY}/@jsr/${cleanScope}__${name}/${version}.tgz`; +} + +/** + * Get URLs from a parsed JSR spec. + * + * @param spec Parsed JSR spec + * @returns Object with various URL formats + */ +export function getJSRUrls(spec: JSRSpec): { + module: string | null; + meta: string; + versionMeta: string | null; + npm: string; +} { + const { scope, name, version, subpath } = spec; + + return { + module: version + ? getJSRModuleUrl(scope, name, version, subpath || "/mod.ts") + : null, + meta: getJSRPackageMetaUrl(scope, name), + versionMeta: version + ? getJSRVersionMetaUrl(scope, name, version) + : null, + npm: getJSRNpmUrl(scope, name), + }; +} + +// ============================================================================= +// npm Compatibility +// ============================================================================= + +/** + * Convert JSR package name to npm compatibility name. + * + * JSR uses `@jsr/scope__name` format for npm compatibility. + * + * @example + * ```ts + * toNpmCompatName("std", "path") + * // "@jsr/std__path" + * ``` + */ +export function toNpmCompatName(scope: string, name: string): string { + const cleanScope = scope.startsWith("@") ? scope.slice(1) : scope; + return `@jsr/${cleanScope}__${name}`; +} + +/** + * Parse an npm compatibility name back to JSR components. + * + * @param npmName npm compatibility name (e.g., "@jsr/std__path") + * @returns Parsed components or null if invalid + * + * @example + * ```ts + * fromNpmCompatName("@jsr/std__path") + * // { scope: "std", name: "path", fullName: "@std/path" } + * + * fromNpmCompatName("lodash") + * // null (not a JSR package) + * ``` + */ +export function fromNpmCompatName(npmName: string): { scope: string; name: string; fullName: string } | null { + const match = /^@jsr\/([a-z][a-z0-9-]*)__([a-z][a-z0-9-]*)$/.exec(npmName); + if (!match) return null; + + const [, scope, name] = match; + return { scope, name, fullName: `@${scope}/${name}` }; +} + +// ============================================================================= +// CDN/Proxy URLs +// ============================================================================= + +/** + * Convert a JSR spec to an esm.sh proxy URL. + * + * Use this when you need npm-compatible URLs in bundlers or environments + * that don't support direct jsr.io imports. + * + * @example + * ```ts + * const spec = parseJSRSpec("jsr:@std/path@1.0.0"); + * jsrToEsmSh(spec) + * // "https://esm.sh/jsr/@std/path@1.0.0" + * ``` + */ +export function jsrToEsmSh(spec: JSRSpec): string { + const { scope, name, version, subpath } = spec; + const versionPart = version ? `@${version}` : ""; + return `https://esm.sh/jsr/@${scope}/${name}${versionPart}${subpath}`; +} + +/** + * Convert a raw JSR specifier to esm.sh URL. + * + * @param input Raw jsr: specifier + * @returns esm.sh URL or null if invalid + */ +export function jsrSpecToEsmSh(input: string): string | null { + const spec = parseJSRSpec(input); + return spec ? jsrToEsmSh(spec) : null; +} + +// ============================================================================= +// Registry API - Fetch Metadata +// ============================================================================= + +/** + * Fetch package metadata from the JSR registry. + * + * @param scope Package scope (without @) + * @param name Package name + * @returns Package metadata + * + * @example + * ```ts + * const meta = await getJSRPackage("std", "path"); + * console.log(Object.keys(meta.versions)); + * // ["1.0.0", "1.0.1", "1.0.2", ...] + * ``` + */ +export async function getJSRPackage(scope: string, name: string): Promise { + const url = getJSRPackageMetaUrl(scope, name); + + const { response } = await fetchWithCache(url, { + cacheMode: "reload", + init: { + headers: { + // Important: JSR returns HTML if Accept includes text/html + "Accept": "application/json", + }, + } + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`JSR package not found: @${scope}/${name}`); + } + throw new Error(`JSR registry error: ${response.status} ${response.statusText}`); + } + + return await response.json() as JSRPackageMeta; +} + +/** + * Fetch version metadata from the JSR registry. + * + * @param scope Package scope + * @param name Package name + * @param version Exact version string + * @returns Version metadata with file manifest and exports + */ +export async function getJSRVersionMeta( + scope: string, + name: string, + version: string, + opts: Pick = {}, +): Promise { + const url = getJSRVersionMetaUrl(scope, name, version); + + const { response } = await fetchWithCache(url, { + init: { headers: { "Accept": "application/json" }, }, + signal: opts.signal, + scope: opts.scope, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`JSR version not found: @${scope}/${name}@${version}`); + } + throw new Error(`JSR registry error: ${response.status} ${response.statusText}`); + } + + return await response.json() as JSRVersionMeta; +} + +/** + * Get list of available versions for a package. + * + * @param scope Package scope + * @param name Package name + * @param includeYanked Whether to include yanked versions + * @returns Array of version strings + */ +export async function getJSRVersions( + scope: string, + name: string, + includeYanked = false +): Promise { + const meta = await getJSRPackage(scope, name); + +return Object.entries(meta.versions) + .filter(([_, info]) => includeYanked || !info.yanked) + .map(([version]) => version); +} + +// ============================================================================= +// Version Resolution +// ============================================================================= + +/** + * Resolve a version range to an exact version. + * + * Resolution order: + * 1. If version is an exact match, return it + * 2. Find the maximum version satisfying the semver range + * + * Note: JSR doesn't have dist-tags like npm, so "latest" is resolved + * as the highest non-yanked version. + * + * @param spec Object with scope, name, and version + * @returns Resolved version or null if no match + * + * @example + * ```ts + * // Resolve a caret range + * await resolveJSRVersion({ scope: "std", name: "path", version: "^1.0.0" }) + * // "1.0.8" (highest 1.x.x) + * + * // Resolve exact version + * await resolveJSRVersion({ scope: "std", name: "path", version: "1.0.0" }) + * // "1.0.0" + * + * // Resolve "latest" (highest non-yanked) + * await resolveJSRVersion({ scope: "std", name: "path", version: "latest" }) + * // "1.0.8" + * ``` + */ +export async function resolveJSRVersion( + spec: { scope: string; name: string; version: string | null } +): Promise { + const { scope, name, version: range } = spec; + + // If no version specified, get latest + if (!range || range === "latest") { + const versions = await getJSRVersions(scope, name, false); + if (versions.length === 0) return null; + + // Find highest version + const parsed = versions + .map(v => { + try { + return { original: v, semver: parse(v) }; + } catch { + return null; + } + }) + .filter((v): v is { original: string; semver: NonNullable> } => + v !== null && v.semver !== null + ); + + if (parsed.length === 0) return versions[0] ?? null; + + // Sort descending by semver (highest first). + // Using @std/semver's compare() instead of localeCompare() because + // localeCompare gets pre-release ordering wrong: + // localeCompare: "1.0.0" < "1.0.0-alpha" (longer string) + // semver spec: "1.0.0-alpha" < "1.0.0" (pre-release is lower) + parsed.sort((a, b) => compare(b.semver, a.semver)); + + return parsed[0]?.original ?? null; + } + + const versions = await getJSRVersions(scope, name, false); + + // Check if exact version exists + if (versions.includes(range)) { + return range; + } + + // Try parsing as semver range + try { + const parsedVersions = versions + .map(v => { + try { + return parse(v); + } catch { + return null; + } + }) + .filter((v): v is NonNullable => v !== null); + + const semverRange = parseRange(range); + const maxVersion = maxSatisfying(parsedVersions, semverRange); + + if (maxVersion) { + return format(maxVersion); + } + } catch { + // Not a valid semver range + } + + console.warn(`[jsr] No version matches @${scope}/${name}@${range}`); + return null; +} + +/** + * Resolve a JSR spec to one with an exact version. + * + * @param spec Parsed JSR spec (potentially with range) + * @returns Spec with resolved version, or original if resolution fails + */ +export async function resolveJSRSpec(spec: JSRSpec): Promise { + if (!spec.version) { + const resolved = await resolveJSRVersion({ + scope: spec.scope, + name: spec.name, + version: "latest", + }); + + return resolved + ? { ...spec, version: resolved } + : spec; + } + + const resolved = await resolveJSRVersion({ + scope: spec.scope, + name: spec.name, + version: spec.version, + }); + + return resolved + ? { ...spec, version: resolved } + : spec; +} + +// ============================================================================= +// Search API +// ============================================================================= + +/** + * Search for packages on JSR. + * + * Note: This uses the management API (api.jsr.io) which is designed for + * search operations, not registry operations. + * + * @param query Search query + * @param options Search options + * @returns Search results + * + * @example + * ```ts + * const results = await searchJSR("http server"); + * for (const pkg of results.items) { + * console.log(`@${pkg.scope}/${pkg.name}: ${pkg.description}`); + * } + * ``` + */ +export async function searchJSR( + query: string, + options: { limit?: number; page?: number } = {} +): Promise { + const { limit = 20, page = 1 } = options; + + const params = new URLSearchParams({ + query, + limit: String(limit), + page: String(page), + }); + + const url = `${JSR_API}/packages?${params}`; + + const { response } = await fetchWithCache(url, { + cacheMode: "reload", + init: { + headers: { "Accept": "application/json" }, + } + }); + + if (!response.ok) { + throw new Error(`JSR search error: ${response.status} ${response.statusText}`); + } + + return await response.json() as JSRSearchResult; +} + +// ============================================================================= +// Import Map Generation +// ============================================================================= + +/** + * Generate import map entries for JSR packages. + * + * @param specs Array of JSR specs + * @param options Generation options + * @returns Import map entries + * + * @example Direct URLs + * ```ts + * const specs = [ + * parseJSRSpec("jsr:@std/path@1.0.0"), + * parseJSRSpec("jsr:@std/fs@1.0.0"), + * ]; + * + * generateImportMapEntries(specs) + * // { + * // "@std/path": "https://jsr.io/@std/path/1.0.0/mod.ts", + * // "@std/fs": "https://jsr.io/@std/fs/1.0.0/mod.ts" + * // } + * ``` + * + * @example Using esm.sh proxy + * ```ts + * generateImportMapEntries(specs, { useEsmSh: true }) + * // { + * // "@std/path": "https://esm.sh/jsr/@std/path@1.0.0", + * // "@std/fs": "https://esm.sh/jsr/@std/fs@1.0.0" + * // } + * ``` + */ +export function generateImportMapEntries( + specs: JSRSpec[], + options: { useEsmSh?: boolean; defaultPath?: string } = {} +): Record { + const { useEsmSh = false, defaultPath = "/mod.ts" } = options; + const entries: Record = {}; + + for (const spec of specs) { + const key = spec.fullName; + if (useEsmSh) { + entries[key] = jsrToEsmSh(spec); + } else if (spec.version) { + entries[key] = getJSRModuleUrl(spec.scope, spec.name, spec.version, defaultPath); + } + } + + return entries; +} \ No newline at end of file diff --git a/packages/utils/lru.ts b/packages/utils/lru.ts new file mode 100644 index 0000000..e038c24 --- /dev/null +++ b/packages/utils/lru.ts @@ -0,0 +1 @@ +export * from "@std/cache" diff --git a/packages/utils/lz-string.ts b/packages/utils/lz-string.ts new file mode 100644 index 0000000..a3526d2 --- /dev/null +++ b/packages/utils/lz-string.ts @@ -0,0 +1,473 @@ +/** + * Based on `@amoutonbrady/lz-string` (https://npmjs.com/@amoutonbrady/lz-string) by @amoutonbrady (https://github.com/amoutonbrady) + */ + +// private property +export const keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; +export const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$"; +export const baseReverseDic: Record> = {}; + +export function getBaseValue(alphabet: string, character: string) { + if (!baseReverseDic[alphabet]) { + baseReverseDic[alphabet] = {}; + + for (let i = 0; i < alphabet.length; i++) { + baseReverseDic[alphabet][alphabet.charAt(i)] = i; + } + } + + return baseReverseDic[alphabet][character]; +} + +export function compressToBase64(input: string): string { + if (input === null) return ""; + const res = _compress(input, 6, (a) => keyStrBase64.charAt(a)); + + // To produce valid Base64 + switch (res.length % 4) { + default: // When could this happen ? + case 0: + return res; + case 1: + return res + "==="; + case 2: + return res + "=="; + case 3: + return res + "="; + } +} + +export function decompressFromBase64(input: string): string | null { + if (input === null) return ""; + if (input === "") return null; + return _decompress(input.length, 32, (index) => getBaseValue(keyStrBase64, input.charAt(index))); +} + +//compress into a string that is already URI encoded +export function compressToURL(input: string): string { + if (input === null) return ""; + return _compress(input, 6, (a) => keyStrUriSafe.charAt(a)); +} + +//decompress from an output of decompressFromURL +export function decompressFromURL(input: string): string | null { + if (input === null) return ""; + if (input === "") return null; + input = input.replaceAll(" ", "+"); + + return _decompress(input.length, 32, (index) => getBaseValue(keyStrUriSafe, input.charAt(index))); +} + +export function compressToUTF16(input: string) { + if (input === null) return ""; + return _compress(input, 15, function (a) { return String.fromCharCode(a + 32); }) + " "; +} + +export function decompressFromUTF16 (compressed: string) { + if (compressed === null) return ""; + if (compressed === "") return null; + return _decompress(compressed.length, 16384, (index) => compressed.charCodeAt(index) - 32); +} + +export function compress(uncompressed: string): string { + return _compress(uncompressed, 16, String.fromCharCode); +} + +export function decompress(compressed: null | string): string | null { + if (compressed === null) return ""; + if (compressed === "") return null; + return _decompress(compressed.length, 32768, (index) => compressed.charCodeAt(index)); +} + +/** + * @internal + */ +export function _compress( + uncompressed: null | string, + bitsPerChar: number, + getCharFromInt: (int: number) => string, +) { + if (uncompressed === null) return ""; + + const contextData = []; + const contextDictionary: Record = {}; + const contextDictionaryToCreate: Record = {}; + + let i: number; + let j: number; + let value: number; + + let contextC = ""; + let contextW = ""; + let contextWc = ""; + + // Compensate for the first entry which should not count + let contextEnlargeIn = 2; + let contextDictSize = 3; + let contextNumBits = 2; + let contextDataVal = 0; + let contextDataPosition = 0; + + for (j = 0; j < uncompressed.length; j += 1) { + contextC = uncompressed.charAt(j); + + if (!Object.hasOwn(contextDictionary, contextC)) { + contextDictionary[contextC] = contextDictSize++; + contextDictionaryToCreate[contextC] = true; + } + + contextWc = contextW + contextC; + + if (Object.hasOwn(contextDictionary, contextWc)) { + contextW = contextWc; + } else { + if (Object.hasOwn(contextDictionaryToCreate, contextW)) { + if (contextW.charCodeAt(0) < 256) { + for (i = 0; i < contextNumBits; i++) { + contextDataVal = contextDataVal << 1; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + } + value = contextW.charCodeAt(0); + for (i = 0; i < 8; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = value >> 1; + } + } else { + value = 1; + for (i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | value; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = 0; + } + value = contextW.charCodeAt(0); + for (i = 0; i < 16; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = value >> 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = Math.pow(2, contextNumBits); + contextNumBits++; + } + delete contextDictionaryToCreate[contextW]; + } else { + value = contextDictionary[contextW]; + for (i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = value >> 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = Math.pow(2, contextNumBits); + contextNumBits++; + } + // Add wc to the dictionary. + contextDictionary[contextWc] = contextDictSize++; + contextW = String(contextC); + } + } + + // Output the code for w. + if (contextW !== "") { + if (Object.hasOwn(contextDictionaryToCreate, contextW)) { + if (contextW.charCodeAt(0) < 256) { + for (i = 0; i < contextNumBits; i++) { + contextDataVal = contextDataVal << 1; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + } + value = contextW.charCodeAt(0); + for (i = 0; i < 8; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = value >> 1; + } + } else { + value = 1; + for (i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | value; + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = 0; + } + value = contextW.charCodeAt(0); + for (i = 0; i < 16; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = value >> 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = Math.pow(2, contextNumBits); + contextNumBits++; + } + delete contextDictionaryToCreate[contextW]; + } else { + value = contextDictionary[contextW]; + for (i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else { + contextDataPosition++; + } + value = value >> 1; + } + } + contextEnlargeIn--; + if (contextEnlargeIn === 0) { + contextEnlargeIn = Math.pow(2, contextNumBits); + contextNumBits++; + } + } + + // Mark the end of the stream + value = 2; + + for (i = 0; i < contextNumBits; i++) { + contextDataVal = (contextDataVal << 1) | (value & 1); + if (contextDataPosition === bitsPerChar - 1) { + contextDataPosition = 0; + contextData.push(getCharFromInt(contextDataVal)); + contextDataVal = 0; + } else contextDataPosition++; + + value = value >> 1; + } + + // Flush the last char + while (true) { + contextDataVal = contextDataVal << 1; + if (contextDataPosition === bitsPerChar - 1) { + contextData.push(getCharFromInt(contextDataVal)); + break; + } else contextDataPosition++; + } + + return contextData.join(""); +} + +/** + * @internal + */ +export function _decompress(length: number, resetValue: number, getNextValue: (index: number) => number) { + const dictionary: (string | number)[] = []; + let next: number; + let enlargeIn = 4; + let dictSize = 4; + let numBits = 3; + let entry = ""; + const result = []; + let i: number; + let w: string | number; + let bits: number; + let resb: number; + let maxpower: number; + let power: number; + let c: string | number; + const data = { val: getNextValue(0), position: resetValue, index: 1 }; + + for (i = 0; i < 3; i += 1) dictionary[i] = i; + + bits = 0; + maxpower = Math.pow(2, 2); + power = 1; + while (power !== maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + + switch ((next = bits)) { + case 0: + bits = 0; + maxpower = Math.pow(2, 8); + power = 1; + while (power !== maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = String.fromCharCode(bits); + break; + case 1: + bits = 0; + maxpower = Math.pow(2, 16); + power = 1; + while (power !== maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = String.fromCharCode(bits); + break; + case 2: + return ""; + } + dictionary[3] = c! as string; + w = c!; + result.push(c!); + while (true) { + if (data.index > length) { + return ""; + } + + bits = 0; + maxpower = Math.pow(2, numBits); + power = 1; + while (power !== maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + + switch ((c = bits)) { + case 0: + bits = 0; + maxpower = Math.pow(2, 8); + power = 1; + while (power !== maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + + dictionary[dictSize++] = String.fromCharCode(bits); + c = dictSize - 1; + enlargeIn--; + break; + case 1: + bits = 0; + maxpower = Math.pow(2, 16); + power = 1; + while (power !== maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position === 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + dictionary[dictSize++] = String.fromCharCode(bits); + c = dictSize - 1; + enlargeIn--; + break; + case 2: + return result.join(""); + } + + if (enlargeIn === 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + + if (dictionary[c]) { + entry = dictionary[c] as string; + } else { + if (c === dictSize && typeof w === "string") { + entry = w + w.charAt(0); + } else { + return null; + } + } + result.push(entry); + + // Add w+entry[0] to the dictionary. + dictionary[dictSize++] = w + entry.charAt(0); + enlargeIn--; + + w = entry; + + if (enlargeIn === 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + } +} \ No newline at end of file diff --git a/packages/utils/media-types.ts b/packages/utils/media-types.ts new file mode 100644 index 0000000..d6df981 --- /dev/null +++ b/packages/utils/media-types.ts @@ -0,0 +1 @@ +export * from "@std/media-types"; \ No newline at end of file diff --git a/packages/utils/mod.ts b/packages/utils/mod.ts new file mode 100644 index 0000000..e02c786 --- /dev/null +++ b/packages/utils/mod.ts @@ -0,0 +1,49 @@ +export * from "./ansi.ts"; +export * from "./async.ts"; +export * from "./deep-object.ts"; +export * from "./encode-decode.ts"; +export * from "./fetch-and-cache.ts"; +export * from "./dispose.ts"; + +export * as lzstring from "./lz-string.ts"; +export * from "./npm-search.ts"; +export * from "./parse-package-name.ts"; + +export * from "./resolve-exports-imports.ts"; +export * from "./resolve-conditions.ts"; +export * from "./archive-detect.ts"; +export * from "./archive-spec.ts"; +export * from "./npm-spec.ts"; +export * from "./jsr-spec.ts"; +export * from "./runtime-builtins.ts"; +export * from "./resolve-import-map.ts"; + +export * as path from "./path.ts"; +export * from "./fmt.ts"; +export * from "./tar.ts"; + +export * from "./json-streams.ts"; + +export * as semver from "./semver.ts"; +export * as url from "./url.ts"; + +export * as json5 from "./json5.ts"; +export * as jsonStream from "./json-streams.ts"; + +export * as html from "./html.ts"; +export * as lru from "./lru.ts"; +export * as tar from "./tar.ts"; +export * as assert from "./assert.ts"; +export * as fmt from "./fmt.ts"; +export * as encoding from "./encoding.ts" +export * as outdent from "./outdent.ts"; +export * as bytes from "./bytes.ts" + +export * as mediaTypes from "./media-types.ts"; + +export * as archiveDetect from "./archive-detect.ts"; +export * as archiveSpec from "./archive-spec.ts"; + +export * as npmDepsSpec from "./npm-spec.ts"; +export * as validatePackageName from "./validate-package-name.ts"; +export * as npmrc from "./npmrc.ts"; \ No newline at end of file diff --git a/packages/utils/npm-search.ts b/packages/utils/npm-search.ts new file mode 100644 index 0000000..ef336b7 --- /dev/null +++ b/packages/utils/npm-search.ts @@ -0,0 +1,521 @@ +/** + * npm Registry API utilities. + * + * Provides functions for searching, fetching metadata, and resolving versions + * from the npm registry. Handles scoped packages correctly by URL-encoding + * the `/` character as required by the registry. + * + * @module + * + * @example Basic usage + * ```ts + * // Get URLs for a scoped package (note the escaped /) + * const urls = getRegistryURL("@tanstack/react-query@5.0.0"); + * // urls.packageURL = "https://registry.npmjs.com/@tanstack%2freact-query" + * + * // Resolve a version range + * const version = await resolveVersion("@types/node@^20"); + * + * // Get full package metadata + * const pkg = await getResolvedPackage("react@^18"); + * ``` + */ + +import type { FetchOptions } from "./fetch-and-cache.ts"; +import type { + FullPackage, + FullPackageVersion, + PackageInfo, + PackageSearchResult, + RegistryURLs, + SearchInfo, +} from "./types.ts"; + +import { fetchWithCache } from "./fetch-and-cache.ts"; +import { parsePackageName } from "./parse-package-name.ts"; +import { maxSatisfying, parse, parseRange, format } from "./semver.ts"; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Default npm registry URL. */ +export const DEFAULT_REGISTRY = "https://registry.npmjs.com"; + +// ============================================================================= +// Tarball URL Construction +// ============================================================================= + +/** + * Construct the npm registry tarball URL for a given package name and version. + * + * npm registries serve tarballs at a predictable URL pattern: + * `//-/-.tgz` + * + * For scoped packages the `` includes the scope but the `` + * is the part after the `/`: + * `/@scope/pkg/-/pkg-1.0.0.tgz` + * + * @param name Full package name (e.g. "lodash-es" or "@tanstack/react-query") + * @param version Exact version string (e.g. "4.17.21") + * @param registry Registry base URL (default: npm public registry) + * @returns The tarball URL string + * + * @example Unscoped package + * ```ts + * getNpmTarballUrl("lodash-es", "4.17.21") + * // "https://registry.npmjs.com/lodash-es/-/lodash-es-4.17.21.tgz" + * ``` + * + * @example Scoped package + * ```ts + * getNpmTarballUrl("@tanstack/react-query", "5.0.0") + * // "https://registry.npmjs.com/@tanstack/react-query/-/react-query-5.0.0.tgz" + * ``` + */ +export function getNpmTarballUrl( + name: string, + version: string, + registry: string = DEFAULT_REGISTRY, +): string { + const host = registry.replace(/\/+$/, ""); + + // For scoped packages (@scope/pkg), the basename in the tarball URL + // is just the part after the slash: @scope/pkg → pkg + const basename = name.includes("/") + ? name.split("/").pop()! + : name; + + return `${host}/${name}/-/${basename}-${version}.tgz`; +} + +/** + * Get the tarball URL for a package, preferring the manifest's `dist.tarball` + * field (authoritative) and falling back to URL construction (convention). + * + * The `dist.tarball` field is the canonical source — the registry itself tells + * us where the tarball lives. The constructed URL is a safe fallback when the + * manifest isn't available yet (e.g. during initial resolution before we fetch + * the full packument). + * + * @param manifest Resolved package version metadata (may be partial) + * @param name Package name (used for fallback construction) + * @param version Exact version (used for fallback construction) + * @param registry Registry base URL (used for fallback construction) + * @returns Tarball URL string + * + * @example With manifest + * ```ts + * const manifest = await getPackageOfVersion("react@18.2.0"); + * getPackageTarballUrl(manifest, "react", "18.2.0") + * // "https://registry.npmjs.org/react/-/react-18.2.0.tgz" (from dist.tarball) + * ``` + * + * @example Without manifest (fallback) + * ```ts + * getPackageTarballUrl(null, "react", "18.2.0") + * // "https://registry.npmjs.com/react/-/react-18.2.0.tgz" (constructed) + * ``` + */ +export function getPackageTarballUrl( + manifest: FullPackageVersion | PackageInfo | null | undefined, + name: string, + version: string, + registry: string = DEFAULT_REGISTRY, +): string { + // Prefer authoritative dist.tarball from the manifest + const dist = (manifest as FullPackageVersion | null)?.dist; + if (dist?.tarball) return dist.tarball; + + // Fallback: construct from convention + return getNpmTarballUrl(name, version, registry); +} + +// ============================================================================= +// URL Utilities +// ============================================================================= + +/** + * Escape a package name for use in registry URLs. + * + * The npm registry requires scoped package names to have `/` encoded as `%2f`. + * + * This follows npm-package-arg's escapedName: + * ```js + * this.escapedName = name.replace('/', '%2f') + * ``` + * + * See: https://github.com/npm/npm-package-arg/blob/main/lib/npa.js#L224 + * + * @param name Package name (potentially scoped) + * @returns URL-safe package name + * + * @example + * escapePackageName("react") // "react" + * escapePackageName("@types/node") // "@types%2fnode" + * escapePackageName("@tanstack/react-query") // "@tanstack%2freact-query" + * + * @example + * escapePackageName("@mdx-js/mdx") -> "@mdx-js%2fmdx" + * + * @example + * escapePackageName("#minpath") -> "%23minpath" + */ +export function escapePackageName(name: string): string { + return name.replace("/", "%2f"); +} + +/** + * Check if a package name is scoped. + * + * @param name Package name + * @returns True if name starts with @ + */ +export function isScopedPackage(name: string): boolean { + return name.startsWith("@"); +} + +/** + * Extract scope from a scoped package name. + * + * @param name Package name + * @returns Scope including @ or undefined + * + * @example + * getPackageScope("@types/node") // "@types" + * getPackageScope("react") // undefined + */ +export function getPackageScope(name: string): string | undefined { + if (!isScopedPackage(name)) return undefined; + const slashIdx = name.indexOf("/"); + return slashIdx > 0 ? name.slice(0, slashIdx) : undefined; +} + +/** + * Generate registry URLs for an npm package. + * + * **Important**: Scoped package names have `/` encoded as `%2f` in URL paths. + * This is required by the npm registry. + * + * @param input Package name with optional version (e.g., "@okikio/animate@1.0") + * @param registry Custom registry URL (defaults to npm) + * @returns URLs and parsed package info + * + * @example + * const urls = getRegistryURL("@okikio/animate@1.0"); + * // { + * // searchURL: "https://registry.npmjs.com/-/v1/search?text=@okikio/animate&...", + * // packageURL: "https://registry.npmjs.com/@okikio%2fanimate", + * // packageVersionURL: "https://registry.npmjs.com/@okikio%2fanimate/1.0", + * // version: "1.0", + * // name: "@okikio/animate", + * // path: "" + * // } + */ +export function getRegistryURL( + input: string, + registry: string = DEFAULT_REGISTRY +): RegistryURLs { + const host = registry.replace(/\/+$/, ""); + const { name, version, path } = parsePackageName(input); + + // CRITICAL: Scoped packages must have / encoded as %2f + // See: https://github.com/npm/npm-package-arg/blob/main/lib/npa.js#L224 + const escapedName = escapePackageName(name); + + // Search uses unescaped name in query param (encodeURIComponent handles it) + const searchURL = `${host}/-/v1/search?text=${encodeURIComponent(name)}&popularity=0.5&size=30`; + + // Package URLs use escaped name in path + const packageURL = `${host}/${escapedName}`; + const packageVersionURL = version + ? `${host}/${escapedName}/${version}` + : packageURL; + + return { + searchURL, + packageURL, + packageVersionURL, + version, + name, + path, + }; +} + +// ============================================================================= +// Search API +// ============================================================================= + +/** + * Search the npm registry for packages. + * + * @param input Package name to search for + * @param registry Custom registry URL + * @returns Search results with package metadata + * + * @example + * const result = await getPackages("@okikio/animate"); + * console.log(result.packages.map(p => p.package.name)); + */ +export async function getPackages( + input: string, + registry?: string +): Promise { + const { searchURL } = getRegistryURL(input, registry); + let result: SearchInfo; + + try { + const { response } = await fetchWithCache(searchURL, { cacheMode: "reload" }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`); + } + + result = await response.json(); + } catch (e) { + console.warn(`[npm-search] Search failed for "${input}":`, e); + throw e; + } + + const packages = result?.objects ?? []; + return { packages, info: result }; +} + +// ============================================================================= +// Package Metadata API +// ============================================================================= + +/** + * Fetch full package metadata (packument) from registry. + * + * Returns the complete package document including all versions. + * For large packages, consider using getPackageOfVersion() instead. + * + * @param input Package name (version ignored) + * @param registry Custom registry URL + * @returns Full package metadata + * + * @example + * const pkg = await getPackage("@okikio/animate"); + * console.log(Object.keys(pkg.versions)); + * console.log(pkg["dist-tags"]); + */ +export async function getPackage( + input: string, + registry?: string +): Promise { + const { packageURL, name } = getRegistryURL(input, registry); + + try { + const { response } = await fetchWithCache(packageURL, { cacheMode: "reload" }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Package not found: ${name}`); + } + throw new Error(`Registry error: ${response.status} ${response.statusText}`); + } + + return await response.json() as FullPackage; + } catch (e) { + console.warn(`[npm-search] Failed to fetch "${name}":`, e); + throw e; + } +} + +/** + * Fetch metadata for a specific package version. + * + * More efficient than getPackage() when you only need one version. + * + * **Scoped package fallback**: The npm registry's version-specific endpoint + * uses `%2f`-encoded package names (`@scope%2fname/version`). Some registries + * and HTTP proxies (Cloudflare, Nginx, etc.) decode `%2f` before routing, + * breaking the path structure. When the version endpoint fails for a scoped + * package, this function falls back to fetching the full packument and + * extracting the requested version from `pkg.versions[version]`. + * + * @param input Package name with version (e.g., "@okikio/animate@1.0.0") + * @param registry Custom registry URL + * @returns Version-specific metadata + * + * @example + * const pkg = await getPackageOfVersion("@okikio/animate@1.0.0"); + * console.log(pkg.dist.tarball); + */ +export async function getPackageOfVersion( + input: string, + registry?: string, + opts: Pick = {}, +): Promise { + const { packageVersionURL, name, version } = getRegistryURL(input, registry); + + try { + const { response } = await fetchWithCache(packageVersionURL, { + signal: opts.signal, + scope: opts.scope, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`Version not found: ${name}@${version}`); + } + throw new Error(`Registry error: ${response.status} ${response.statusText}`); + } + + return await response.json() as FullPackageVersion; + } catch (primaryError) { + // ── Scoped-package fallback ───────────────────────────────────────── + // The version-specific endpoint for scoped packages encodes `/` as + // `%2f` in the URL path (e.g. `@scope%2fname/1.0.0`). Some HTTP + // infrastructure decodes `%2f` before routing, turning the 2-segment + // path into 3 segments and confusing the registry router. + // + // Fallback: fetch the full packument (which uses a 1-segment path + // that works even with `%2f` decoding) and extract the version. + if (name.includes("/") && version) { + try { + const pkg = await getPackage(name, registry); + if (pkg.versions?.[version]) { + return pkg.versions[version]; + } + } catch { + // Full packument also failed — throw the original error + } + } + + console.warn(`[npm-search] Failed to fetch "${name}@${version}":`, primaryError); + throw primaryError; + } +} + +/** + * Fetch all versions and dist-tags for a package. + * + * @param input Package name (version ignored) + * @param registry Custom registry URL + * @returns Versions array and dist-tags object + * + * @example + * const info = await getPackageVersions("@okikio/animate"); + * console.log(info.versions); // ["0.0.3", "0.0.4", ...] + * console.log(info.tags); // { latest: "2.3.1", beta: "2.4.0" } + */ +export async function getPackageVersions( + input: string, + registry?: string +): Promise { + try { + const pkg = await getPackage(input, registry); + const versions = Object.keys(pkg.versions); + const tags = pkg["dist-tags"]; + return { versions, tags }; + } catch (e) { + console.warn(`[npm-search] Failed to get versions for "${input}":`, e); + throw e; + } +} + +// ============================================================================= +// Version Resolution +// ============================================================================= + +/** + * Resolve the best version matching a range. + * + * Resolution order: + * 1. If range matches a dist-tag, return that tag's version + * 2. If range is an exact version that exists, return it + * 3. Find the maximum version satisfying the range + * + * @param input Package with version range (e.g., "@okikio/animate@^1.0.0") + * @param registry Custom registry URL + * @returns Resolved version or null if no match + * + * @example + * const v = await resolveVersion("@okikio/animate@^1.0.0"); + * // Returns highest 1.x.x version + * + * const v2 = await resolveVersion("@okikio/animate@latest"); + * // Returns version that 'latest' tag points to + */ +export async function resolveVersion( + input: string, + registry?: string +): Promise { + const { version: range, name } = getRegistryURL(input, registry); + + if (!range) { + console.warn(`[npm-search] No version specified for "${name}"`); + return null; + } + + try { + const { versions, tags } = await getPackageVersions(input, registry); + + // Check if range is a dist-tag + if (range in tags) { + return tags[range]; + } + + // Check if exact version exists + if (versions.includes(range)) { + return range; + } + + // Try parsing as semver range + try { + const parsedVersions = versions + .map(v => { + try { + return parse(v); + } catch { + return null; + } + }) + .filter((v): v is NonNullable => v !== null); + + const semverRange = parseRange(range); + const maxVersion = maxSatisfying(parsedVersions, semverRange); + + if (maxVersion) { + return format(maxVersion); + } + } catch { + // Not a valid semver range + } + + console.warn(`[npm-search] No match for "${name}@${range}"`); + return null; + } catch (e) { + console.warn(`[npm-search] Version resolution failed for "${input}":`, e); + throw e; + } +} + +/** + * Fetch metadata for a package with resolved version. + * + * Combines resolveVersion() and getPackageOfVersion() for convenience. + * + * @param input Package with version range + * @param registry Custom registry URL + * @returns Metadata for the resolved version + * + * @example + * const pkg = await getResolvedPackage("@okikio/animate@^1.0.0"); + * console.log(pkg.version); // Resolved from ^1.0.0 + */ +export async function getResolvedPackage( + input: string, + registry?: string +): Promise { + const { name } = getRegistryURL(input, registry); + const version = await resolveVersion(input, registry); + + if (!version) { + throw new Error(`Could not resolve version for "${input}"`); + } + + return getPackageOfVersion(`${name}@${version}`, registry); +} \ No newline at end of file diff --git a/packages/utils/npm-spec.ts b/packages/utils/npm-spec.ts new file mode 100644 index 0000000..4edbaa6 --- /dev/null +++ b/packages/utils/npm-spec.ts @@ -0,0 +1,878 @@ +/** + * npm dependency specifier parser. + * + * Parses package.json dependency specifiers following npm-package-arg behavior. + * Used by the CDN plugin to classify and route dependencies correctly. + * + * Key features: + * - GitHub shorthand detection (user/repo → git) + * - Git fragment parsing (#semver:^1.0.0, #path:/packages/foo) + * - npm alias validation (no nested, registry-only targets) + * - Dist-tag validation using npm's encodeURIComponent rule + * - File vs directory classification by extension + * + * @module + * + * @example Basic usage + * ```ts + * import { parseNpmSpec, isRegistrySpec, isUrlSpec } from "./npm-spec.ts"; + * + * const spec = parseNpmSpec("^1.2.3"); + * if (isRegistrySpec(spec)) { + * // Resolve via npm registry + * } + * + * const urlSpec = parseNpmSpec("https://pkg.pr.new/@tanstack/react-query@7988"); + * if (isUrlSpec(urlSpec)) { + * // Route to TarballPlugin + * } + * ``` + */ + +import { parsePackageName } from "./parse-package-name.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed git fragment data. + * + * npm supports these fragment formats separated by `::`: + * - Plain committish: #v1.0.0, #main, #abc123 + * - Semver range: #semver:^1.0.0 + * - Subdir path: #path:/packages/foo + * - Combined: #main::semver:^1.0.0::path:/packages/foo + */ +export interface GitFragment { + /** Git ref (branch, tag, commit SHA) */ + committish: string | null; + /** Semver range to match against git tags */ + semverRange: string | null; + /** Subdirectory within the repo */ + subdir: string | null; +} + +/** + * Hosted git service info. + */ +export interface HostedGitInfo { + type: "github" | "gitlab" | "bitbucket" | "gist"; + user: string | null; + project: string | null; +} + +/** + * Base fields for all spec types. + */ +interface BaseSpec { + /** Original raw specifier string */ + raw: string; +} + +/** + * Registry-resolvable spec (semver range, exact version, or dist-tag). + */ +export interface RegistrySpec extends BaseSpec { + kind: "semver" | "version" | "tag"; + /** The spec to use for registry lookup */ + fetchSpec: string; +} + +/** + * npm alias spec (npm:package@version). + */ +export interface AliasSpec extends BaseSpec { + kind: "alias"; + /** The inner registry spec */ + subSpec: RegistrySpec; + /** Parsed target package info */ + target: { + name: string; + version: string; + path: string; + }; +} + +/** + * HTTP/HTTPS URL spec (remote tarball). + */ +export interface UrlSpec extends BaseSpec { + kind: "url"; + /** The URL to fetch */ + url: string; +} + +/** + * Git-based spec with parsed fragment. + */ +export interface GitSpec extends BaseSpec { + kind: "git"; + /** Git committish (branch, tag, SHA) */ + gitCommittish: string | null; + /** Semver range from fragment (#semver:^1.0.0) */ + gitRange: string | null; + /** Subdirectory from fragment (#path:/packages/foo) */ + gitSubdir: string | null; + /** Hosted service info if recognized */ + hosted: HostedGitInfo | null; +} + +/** + * Local file spec (tarball). + */ +export interface FileSpec extends BaseSpec { + kind: "file"; + /** Path to the file */ + fetchSpec: string; +} + +/** + * Local directory spec. + */ +export interface DirectorySpec extends BaseSpec { + kind: "directory"; + /** Path to the directory */ + fetchSpec: string; +} + +/** + * Workspace protocol spec (workspace:*). + */ +export interface WorkspaceSpec extends BaseSpec { + kind: "workspace"; + /** The workspace selector (*, ^, ~, or version) */ + workspaceSpec: string; +} + +/** + * Link protocol spec (link:../path). + */ +export interface LinkSpec extends BaseSpec { + kind: "link"; + /** The link target path */ + fetchSpec: string; +} + +/** + * Unknown or invalid spec. + */ +export interface UnknownSpec extends BaseSpec { + kind: "unknown"; + /** Error message describing why parsing failed */ + error?: string; +} + +/** + * Union of all spec types. + */ +export type NpmDependencySpec = + | RegistrySpec + | AliasSpec + | UrlSpec + | GitSpec + | FileSpec + | DirectorySpec + | WorkspaceSpec + | LinkSpec + | UnknownSpec; + +// ============================================================================= +// Detection Patterns +// ============================================================================= + +/** + * Explicit git protocol prefixes. + * + * Matches: + * - git+https://, git+ssh://, git+file://, git:// + * - github:, gitlab:, bitbucket:, gist: + * - ssh:// + * - git@ (SCP-style) + */ +const GIT_PREFIX_RE = /^(git\+|git:\/\/|github:|gitlab:|bitbucket:|gist:|ssh:\/\/|git@)/i; + +/** + * Tarball file extensions. + * Used to distinguish file: specs from directory: specs. + */ +const TARBALL_EXT_RE = /[.](?:tgz|tar\.gz|tar)$/i; + +/** + * Port number pattern (used to distinguish SCP from URL with port). + */ +const PORT_RE = /:[0-9]+(\/|$)/; + +/** + * File-like path pattern (POSIX). + */ +const FILE_PATH_RE = /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/; + +// ============================================================================= +// GitHub Shorthand Detection +// ============================================================================= + +/** + * Detect GitHub shorthand format: user/repo or user/repo#ref + * + * Based on hosted-git-info's isGitHubShorthand() function. + * See: https://github.com/npm/hosted-git-info/blob/main/lib/from-url.js + * + * Must NOT match: + * - Scoped packages (@scope/pkg) - @ before first / + * - Relative paths (./foo, ../foo) - starts with . + * - URLs (has ://) + * - SCP URLs (git@host:path) - @ before first / + * - Multi-level paths (a/b/c) - second / before # + * + * @param arg The string to test + * @returns True if this looks like user/repo shorthand + * + * @example + * isGitHubShorthand("facebook/react") // true + * isGitHubShorthand("facebook/react#main") // true + * isGitHubShorthand("@types/node") // false (scoped package) + * isGitHubShorthand("./local") // false (relative path) + * isGitHubShorthand("git@github.com:user/repo") // false (SCP) + */ +export function isGitHubShorthand(arg: string): boolean { + const firstHash = arg.indexOf("#"); + const firstSlash = arg.indexOf("/"); + const secondSlash = arg.indexOf("/", firstSlash + 1); + const firstColon = arg.indexOf(":"); + const firstSpace = arg.search(/\s/); + const firstAt = arg.indexOf("@"); + + // Must have exactly one slash (before #) + const hasSlash = firstSlash > 0; + const secondSlashOnlyAfterHash = + secondSlash === -1 || (firstHash > -1 && secondSlash > firstHash); + + // No colons before # (protocols, SCP) + const colonOnlyAfterHash = + firstColon === -1 || (firstHash > -1 && firstColon > firstHash); + + // No @ before first slash (scoped packages, SCP) + const atOnlyAfterHash = + firstAt === -1 || (firstHash > -1 && firstAt > firstHash); + + // No whitespace before # + const spaceOnlyAfterHash = + firstSpace === -1 || (firstHash > -1 && firstSpace > firstHash); + + // Doesn't start with . (relative path) + const doesNotStartWithDot = !arg.startsWith("."); + + // Doesn't end with / right before # (malformed) + const doesNotEndWithSlash = + firstHash > -1 ? arg[firstHash - 1] !== "/" : !arg.endsWith("/"); + + return ( + hasSlash && + secondSlashOnlyAfterHash && + colonOnlyAfterHash && + atOnlyAfterHash && + spaceOnlyAfterHash && + doesNotStartWithDot && + doesNotEndWithSlash + ); +} + +// ============================================================================= +// Git Fragment Parsing +// ============================================================================= + +/** + * Parse git fragment (part after #) into structured data. + * + * Based on npm-package-arg's setGitAttrs() function. + * See: https://github.com/npm/npm-package-arg/blob/main/lib/npa.js + * + * Fragment formats separated by `::`: + * - Plain committish: v1.0.0, main, abc123 + * - Semver range: semver:^1.0.0 + * - Subdir path: path:/packages/foo + * - Combined: main::path:/packages/foo + * + * @param fragment The fragment string (without leading #) + * @returns Parsed fragment data + * @throws Error if fragment has conflicting parts + * + * @example + * parseGitFragment("main") + * // => { committish: "main", semverRange: null, subdir: null } + * + * parseGitFragment("semver:^1.0.0") + * // => { committish: null, semverRange: "^1.0.0", subdir: null } + * + * parseGitFragment("v1.0.0::path:/packages/core") + * // => { committish: "v1.0.0", semverRange: null, subdir: "/packages/core" } + */ +export function parseGitFragment(fragment: string): GitFragment { + if (!fragment) { + return { committish: null, semverRange: null, subdir: null }; + } + + let committish: string | null = null; + let semverRange: string | null = null; + let subdir: string | null = null; + + // Split on :: (npm's separator) + for (const part of fragment.split("::")) { + const colonIdx = part.indexOf(":"); + + // No colon = plain committish + if (colonIdx === -1) { + if (!committish && !semverRange) { + committish = part; + } + continue; + } + + const key = part.slice(0, colonIdx); + const value = part.slice(colonIdx + 1); + + if (key === "semver") { + if (committish) { + throw new Error("Cannot use semver range with explicit committish"); + } + if (semverRange) { + throw new Error("Multiple semver ranges not allowed"); + } + semverRange = decodeURIComponent(value); + } else if (key === "path") { + if (subdir) { + throw new Error("Multiple path specifiers not allowed"); + } + subdir = value.startsWith("/") ? value : `/${value}`; + } + // Unknown keys silently ignored (matches npm behavior) + } + + return { committish, semverRange, subdir }; +} + +// ============================================================================= +// Hosted Git Parsing +// ============================================================================= + +/** + * Extract hosted git service info from a spec. + * + * @param spec The spec string + * @returns Hosted info or null if not a recognized host + */ +function parseHostedGit(spec: string): HostedGitInfo | null { + // Explicit shortcuts: github:user/repo, gitlab:user/repo, etc. + const shortcutMatch = spec.match(/^(github|gitlab|bitbucket|gist):/i); + if (shortcutMatch) { + const type = shortcutMatch[1].toLowerCase() as HostedGitInfo["type"]; + const rest = spec.slice(shortcutMatch[0].length); + const hashIdx = rest.indexOf("#"); + const pathPart = hashIdx > -1 ? rest.slice(0, hashIdx) : rest; + + const slashIdx = pathPart.indexOf("/"); + if (slashIdx > 0) { + const user = pathPart.slice(0, slashIdx); + let project = pathPart.slice(slashIdx + 1); + if (project.endsWith(".git")) project = project.slice(0, -4); + return { type, user, project }; + } + + // Gist can be just a hash + if (type === "gist" && pathPart) { + return { type, user: null, project: pathPart }; + } + } + + // GitHub shorthand: user/repo + if (isGitHubShorthand(spec)) { + const hashIdx = spec.indexOf("#"); + const pathPart = hashIdx > -1 ? spec.slice(0, hashIdx) : spec; + const slashIdx = pathPart.indexOf("/"); + + if (slashIdx > 0) { + const user = pathPart.slice(0, slashIdx); + let project = pathPart.slice(slashIdx + 1); + if (project.endsWith(".git")) project = project.slice(0, -4); + return { type: "github", user, project }; + } + } + + // Full URLs to known hosts + try { + const url = new URL(spec.replace(/^git\+/, "")); + const hostname = url.hostname.replace(/^www\./, ""); + + const hostMap: Record = { + "github.com": "github", + "gitlab.com": "gitlab", + "bitbucket.org": "bitbucket", + "gist.github.com": "gist", + }; + + const type = hostMap[hostname]; + if (type) { + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length >= 2) { + const user = parts[0]; + let project = parts[1]; + if (project.endsWith(".git")) project = project.slice(0, -4); + return { type, user, project }; + } + } + } catch { + // Not a valid URL + } + + return null; +} + +// ============================================================================= +// Spec Type Detection +// ============================================================================= + +/** + * Check if spec looks like a file path. + */ +function isFilePath(spec: string): boolean { + if (spec.toLowerCase().startsWith("file:")) return true; + return FILE_PATH_RE.test(spec); +} + +/** + * Check if spec is a git URL or shorthand. + */ +function isGitUrl(spec: string): boolean { + // Explicit git prefixes + if (GIT_PREFIX_RE.test(spec)) return true; + + // SCP-style URLs, but not URLs with port numbers + // git@github.com:user/repo vs ssh://git@host:1234/repo + if (/^[^@]+@[^:]+:.+/.test(spec) && !PORT_RE.test(spec)) return true; + + // GitHub shorthand + if (isGitHubShorthand(spec)) return true; + + return false; +} + +/** + * Validate dist-tag name using npm's rule. + * + * npm uses: encodeURIComponent(tag) !== tag + * This catches any character that needs URL encoding. + */ +function isValidDistTag(tag: string): boolean { + return encodeURIComponent(tag) === tag; +} + +/** + * Check if a string looks like a semver version or range. + * + * Uses a first-character heuristic: semver versions/ranges always start with + * a digit (1.0.0), v/V prefix (v1.0.0), range operator (~^<>=), or wildcard (*xX). + * + * Previous approach (character class whitelist) missed pre-release versions + * like "1.0.0-rc.1" and ranges like ">=1.0.0-beta.1" because those contain + * lowercase letters. The first-character check is both simpler and more correct. + * + * Known edge case: dist-tags starting with a digit (e.g., "1fix") would be + * misclassified as semver. This is extremely rare in practice. + */ +function looksLikeSemver(spec: string): boolean { + if (spec === "*" || spec === "") return true; + // If first character is semver-indicative, treat the whole string as semver. + // The exact-version vs range distinction happens in parseRegistrySpec(). + return /^[0-9vV*xX~^<>=]/.test(spec); +} + +// ============================================================================= +// Main Parser +// ============================================================================= + +/** + * Parse an npm dependency specifier into a classified spec. + * + * Follows npm-package-arg's parsing order: + * 1. npm: alias prefix + * 2. workspace:/link: protocols + * 3. file: or path-like specs + * 4. Git URLs and shorthands + * 5. HTTP/HTTPS URLs + * 6. Semver ranges + * 7. Dist-tags + * + * @param raw The raw specifier from package.json dependencies + * @returns Classified spec with kind and relevant data + * + * @example Registry specs + * ```ts + * parseNpmSpec("^1.2.3") + * // => { kind: "semver", raw: "^1.2.3", fetchSpec: "^1.2.3" } + * + * parseNpmSpec("1.2.3") + * // => { kind: "version", raw: "1.2.3", fetchSpec: "1.2.3" } + * + * parseNpmSpec("latest") + * // => { kind: "tag", raw: "latest", fetchSpec: "latest" } + * ``` + * + * @example Alias specs + * ```ts + * parseNpmSpec("npm:react@^18") + * // => { kind: "alias", subSpec: { kind: "semver", ... }, + * // target: { name: "react", version: "^18", path: "" } } + * ``` + * + * @example Git specs + * ```ts + * parseNpmSpec("github:user/repo#semver:^1.0.0") + * // => { kind: "git", gitRange: "^1.0.0", hosted: { type: "github", ... } } + * + * parseNpmSpec("user/repo") + * // => { kind: "git", hosted: { type: "github", user: "user", project: "repo" } } + * ``` + */ +export function parseNpmSpec(raw: string): NpmDependencySpec { + const spec = (raw ?? "").trim(); + if (!spec) { + return { kind: "unknown", raw, error: "Empty specifier" }; + } + + // ========================================================================= + // npm: alias + // ========================================================================= + if (spec.toLowerCase().startsWith("npm:")) { + const inner = spec.slice(4); + + // No nested aliases + if (inner.toLowerCase().startsWith("npm:")) { + return { + kind: "unknown", + raw, + error: "Nested aliases not supported", + }; + } + + // Parse inner as package name + const parsed = parsePackageName(inner, { ignoreError: true }); + + // Must have a name + if (!parsed.name) { + return { + kind: "unknown", + raw, + error: "Alias must specify package name", + }; + } + + // Target must be registry-resolvable (not path or URL) + if ( + parsed.name.startsWith(".") || + parsed.name.startsWith("/") || + parsed.name.includes("://") || + (parsed.name.includes(":") && !parsed.name.startsWith("@")) + ) { + return { + kind: "unknown", + raw, + error: "Alias targets must be registry packages", + }; + } + + // Parse version as registry spec + // npm defaults to * not latest when no version specified + const versionSpec = parsed.version ?? "*"; + const subSpec = parseRegistrySpec(versionSpec); + + if (subSpec.kind === "unknown") { + return { + kind: "unknown", + raw, + error: `Invalid alias version: ${versionSpec}`, + }; + } + + return { + kind: "alias", + raw, + subSpec: subSpec as RegistrySpec, + target: { + name: parsed.name, + version: versionSpec, + path: parsed.path ?? "", + }, + }; + } + + // ========================================================================= + // workspace: protocol + // ========================================================================= + if (spec.toLowerCase().startsWith("workspace:")) { + return { + kind: "workspace", + raw, + workspaceSpec: spec.slice(10) || "*", + }; + } + + // ========================================================================= + // link: protocol + // ========================================================================= + if (spec.toLowerCase().startsWith("link:")) { + return { + kind: "link", + raw, + fetchSpec: spec.slice(5), + }; + } + + // ========================================================================= + // file: or path-like + // ========================================================================= + if (isFilePath(spec)) { + const path = spec.toLowerCase().startsWith("file:") ? spec.slice(5) : spec; + + // Distinguish file (tarball) from directory by extension + if (TARBALL_EXT_RE.test(path)) { + return { kind: "file", raw, fetchSpec: path }; + } + return { kind: "directory", raw, fetchSpec: path }; + } + + // ========================================================================= + // Git specs + // ========================================================================= + if (isGitUrl(spec)) { + const hashIdx = spec.indexOf("#"); + const fragment = hashIdx > -1 ? spec.slice(hashIdx + 1) : ""; + + let fragmentData: GitFragment; + try { + fragmentData = parseGitFragment(fragment); + } catch (e) { + return { + kind: "unknown", + raw, + error: e instanceof Error ? e.message : "Invalid git fragment", + }; + } + + const hosted = parseHostedGit(spec); + + return { + kind: "git", + raw, + gitCommittish: fragmentData.committish, + gitRange: fragmentData.semverRange, + gitSubdir: fragmentData.subdir, + hosted, + }; + } + + // ========================================================================= + // HTTP/HTTPS URLs + // ========================================================================= + if (/^https?:\/\//i.test(spec)) { + return { kind: "url", raw, url: spec }; + } + + // ========================================================================= + // Registry specs (semver, version, tag) + // ========================================================================= + return parseRegistrySpec(spec); +} + +/** + * Parse a registry spec (semver range, exact version, or tag). + */ +function parseRegistrySpec(spec: string): RegistrySpec | UnknownSpec { + const trimmed = spec.trim(); + + // Empty or wildcard = valid semver range + if (trimmed === "" || trimmed === "*") { + return { kind: "semver", raw: spec, fetchSpec: trimmed || "*" }; + } + + // Check for semver range characters + if (looksLikeSemver(trimmed)) { + // Further validation would use parseRange() but that throws + // For now, if it looks like semver, treat it as semver + // The exact vs range distinction is less important for routing + if (/^\d+\.\d+\.\d+/.test(trimmed) && !/[~^<>=|]/.test(trimmed)) { + return { kind: "version", raw: spec, fetchSpec: trimmed }; + } + return { kind: "semver", raw: spec, fetchSpec: trimmed }; + } + + // Check if valid dist-tag + if (isValidDistTag(trimmed)) { + return { kind: "tag", raw: spec, fetchSpec: trimmed }; + } + + return { + kind: "unknown", + raw: spec, + error: "Not a valid semver range or dist-tag", + }; +} + +// ============================================================================= +// Type Guards +// ============================================================================= + +/** + * Check if spec is a URL to be handled by TarballPlugin. + */ +export function isUrlSpec(spec: NpmDependencySpec): spec is UrlSpec { + return spec.kind === "url"; +} + +/** + * Check if spec is an npm alias. + */ +export function isAliasSpec( + spec: NpmDependencySpec +): spec is AliasSpec { + return spec.kind === "alias"; +} + +/** + * Check if spec is a git reference. + */ +export function isGitSpec(spec: NpmDependencySpec): spec is GitSpec { + return spec.kind === "git"; +} + +/** + * Check if spec resolves via npm registry. + */ +export function isRegistrySpec( + spec: NpmDependencySpec +): spec is RegistrySpec | AliasSpec { + return ( + spec.kind === "semver" || + spec.kind === "version" || + spec.kind === "tag" || + spec.kind === "alias" + ); +} + +/** + * Check if spec is unsupported in browser context. + */ +export function isUnsupportedSpec(spec: NpmDependencySpec): boolean { + return ( + spec.kind === "git" || + spec.kind === "file" || + spec.kind === "directory" || + spec.kind === "workspace" || + spec.kind === "link" + ); +} + +/** + * Check if spec is a standard npm version (semver or tag). + * @deprecated Use isRegistrySpec instead + */ +export function isNpmVersionSpec(spec: NpmDependencySpec): boolean { + return spec.kind === "semver" || spec.kind === "version" || spec.kind === "tag"; +} + +// ============================================================================= +// Path Utilities +// ============================================================================= + +/** + * Join two subpaths, handling slashes. + * + * @example + * joinSubpath("/build", "modern") // "/build/modern" + * joinSubpath("", "/dist/index.js") // "/dist/index.js" + */ +export function joinSubpath(a: string, b: string): string { + const left = (a ?? "").replace(/\/+$/, ""); + const right = (b ?? "").replace(/^\/+/, ""); + if (!left && !right) return ""; + if (!left) return "/" + right; + if (!right) return left.startsWith("/") ? left : "/" + left; + return (left.startsWith("/") ? left : "/" + left) + "/" + right; +} + +/** + * Append a subpath to a URL's pathname. + * + * @example + * appendUrlSubpath("https://pkg.pr.new/@tanstack/react-query@7988", "/build/modern") + * // "https://pkg.pr.new/@tanstack/react-query@7988/build/modern" + */ +export function appendUrlSubpath(baseUrl: string, subpath: string): string { + if (!subpath) return baseUrl; + const u = new URL(baseUrl); + u.pathname = joinSubpath(u.pathname, subpath); + return u.toString(); +} + +// ============================================================================= +// Error Messages +// ============================================================================= + +/** + * Generate user-friendly error for unsupported spec types. + */ +export function getUnsupportedSpecError( + spec: NpmDependencySpec, + packageName: string +): string { + switch (spec.kind) { + case "git": { + const gitSpec = spec as GitSpec; + const hostedInfo = gitSpec.hosted + ? ` (${gitSpec.hosted.type}: ${gitSpec.hosted.user}/${gitSpec.hosted.project})` + : ""; + return ( + `Package "${packageName}" uses a git dependency${hostedInfo}: "${spec.raw}". ` + + `Git dependencies are not supported in browser bundlers. ` + + `Consider using an npm-published version or a tarball URL instead.` + ); + } + case "file": + return ( + `Package "${packageName}" uses a file dependency: "${spec.raw}". ` + + `Local file dependencies cannot be resolved in a browser bundler. ` + + `Upload the package to npm or use a tarball URL instead.` + ); + case "directory": + return ( + `Package "${packageName}" uses a directory dependency: "${spec.raw}". ` + + `Local directory dependencies cannot be resolved in a browser bundler. ` + + `Upload the package to npm or use a tarball URL instead.` + ); + case "workspace": + return ( + `Package "${packageName}" uses a workspace dependency: "${spec.raw}". ` + + `Workspace protocols require a monorepo context. ` + + `Use an explicit version or publish the package to npm.` + ); + case "link": + return ( + `Package "${packageName}" uses a link dependency: "${spec.raw}". ` + + `Link protocols require filesystem access. ` + + `Use an explicit version or publish the package to npm.` + ); + case "unknown": { + const error = (spec as UnknownSpec).error; + return ( + `Package "${packageName}" has an invalid dependency: "${spec.raw}". ` + + (error ? `${error}. ` : "") + + `Use a semver version, dist-tag, npm alias, or tarball URL.` + ); + } + default: + return ( + `Package "${packageName}" uses an unsupported format: "${spec.raw}". ` + + `Use a semver version, dist-tag, npm alias, or tarball URL.` + ); + } +} \ No newline at end of file diff --git a/packages/utils/npmrc.ts b/packages/utils/npmrc.ts new file mode 100644 index 0000000..a2ae9c4 --- /dev/null +++ b/packages/utils/npmrc.ts @@ -0,0 +1,424 @@ +/** + * .npmrc Parser & Registry Resolution + * + * Parses `.npmrc` configuration files to extract registry URLs, supporting: + * - Default registry override (`registry=https://...`) + * - Scoped registries (`@scope:registry=https://...`) + * - Comment stripping (`#` and `;` line comments) + * - Environment variable interpolation (`${VAR}`) + * + * This is intentionally minimal — it only extracts registry configuration, + * not auth tokens, proxy settings, or other npm config. Auth tokens are + * security-sensitive and should not be exposed in a web-facing bundler. + * + * ## .npmrc format reference + * + * ```ini + * # Default registry (applies to all unscoped packages) + * registry=https://registry.npmjs.org/ + * + * # Scoped registry (applies to packages under @scope) + * @jsr:registry=https://npm.jsr.io + * @mycompany:registry=https://npm.mycompany.com/ + * + * # Auth tokens (parsed but NOT exposed — security boundary) + * //registry.npmjs.org/:_authToken=${NPM_TOKEN} + * ``` + * + * @module + * + * @example Parse .npmrc content + * ```ts + * const config = parseNpmrc(` + * registry=https://registry.npmjs.org/ + * @jsr:registry=https://npm.jsr.io + * `); + * // { + * // registry: "https://registry.npmjs.org/", + * // scopedRegistries: { "@jsr": "https://npm.jsr.io" } + * // } + * ``` + * + * @example Resolve registry for a package + * ```ts + * getRegistryForPackage("@jsr/std__path", config) + * // "https://npm.jsr.io" + * + * getRegistryForPackage("react", config) + * // "https://registry.npmjs.org/" + * ``` + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Parsed registry configuration from .npmrc. + * + * Only registry-related settings are extracted by default. Auth tokens + * are opt-in via `parseNpmrc(..., { extractAuth: true })` and stored + * separately for clear security boundaries. + */ +export interface RegistryConfig { + /** + * Default registry URL for unscoped packages. + * + * Corresponds to `registry=` in .npmrc. + * Falls back to npm public registry if not set. + */ + registry?: string; + + /** + * Scoped registry overrides. + * + * Keys are scopes WITH the `@` prefix (e.g., `"@jsr"`, `"@mycompany"`). + * Values are registry URLs. + * + * Corresponds to `@scope:registry=` in .npmrc. + * + * @example + * ```ts + * { + * "@jsr": "https://npm.jsr.io", + * "@mycompany": "https://npm.mycompany.com/" + * } + * ``` + */ + scopedRegistries?: Record; + + /** + * Registry-scoped auth tokens. Only populated when `parseNpmrc()` is + * called with `{ extractAuth: true }`. + * + * Keys are registry hostnames/paths (the part after `//` in .npmrc), + * values are the auth token strings. + * + * **Security note**: These tokens grant access to private packages. + * Never log, serialize, or expose them in user-facing output. + * + * @example + * ```ts + * // From: //npm.mycompany.com/:_authToken=abc123 + * { + * "npm.mycompany.com/": "abc123" + * } + * ``` + */ + authTokens?: Record; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * The official npm public registry URL. + * + * Used as the fallback when no registry is configured. + */ +export const NPM_PUBLIC_REGISTRY = "https://registry.npmjs.org"; + +// ============================================================================= +// Parser +// ============================================================================= + +/** + * Parse raw `.npmrc` content into a structured registry config. + * + * Handles: + * - `registry=` — default registry + * - `@scope:registry=` — scoped registry overrides + * - `#` and `;` line comments + * - Leading/trailing whitespace + * - Environment variable interpolation (`${VAR}`) — replaced with empty + * string since we don't have access to the user's env vars + * + * Ignores all other .npmrc directives (auth tokens, proxy settings, etc.) + * for security and simplicity. + * + * @param content Raw .npmrc file content + * @returns Parsed registry configuration + * + * @example Basic usage + * ```ts + * parseNpmrc("registry=https://registry.npmjs.org/") + * // { registry: "https://registry.npmjs.org/" } + * ``` + * + * @example Scoped registries + * ```ts + * parseNpmrc(` + * @jsr:registry=https://npm.jsr.io + * @company:registry=https://npm.company.com/ + * `) + * // { + * // scopedRegistries: { + * // "@jsr": "https://npm.jsr.io", + * // "@company": "https://npm.company.com/" + * // } + * // } + * ``` + * + * @example With comments and env vars + * ```ts + * parseNpmrc(` + * # Use custom registry + * registry=https://my-registry.com/ + * ; Scoped to our org + * @myorg:registry=https://npm.myorg.com/ + * //npm.myorg.com/:_authToken=${NPM_TOKEN} + * `) + * // { registry: "https://my-registry.com/", scopedRegistries: { "@myorg": "https://npm.myorg.com/" } } + * ``` + */ +export function parseNpmrc(content: string, options?: { extractAuth?: boolean }): RegistryConfig { + const config: RegistryConfig = {}; + const extractAuth = options?.extractAuth ?? false; + const lines = content.split(/\r?\n/); + + for (const rawLine of lines) { + // Strip comments (# or ; at start of line, after optional whitespace) + const line = rawLine.replace(/^\s*[#;].*$/, "").trim(); + if (!line) continue; + + // ── Auth token lines ──────────────────────────────────────────────── + // Format: //registry.example.com/:_authToken= + // Only parsed when opt-in via extractAuth flag. + if (line.startsWith("//")) { + if (extractAuth) { + const authMatch = /^\/\/([^:]+\/?):_authToken\s*=\s*(.+)$/.exec(line); + if (authMatch) { + const registryKey = authMatch[1]; // e.g., "npm.mycompany.com/" + const token = interpolateEnvVars(authMatch[2].trim()); + // Only store non-empty tokens (env vars may resolve to empty) + if (token) { + if (!config.authTokens) config.authTokens = {}; + config.authTokens[registryKey] = token; + } + } + } + continue; + } + + // ── Default registry ────────────────────────────────────────────── + // Format: registry= + const defaultMatch = /^registry\s*=\s*(.+)$/.exec(line); + if (defaultMatch) { + config.registry = interpolateEnvVars(defaultMatch[1].trim()); + continue; + } + + // ── Scoped registry ─────────────────────────────────────────────── + // Format: @scope:registry= + const scopedMatch = /^(@[a-z][a-z0-9._-]*):registry\s*=\s*(.+)$/i.exec(line); + if (scopedMatch) { + const scope = scopedMatch[1].toLowerCase(); + const url = interpolateEnvVars(scopedMatch[2].trim()); + + if (!config.scopedRegistries) config.scopedRegistries = {}; + config.scopedRegistries[scope] = url; + continue; + } + + // All other directives are ignored (proxy, cache, strict-ssl, etc.) + } + + return config; +} + +/** + * Replace `${VAR}` patterns with empty strings. + * + * In a web/edge context we don't have access to the user's environment + * variables, so interpolation tokens are stripped. This prevents URLs + * with unresolved `${...}` from causing fetch failures. + * + * @param value String potentially containing `${VAR}` patterns + * @returns String with env var references removed + */ +function interpolateEnvVars(value: string): string { + return value.replace(/\$\{[^}]*\}/g, ""); +} + +// ============================================================================= +// Registry Lookup +// ============================================================================= + +/** + * Get the appropriate registry URL for a given package name. + * + * Resolution order: + * 1. Scoped override — checks if the package scope has a custom registry + * 2. Default registry — uses the config's `registry` field + * 3. Fallback — returns the provided fallback URL (or npm public registry) + * + * @param name Full package name (e.g., "@jsr/std__path" or "react") + * @param config Parsed registry configuration + * @param fallback Fallback registry URL when no config match + * @returns Registry URL for the package + * + * @example Scoped match + * ```ts + * const config = { scopedRegistries: { "@jsr": "https://npm.jsr.io" } }; + * getRegistryForPackage("@jsr/std__path", config) + * // "https://npm.jsr.io" + * ``` + * + * @example Default match + * ```ts + * const config = { registry: "https://my-registry.com/" }; + * getRegistryForPackage("react", config) + * // "https://my-registry.com/" + * ``` + * + * @example No match + * ```ts + * getRegistryForPackage("react", {}) + * // "https://registry.npmjs.org" (fallback) + * ``` + */ +export function getRegistryForPackage( + name: string, + config: RegistryConfig | null | undefined, + fallback: string = NPM_PUBLIC_REGISTRY, +): string { + if (!config) return fallback; + + // Check scoped registries first + if (config.scopedRegistries && name.startsWith("@")) { + const slashIdx = name.indexOf("/"); + if (slashIdx > 0) { + const scope = name.slice(0, slashIdx); // e.g., "@jsr" + if (scope in config.scopedRegistries) { + return config.scopedRegistries[scope]; + } + } + } + + // Fall back to default registry, then to provided fallback + return config.registry ?? fallback; +} + +// ============================================================================= +// Config Normalization +// ============================================================================= + +/** + * Normalize registry configuration from various input formats. + * + * Accepts: + * - `undefined/null` — returns undefined (use defaults) + * - `string` — could be a registry URL or raw .npmrc content + * - If it contains `=` or newlines, treated as .npmrc content + * - Otherwise, treated as a default registry URL + * - `RegistryConfig` object — returned as-is + * + * @param input Registry configuration input + * @returns Normalized registry config, or undefined if no config + * + * @example Registry URL string + * ```ts + * normalizeRegistryConfig("https://npm.jsr.io") + * // { registry: "https://npm.jsr.io" } + * ``` + * + * @example Raw .npmrc content + * ```ts + * normalizeRegistryConfig("@jsr:registry=https://npm.jsr.io\nregistry=https://my-reg.com") + * // { registry: "https://my-reg.com", scopedRegistries: { "@jsr": "https://npm.jsr.io" } } + * ``` + * + * @example Structured config + * ```ts + * normalizeRegistryConfig({ registry: "https://npm.jsr.io" }) + * // { registry: "https://npm.jsr.io" } + * ``` + */ +export function normalizeRegistryConfig( + input: string | RegistryConfig | null | undefined, +): RegistryConfig | undefined { + if (input == null) return undefined; + + if (typeof input === "string") { + // Heuristic: if it contains `=` or newlines, it's probably .npmrc content + if (input.includes("=") || input.includes("\n")) { + return parseNpmrc(input); + } + + // Otherwise treat as a plain registry URL + return { registry: input }; + } + + // Already a RegistryConfig object + return input; +} + +// ============================================================================= +// Auth Headers +// ============================================================================= + +/** + * Get Authorization headers for a registry URL, if auth tokens are configured. + * + * Matches the registry URL's host+path against configured auth tokens. + * Uses prefix matching: a token for `npm.mycompany.com/` applies to + * `https://npm.mycompany.com/any/path`. + * + * Returns `undefined` when no token matches — callers should only + * attach the header when a value is returned. + * + * **Security**: The returned header value is a Bearer token. Never log + * or serialize it in user-facing output. + * + * @param registryUrl The full registry URL to authenticate against + * @param config Parsed registry config (must include authTokens) + * @returns Authorization header value (e.g., `"Bearer abc123"`), or undefined + * + * @example + * ```ts + * const config = parseNpmrc( + * `//npm.mycompany.com/:_authToken=abc123`, + * { extractAuth: true }, + * ); + * + * getAuthHeaderForRegistry("https://npm.mycompany.com/@scope/pkg", config) + * // "Bearer abc123" + * + * getAuthHeaderForRegistry("https://registry.npmjs.org/react", config) + * // undefined (no matching token) + * ``` + */ +export function getAuthHeaderForRegistry( + registryUrl: string, + config: RegistryConfig | null | undefined, +): string | undefined { + if (!config?.authTokens) return undefined; + + let url: URL; + try { + url = new URL(registryUrl); + } catch { + return undefined; + } + + // Build the key to match against: host + pathname (with trailing slash) + // Example: "npm.mycompany.com/" or "npm.mycompany.com/custom-path/" + const hostPath = url.host + url.pathname.replace(/\/?$/, "/"); + + // Try exact prefix match, longest-match-wins + let bestMatch = ""; + let bestToken: string | undefined; + + for (const [key, token] of Object.entries(config.authTokens)) { + // Normalize the key to ensure consistent matching + const normalizedKey = key.replace(/\/?$/, "/"); + + if (hostPath.startsWith(normalizedKey) && normalizedKey.length > bestMatch.length) { + bestMatch = normalizedKey; + bestToken = token; + } + } + + return bestToken ? `Bearer ${bestToken}` : undefined; +} diff --git a/packages/utils/outdent.ts b/packages/utils/outdent.ts new file mode 100644 index 0000000..ae89f1c --- /dev/null +++ b/packages/utils/outdent.ts @@ -0,0 +1,2 @@ +export * from "@okikio/undent"; +export { default } from "@okikio/undent"; \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..cc11531 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,68 @@ +{ + "name": "@bundle/utils", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./mod.ts", + "./ansi": "./ansi.ts", + "./archive-detect": "./archive-detect.ts", + "./archive-spec": "./archive-spec.ts", + "./assert": "./assert.ts", + "./async": "./async.ts", + "./bytes": "./bytes.ts", + "./deep-object": "./deep-object.ts", + "./dispose": "./dispose.ts", + "./encode-decode": "./encode-decode.ts", + "./encoding": "./encoding.ts", + "./fetch-and-cache": "./fetch-and-cache.ts", + "./fmt": "./fmt.ts", + "./html": "./html.ts", + "./jsr-spec": "./jsr-spec.ts", + "./json-stream": "./json-streams.ts", + "./json5": "./json5.ts", + "./lru": "./lru.ts", + "./lz-string": "./lz-string.ts", + "./media-types": "./media-types.ts", + "./mod": "./mod.ts", + "./npm-search": "./npm-search.ts", + "./npm-spec": "./npm-spec.ts", + "./npmrc": "./npmrc.ts", + "./outdent": "./outdent.ts", + "./parse-package-name": "./parse-package-name.ts", + "./path": "./path.ts", + "./resolve-conditions": "./resolve-conditions.ts", + "./resolve-exports-imports": "./resolve-exports-imports.ts", + "./resolve-import-map": "./resolve-import-map.ts", + "./runtime-builtins": "./runtime-builtins.ts", + "./semver": "./semver.ts", + "./tar": "./tar.ts", + "./types": "./types.ts", + "./url": "./url.ts", + "./validate-package-name": "./validate-package-name.ts" + }, + "dependencies": { + "@nick/dispose": "npm:@jsr/nick__dispose@^1", + "@opentf/obj-diff": "npm:@jsr/opentf__obj-diff@^0", + "@std/assert": "npm:@jsr/std__assert@^1", + "@std/async": "npm:@jsr/std__async@^1", + "@std/bytes": "npm:@jsr/std__bytes@^1", + "@std/cache": "npm:@jsr/std__cache@^0", + "@std/collections": "npm:@jsr/std__collections@^1", + "@std/encoding": "npm:@jsr/std__encoding@^1", + "@std/fmt": "npm:@jsr/std__fmt@^1", + "@std/html": "npm:@jsr/std__html@^1", + "@std/json": "npm:@jsr/std__json@^1", + "@std/jsonc": "npm:@jsr/std__jsonc@^1", + "@std/media-types": "npm:@jsr/std__media-types@^1", + "@std/path": "npm:@jsr/std__path@^1", + "@std/semver": "npm:@jsr/std__semver@^1", + "@std/tar": "npm:@jsr/std__tar@^0", + "json5": "^2.2.3", + "@okikio/undent": "^0.2.1", + "resolve.exports": "^2.0.3" + }, + "scripts": { + "dev": "deno run -A --watch _repl.ts" + } +} \ No newline at end of file diff --git a/packages/utils/parse-package-name.ts b/packages/utils/parse-package-name.ts new file mode 100644 index 0000000..6048ff1 --- /dev/null +++ b/packages/utils/parse-package-name.ts @@ -0,0 +1,378 @@ +/** + * Enhanced npm package name parser. + * + * Parses package specifiers in various formats: + * - Simple: react, lodash + * - Versioned: react@18.2.0, lodash@^4.17.0 + * - Scoped: @types/node, @tanstack/react-query@5.0.0 + * - With subpath: lodash/get, @emotion/react@11/jsx-runtime + * + * Enhancements over basic parser: + * - Integrates package name validation + * - Provides URL-escaped name for registry calls + * - Extracts scope separately + * - Includes structured validation result + * + * @module + * + * @example Basic usage + * ```ts + * import { parsePackageName, parsePackageSpec } from "./parse-package-name.ts"; + * + * const result = parsePackageName("@tanstack/react-query@5.0.0/build"); + * // { + * // name: "@tanstack/react-query", + * // version: "5.0.0", + * // path: "/build", + * // scope: "@tanstack", + * // escapedName: "@tanstack%2freact-query", + * // isScoped: true + * // } + * ``` + */ + +import { validatePackageName, type ValidateResult } from "./validate-package-name.ts"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Options for parsing package names. + */ +export interface ParsePackageOptions { + /** + * If true, return partial result instead of throwing on parse errors. + * @default false + */ + ignoreError?: boolean; + + /** + * Default version when none is specified. + * Set to null to indicate no version. + * @default "latest" + */ + defaultVersion?: string | null; + + /** + * Whether to validate the package name. + * @default false + */ + validate?: boolean; +} + +/** + * Result of parsing a package specifier. + */ +export interface ParsedPackage { + /** Full package name (e.g., "@types/node") */ + name: string; + /** Version or version range (e.g., "^18.0.0") */ + version: string | null; + /** Subpath within package (e.g., "/get") */ + path: string; + /** Package scope if scoped (e.g., "@types") */ + scope: string | null; + /** URL-escaped name for registry API calls */ + escapedName: string; + /** Whether this is a scoped package */ + isScoped: boolean; + /** Validation result if validate=true was passed */ + validation?: ValidateResult; +} + +/** + * Extended result including the original input. + */ +export interface ParsedPackageSpec extends ParsedPackage { + /** Original input string */ + raw: string; + /** Whether the input contained an explicit version */ + hasExplicitVersion: boolean; +} + +// ============================================================================= +// Regex Patterns +// ============================================================================= + +/** + * Pattern for scoped packages: @scope/name@version/path + * + * Groups: + * 1. Full scoped name (@scope/name) + * 2. Version (optional) + * 3. Path (optional) + */ +export const RE_SCOPED = /^(@[^/]+\/[^@/]+)(?:@([^/]+))?(\/.*)?$/; + +/** + * Pattern for non-scoped packages: name@version/path + * + * Groups: + * 1. Package name + * 2. Version (optional) + * 3. Path (optional) + */ +export const RE_NON_SCOPED = /^([^@/]+)(?:@([^/]+))?(\/.*)?$/; + +// ============================================================================= +// Core Parsing +// ============================================================================= + +/** + * Parse a package name string into components. + * + * @param input Package specifier string + * @param options Parsing options + * @returns Parsed components + * @throws Error if parse fails and ignoreError is false + * + * @example Scoped package + * ```ts + * parsePackageName("@tanstack/react-query@5.0.0/build") + * // { + * // name: "@tanstack/react-query", + * // version: "5.0.0", + * // path: "/build", + * // scope: "@tanstack", + * // escapedName: "@tanstack%2freact-query", + * // isScoped: true + * // } + * ``` + * + * @example Non-scoped package + * ```ts + * parsePackageName("lodash@^4.17.0/get") + * // { + * // name: "lodash", + * // version: "^4.17.0", + * // path: "/get", + * // scope: null, + * // escapedName: "lodash", + * // isScoped: false + * // } + * ``` + */ +export function parsePackageName( + input: string, + options: ParsePackageOptions = {} +): ParsedPackage { + const { + ignoreError = false, + defaultVersion = "latest", + validate = false, + } = options; + + // Try scoped pattern first, then non-scoped + const match = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); + + if (!match) { + if (!ignoreError) { + throw new Error(`[parse-package-name] invalid package name: ${input}`); + } + return { + name: "", + version: defaultVersion, + path: "", + scope: null, + escapedName: "", + isScoped: false, + ...(validate ? { validation: { valid: false, errors: ["Invalid format"], warnings: [] } } : {}), + }; + } + + const name = match[1] || ""; + const version = match[2] ?? defaultVersion; + const path = match[3] || ""; + + // Extract scope from scoped packages + const isScoped = name.startsWith("@"); + const scope = isScoped ? name.slice(0, name.indexOf("/")) : null; + + // Escape name for registry URLs (/ -> %2f) + const escapedName = name.replace("/", "%2f"); + + // Optionally validate + const validation = validate ? validatePackageName(name) : undefined; + + return { + name, + version, + path, + scope, + escapedName, + isScoped, + ...(validation ? { validation } : {}), + }; +} + +/** + * Parse a package specifier with additional metadata. + * + * @param input Package specifier string + * @param options Parsing options + * @returns Extended parsed result + */ +export function parsePackageSpec( + input: string, + options: ParsePackageOptions = {} +): ParsedPackageSpec { + const parsed = parsePackageName(input, options); + + return { + ...parsed, + raw: input, + hasExplicitVersion: Boolean( + RE_SCOPED.exec(input)?.[2] || RE_NON_SCOPED.exec(input)?.[2] + ), + }; +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Check if a string looks like a package name (not a path, URL, etc). + * + * @param input String to check + * @returns True if it looks like a package name + * + * @example + * ```ts + * isPackageName("react") // true + * isPackageName("@types/node") // true + * isPackageName("./local") // false + * isPackageName("https://...") // false + * ``` + */ +export function isPackageName(input: string): boolean { + if (!input || typeof input !== "string") return false; + + // Not a path + if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) { + return false; + } + + // Not a URL + if (/^[a-z][a-z0-9+.-]*:/i.test(input)) { + return false; + } + + // Try to parse + const match = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); + return match !== null; +} + +/** + * Build a package specifier string from components. + * + * @param name Package name + * @param version Optional version + * @param path Optional subpath + * @returns Package specifier string + * + * @example + * ```ts + * buildPackageSpec("react", "18.2.0") + * // "react@18.2.0" + * + * buildPackageSpec("@types/node", "^20", "/fs") + * // "@types/node@^20/fs" + * ``` + */ +export function buildPackageSpec( + name: string, + version?: string | null, + path?: string +): string { + let result = name; + + if (version) { + result += `@${version}`; + } + + if (path) { + // Ensure path starts with / + result += path.startsWith("/") ? path : `/${path}`; + } + + return result; +} + +/** + * Normalize a subpath (ensure leading slash, remove trailing slash). + * + * @param path Subpath to normalize + * @returns Normalized path + */ +export function normalizeSubpath(path: string): string { + if (!path) return ""; + + let normalized = path; + + // Ensure leading slash + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + + // Remove trailing slash (unless it's just "/") + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + + return normalized; +} + +/** + * Join multiple subpaths. + * + * @param paths Paths to join + * @returns Joined path + */ +export function joinSubpaths(...paths: (string | undefined | null)[]): string { + const parts = paths + .filter((p): p is string => Boolean(p)) + .flatMap((p) => p.split("/").filter(Boolean)); + + if (parts.length === 0) return ""; + return "/" + parts.join("/"); +} + +// ============================================================================= +// Scope Utilities +// ============================================================================= + +/** + * Extract scope from a package name. + * + * @param name Package name + * @returns Scope with @ prefix, or null + */ +export function getScope(name: string): string | null { + if (!name.startsWith("@")) return null; + const slashIdx = name.indexOf("/"); + return slashIdx > 0 ? name.slice(0, slashIdx) : null; +} + +/** + * Get the unscoped name from a package name. + * + * @param name Package name + * @returns Name without scope + * + * @example + * getUnscopedName("@types/node") // "node" + * getUnscopedName("react") // "react" + */ +export function getUnscopedName(name: string): string { + if (!name.startsWith("@")) return name; + const slashIdx = name.indexOf("/"); + return slashIdx > 0 ? name.slice(slashIdx + 1) : name; +} + +// ============================================================================= +// Default Export (for backwards compatibility) +// ============================================================================= + +export default parsePackageName; \ No newline at end of file diff --git a/packages/utils/path.ts b/packages/utils/path.ts new file mode 100644 index 0000000..1671af1 --- /dev/null +++ b/packages/utils/path.ts @@ -0,0 +1,23 @@ +import { isAbsolute } from "@std/path/posix"; + +export * from "@std/path/posix"; +export * as posix from "@std/path/posix"; +export * as windows from "@std/path/windows"; + +/** + * An import counts as a bare import if it's neither: + * - A relative import (./foo, ../bar) + * - An absolute import (/foo, C:\foo) + * - A data: URL + * - A private/subpath import (#internal) + * + * Private imports (#...) are Node.js subpath imports that must be resolved + * against the importer's package.json "imports" field, not treated as npm packages. + * @see https://nodejs.org/api/packages.html#subpath-imports + */ +export const isBareImport = (importStr: string) => { + // Private imports are NOT bare imports - they have special resolution rules + if (/^#/.test(importStr)) return false; + + return /^(?!\.).*/.test(importStr) && !importStr.startsWith("data:") && !isAbsolute(importStr); +}; diff --git a/packages/utils/resolve-conditions.ts b/packages/utils/resolve-conditions.ts new file mode 100644 index 0000000..5b4e1dd --- /dev/null +++ b/packages/utils/resolve-conditions.ts @@ -0,0 +1,540 @@ +/** + * Package exports/imports condition resolver. + * + * Computes the appropriate export conditions for resolving Node.js conditional exports + * based on the target platform, runtime, and build configuration. + * + * Supports multiple runtimes: + * - Node.js (default when platform="node") + * - Browsers (default when platform="browser") + * - Deno + * - Bun + * - Electron (main and renderer) + * - React Native + * - Edge runtimes (Cloudflare Workers, Vercel Edge, etc.) + * + * @module + * + * @example Basic usage + * ```ts + * import { getResolverConditions, detectRuntime } from "./resolve-conditions.ts"; + * + * // Browser build + * const browserConditions = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "browser" } + * ); + * // conditions: ["import", "browser", "module", "default"] + * + * // Deno build + * const denoConditions = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "neutral", runtime: "deno" } + * ); + * // conditions: ["import", "deno", "node", "module", "default"] + * ``` + * + * @see https://nodejs.org/api/packages.html#conditional-exports + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * esbuild platform option. + */ +export type Platform = "browser" | "node" | "neutral"; + +/** + * esbuild output format. + */ +export type Format = "iife" | "cjs" | "esm"; + +/** + * esbuild import kinds. + */ +export type ImportKind = + | "entry-point" + | "import-statement" + | "require-call" + | "dynamic-import" + | "require-resolve" + | "import-rule" + | "composes-from" + | "url-token"; + +/** + * Target runtime for resolution. + * + * These are distinct from esbuild's `platform` because many runtimes + * don't map cleanly to browser/node/neutral. + */ +export type ResolveRuntime = + | "react-native" + | "electron-main" + | "electron-renderer" + | "electron-preload" + | "deno" + | "bun" + | "workerd" + | "edge-light" + | "netlify" + | "vercel"; + +/** + * Input options for computing resolver conditions. + */ +export interface ResolverConditionInputs { + platform?: Platform; + conditions?: string[]; + format?: Format; + mainFields?: string[]; + runtime?: ResolveRuntime; + runtimeConditions?: readonly string[]; +} + +/** + * Computed conditions result. + */ +export interface ResolverConditions { + /** Whether to respect package.json browser field */ + browser: boolean; + /** Whether we're in CommonJS require context */ + require: boolean; + /** Ordered list of conditions for exports resolution */ + conditions: string[]; +} + +/** + * Runtime policy defaults. + */ +export interface RuntimeDefaults { + conditions: string[]; + browserField: boolean | null; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Well-known export conditions organized by category. + */ +export const KNOWN_CONDITIONS = { + // Module system + import: "import", + require: "require", + module: "module", + default: "default", + + // Platform + browser: "browser", + node: "node", + deno: "deno", + bun: "bun", + + // Environment + development: "development", + production: "production", + + // Runtimes + "react-native": "react-native", + electron: "electron", + workerd: "workerd", + worker: "worker", + "edge-light": "edge-light", + + // Bundlers + bundler: "bundler", + types: "types", +} as const; + +/** + * Default legacy main fields by platform. + */ +export const DEFAULT_MAIN_FIELDS = { + browser: ["browser", "module", "main"], + node: ["module", "main"], + neutral: ["module", "main"], +} as const; + +// ============================================================================= +// Runtime Detection +// ============================================================================= + +/** + * Detect the current runtime environment. + * + * Useful for auto-configuring build settings. + * + * @returns Detected runtime or null if unknown + * + * @example + * ```ts + * const runtime = detectRuntime(); + * if (runtime === "deno") { + * // Configure for Deno + * } + * ``` + */ +export function detectRuntime(): ResolveRuntime | null { + // Check for Deno global + if (typeof globalThis !== "undefined" && "Deno" in globalThis) { + return "deno"; + } + + // Check for Bun global + if (typeof globalThis !== "undefined" && "Bun" in globalThis) { + return "bun"; + } + + // Check for Cloudflare Workers (workerd) + if ( + typeof globalThis !== "undefined" && + "caches" in globalThis && + typeof (globalThis as any).caches?.default !== "undefined" + ) { + return "workerd"; + } + + // Check for Node.js with Electron + if (typeof globalThis?.process !== "undefined" && globalThis?.process?.versions) { + if (globalThis?.process?.versions?.electron) { + // Check if we're in main or renderer + if (typeof window !== "undefined") { + return "electron-renderer"; + } + return "electron-main"; + } + } + + // Note: React Native detection requires different approach (not reliable from globals) + + return null; +} + +/** + * Get the recommended platform for a runtime. + * + * @param runtime Target runtime + * @returns Recommended esbuild platform + */ +export function getPlatformForRuntime(runtime: ResolveRuntime): Platform { + switch (runtime) { + case "react-native": + case "deno": + case "bun": + case "electron-main": + case "electron-preload": + return "node"; // Node-compatible APIs + + case "electron-renderer": + case "workerd": + case "edge-light": + case "netlify": + case "vercel": + return "browser"; // Web-like APIs + + default: + return "neutral"; + } +} + +// ============================================================================= +// Runtime Defaults +// ============================================================================= + +/** + * Get runtime-specific default conditions and browser field behavior. + * + * @param runtime Target runtime + * @returns Default conditions and browser field setting + */ +export function getRuntimeDefaults( + runtime: ResolveRuntime | undefined +): RuntimeDefaults { + if (!runtime) { + return { conditions: [], browserField: null }; + } + + switch (runtime) { + case "react-native": + // Metro bundler uses "react-native" condition + // Not browser, so disable browser field mapping + return { conditions: ["react-native"], browserField: false }; + + case "electron-main": + // Main process is Node-like + return { conditions: ["electron", "node"], browserField: false }; + + case "electron-renderer": + // Renderer is web-like (Chromium) + return { conditions: ["electron", "browser"], browserField: true }; + + case "electron-preload": + // Preload scripts are special (Node + limited DOM) + return { conditions: ["electron", "node"], browserField: false }; + + case "deno": + // Deno has Node compatibility layer + return { conditions: ["deno", "node"], browserField: false }; + + case "bun": + // Bun is Node-compatible with its own condition + return { conditions: ["bun", "node"], browserField: false }; + + case "workerd": + // Cloudflare Workers / workerd + return { conditions: ["workerd", "worker", "browser"], browserField: false }; + + case "edge-light": + // Vercel Edge, other edge runtimes + return { conditions: ["edge-light", "worker", "browser"], browserField: true }; + + case "netlify": + // Netlify Edge Functions + return { conditions: ["netlify", "edge-light", "worker"], browserField: false }; + + case "vercel": + // Vercel Edge Functions + return { conditions: ["vercel", "edge-light", "worker"], browserField: false }; + + default: + return { conditions: [], browserField: null }; + } +} + +// ============================================================================= +// Condition Computation +// ============================================================================= + +/** + * Check if an import kind represents require() usage. + */ +export function isRequireKind(kind: ImportKind): boolean { + return kind === "require-call" || kind === "require-resolve"; +} + +/** + * Check if the resolution context is CommonJS. + */ +export function isRequireContext( + args: { kind: ImportKind }, + esbuildOpts: { format?: Format } +): boolean { + if (isRequireKind(args.kind)) return true; + if (args.kind === "entry-point") return esbuildOpts.format === "cjs"; + return false; +} + +/** + * Compute the set of conditions for package exports resolution. + * + * Combines: + * 1. Import/require context + * 2. Platform conditions (browser, node) + * 3. esbuild's implicit "module" condition + * 4. Runtime overlay + * 5. User-specified conditions + * 6. Always includes "default" + * + * @param args Resolution args (needs kind) + * @param esbuildOpts Build options + * @returns Computed conditions + * + * @example Default browser build + * ```ts + * const c = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "browser", format: "esm" } + * ); + * // c.conditions: ["import", "browser", "module", "default"] + * // c.browser: true + * ``` + * + * @example React Native build + * ```ts + * const c = getResolverConditions( + * { kind: "import-statement" }, + * { platform: "neutral", runtime: "react-native" } + * ); + * // c.conditions: ["import", "module", "react-native", "default"] + * // c.browser: false + * ``` + */ +export function getResolverConditions( + args: { kind: ImportKind }, + esbuildOpts: ResolverConditionInputs +): ResolverConditions { + const platform = esbuildOpts.platform ?? "browser"; + + // Determine require vs import context + const require = isRequireContext(args, esbuildOpts); + + // Browser field is enabled by default for browser platform + let browserField = platform === "browser"; + + // Check if user provided explicit conditions + const userProvidedConditions = esbuildOpts.conditions !== undefined; + + // Only auto-add "module" for browser/node when no explicit conditions + const mayAutoAddModule = + !userProvidedConditions && (platform === "browser" || platform === "node"); + + const computed: string[] = []; + + // 1. Import/require dimension + computed.push(require ? "require" : "import"); + + // 2. Platform dimension + if (platform === "browser") computed.push("browser"); + if (platform === "node") computed.push("node"); + + // 3. Auto-add "module" for bundlers (esbuild convention) + if (mayAutoAddModule) computed.push("module"); + + // 4. Runtime overlay (additive, doesn't affect mayAutoAddModule) + const runtimeDefaults = getRuntimeDefaults(esbuildOpts.runtime); + if (runtimeDefaults.browserField !== null) { + browserField = runtimeDefaults.browserField; + } + for (const c of runtimeDefaults.conditions) { + if (!computed.includes(c)) computed.push(c); + } + + // 5. Extra runtime conditions + if (esbuildOpts.runtimeConditions?.length) { + for (const c of esbuildOpts.runtimeConditions) { + if (!computed.includes(c)) computed.push(c); + } + } + + // 6. User-specified conditions + if (esbuildOpts.conditions?.length) { + for (const c of esbuildOpts.conditions) { + if (!computed.includes(c)) computed.push(c); + } + } + + // 7. "default" is always active + if (!computed.includes("default")) computed.push("default"); + + return { + browser: browserField, + require, + conditions: computed, + }; +} + +// ============================================================================= +// Legacy Main Fields +// ============================================================================= + +/** + * Get the legacy main field resolution order. + * + * Used when package.json doesn't have "exports" field. + * + * @param manifest Package.json contents + * @param args Resolution args + * @param esbuildOpts Build options + * @returns Ordered list of fields to check + */ +export function getLegacyMainFields( + manifest: { type?: string }, + args: { kind: ImportKind }, + esbuildOpts: ResolverConditionInputs +): string[] { + // User-specified mainFields take precedence + if (esbuildOpts.mainFields?.length) { + return esbuildOpts.mainFields; + } + + const platform = esbuildOpts.platform ?? "browser"; + const require = isRequireContext(args, esbuildOpts); + const isModulePackage = manifest.type === "module"; + + // Base fields by platform + const baseFields = DEFAULT_MAIN_FIELDS[platform] ?? DEFAULT_MAIN_FIELDS.neutral; + const fields = [...baseFields]; + + // For require context in non-module packages, prefer main + if (require && !isModulePackage) { + // Move "main" to front + const mainIdx = fields.indexOf("main"); + if (mainIdx > 0) { + fields.splice(mainIdx, 1); + fields.unshift("main"); + } + } + + return fields; +} + +// ============================================================================= +// Condition Utilities +// ============================================================================= + +/** + * Check if a condition is satisfied. + * + * @param condition Condition to check + * @param activeConditions List of active conditions + * @returns True if condition is active + */ +export function conditionMatches( + condition: string, + activeConditions: string[] +): boolean { + return activeConditions.includes(condition); +} + +/** + * Merge condition sets, preserving order and removing duplicates. + * + * @param base Base conditions + * @param additions Conditions to add + * @returns Merged conditions + */ +export function mergeConditions( + base: string[], + additions: string[] +): string[] { + const result = [...base]; + for (const c of additions) { + if (!result.includes(c)) { + result.push(c); + } + } + return result; +} + +/** + * Get a description of what a condition is for. + * + * @param condition Condition name + * @returns Human-readable description + */ +export function describeCondition(condition: string): string { + const descriptions: Record = { + import: "ESM import statements", + require: "CommonJS require() calls", + module: "Bundler-aware ESM entry", + default: "Fallback entry", + browser: "Browser environments", + node: "Node.js environments", + deno: "Deno runtime", + bun: "Bun runtime", + "react-native": "React Native apps", + electron: "Electron apps", + workerd: "Cloudflare Workers", + worker: "Web Workers", + "edge-light": "Edge runtimes", + development: "Development builds", + production: "Production builds", + types: "TypeScript type definitions", + }; + + return descriptions[condition] ?? `Custom condition: ${condition}`; +} \ No newline at end of file diff --git a/packages/utils/resolve-exports-imports.ts b/packages/utils/resolve-exports-imports.ts new file mode 100644 index 0000000..184ab6b --- /dev/null +++ b/packages/utils/resolve-exports-imports.ts @@ -0,0 +1,4 @@ +/** + * Based on `resolve.exports` (https://npmjs.com/resolve.exports) by @lukeed (https://github.com/lukeed) + */ +export * from "resolve.exports"; \ No newline at end of file diff --git a/packages/utils/resolve-import-map.ts b/packages/utils/resolve-import-map.ts new file mode 100644 index 0000000..69e19ce --- /dev/null +++ b/packages/utils/resolve-import-map.ts @@ -0,0 +1,727 @@ +/** + * Import Map Utilities (HTML Spec Compliant) + * + * Utilities for working with import maps as defined in the HTML Standard. + * Supports browser native import maps and Deno/Bun configurations. + * + * This implementation follows the WHATWG HTML specification for import maps: + * https://html.spec.whatwg.org/multipage/webappapis.html#import-maps + * + * @module + * + * @example Basic usage + * ```ts + * import { + * createImportMap, + * resolveImportMap, + * validateImportMap, + * } from "./import-map.ts"; + * + * const map = createImportMap({ + * imports: { + * "react": "https://esm.sh/react@18", + * "lodash/": "https://esm.sh/lodash-es/" + * } + * }); + * + * resolveImportMap(map, "react") // "https://esm.sh/react@18" + * resolveImportMap(map, "lodash/get") // "https://esm.sh/lodash-es/get" + * ``` + * + * @see https://html.spec.whatwg.org/multipage/webappapis.html#import-maps + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap + * @see https://docs.deno.com/runtime/fundamentals/modules/#import-maps + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Import map structure as defined by the HTML spec. + * + * @example Basic import map + * ```json + * { + * "imports": { + * "react": "https://esm.sh/react@18", + * "lodash/": "https://esm.sh/lodash-es/" + * } + * } + * ``` + * + * @example With scopes + * ```json + * { + * "imports": { + * "react": "https://esm.sh/react@18" + * }, + * "scopes": { + * "/vendor/": { + * "react": "https://esm.sh/react@17" + * } + * } + * } + * ``` + * + * @example With integrity (browser extension) + * ```json + * { + * "imports": { + * "lodash": "https://esm.sh/lodash-es@4" + * }, + * "integrity": { + * "https://esm.sh/lodash-es@4": "sha384-..." + * } + * } + * ``` + */ +export interface ImportMap { + /** Top-level import mappings (specifier → URL/path) */ + imports?: Record; + /** + * Scoped import mappings. + * Keys are URL prefixes, values are mappings that apply when + * importing from scripts whose URL starts with that prefix. + */ + scopes?: Record>; + /** + * Integrity metadata (optional browser extension). + * Maps URLs to subresource integrity hashes. + */ + integrity?: Record; +} + +/** + * Import map generation options. + */ +export interface GenerateImportMapOptions { + /** Base URL for relative resolutions */ + baseUrl?: string; + /** CDN to use for npm packages */ + cdn?: "esm.sh" | "unpkg" | "jsdelivr" | "skypack" | "jsr"; + /** Include dev dependencies */ + includeDevDeps?: boolean; + /** Include peer dependencies */ + includePeerDeps?: boolean; + /** Custom URL generator */ + urlGenerator?: (name: string, version: string) => string; +} + +/** + * Package.json dependencies subset. + */ +interface PackageDeps { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +/** + * Validation result for import maps. + */ +export interface ImportMapValidation { + /** Whether the import map is valid (no errors) */ + valid: boolean; + /** Errors that prevent the import map from working */ + errors: string[]; + /** Warnings about potential issues */ + warnings: string[]; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * CDN URL templates for package resolution. + * + * @example + * ```ts + * CDN_TEMPLATES["esm.sh"]("react", "^18.2.0") + * // "https://esm.sh/react@^18.2.0" + * + * CDN_TEMPLATES["jsr"]("@std/path", "1.0.0") + * // "https://jsr.io/@std/path/1.0.0/mod.ts" + * ``` + */ +export const CDN_TEMPLATES: Record string> = { + "esm.sh": (name, version) => `https://esm.sh/${name}@${version}`, + unpkg: (name, version) => `https://unpkg.com/${name}@${version}`, + jsdelivr: (name, version) => `https://cdn.jsdelivr.net/npm/${name}@${version}`, + skypack: (name, version) => `https://cdn.skypack.dev/${name}@${version}`, + jsr: (name, version) => { + // JSR packages are always scoped + if (name.startsWith("@")) { + const [scope, pkg] = name.slice(1).split("/"); + return `https://jsr.io/@${scope}/${pkg}/${version}/mod.ts`; + } + // Non-scoped name can't be a JSR package + return `https://esm.sh/${name}@${version}`; + }, +}; + +// ============================================================================= +// Import Map Creation +// ============================================================================= + +/** + * Create an import map from configuration. + * + * Creates a copy to avoid mutations to the original. + * + * @param config Import map configuration + * @returns Validated import map + * + * @example + * ```ts + * const map = createImportMap({ + * imports: { + * "react": "https://esm.sh/react@18", + * "react-dom": "https://esm.sh/react-dom@18" + * }, + * scopes: { + * "/vendor/": { + * "react": "https://esm.sh/react@17" + * } + * } + * }); + * ``` + */ +export function createImportMap(config: ImportMap): ImportMap { + const map: ImportMap = {}; + + if (config.imports) { + map.imports = { ...config.imports }; + } + + if (config.scopes) { + map.scopes = {}; + for (const [scope, mappings] of Object.entries(config.scopes)) { + map.scopes[scope] = { ...mappings }; + } + } + + if (config.integrity) { + map.integrity = { ...config.integrity }; + } + + return map; +} + +/** + * Generate an import map from package.json dependencies. + * + * @param pkg Package.json contents + * @param options Generation options + * @returns Generated import map + * + * @example Basic usage + * ```ts + * const pkg = { + * dependencies: { + * "react": "^18.2.0", + * "lodash": "^4.17.0" + * } + * }; + * + * generateImportMap(pkg, { cdn: "esm.sh" }); + * // { + * // imports: { + * // "react": "https://esm.sh/react@^18.2.0", + * // "lodash": "https://esm.sh/lodash@^4.17.0" + * // } + * // } + * ``` + */ +export function generateImportMap( + pkg: PackageDeps, + options: GenerateImportMapOptions = {} +): ImportMap { + const { + cdn = "esm.sh", + includeDevDeps = false, + includePeerDeps = false, + urlGenerator = CDN_TEMPLATES[cdn], + } = options; + + const imports: Record = {}; + + // Add dependencies + if (pkg.dependencies) { + for (const [name, version] of Object.entries(pkg.dependencies)) { + imports[name] = urlGenerator(name, cleanVersion(version)); + } + } + + // Add dev dependencies (don't override regular deps) + if (includeDevDeps && pkg.devDependencies) { + for (const [name, version] of Object.entries(pkg.devDependencies)) { + if (!(name in imports)) { + imports[name] = urlGenerator(name, cleanVersion(version)); + } + } + } + + // Add peer dependencies (don't override) + if (includePeerDeps && pkg.peerDependencies) { + for (const [name, version] of Object.entries(pkg.peerDependencies)) { + if (!(name in imports)) { + imports[name] = urlGenerator(name, cleanVersion(version)); + } + } + } + + return { imports }; +} + +/** + * Clean version string for URL usage. + */ +function cleanVersion(version: string): string { + // Remove npm: alias prefix + if (version.startsWith("npm:")) { + version = version.slice(4); + } + + // Remove workspace: prefix + if (version.startsWith("workspace:")) { + return "latest"; + } + + // Remove jsr: prefix + if (version.startsWith("jsr:")) { + version = version.slice(4); + } + + return version; +} + +// ============================================================================= +// Import Map Merging +// ============================================================================= + +/** + * Merge multiple import maps. + * + * Per the HTML spec, later maps take precedence over earlier ones. + * + * @param maps Import maps to merge (later takes precedence) + * @returns Merged import map + * + * @example + * ```ts + * const base = { + * imports: { "react": "https://esm.sh/react@17" } + * }; + * const override = { + * imports: { "react": "https://esm.sh/react@18" } + * }; + * + * mergeImportMaps(base, override); + * // { imports: { "react": "https://esm.sh/react@18" } } + * ``` + */ +export function mergeImportMaps(...maps: ImportMap[]): ImportMap { + const result: ImportMap = {}; + + for (const map of maps) { + // Merge imports + if (map.imports) { + result.imports = { ...result.imports, ...map.imports }; + } + + // Merge scopes (deep merge) + if (map.scopes) { + result.scopes = result.scopes || {}; + for (const [scope, mappings] of Object.entries(map.scopes)) { + result.scopes[scope] = { ...result.scopes[scope], ...mappings }; + } + } + + // Merge integrity + if (map.integrity) { + result.integrity = { ...result.integrity, ...map.integrity }; + } + } + + return result; +} + +/** + * Add entries to an import map. + * + * @param map Base import map + * @param imports Entries to add + * @param scope Optional scope for scoped imports + * @returns New import map with additions + */ +export function addToImportMap( + map: ImportMap, + imports: Record, + scope?: string +): ImportMap { + const result: ImportMap = { + imports: map.imports ? { ...map.imports } : undefined, + scopes: map.scopes ? { ...map.scopes } : undefined, + integrity: map.integrity ? { ...map.integrity } : undefined, + }; + + if (scope) { + result.scopes = result.scopes || {}; + result.scopes[scope] = { ...result.scopes[scope], ...imports }; + } else { + result.imports = { ...result.imports, ...imports }; + } + + return result; +} + +// ============================================================================= +// Import Map Resolution (HTML Spec Compliant) +// ============================================================================= + +/** + * Resolve a specifier against mappings. + * + * Implements the resolution algorithm from the HTML spec: + * 1. Check for exact match + * 2. Check for package prefix match (keys ending with /) + */ +function resolveFromMappings( + mappings: Record, + specifier: string +): string | null { + // 1. Exact match + if (specifier in mappings) { + return mappings[specifier]; + } + + // 2. Package prefix match - sorted by length (longest first) per spec + const prefixKeys = Object.keys(mappings) + .filter((k) => k.endsWith("/")) + .sort((a, b) => b.length - a.length); + + for (const key of prefixKeys) { + if (specifier.startsWith(key)) { + const suffix = specifier.slice(key.length); + const value = mappings[key]; + // Per WHATWG spec, value should also end with / for prefix keys. + // We do lenient concatenation here since bundler contexts often + // have values without trailing / (e.g., CDN URLs). + return value.endsWith("/") ? value + suffix : value + "/" + suffix; + } + } + + return null; +} + +/** + * Resolve a specifier using an import map. + * + * Implements the WHATWG HTML import map resolution algorithm: + * 1. If referrer provided, check scopes (longest match first) + * 2. Check top-level imports + * 3. Return null if not mapped + * + * @param map Import map + * @param specifier Module specifier to resolve + * @param referrer Optional referrer URL for scoped resolution + * @returns Resolved URL or null if not mapped + * + * @example Basic resolution + * ```ts + * const map = { + * imports: { + * "react": "https://esm.sh/react@18", + * "lodash/": "https://esm.sh/lodash-es/" + * } + * }; + * + * resolveImportMap(map, "react"); + * // "https://esm.sh/react@18" + * + * resolveImportMap(map, "lodash/get"); + * // "https://esm.sh/lodash-es/get" + * + * resolveImportMap(map, "unknown"); + * // null + * ``` + * + * @example Scoped resolution + * ```ts + * const map = { + * imports: { "react": "https://esm.sh/react@18" }, + * scopes: { + * "/vendor/legacy/": { "react": "https://esm.sh/react@16" }, + * "/vendor/": { "react": "https://esm.sh/react@17" } + * } + * }; + * + * // From a script at /vendor/legacy/old.js + * resolveImportMap(map, "react", "/vendor/legacy/old.js"); + * // "https://esm.sh/react@16" (longest scope match) + * ``` + */ +export function resolveImportMap( + map: ImportMap, + specifier: string, + referrer?: string +): string | null { + // Check scoped mappings first (if referrer provided) + if (referrer && map.scopes) { + // Sort scopes by length (longest first) per spec + const sortedScopes = Object.keys(map.scopes).sort( + (a, b) => b.length - a.length + ); + + for (const scope of sortedScopes) { + if (referrer.startsWith(scope)) { + const resolved = resolveFromMappings(map.scopes[scope], specifier); + if (resolved !== null) return resolved; + } + } + } + + // Check top-level imports + if (map.imports) { + return resolveFromMappings(map.imports, specifier); + } + + return null; +} + +/** + * Get the integrity hash for a URL if specified. + * + * @param map Import map + * @param url URL to look up + * @returns Integrity hash or null + */ +export function getIntegrity(map: ImportMap, url: string): string | null { + return map.integrity?.[url] ?? null; +} + +// ============================================================================= +// Import Map Validation +// ============================================================================= + +/** + * Validate an import map. + * + * Checks for common errors and issues per the HTML spec. + * + * @param map Import map to validate + * @returns Validation result + * + * @example + * ```ts + * validateImportMap({ + * imports: { + * "": "https://example.com", // Empty key - error + * "foo": "not-a-valid-url", // Invalid URL - error + * "bar/": "https://example.com" // Missing trailing / - warning + * } + * }); + * // { + * // valid: false, + * // errors: ["Empty key in imports", "Invalid mapping value..."], + * // warnings: ["Key \"bar/\" ends with /..."] + * // } + * ``` + */ +export function validateImportMap(map: ImportMap): ImportMapValidation { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate imports + if (map.imports) { + for (const [key, value] of Object.entries(map.imports)) { + if (!key) { + errors.push("Empty key in imports"); + continue; + } + + if (!isValidMappingValue(value)) { + errors.push(`Invalid mapping value for "${key}": ${value}`); + } + + if (key.endsWith("/") && !value.endsWith("/")) { + warnings.push( + `Key "${key}" ends with / but value doesn't - subpath imports may fail` + ); + } + } + } + + // Validate scopes + if (map.scopes) { + for (const [scope, mappings] of Object.entries(map.scopes)) { + if (!isValidScopeKey(scope)) { + warnings.push(`Scope "${scope}" should start with / or be a URL`); + } + + for (const [key, value] of Object.entries(mappings)) { + if (!key) { + errors.push(`Empty key in scope "${scope}"`); + continue; + } + + if (!isValidMappingValue(value)) { + errors.push( + `Invalid mapping value for "${key}" in scope "${scope}": ${value}` + ); + } + + if (key.endsWith("/") && !value.endsWith("/")) { + warnings.push( + `Key "${key}" in scope "${scope}" ends with / but value doesn't` + ); + } + } + } + } + + // Validate integrity + if (map.integrity) { + for (const [url, hash] of Object.entries(map.integrity)) { + if (!isValidIntegrityHash(hash)) { + warnings.push(`Invalid integrity hash for "${url}": ${hash}`); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +function isValidMappingValue(value: string): boolean { + return ( + value.startsWith("http://") || + value.startsWith("https://") || + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith("data:") || + value.startsWith("blob:") + ); +} + +function isValidScopeKey(scope: string): boolean { + return ( + scope.startsWith("/") || + scope.startsWith("http://") || + scope.startsWith("https://") || + scope.startsWith("./") || + scope.startsWith("../") + ); +} + +function isValidIntegrityHash(hash: string): boolean { + return /^(sha256|sha384|sha512)-[A-Za-z0-9+/]+=*$/.test(hash); +} + +// ============================================================================= +// Serialization +// ============================================================================= + +/** + * Serialize an import map to JSON. + * + * @param map Import map + * @param pretty Whether to pretty-print (default: false) + * @returns JSON string + */ +export function serializeImportMap(map: ImportMap, pretty = false): string { + const output: ImportMap = {}; + if (map.imports && Object.keys(map.imports).length > 0) { + output.imports = map.imports; + } + if (map.scopes && Object.keys(map.scopes).length > 0) { + output.scopes = map.scopes; + } + if (map.integrity && Object.keys(map.integrity).length > 0) { + output.integrity = map.integrity; + } + + return JSON.stringify(output, null, pretty ? 2 : undefined); +} + +/** + * Parse an import map from JSON. + * + * @param json JSON string + * @returns Parsed import map + * @throws Error if JSON is invalid or doesn't match import map structure + */ +export function parseImportMap(json: string): ImportMap { + const parsed = JSON.parse(json); + + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Import map must be an object"); + } + + if (parsed.imports !== undefined && typeof parsed.imports !== "object") { + throw new Error("imports must be an object"); + } + + if (parsed.scopes !== undefined && typeof parsed.scopes !== "object") { + throw new Error("scopes must be an object"); + } + + if (parsed.integrity !== undefined && typeof parsed.integrity !== "object") { + throw new Error("integrity must be an object"); + } + + return parsed as ImportMap; +} + +/** + * Generate an HTML script tag for an import map. + * + * @param map Import map + * @returns HTML script element string + */ +export function toScriptTag(map: ImportMap): string { + const json = serializeImportMap(map, true); + return ``; +} + +// ============================================================================= +// Deno/Bun Integration +// ============================================================================= + +/** + * Convert an import map to Deno's deno.json format. + * + * @param map Import map + * @returns deno.json compatible object + */ +export function toDenoConfig(map: ImportMap): { + imports?: Record; + scopes?: Record>; +} { + return { + imports: map.imports, + scopes: map.scopes, + }; +} + +/** + * Create an import map from Deno's deno.json format. + * + * @param config deno.json contents + * @returns Import map + */ +export function fromDenoConfig(config: { + imports?: Record; + scopes?: Record>; +}): ImportMap { + return { + imports: config.imports, + scopes: config.scopes, + }; +} \ No newline at end of file diff --git a/packages/utils/runtime-builtins.ts b/packages/utils/runtime-builtins.ts new file mode 100644 index 0000000..1abff64 --- /dev/null +++ b/packages/utils/runtime-builtins.ts @@ -0,0 +1,1206 @@ +/** + * Runtime Built-in Module Utilities + * + * Cross-runtime built-in module handling for Node.js, Deno, and Bun. + * Provides detection, normalization, polyfill mapping, and external + * configuration generation for bundlers like esbuild. + * + * This module helps bundlejs and similar tools properly handle built-in + * modules across different JavaScript runtimes. + * + * @module + * + * @example Detection + * ```ts + * import { isBuiltin, getBuiltinInfo } from "./runtime-builtins.ts"; + * + * isBuiltin("fs") // true (Node.js) + * isBuiltin("node:fs") // true (Node.js with prefix) + * isBuiltin("Deno.readFile") // false (Deno global, not a module) + * + * getBuiltinInfo("path") + * // { name: "path", category: "core", polyfill: "path-browserify", ... } + * ``` + * + * @example Polyfills + * ```ts + * import { getPolyfill, createPolyfillMap } from "./runtime-builtins.ts"; + * + * getPolyfill("path") // "path-browserify" + * getPolyfill("fs") // null (no browser polyfill) + * + * createPolyfillMap() + * // { "path": "path-browserify", "node:path": "path-browserify", ... } + * ``` + * + * @example External Configuration + * ```ts + * import { createExternalPatterns } from "./runtime-builtins.ts"; + * + * // For esbuild + * createExternalPatterns({ runtime: "node" }) + * // ["fs", "node:fs", "path", "node:path", ...] + * + * createExternalPatterns({ runtime: "browser", polyfillable: true }) + * // ["child_process", "node:child_process", "cluster", ...] // Only non-polyfillable + * ``` + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Supported runtime environments. + */ +export type RuntimeTarget = "node" | "deno" | "bun" | "browser" | "workerd"; + +/** + * Built-in module categories. + */ +export type BuiltinCategory = + | "core" // Always available (fs, path, etc.) + | "worker" // Available in Worker threads + | "deprecated" // Deprecated modules (sys, punycode) + | "experimental" // Experimental APIs (wasi, sqlite) + | "internal"; // Internal modules (_http_agent, etc.) + +/** + * Information about a built-in module. + */ +export interface BuiltinInfo { + /** Module name (without node: prefix) */ + name: string; + /** Module category */ + category: BuiltinCategory; + /** Browser polyfill package name (null if not polyfillable) */ + polyfill: string | null; + /** Whether this module has subpath exports (e.g., fs/promises) */ + hasSubpaths: boolean; + /** Notable subpaths */ + subpaths?: string[]; + /** Runtime support */ + runtimes: { + node: boolean | "partial"; + deno: boolean | "partial"; + bun: boolean | "partial"; + }; +} + +/** + * Polyfill configuration for bundlers. + */ +export interface PolyfillConfig { + /** Module to polyfill */ + builtin: string; + /** Polyfill package to use */ + package: string; + /** Optional exports/conditions handling */ + exports?: string; +} + +// ============================================================================= +// Node.js Built-in Database +// ============================================================================= + +/** + * Complete database of Node.js built-in modules with metadata. + * + * @example Reading the database + * ```ts + * NODE_BUILTINS["path"] + * // { + * // name: "path", + * // category: "core", + * // polyfill: "path-browserify", + * // hasSubpaths: true, + * // subpaths: ["posix", "win32"], + * // runtimes: { node: true, deno: true, bun: true } + * // } + * + * NODE_BUILTINS["child_process"] + * // { + * // name: "child_process", + * // category: "core", + * // polyfill: null, // Cannot be polyfilled in browser + * // hasSubpaths: false, + * // runtimes: { node: true, deno: "partial", bun: true } + * // } + * ``` + */ +export const NODE_BUILTINS: Record = { + // Core modules - filesystem & I/O + fs: { + name: "fs", + category: "core", + polyfill: null, // memfs/browserify-fs exist but are complex + hasSubpaths: true, + subpaths: ["promises"], + runtimes: { node: true, deno: true, bun: true }, + }, + path: { + name: "path", + category: "core", + polyfill: "path-browserify", + hasSubpaths: true, + subpaths: ["posix", "win32"], + runtimes: { node: true, deno: true, bun: true }, + }, + os: { + name: "os", + category: "core", + polyfill: "os-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - process & buffer + process: { + name: "process", + category: "core", + polyfill: "process", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + buffer: { + name: "buffer", + category: "core", + polyfill: "buffer", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - crypto & security + crypto: { + name: "crypto", + category: "core", + polyfill: "crypto-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + tls: { + name: "tls", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: true }, + }, + + // Core modules - networking + http: { + name: "http", + category: "core", + polyfill: "stream-http", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + https: { + name: "https", + category: "core", + polyfill: "https-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + http2: { + name: "http2", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: "partial" }, + }, + net: { + name: "net", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + dgram: { + name: "dgram", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: "partial" }, + }, + dns: { + name: "dns", + category: "core", + polyfill: null, + hasSubpaths: true, + subpaths: ["promises"], + runtimes: { node: true, deno: "partial", bun: true }, + }, + + // Core modules - streams + stream: { + name: "stream", + category: "core", + polyfill: "stream-browserify", + hasSubpaths: true, + subpaths: ["promises", "consumers", "web"], + runtimes: { node: true, deno: true, bun: true }, + }, + string_decoder: { + name: "string_decoder", + category: "core", + polyfill: "string_decoder", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - utilities + util: { + name: "util", + category: "core", + polyfill: "util", + hasSubpaths: true, + subpaths: ["types"], + runtimes: { node: true, deno: true, bun: true }, + }, + events: { + name: "events", + category: "core", + polyfill: "events", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + assert: { + name: "assert", + category: "core", + polyfill: "assert", + hasSubpaths: true, + subpaths: ["strict"], + runtimes: { node: true, deno: true, bun: true }, + }, + url: { + name: "url", + category: "core", + polyfill: "url", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + querystring: { + name: "querystring", + category: "core", + polyfill: "querystring-es3", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - timers + timers: { + name: "timers", + category: "core", + polyfill: "timers-browserify", + hasSubpaths: true, + subpaths: ["promises"], + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - compression + zlib: { + name: "zlib", + category: "core", + polyfill: "browserify-zlib", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - console & terminal + console: { + name: "console", + category: "core", + polyfill: "console-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + tty: { + name: "tty", + category: "core", + polyfill: "tty-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + readline: { + name: "readline", + category: "core", + polyfill: null, + hasSubpaths: true, + subpaths: ["promises"], + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - child processes & clusters + child_process: { + name: "child_process", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: true }, + }, + cluster: { + name: "cluster", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: "partial" }, + }, + worker_threads: { + name: "worker_threads", + category: "worker", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Core modules - module system + module: { + name: "module", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: true }, + }, + + // Core modules - VM & debugging + vm: { + name: "vm", + category: "core", + polyfill: "vm-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: "partial" }, + }, + v8: { + name: "v8", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: false }, + }, + inspector: { + name: "inspector", + category: "core", + polyfill: null, + hasSubpaths: true, + subpaths: ["promises"], + runtimes: { node: true, deno: false, bun: false }, + }, + repl: { + name: "repl", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: false }, + }, + + // Core modules - diagnostics + perf_hooks: { + name: "perf_hooks", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + async_hooks: { + name: "async_hooks", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: "partial", bun: "partial" }, + }, + diagnostics_channel: { + name: "diagnostics_channel", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: "partial" }, + }, + trace_events: { + name: "trace_events", + category: "core", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: false }, + }, + + // Core modules - misc + constants: { + name: "constants", + category: "core", + polyfill: "constants-browserify", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + + // Node.js test runner + test: { + name: "test", + category: "core", + polyfill: null, + hasSubpaths: true, + subpaths: ["reporters"], + runtimes: { node: true, deno: false, bun: false }, + }, + + // Deprecated modules + domain: { + name: "domain", + category: "deprecated", + polyfill: "domain-browser", + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: "partial" }, + }, + punycode: { + name: "punycode", + category: "deprecated", + polyfill: "punycode", + hasSubpaths: false, + runtimes: { node: true, deno: true, bun: true }, + }, + sys: { + name: "sys", + category: "deprecated", + polyfill: "util", + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: false }, + }, + + // Experimental modules + wasi: { + name: "wasi", + category: "experimental", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: false }, + }, + sqlite: { + name: "sqlite", + category: "experimental", + polyfill: null, + hasSubpaths: false, + runtimes: { node: true, deno: false, bun: true }, + }, +}; + +/** + * Set of built-in module names for fast lookup. + */ +export const BUILTIN_NAMES = new Set(Object.keys(NODE_BUILTINS)); + +/** + * Deprecated Node.js API paths that should be externalized. + * + * These paths refer to APIs that have been removed or deprecated + * from Node.js and should be marked as external. + */ +export const DEPRECATED_API_PATHS = [ + "v8/tools/codemap", + "v8/tools/consarray", + "v8/tools/csvparser", + "v8/tools/logreader", + "v8/tools/profile_view", + "v8/tools/profile", + "v8/tools/SourceMap", + "v8/tools/splaytree", + "v8/tools/tickprocessor-driver", + "v8/tools/tickprocessor", + "node-inspect/lib/_inspect", + "node-inspect/lib/internal/inspect_client", + "node-inspect/lib/internal/inspect_repl", + "_linklist", + "_stream_wrap", + "_stream_duplex", + "_stream_readable", + "_stream_writable", + "_stream_transform", + "_stream_passthrough", +] as const; + +// ============================================================================= +// Detection +// ============================================================================= + +/** + * Check if a specifier refers to a built-in module. + * + * Handles both prefixed (node:fs) and unprefixed (fs) specifiers, + * including subpaths (node:fs/promises). + * + * @param specifier Module specifier + * @returns True if it's a built-in module + * + * @example + * ```ts + * isBuiltin("fs") // true + * isBuiltin("node:fs") // true + * isBuiltin("node:fs/promises") // true + * isBuiltin("react") // false + * isBuiltin("fsevents") // false (native addon, not built-in) + * isBuiltin("pnpapi") // false (Yarn PnP API) + * ``` + */ +export function isBuiltin(specifier: string): boolean { + const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + const baseName = normalized.split("/")[0]; + return BUILTIN_NAMES.has(baseName); +} + +/** + * Get the base built-in module name from a specifier. + * + * @param specifier Module specifier + * @returns Base module name or null if not a built-in + * + * @example + * ```ts + * getBuiltinName("node:fs/promises") // "fs" + * getBuiltinName("path") // "path" + * getBuiltinName("react") // null + * ``` + */ +export function getBuiltinName(specifier: string): string | null { + const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + const baseName = normalized.split("/")[0]; + return BUILTIN_NAMES.has(baseName) ? baseName : null; +} + +/** + * Get information about a built-in module. + * + * @param specifier Module specifier + * @returns Module info or null if not a built-in + * + * @example + * ```ts + * getBuiltinInfo("path") + * // { + * // name: "path", + * // category: "core", + * // polyfill: "path-browserify", + * // hasSubpaths: true, + * // subpaths: ["posix", "win32"], + * // runtimes: { node: true, deno: true, bun: true } + * // } + * + * getBuiltinInfo("child_process") + * // { + * // name: "child_process", + * // category: "core", + * // polyfill: null, + * // hasSubpaths: false, + * // runtimes: { node: true, deno: "partial", bun: true } + * // } + * ``` + */ +export function getBuiltinInfo(specifier: string): BuiltinInfo | null { + const name = getBuiltinName(specifier); + return name ? NODE_BUILTINS[name] : null; +} + +// ============================================================================= +// Normalization +// ============================================================================= + +/** + * Normalize a built-in module specifier to use node: prefix. + * + * The node: prefix is recommended for clarity and to avoid + * conflicts with npm packages. + * + * @param specifier Module specifier + * @returns Normalized specifier with node: prefix + * + * @example + * ```ts + * normalizeBuiltin("fs") // "node:fs" + * normalizeBuiltin("node:fs") // "node:fs" + * normalizeBuiltin("fs/promises") // "node:fs/promises" + * normalizeBuiltin("node:fs/promises") // "node:fs/promises" + * normalizeBuiltin("react") // "react" (not a builtin) + * ``` + */ +export function normalizeBuiltin(specifier: string): string { + if (specifier.startsWith("node:")) return specifier; + if (isBuiltin(specifier)) return `node:${specifier}`; + return specifier; +} + +/** + * Strip the node: prefix from a built-in specifier. + * + * @param specifier Module specifier + * @returns Specifier without node: prefix + * + * @example + * ```ts + * stripNodePrefix("node:fs") // "fs" + * stripNodePrefix("fs") // "fs" + * stripNodePrefix("node:fs/promises") // "fs/promises" + * ``` + */ +export function stripNodePrefix(specifier: string): string { + return specifier.startsWith("node:") ? specifier.slice(5) : specifier; +} + +/** + * Get the subpath from a built-in specifier. + * + * @param specifier Module specifier + * @returns Subpath or null if none + * + * @example + * ```ts + * getBuiltinSubpath("node:fs/promises") // "promises" + * getBuiltinSubpath("fs/promises") // "promises" + * getBuiltinSubpath("fs") // null + * getBuiltinSubpath("path/posix") // "posix" + * ``` + */ +export function getBuiltinSubpath(specifier: string): string | null { + const normalized = stripNodePrefix(specifier); + const slashIdx = normalized.indexOf("/"); + return slashIdx > 0 ? normalized.slice(slashIdx + 1) : null; +} + +// ============================================================================= +// Polyfills +// ============================================================================= + +/** + * Get the browser polyfill package for a built-in module. + * + * Returns null for modules that cannot be polyfilled in browsers + * (e.g., child_process, fs, cluster). + * + * @param specifier Module specifier + * @returns Polyfill package name or null + * + * @example + * ```ts + * getPolyfill("path") // "path-browserify" + * getPolyfill("events") // "events" + * getPolyfill("fs") // null + * getPolyfill("child_process") // null + * getPolyfill("react") // null (not a builtin) + * ``` + */ +export function getPolyfill(specifier: string): string | null { + const info = getBuiltinInfo(specifier); + return info?.polyfill ?? null; +} + +/** + * Check if a built-in module has a browser polyfill. + * + * @param specifier Module specifier + * @returns True if polyfill is available + * + * @example + * ```ts + * hasPolyfill("path") // true + * hasPolyfill("fs") // false + * hasPolyfill("child_process") // false + * ``` + */ +export function hasPolyfill(specifier: string): boolean { + return getPolyfill(specifier) !== null; +} + +/** + * Get all built-in modules that have polyfills. + * + * @returns Array of [builtin, polyfill] pairs + * + * @example + * ```ts + * getAllPolyfills() + * // [ + * // ["path", "path-browserify"], + * // ["events", "events"], + * // ["buffer", "buffer"], + * // ["process", "process"], + * // ... + * // ] + * ``` + */ +export function getAllPolyfills(): [string, string][] { + return Object.entries(NODE_BUILTINS) + .filter(([_, info]) => info.polyfill !== null) + .map(([name, info]) => [name, info.polyfill!]); +} + +// ============================================================================= +// External Configuration +// ============================================================================= + +/** + * Options for generating external patterns. + */ +export interface ExternalPatternsOptions { + /** Target runtime (affects which modules are external) */ + runtime?: RuntimeTarget; + /** Include deprecated modules */ + includeDeprecated?: boolean; + /** Include experimental modules */ + includeExperimental?: boolean; + /** Include modules with polyfills (false = only non-polyfillable) */ + includePolyfillable?: boolean; + /** Include subpath patterns (fs/*) */ + includeSubpaths?: boolean; + /** Include deprecated API paths */ + includeDeprecatedPaths?: boolean; +} + +/** + * Create external patterns for bundler configuration. + * + * Generates patterns suitable for esbuild's `external` option. + * + * @param options External options + * @returns Array of external patterns + * + * @example All built-ins (for Node.js target) + * ```ts + * createExternalPatterns({ runtime: "node" }) + * // [ + * // "fs", "node:fs", "fs/*", "node:fs/*", + * // "path", "node:path", "path/*", "node:path/*", + * // ... + * // ] + * ``` + * + * @example Only non-polyfillable (for browser with polyfills) + * ```ts + * createExternalPatterns({ + * runtime: "browser", + * includePolyfillable: false + * }) + * // [ + * // "fs", "node:fs", + * // "child_process", "node:child_process", + * // "cluster", "node:cluster", + * // ... + * // ] + * ``` + * + * @example Usage with esbuild + * ```ts + * esbuild.build({ + * external: createExternalPatterns({ runtime: "node" }), + * // ... + * }); + * ``` + */ +export function createExternalPatterns(options: ExternalPatternsOptions = {}): string[] { + const { + runtime = "node", + includeDeprecated = true, + includeExperimental = false, + includePolyfillable = true, + includeSubpaths = true, + includeDeprecatedPaths = true, + } = options; + + const externals: string[] = []; + + for (const [name, info] of Object.entries(NODE_BUILTINS)) { + // Filter by category + if (!includeDeprecated && info.category === "deprecated") continue; + if (!includeExperimental && info.category === "experimental") continue; + + // Filter by polyfill availability + if (!includePolyfillable && info.polyfill !== null) continue; + + // Check runtime support (for browser, only include non-polyfillable) + if (runtime === "browser" && info.polyfill !== null && includePolyfillable) { + continue; + } + + // Add both prefixed and unprefixed + externals.push(name); + externals.push(`node:${name}`); + + // Add subpath patterns + if (includeSubpaths && info.hasSubpaths) { + externals.push(`${name}/*`); + externals.push(`node:${name}/*`); + } + } + + // Add deprecated API paths + if (includeDeprecatedPaths) { + externals.push(...DEPRECATED_API_PATHS); + } + + // Add common non-module externals + externals.push("pnpapi"); // Yarn PnP + + return externals; +} + +/** + * Get all Node.js built-in module names. + * + * @param options Filter options + * @returns Array of built-in names + * + * @example + * ```ts + * getBuiltinList() + * // ["assert", "buffer", "child_process", "cluster", ...] + * + * getBuiltinList({ includeNodePrefix: true }) + * // ["assert", "node:assert", "buffer", "node:buffer", ...] + * + * getBuiltinList({ includeDeprecated: false }) + * // ["assert", "buffer", ...] // without domain, punycode, sys + * ``` + */ +export function getBuiltinList( + options: { + includeNodePrefix?: boolean; + includeDeprecated?: boolean; + includeExperimental?: boolean; + } = {} +): string[] { + const { + includeNodePrefix = false, + includeDeprecated = true, + includeExperimental = false, + } = options; + + let names = Object.entries(NODE_BUILTINS) + .filter(([_, info]) => { + if (!includeDeprecated && info.category === "deprecated") return false; + if (!includeExperimental && info.category === "experimental") return false; + return true; + }) + .map(([name]) => name); + + if (includeNodePrefix) { + names = [...names, ...names.map((n) => `node:${n}`)]; + } + + return names; +} + +// ============================================================================= +// Runtime Support +// ============================================================================= + +/** + * Check if a built-in is supported in a specific runtime. + * + * @param specifier Module specifier + * @param runtime Target runtime + * @returns Support status: true, false, or "partial" + * + * @example + * ```ts + * isBuiltinSupported("fs", "node") // true + * isBuiltinSupported("fs", "deno") // true + * isBuiltinSupported("fs", "browser") // false + * + * isBuiltinSupported("cluster", "deno") // false + * isBuiltinSupported("cluster", "bun") // "partial" + * + * isBuiltinSupported("path", "browser") // true (has polyfill) + * ``` + */ +export function isBuiltinSupported( + specifier: string, + runtime: RuntimeTarget +): boolean | "partial" { + const info = getBuiltinInfo(specifier); + if (!info) return false; + + switch (runtime) { + case "node": + return info.runtimes.node; + case "deno": + return info.runtimes.deno; + case "bun": + return info.runtimes.bun; + case "browser": + // Browser supports if there's a polyfill + return info.polyfill !== null; + case "workerd": + // Cloudflare Workers have limited Node.js compat + // Most core modules work via nodejs_compat flag + return info.runtimes.node === true && info.polyfill !== null; + default: + return false; + } +} + +/** + * Get built-in modules supported by a runtime. + * + * @param runtime Target runtime + * @param options Filter options + * @returns Array of supported module names + * + * @example + * ```ts + * getBuiltinsForRuntime("browser") + * // ["path", "events", "buffer", "process", ...] // Polyfillable modules + * + * getBuiltinsForRuntime("deno") + * // ["fs", "path", "http", ...] // Most Node.js modules + * + * getBuiltinsForRuntime("bun") + * // ["fs", "path", "http", "child_process", ...] // Nearly all + * ``` + */ +export function getBuiltinsForRuntime( + runtime: RuntimeTarget, + options: { includePartial?: boolean } = {} +): string[] { + const { includePartial = false } = options; + + return Object.entries(NODE_BUILTINS) + .filter(([_, info]) => { + const support = (() => { + switch (runtime) { + case "node": + return info.runtimes.node; + case "deno": + return info.runtimes.deno; + case "bun": + return info.runtimes.bun; + case "browser": + return info.polyfill !== null; + case "workerd": + return info.polyfill !== null; + default: + return false; + } + })(); + + if (support === true) return true; + if (support === "partial" && includePartial) return true; + return false; + }) + .map(([name]) => name); +} + +// ============================================================================= +// Extended Polyfills (for full browser compatibility) +// ============================================================================= + +/** + * Extended polyfill mappings that go beyond the standard polyfills. + * + * These include: + * - Alternative packages for modules that have multiple polyfill options + * - "Aggressive" polyfills for modules normally marked as non-polyfillable + * - Subpath-specific mappings + * + * **Use with caution**: Some of these polyfills provide limited functionality + * and may not work for all use cases. + * + * @example + * ```ts + * EXTENDED_POLYFILLS["fs"] + * // "memfs" - in-memory filesystem (limited but works in browsers) + * + * EXTENDED_POLYFILLS["http"] + * // "http-browserify" - alternative to stream-http + * ``` + */ +export const EXTENDED_POLYFILLS: Record = { + // Alternative HTTP polyfills + http: "http-browserify", // Alternative to stream-http + + // Extended polyfills for normally non-polyfillable modules + fs: "memfs", // In-memory filesystem + net: "net-browserify", // Limited net polyfill + dgram: "browser-node-dgram", + readline: "readline-browser", + tls: "browserify-tls", + + // Different path styles for common polyfills + url: "browserify-url", + util: "util/util.js", // More specific path + os: "os-browserify/browser", + process: "process/browser", + + // Simpler querystring (vs querystring-es3) + querystring: "querystring", + + // Additional utilities + Dirent: "dirent", // File system directory entry type +}; + +/** + * Subpath mappings for packages with complex exports. + * + * Maps specific subpaths to their polyfill equivalents. + */ +export const SUBPATH_POLYFILLS: Record = { + "readable-stream/": "readable-stream/lib", + "readable-stream/duplex": "readable-stream/lib/duplex.js", + "readable-stream/readable": "readable-stream/lib/readable.js", + "readable-stream/writable": "readable-stream/lib/writable.js", + "readable-stream/transform": "readable-stream/lib/transform.js", + "readable-stream/passthrough": "readable-stream/lib/passthrough.js", +}; + +/** + * Polyfill profile options. + */ +export type PolyfillProfile = "conservative" | "aggressive" | "maximal"; + +/** + * Create a polyfill map with different profiles. + * + * Profiles: + * - `conservative`: Only well-tested, reliable polyfills (default) + * - `aggressive`: Include extended polyfills for more modules + * - `maximal`: All possible polyfills, even experimental ones + * + * @param profile Polyfill profile + * @returns Polyfill map + * + * @example Conservative (default) + * ```ts + * createPolyfillMapWithProfile("conservative") + * // { path: "path-browserify", events: "events", ... } + * // Does NOT include fs, net, etc. + * ``` + * + * @example Aggressive + * ```ts + * createPolyfillMapWithProfile("aggressive") + * // { path: "path-browserify", fs: "memfs", net: "net-browserify", ... } + * // Includes extended polyfills + * ``` + * + * @example Maximal (with subpaths) + * ```ts + * createPolyfillMapWithProfile("maximal") + * // Includes all above plus subpath mappings + * ``` + */ +export function createPolyfillMapWithProfile( + profile: PolyfillProfile = "conservative" +): Record { + const map: Record = {}; + + // Add standard polyfills (with node: prefix variants) + for (const [name, info] of Object.entries(NODE_BUILTINS)) { + if (info.polyfill) { + map[name] = info.polyfill; + map[`node:${name}`] = info.polyfill; + } + } + + // Add extended polyfills for aggressive/maximal + if (profile === "aggressive" || profile === "maximal") { + for (const [name, polyfill] of Object.entries(EXTENDED_POLYFILLS)) { + // Only add if not already in standard polyfills + if (!(name in map)) { + map[name] = polyfill; + // Only add node: prefix for actual Node builtins + if (BUILTIN_NAMES.has(name)) { + map[`node:${name}`] = polyfill; + } + } + } + } + + // Add subpath mappings for maximal + if (profile === "maximal") { + Object.assign(map, SUBPATH_POLYFILLS); + } + + return map; +} + +// ============================================================================= +// Legacy Compatibility (for external.ts) +// ============================================================================= + +/** + * Create the legacy PolyfillMap format used by external.ts. + * + * This generates a map compatible with the existing external plugin, + * using the "aggressive" polyfill profile which includes extended polyfills. + * + * @returns Legacy-compatible polyfill map + * + * @example + * ```ts + * const PolyfillMap = createLegacyPolyfillMap(); + * // { + * // "console": "console-browserify", + * // "constants": "constants-browserify", + * // "crypto": "crypto-browserify", + * // "fs": "memfs", + * // ... + * // } + * ``` + * + * @deprecated Prefer using `createPolyfillMapWithProfile("aggressive")` directly + */ +export function createLegacyPolyfillMap(): Record { + const map = createPolyfillMapWithProfile("maximal"); + + // Remove node: prefixed entries for legacy compatibility + const legacyMap: Record = {}; + for (const [key, value] of Object.entries(map)) { + if (!key.startsWith("node:")) { + legacyMap[key] = value; + } + } + + return legacyMap; +} + +/** + * Packages that should always be marked as external. + * + * This includes: + * - Non-polyfillable Node.js modules + * - Native addons (fsevents) + * - Package manager APIs (pnpapi) + * - Build-time utilities (chokidar, yargs) + */ +export const ALWAYS_EXTERNAL = [ + "pnpapi", // Yarn PnP API + "v8", // V8 engine internals + "node-inspect", // Node.js inspector + "sys", // Deprecated alias for util + "repl", // REPL (no browser equivalent) + "dns", // DNS resolution + "child_process",// Process spawning + "module", // Module system internals + "cluster", // Process clustering + "chokidar", // File watcher (native) + "yargs", // CLI framework (usually build-time) + "fsevents", // macOS native file events + "worker_threads", + "async_hooks", + "diagnostics_channel", + "http2", + "inspector", + "perf_hooks", + "trace_events", + "wasi", +] as const; + +/** + * Get all packages that should be marked as external. + * + * Combines deprecated APIs, always-external packages, and polyfillable modules. + * + * @returns Array of external package patterns + */ +export function getExternalPackages(): string[] { + const legacyMap = createPolyfillMapWithProfile("aggressive"); + return [ + ...ALWAYS_EXTERNAL, + ...DEPRECATED_API_PATHS, + ...Object.keys(legacyMap), + ]; +} diff --git a/packages/utils/semver.ts b/packages/utils/semver.ts new file mode 100644 index 0000000..37203ca --- /dev/null +++ b/packages/utils/semver.ts @@ -0,0 +1 @@ +export * from "@std/semver"; \ No newline at end of file diff --git a/packages/utils/tar.ts b/packages/utils/tar.ts new file mode 100644 index 0000000..b2345f0 --- /dev/null +++ b/packages/utils/tar.ts @@ -0,0 +1 @@ +export * from "@std/tar" \ No newline at end of file diff --git a/packages/utils/types.ts b/packages/utils/types.ts new file mode 100644 index 0000000..34c061b --- /dev/null +++ b/packages/utils/types.ts @@ -0,0 +1,458 @@ +export type AutoCompleteIndicator = (string & {}); + +/** export interface representing the parsed package name and its components. */ +export interface ParsedPackageName { + name: string; + version: string; + path: string; +} + +/** export interface representing the URLs generated for the npm registry. */ +export interface RegistryURLs { + searchURL: string; + packageURL: string; + packageVersionURL: string; + version: string | null; + name: string; + path: string; +} + +export interface PackageURLs { + searchURL: string; + packageURL: string; + packageVersionURL: string; + version: string; + name: string; + path: string; +} + +export interface PackageSearchResult { + packages: SearchObject[]; + info: SearchInfo; +} + +export interface SearchPackage { + name: string; + scope: string; + version: string; + description: string; + keywords: string[]; + date: string; + links: Links; + author: Author; + publisher: Publisher; + maintainers: Maintainer[]; +} + +export interface Links { + npm: string; + homepage: string; + repository: string; + bugs: string; +} + +/** + * A “person” is an object with a “name” field and optionally “url” and “email”. Or you can shorten that all into a single string, and npm will parse it for you. + */ +export type Author = string | { + name: string; + email?: string; + url?: string; +} + +export interface Publisher { + username: string; + email: string; +} + +export interface Maintainer { + username?: string; + name: string; + email: string; +} + +export interface SearchInfo { + total: number; + time: string; + objects: SearchObject[]; +} + +export interface SearchObject { + package: SearchPackage; + flags: Flags; + score: Score; + searchScore: number; +} + +export interface Flags { + insecure: number; +} + +export interface Score { + final: number; + detail: Detail; +} + +export interface Detail { + quality: number; + popularity: number; + maintenance: number; +} + +export interface PackageInfo { + versions: string[]; + tags: Record; +} + +export interface FullPackage { + name: string; + scope: string; + version: string; + description: string; + keywords: string[]; + date: string; + links: Links; + author: Author; + publisher: Publisher; + maintainers: Maintainer[]; + versions: Record; + "dist-tags": Record; +} + +export interface FullPackageVersion { + /** + * The name is what your thing is called. + * Some rules: + + - The name must be less than or equal to 214 characters. This includes the scope for scoped packages. + - The name can’t start with a dot or an underscore. + - New packages must not have uppercase letters in the name. + - The name ends up being part of a URL, an argument on the command line, and a folder name. Therefore, the name can’t contain any non-URL-safe characters. + + */ + name?: string; + /** + * Version must be parseable by `node-semver`, which is bundled with npm as a dependency. (`npm install semver` to use it yourself.) + */ + version?: string; + /** + * Make main entry-point be loaded as an ESM module, support "export" syntax instead of "require" + * + * Docs: + * - https://nodejs.org/docs/latest-v14.x/api/esm.html#esm_package_json_type_field + * + * @default 'commonjs' + * @since Node.js v14 + */ + type?: "module" | "commonjs" | AutoCompleteIndicator; + sideEffects: boolean; + /** + * Put a description in it. It’s a string. This helps people discover your package, as it’s listed in `npm search`. + */ + description?: string; + publishConfig?: { + access?: string; + main?: string; + types?: string; + browser?: string; + module?: string; + exports?: Record | string> | string; + }; + directories: Record; + /** + * The optional `files` field is an array of file patterns that describes the entries to be included when your package is installed as a dependency. File patterns follow a similar syntax to `.gitignore`, but reversed: including a file, directory, or glob pattern (`*`, `**\/*`, and such) will make it so that file is included in the tarball when it’s packed. Omitting the field will make it default to `["*"]`, which means it will include all files. + */ + files?: string[]; + /** + * Put keywords in it. It’s an array of strings. This helps people discover your package as it’s listed in `npm search`. + */ + keywords?: string[]; + author?: Author; + /** + * The url to the project homepage. + */ + homepage?: string; + + /** + * The url to your project’s issue tracker and / or the email address to which issues should be reported. These are helpful for people who encounter issues with your package. + */ + bugs?: + | string + | { + url?: string; + email?: string; + }; + /** + * You should specify a license for your package so that people know how they are permitted to use it, and any restrictions you’re placing on it. + */ + license?: string; + /** + * Specify the place where your code lives. This is helpful for people who want to contribute. If the git repo is on GitHub, then the `npm docs` command will be able to find you. + * For GitHub, GitHub gist, Bitbucket, or GitLab repositories you can use the same shortcut syntax you use for npm install: + */ + repository?: + | string + | { + type: string; + url: string; + /** + * If the `package.json` for your package is not in the root directory (for example if it is part of a monorepo), you can specify the directory in which it lives: + */ + directory?: string; + }; + + /** + * Dependencies are specified in a simple object that maps a package name to a version range. The version range is a string which has one or more space-separated descriptors. Dependencies can also be identified with a tarball or git URL. + */ + dependencies?: Record; + /** + * If someone is planning on downloading and using your module in their program, then they probably don’t want or need to download and build the external test or documentation framework that you use. + * In this case, it’s best to map these additional items in a `devDependencies` object. + */ + devDependencies?: Record; + /** + * If a dependency can be used, but you would like npm to proceed if it cannot be found or fails to install, then you may put it in the `optionalDependencies` object. This is a map of package name to version or url, just like the `dependencies` object. The difference is that build failures do not cause installation to fail. + */ + optionalDependencies?: Record; + /** + * In some cases, you want to express the compatibility of your package with a host tool or library, while not necessarily doing a `require` of this host. This is usually referred to as a plugin. Notably, your module may be exposing a specific interface, expected and specified by the host documentation. + */ + peerDependencies?: Record; + gitHead: string; + scripts: Record; + /** + * TypeScript typings, typically ending by .d.ts + */ + types?: string; + typings?: string; + /** + * The main field is a module ID that is the primary entry point to your program. That is, if your package is named `foo`, and a user installs it, and then does `require("foo")`, then your main module’s exports object will be returned. + * This should be a module ID relative to the root of your package folder. + * For most modules, it makes the most sense to have a main script and often not much else. + */ + main?: string; + /** + * If your module is meant to be used client-side the browser field should be used instead of the main field. This is helpful to hint users that it might rely on primitives that aren’t available in Node.js modules. (e.g. window) + */ + browser?: string | false | Record; + /** + * Non-Standard Node.js alternate entry-point to main. + * An initial implementation for supporting CJS packages (from main), and use module for ESM modules. + */ + module?: string; + exports: string | Record< + "import" | "require" | "." | "node" | "browser" | AutoCompleteIndicator, + Record< + "import" | "require" | AutoCompleteIndicator, + string + > | string + >; + /** + * A map of command name to local file name. On install, npm will symlink that file into `prefix/bin` for global installs, or `./node_modules/.bin/` for local installs. + */ + bin?: string | Record; + /** + * Specify either a single file or an array of filenames to put in place for the `man` program to find. + */ + man?: string | string[]; + workspaces?: string[]; + _id: string; + _nodeVersion: string; + _npmVersion: string; + dist: { + integrity: string; + shasum: string; + tarball: string; + fileCount: number; + unpackedSize: number; + "npm-signature": string; + signatures: { + keyid: string; + sig: string; + }[]; + }; + _npmUser: { + name: string; + email: string; + }; + maintainers: Maintainer[]; + _npmOperationalInternal: { + host: string; + tmp: string; + }; + _hasShrinkwrap: boolean; +} + +/** + * Recursive type for the `exports` / `imports` condition tree. + * + * A leaf is a string (resolved path) or `null` (explicit exclusion). + * A branch is an object mapping condition keys to nested entries, + * or an array of entries (fallback list). + * + * @see https://nodejs.org/api/packages.html#conditional-exports + */ +export type PackageExportEntry = + | string + | null + | PackageExportEntry[] + | { [condition: string]: PackageExportEntry }; + +export interface PackageJson { + /** + * The name is what your thing is called. + * Some rules: + + - The name must be less than or equal to 214 characters. This includes the scope for scoped packages. + - The name can’t start with a dot or an underscore. + - New packages must not have uppercase letters in the name. + - The name ends up being part of a URL, an argument on the command line, and a folder name. Therefore, the name can’t contain any non-URL-safe characters. + + */ + name?: string; + /** + * Version must be parseable by `node-semver`, which is bundled with npm as a dependency. (`npm install semver` to use it yourself.) + */ + version?: string; + /** + * Put a description in it. It’s a string. This helps people discover your package, as it’s listed in `npm search`. + */ + description?: string; + /** + * Put keywords in it. It’s an array of strings. This helps people discover your package as it’s listed in `npm search`. + */ + keywords?: string[]; + /** + * The url to the project homepage. + */ + homepage?: string; + + /** + * The url to your project’s issue tracker and / or the email address to which issues should be reported. These are helpful for people who encounter issues with your package. + */ + bugs?: + | string + | { + url?: string; + email?: string; + }; + /** + * You should specify a license for your package so that people know how they are permitted to use it, and any restrictions you’re placing on it. + */ + license?: string; + /** + * Specify the place where your code lives. This is helpful for people who want to contribute. If the git repo is on GitHub, then the `npm docs` command will be able to find you. + * For GitHub, GitHub gist, Bitbucket, or GitLab repositories you can use the same shortcut syntax you use for npm install: + */ + repository?: + | string + | { + type: string; + url: string; + /** + * If the `package.json` for your package is not in the root directory (for example if it is part of a monorepo), you can specify the directory in which it lives: + */ + directory?: string; + }; + scripts?: Record; + /** + * If you set `"private": true` in your package.json, then npm will refuse to publish it. + */ + private?: boolean; + /** + * The “author” is one person. + */ + author?: Author; + /** + * “contributors” is an array of people. + */ + contributors?: Author[]; + /** + * The optional `files` field is an array of file patterns that describes the entries to be included when your package is installed as a dependency. File patterns follow a similar syntax to `.gitignore`, but reversed: including a file, directory, or glob pattern (`*`, `**\/*`, and such) will make it so that file is included in the tarball when it’s packed. Omitting the field will make it default to `["*"]`, which means it will include all files. + */ + files?: string[]; + /** + * The main field is a module ID that is the primary entry point to your program. That is, if your package is named `foo`, and a user installs it, and then does `require("foo")`, then your main module’s exports object will be returned. + * This should be a module ID relative to the root of your package folder. + * For most modules, it makes the most sense to have a main script and often not much else. + */ + main?: string; + /** + * If your module is meant to be used client-side the browser field should be used instead of the main field. This is helpful to hint users that it might rely on primitives that aren’t available in Node.js modules. (e.g. window) + */ + browser?: string | false | Record; + /** + * A map of command name to local file name. On install, npm will symlink that file into `prefix/bin` for global installs, or `./node_modules/.bin/` for local installs. + */ + bin?: string | Record; + /** + * Specify either a single file or an array of filenames to put in place for the `man` program to find. + */ + man?: string | string[]; + /** + * Dependencies are specified in a simple object that maps a package name to a version range. The version range is a string which has one or more space-separated descriptors. Dependencies can also be identified with a tarball or git URL. + */ + dependencies?: Record; + /** + * If someone is planning on downloading and using your module in their program, then they probably don’t want or need to download and build the external test or documentation framework that you use. + * In this case, it’s best to map these additional items in a `devDependencies` object. + */ + devDependencies?: Record; + /** + * If a dependency can be used, but you would like npm to proceed if it cannot be found or fails to install, then you may put it in the `optionalDependencies` object. This is a map of package name to version or url, just like the `dependencies` object. The difference is that build failures do not cause installation to fail. + */ + optionalDependencies?: Record; + /** + * In some cases, you want to express the compatibility of your package with a host tool or library, while not necessarily doing a `require` of this host. This is usually referred to as a plugin. Notably, your module may be exposing a specific interface, expected and specified by the host documentation. + */ + peerDependencies?: Record; + /** + * TypeScript typings, typically ending by .d.ts + */ + types?: string; + typings?: string; + /** + * Non-Standard Node.js alternate entry-point to main. + * An initial implementation for supporting CJS packages (from main), and use module for ESM modules. + */ + module?: string; + /** + * Make main entry-point be loaded as an ESM module, support "export" syntax instead of "require" + * + * Docs: + * - https://nodejs.org/docs/latest-v14.x/api/esm.html#esm_package_json_type_field + * + * @default 'commonjs' + * @since Node.js v14 + */ + type?: "module" | "commonjs" | AutoCompleteIndicator; + /** + * Alternate and extensible alternative to "main" entry point. + * + * When using `{type: "module"}`, any ESM module file MUST end with `.mjs` extension. + * + * The `exports` field supports arbitrary nesting of condition keys, + * array fallbacks, and `null` to explicitly exclude subpaths. + * + * Docs: + * - https://nodejs.org/docs/latest-v14.x/api/packages.html#exports + * + * @since Node.js v12.7 (unflagged v14) + */ + exports?: PackageExportEntry; + /** + * Package-internal import mappings (`#` imports). + * + * Allows a package to define private resolution aliases that are + * only importable from within the package itself. Supports the same + * condition nesting as `exports`. + * + * Docs: + * - https://nodejs.org/docs/latest-v14.x/api/packages.html#imports + * + * @since Node.js v14 + */ + imports?: Record; + workspaces?: string[]; + [key: string]: any; // deno-lint-ignore no-explicit-any +} \ No newline at end of file diff --git a/packages/utils/url.ts b/packages/utils/url.ts new file mode 100644 index 0000000..d165fd3 --- /dev/null +++ b/packages/utils/url.ts @@ -0,0 +1,36 @@ +import { join as joinUrl } from "@std/path/posix"; + +export const WHITESPACE_ENCODINGS: Record = { + "\u0009": "%09", + "\u000A": "%0A", + "\u000B": "%0B", + "\u000C": "%0C", + "\u000D": "%0D", + "\u0020": "%20", +}; + +export function encodeWhitespace(string: string): string { + return string.replaceAll(/[\s]/g, (c) => { + return WHITESPACE_ENCODINGS[c] ?? c; + }); +} + +/** + * Based on https://github.com/egoist/play-esbuild/blob/main/src/lib/path.ts#L123 + * + * Support joining paths to a URL + */ +export const urlJoin = (urlStr: string, ...args: string[]) => { + const url = new URL(urlStr); + url.pathname = encodeWhitespace( + joinUrl(url.pathname, ...args).replace(/%/g, "%25").replace(/\\/g, "%5C"), + ); + return url.toString(); +}; + +export function toURLPath(url: string | URL, base?: URL | string) { + const _url = new URL(url, base); + const _host = _url.host.replace(/\./g, "_"); + const _path = _url.pathname || "/"; + return `/${_host}${_path}`; +} diff --git a/packages/utils/validate-package-name.ts b/packages/utils/validate-package-name.ts new file mode 100644 index 0000000..c786668 --- /dev/null +++ b/packages/utils/validate-package-name.ts @@ -0,0 +1,324 @@ +/** + * Package name validation following npm's validate-npm-package-name rules. + * + * npm package names must: + * - Be 214 characters or less (including scope) + * - Not start with . or _ + * - Not have uppercase letters + * - Not contain URL-unsafe characters + * - Not be a Node.js core module name + * + * @module + * + * @example + * ```ts + * import { validatePackageName, isValidPackageName } from "./validate-package-name.ts"; + * + * const result = validatePackageName("my-package"); + * // { valid: true, errors: [], warnings: [] } + * + * const invalid = validatePackageName("My-Package"); + * // { valid: false, errors: ["name must be lowercase"], warnings: [] } + * + * if (isValidPackageName("lodash")) { + * // Safe to use + * } + * ``` + * + * @see https://github.com/npm/validate-npm-package-name + */ + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Result of package name validation. + */ +export interface ValidateResult { + /** Whether the name is valid for npm */ + valid: boolean; + /** Errors that make the name invalid */ + errors: string[]; + /** Warnings that don't invalidate but are discouraged */ + warnings: string[]; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Node.js built-in module names. + * These cannot be used as package names. + */ +const NODE_BUILTINS = new Set([ + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "events", + "fs", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "string_decoder", + "sys", + "timers", + "tls", + "trace_events", + "tty", + "url", + "util", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]); + +/** + * "Blacklisted" names that npm reserves. + */ +const BLACKLIST = new Set([ + "node_modules", + "favicon.ico", +]); + +/** + * Characters that must not appear in package names. + * These make URLs problematic. + */ +const SPECIAL_CHARS = /[~'!()*]/; + +/** + * URL-unsafe characters that would need encoding. + * We check this separately from SPECIAL_CHARS for more specific errors. + */ +const URL_UNSAFE = /[^a-z0-9\-._@/]/; + +// ============================================================================= +// Main Validation +// ============================================================================= + +/** + * Validate an npm package name. + * + * @param name Package name to validate + * @returns Validation result with errors and warnings + * + * @example Valid names + * ```ts + * validatePackageName("lodash") + * // { valid: true, errors: [], warnings: [] } + * + * validatePackageName("@scope/package") + * // { valid: true, errors: [], warnings: [] } + * + * validatePackageName("my-package-123") + * // { valid: true, errors: [], warnings: [] } + * ``` + * + * @example Invalid names + * ```ts + * validatePackageName("My-Package") + * // { valid: false, errors: ["name must be lowercase"], ... } + * + * validatePackageName(".hidden") + * // { valid: false, errors: ["name cannot start with a period"], ... } + * + * validatePackageName("http") + * // { valid: false, errors: ["http is a core Node.js module"], ... } + * ``` + */ +export function validatePackageName(name: string): ValidateResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Null/undefined check + if (name == null) { + errors.push("name cannot be null or undefined"); + return { valid: false, errors, warnings }; + } + + // Must be a string + if (typeof name !== "string") { + errors.push("name must be a string"); + return { valid: false, errors, warnings }; + } + + // Cannot be empty + if (name.length === 0) { + errors.push("name cannot be empty"); + return { valid: false, errors, warnings }; + } + + // Length limit (214 characters including scope) + if (name.length > 214) { + errors.push("name cannot be longer than 214 characters"); + } + + // Cannot start with . or _ + if (name.startsWith(".")) { + errors.push("name cannot start with a period"); + } + if (name.startsWith("_")) { + errors.push("name cannot start with an underscore"); + } + + // Cannot have leading or trailing spaces + if (name !== name.trim()) { + errors.push("name cannot have leading or trailing spaces"); + } + + // Must be lowercase + if (name !== name.toLowerCase()) { + errors.push("name must be lowercase"); + } + + // Cannot contain special characters + if (SPECIAL_CHARS.test(name)) { + errors.push("name cannot contain special characters: ~ ' ! ( ) *"); + } + + // Check for URL-unsafe characters (scoped packages handled separately) + const nameWithoutScope = name.startsWith("@") + ? name.slice(name.indexOf("/") + 1) + : name; + + if (URL_UNSAFE.test(nameWithoutScope)) { + // More specific error for common issues + if (/\s/.test(nameWithoutScope)) { + errors.push("name cannot contain spaces"); + } else { + errors.push("name contains URL-unsafe characters"); + } + } + + // Scoped package validation + if (name.startsWith("@")) { + const slashIndex = name.indexOf("/"); + if (slashIndex === -1) { + errors.push("scoped package name must include a slash"); + } else { + const scope = name.slice(1, slashIndex); + const pkg = name.slice(slashIndex + 1); + + if (scope.length === 0) { + errors.push("scope cannot be empty"); + } + if (pkg.length === 0) { + errors.push("package name cannot be empty after scope"); + } + + // Validate scope characters + if (URL_UNSAFE.test(scope)) { + errors.push("scope contains URL-unsafe characters"); + } + } + } + + // Cannot be a Node.js core module + const baseName = name.startsWith("@") + ? name.slice(name.indexOf("/") + 1) + : name; + + if (NODE_BUILTINS.has(baseName)) { + errors.push(`"${baseName}" is a Node.js core module name`); + } + + // Cannot be blacklisted + if (BLACKLIST.has(baseName)) { + errors.push(`"${baseName}" is a reserved name`); + } + + // Warnings (not errors but discouraged) + if (/^node-/.test(baseName) || /^nodejs-/.test(baseName)) { + warnings.push("name starting with 'node-' or 'nodejs-' is discouraged"); + } + if (/-js$/.test(baseName) || /\.js$/.test(baseName)) { + warnings.push("name ending with '-js' or '.js' is discouraged"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Quick check if a package name is valid. + * + * @param name Package name to check + * @returns True if valid + * + * @example + * ```ts + * if (isValidPackageName(userInput)) { + * // Safe to use + * } + * ``` + */ +export function isValidPackageName(name: string): boolean { + return validatePackageName(name).valid; +} + +/** + * Check if a name is a Node.js built-in module. + * + * @param name Module name to check + * @returns True if it's a built-in + * + * @example + * ```ts + * isNodeBuiltin("fs") // true + * isNodeBuiltin("lodash") // false + * ``` + */ +export function isNodeBuiltin(name: string): boolean { + // Handle node: prefix + const baseName = name.startsWith("node:") ? name.slice(5) : name; + // Handle subpaths like "fs/promises" + const moduleName = baseName.split("/")[0]; + return NODE_BUILTINS.has(moduleName); +} + +/** + * Get a list of all Node.js built-in module names. + * + * @returns Array of built-in module names + */ +export function getNodeBuiltins(): string[] { + return Array.from(NODE_BUILTINS); +} + +// ============================================================================= +// Exports for testing +// ============================================================================= + +export { NODE_BUILTINS, BLACKLIST }; \ No newline at end of file