From 5df832103cc704dcc54952013cc79d86a2212ac2 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Sun, 25 Jan 2026 05:14:45 -0500 Subject: [PATCH] fix: module not found error Signed-off-by: Okiki Ojo --- core/plugins/cdn.ts | 2 +- core/plugins/fs.ts | 2 - core/plugins/http.ts | 2 +- core/plugins/tar.ts | 253 +++++++++++++++++++++++++++++++------- core/utils/get-esbuild.ts | 51 ++++---- edge/mod.ts | 24 ++-- utils/bytes.ts | 1 + utils/deno.jsonc | 6 + utils/fetch-and-cache.ts | 77 +++++++----- utils/json-streams.ts | 2 + utils/lru.ts | 2 + utils/mod.ts | 5 + utils/npm-search.ts | 6 +- 13 files changed, 316 insertions(+), 117 deletions(-) create mode 100644 utils/bytes.ts create mode 100644 utils/json-streams.ts diff --git a/core/plugins/cdn.ts b/core/plugins/cdn.ts index c233b1a..e172a95 100644 --- a/core/plugins/cdn.ts +++ b/core/plugins/cdn.ts @@ -339,7 +339,7 @@ export function CdnResolution(StateContext: Context>) { try { // Strongly cache package.json files - const res = await getRequest(url, true); + const res = await getRequest(url, { permanent: true }); if (!res.ok) throw new Error(await res.text()); resolvedManifest = await res.json(); diff --git a/core/plugins/fs.ts b/core/plugins/fs.ts index 3b4f1d1..714d33b 100644 --- a/core/plugins/fs.ts +++ b/core/plugins/fs.ts @@ -103,8 +103,6 @@ export function VfsResolution(StateContext: Context>) { const prefixes = fromContext("prefixes", StateContext) ?? []; return async function (args: ESBUILD.OnResolveArgs): Promise { - - // 1) Never let VFS touch URLs. These belong to the HTTP/CDN pipeline. if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(args.path)) return; diff --git a/core/plugins/http.ts b/core/plugins/http.ts index f3af374..d549b3b 100644 --- a/core/plugins/http.ts +++ b/core/plugins/http.ts @@ -59,7 +59,7 @@ export interface HttpResolutionState extends LocalState { */ export async function fetchPkg(url: string, fetchOpts?: RequestInit) { try { - const response = await getRequest(url, undefined, fetchOpts); + const response = await getRequest(url, { fetchOpts, retry: 1 }); if (!response.ok) throw new Error(`Couldn't load ${response.url || url} (${response.status} code)`); diff --git a/core/plugins/tar.ts b/core/plugins/tar.ts index 09c97f4..b8c5d8c 100644 --- a/core/plugins/tar.ts +++ b/core/plugins/tar.ts @@ -41,6 +41,8 @@ import { type ArchiveDiagnostic } from "@bundle/utils/archive-detect"; +import { parsePackageName } from "@bundle/utils/parse-package-name"; + /** Tarball Plugin Namespace */ export const TARBALL_NAMESPACE = "tarball-url"; @@ -99,51 +101,218 @@ async function getTarballKey(url: string): Promise { return hashArray.join("").slice(0, 16); } +export interface ParseTarballUrlResult { + /** + * The raw package spec as it appears in the URL path. + * + * Examples: + * - "@tanstack/react-query@7988" + * - "tinybench@a832a55" + */ + pkgSpec: string; + + /** + * The parsed npm package name (scope included if present). + * + * Examples: + * - "@tanstack/react-query" + * - "tinybench" + */ + name: string; + + /** + * The version selector from the spec. + * + * For pkg.pr.new this is commonly a commit SHA or PR number, + * but you should treat it as an opaque string. + */ + version: string | null; + + /** + * Any remaining URL path after the package spec. + * + * Examples: + * - "" + * - "/build/modern" + */ + subpath: string; + + /** + * The “package root” URL (same origin, no subpath, no query/hash). + */ + packageUrl: URL; + + /** + * Present when the URL is the non-compact form: /owner/repo/... + */ + owner: string | null; + + /** + * Present when the URL is the non-compact form: /owner/repo/... + */ + repo: string | null; +} + +export interface ParseTarballUrlOptions { + /** + * If true, return empty-ish outputs instead of throwing on invalid inputs. + */ + ignoreError?: boolean; + + /** + * Used when the spec does not include "@version". + * (You may want to set this to null if pkg.pr.new always requires it.) + */ + defaultVersion?: string | null; +} + /** - * Parse pkg.pr.new URL to extract package spec and subpath - * + * Parse a pkg.pr.new URL and extract the package spec + subpath. + * + * Supports both URL shapes: + * - Compact: // + * - Non-compact: //// + * * @example + * ```ts + * const r = parseTarballUrl(new URL("https://pkg.pr.new/@tanstack/react-query@7988/build/modern")); + * // r.pkgSpec -> "@tanstack/react-query@7988" + * // r.name -> "@tanstack/react-query" + * // r.version -> "7988" + * // r.subpath -> "/build/modern" + * // r.packageUrl.href -> "https://pkg.pr.new/@tanstack/react-query@7988" * ``` - * https://pkg.pr.new/@tanstack/react-query@7988 - * -> { pkgSpec: "@tanstack/react-query@7988", subpath: "" } - * - * https://pkg.pr.new/@tanstack/react-query@7988/build/modern - * -> { pkgSpec: "@tanstack/react-query@7988", subpath: "/build/modern" } + * + * @example + * ```ts + * const r = parseTarballUrl(new URL("https://pkg.pr.new/tinylibs/tinybench/tinybench@a832a55")); + * // r.owner -> "tinylibs" + * // r.repo -> "tinybench" + * // r.pkgSpec -> "tinybench@a832a55" + * // r.packageUrl.href -> "https://pkg.pr.new/tinylibs/tinybench/tinybench@a832a55" * ``` */ -export function parseTarballUrl(url: URL): { pkgSpec: string; subpath: string; packageUrl: URL } { - const parts = url.pathname.split("/").filter(Boolean); - - let pkgSpec = ""; - let rest: string[] = []; - - if (parts.length === 0) { - return { pkgSpec: "", subpath: "", packageUrl: url }; - } - - // Handle scoped packages (@scope/name@version) - if (parts[0].startsWith("@")) { - pkgSpec = parts.slice(0, 2).join("/"); - rest = parts.slice(2); - } else { - pkgSpec = parts[0]; - rest = parts.slice(1); - } - - // Build the package root URL (without subpath) - const packageUrl = new URL(url.toString()); - if (pkgSpec) { - const scopedParts = pkgSpec.split("/"); - packageUrl.pathname = `/${scopedParts.join("/")}`; - } - - return { - pkgSpec, - subpath: rest.length > 0 ? `/${rest.join("/")}` : "", - packageUrl - }; +export function parseTarballUrl( + url: URL, + options: ParseTarballUrlOptions = {}, +): ParseTarballUrlResult { + const ignoreError = options.ignoreError ?? false; + const defaultVersion = options.defaultVersion ?? "latest"; + + const parts = url.pathname.split("/").filter(Boolean); + + if (parts.length === 0) { + return { + pkgSpec: "", + name: "", + version: defaultVersion, + subpath: "", + packageUrl: new URL(url.toString()), + owner: null, + repo: null, + }; + } + + // Guard against known non-package routes (fail fast rather than mis-parse). + // (Extend this list if you discover more.) + if (parts[0] === "template" || parts[0] === "badge" || parts[0] === "~") { + if (!ignoreError) { + throw new Error(`[parseTarballUrl] not a package URL: ${url.toString()}`); + } + + return { + pkgSpec: "", + name: "", + version: defaultVersion, + subpath: "", + packageUrl: new URL(url.toString()), + owner: null, + repo: null, + }; + } + + // Detect non-compact form: /owner/repo//... + // The README shows: /${owner}/${repo}/${package}@{commit} :contentReference[oaicite:3]{index=3} + let owner: string | null = null; + let repo: string | null = null; + let startIndex = 0; + + if (parts.length >= 3) { + const third = parts[2]; + + // Heuristic: the third segment is where the package spec begins. + // It is either: + // - "@scope" (scoped package begins) + // - "name@version" (unscoped with version selector) + // + // This matches the documented non-compact examples. :contentReference[oaicite:4]{index=4} + if (third.startsWith("@") || third.includes("@")) { + owner = parts[0] ?? null; + repo = parts[1] ?? null; + startIndex = 2; + } + } + + // Determine how many segments represent the package spec. + // Scoped: ["@scope", "name@ver"] (2 segments) + // Non-scoped: ["name@ver"] (1 segment) + const isScoped = (parts[startIndex] ?? "").startsWith("@"); + const pkgSegCount = isScoped ? 2 : 1; + + if (parts.length < startIndex + pkgSegCount) { + if (!ignoreError) { + throw new Error(`[parseTarballUrl] invalid pkg.pr.new package path: ${url.toString()}`); + } + + return { + pkgSpec: "", + name: "", + version: defaultVersion, + subpath: "", + packageUrl: new URL(url.toString()), + owner, + repo, + }; + } + + const pkgSpecParts = parts.slice(startIndex, startIndex + pkgSegCount); + const rest = parts.slice(startIndex + pkgSegCount); + + const pkgSpec = pkgSpecParts.join("/"); + + // Use your existing parser to canonicalize name/version. + const parsed = parsePackageName(pkgSpec, { ignoreError, defaultVersion }); + + // If someone embeds a path in pkgSpec (rare), respect it. + const subpathFromSpec = parsed.path || ""; + const subpathFromUrl = rest.length > 0 ? `/${rest.join("/")}` : ""; + const subpath = `${subpathFromSpec}${subpathFromUrl}`; + + // Build the package root URL (no subpath, no query/hash). + const packageUrl = new URL(url.toString()); + packageUrl.search = ""; + packageUrl.hash = ""; + const rootParts: string[] = []; + + if (owner && repo) { + rootParts.push(owner, repo); + } + + rootParts.push(...pkgSpecParts); + packageUrl.pathname = `/${rootParts.join("/")}`; + + return { + pkgSpec, + name: parsed.name, + version: parsed.version, + subpath, + packageUrl, + owner, + repo, + }; } + /** * Strip the "package/" prefix that npm tarballs typically have */ @@ -490,7 +659,7 @@ export function TarResolution(StateContext: Context>) { if (/^https?:\/\//.test(args.path)) { // Not a valid URL, let other plugins handle const url = URL.parse(args.path); - if (!url) return; + if (!url) return; // Not a tarball CDN, let HTTP/CDN plugins handle if (!isTarballUrl(url)) return; @@ -499,9 +668,6 @@ export function TarResolution(StateContext: Context>) { const conditions = getResolverConditions(args, esbuildOpts); try { - console.log({ - packageUrl - }) const mount = await getOrCreateMount(packageUrl.toString(), StateContext); const entryPath = resolvePackageEntry(mount.manifest, subpath, conditions); const resolvedPath = join(mount.packageRoot, entryPath); @@ -599,8 +765,7 @@ export function TarballPlugin(StateContext: Context & TarballSt name: TARBALL_NAMESPACE, setup(build) { // Intercept tarball URLs before HTTP plugin - // Filter matches any https:// URL, resolution function checks if it's actually a tarball CDN - build.onResolve({ filter: /^https?:\/\// }, TarResolution(StateContext)); + build.onResolve({ filter: /.*/ }, TarResolution(StateContext)); // Also handle resolution within the tarball namespace (for chained resolution) build.onResolve({ filter: /.*/, namespace: TARBALL_NAMESPACE }, TarResolution(StateContext)); diff --git a/core/utils/get-esbuild.ts b/core/utils/get-esbuild.ts index dd477d5..bde02d4 100644 --- a/core/utils/get-esbuild.ts +++ b/core/utils/get-esbuild.ts @@ -8,7 +8,7 @@ import { PLATFORM_AUTO } from "../configs/platform.ts"; // export const { version: defaultVersion } = pkg; export const defaultVersion = "0.27.2"; -import * as ESBUILD_IMPORT from "esbuild" +import * as ESBUILD_IMPORT from "esbuild-wasm" /** * Determines which esbuild skew to use depending on the platform option supplied, @@ -24,31 +24,32 @@ import * as ESBUILD_IMPORT from "esbuild" */ export async function getEsbuild(platform: Platform = PLATFORM_AUTO, version?: string | null): Promise { version ??= defaultVersion; + return ESBUILD_IMPORT; - try { - switch (platform) { - case "builtin": - return ESBUILD_IMPORT; - case "deno": - return await import( - /* @vite-ignore */ - `https://deno.land/x/esbuild@v${version}/mod.js` - ); - case "deno-wasm": - return await import( - /* @vite-ignore */ - `https://deno.land/x/esbuild@v${version}/wasm.js` - ); - case "node": - return await import("esbuild"); - case "browser": - case "edge": - default: - return await import("esbuild-wasm"); - } - } catch (e) { - throw e; - } + // try { + // switch (platform) { + // case "builtin": + // return ESBUILD_IMPORT; + // case "deno": + // return await import( + // /* @vite-ignore */ + // `https://deno.land/x/esbuild@v${version}/mod.js` + // ); + // case "deno-wasm": + // return await import( + // /* @vite-ignore */ + // `https://deno.land/x/esbuild@v${version}/wasm.js` + // ); + // case "node": + // return await import("esbuild"); + // case "browser": + // case "edge": + // default: + // return await import("esbuild-wasm"); + // } + // } catch (e) { + // throw e; + // } } export async function getEsbuildVersion(_version = defaultVersion) { diff --git a/edge/mod.ts b/edge/mod.ts index cc48cb7..ed8925e 100644 --- a/edge/mod.ts +++ b/edge/mod.ts @@ -233,9 +233,9 @@ export default { formatQuery ? { format } : {}, ), init: { - platform: "builtin", + platform: "deno-wasm", worker: false, - wasmModule + wasmModule: undefined }, } as Config ), { @@ -296,9 +296,9 @@ export default { textQuery }) - const { init, ..._configObj } = earlyConfigObj; - const { wasmModule: _wasmModule, ..._init } = init || {}; - const jsonKeyObj = Object.assign({ init: _init }, _configObj, { + const { init = {}, ..._configObj } = earlyConfigObj; + const jsonKeyObj = Object.assign({}, _configObj, { + init, versions, modules, initialValue: initialValue.trim(), @@ -308,12 +308,6 @@ export default { jsonKey }) - const inputFileHash = await hashString(jsonKey) - const configObj = Object.assign({}, jsonKeyObj, { - entryPoints: [`/index.${inputFileHash}${tsxQuery || initialConfig.tsx ? ".tsx" : ".ts"}`], - }) - - const badgeResult = url.searchParams.get("badge"); const badgeStyle = url.searchParams.get("badge-style"); @@ -425,6 +419,14 @@ export default { wasmModule = new WebAssembly.Module(WASM_MODULE as BufferSource); } + const inputFileHash = await hashString(jsonKey) + const configObj = Object.assign({}, jsonKeyObj, { + entryPoints: [`/index.${inputFileHash}${tsxQuery || initialConfig.tsx ? ".tsx" : ".ts"}`], + init: { + wasmModule + } + }) + const [response, resultText] = await bundle(url, initialValue, configObj, versions, modules, query); if (!response.ok) { diff --git a/utils/bytes.ts b/utils/bytes.ts new file mode 100644 index 0000000..55a867f --- /dev/null +++ b/utils/bytes.ts @@ -0,0 +1 @@ +export * from "@std/bytes" \ No newline at end of file diff --git a/utils/deno.jsonc b/utils/deno.jsonc index 0d0e24f..8ae3489 100644 --- a/utils/deno.jsonc +++ b/utils/deno.jsonc @@ -16,6 +16,8 @@ "./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", @@ -36,10 +38,14 @@ "@opentf/obj-diff": "jsr:@opentf/obj-diff@^0", "@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", diff --git a/utils/fetch-and-cache.ts b/utils/fetch-and-cache.ts index e588606..84debe0 100644 --- a/utils/fetch-and-cache.ts +++ b/utils/fetch-and-cache.ts @@ -1,9 +1,10 @@ -import { LRU } from "./lru.ts"; +import { retry } from "./async.ts"; +import { LruCache } from "./lru.ts"; /** * An LRU cache to store network responses with a capacity of 300 entries. */ -export const CACHE = new LRU(300); +export const CACHE = new LruCache(300); /** * The name of the cache used for storing external fetches. @@ -35,7 +36,7 @@ export function requestKey(request: RequestInfo) { * * @param request - The request info (URL or Request object). * @param options - Options for the request including cache, fetch options, cloning, and retry count. - * @param options.retry - Number of retry attempts for the request (default is 3). + * @param options.retry - Number of retry attempts for the request (default is 2). * @param options.cache - The Cache object to store the response. * @param options.fetchOpts - Fetch options such as method, headers, etc. * @param options.clone - Whether to clone the response before returning (default is true). @@ -54,33 +55,46 @@ export function requestKey(request: RequestInfo) { */ export async function newRequest( request: RequestInfo, - { cache, fetchOpts, clone = true, retry = 3 }: + { cache, fetchOpts, clone = true, retry: maxAttempts = 2 }: { retry?: number, cache?: Cache, fetchOpts?: RequestInit, clone?: boolean } ): Promise { const url = requestKey(request); // Generate a unique key for the request URL - let networkResponse: Response; - for (let attempt = 0; attempt < retry; attempt++) { - try { - // Attempt to fetch the resource from the network - networkResponse = await fetch(url, fetchOpts); + let attempt = 0; + const networkResponse = await retry(async () => { + attempt++; + const networkResponse = await fetch(url, fetchOpts); - // Check if the response status indicates success (2xx status codes) - if (!networkResponse.ok) { - throw new Error(`Failed to fetch ${typeof request === "string" ? request : request.url}: ${networkResponse.statusText}`); - } + // Check if the response status indicates success (2xx status codes) + if (!networkResponse.ok) { + console.error(`Attempt ${attempt + 1} to fetch ${url} failed: Failed to fetch ${typeof request === "string" ? request : request.url}: ${networkResponse.statusText}`); + } - // If the request is successful, break out of the retry loop - break; - } catch (error) { - const err = error as Error; - console.error(`Attempt ${attempt + 1} to fetch ${url} failed: ${err?.message}`); + return networkResponse + }, { maxAttempts }) - // If the last attempt also fails, return a Response object with an error state - if (attempt === retry - 1) - return networkResponse!; - } - } + // let networkResponse: Response; + // for (let attempt = 0; attempt < maxAttempts + 1; attempt++) { + // try { + // // Attempt to fetch the resource from the network + // networkResponse = await fetch(url, fetchOpts); + + // // Check if the response status indicates success (2xx status codes) + // if (!networkResponse.ok) { + // throw new Error(`Failed to fetch ${typeof request === "string" ? request : request.url}: ${networkResponse.statusText}`); + // } + + // // If the request is successful, break out of the retry loop + // break; + // } catch (error) { + // const err = error as Error; + // console.error(`Attempt ${attempt + 1} to fetch ${url} failed: ${err?.message}`); + + // // If the last attempt also fails, return a Response object with an error state + // if (attempt === retry - 1) + // return networkResponse!; + // } + // } // Only cache GET requests to avoid storing potentially sensitive data or modifying the cache unintentionally if (!fetchOpts?.method || (fetchOpts?.method && fetchOpts.method.toUpperCase() === "GET")) { @@ -133,13 +147,15 @@ export async function openCache(): Promise { * Retrieves a response from the cache or network, with optional permanent caching. * * @param url - The URL or Request object for the resource. - * @param permanent - Whether to use the cache permanently (default is false). - * @param fetchOpts - Fetch options such as method, headers, etc. + * @param opts - config options for request + * - permanent - Whether to use the cache permanently (default is false). + * - fetchOpts - Fetch options such as method, headers, etc. + * - retry - The number of times the request should be retried if an error occurs * @returns The network or cached response. * * Example usage: * ```ts - * const response = await getRequest('https://example.com/api/data', true); + * const response = await getRequest('https://example.com/api/data', { permanent: true }); * if (response.ok) { * const data = await response.json(); * console.log(data); @@ -148,8 +164,8 @@ export async function openCache(): Promise { * } * ``` */ -export async function getRequest(url: RequestInfo | URL, permanent = false, fetchOpts?: RequestInit): Promise { - const request = SUPPORTS_REQUEST_API ? new Request(url.toString(), fetchOpts) : url.toString(); +export async function getRequest(url: RequestInfo | URL, opts: { permanent?: boolean, retry?: number, fetchOpts?: RequestInit } = {}): Promise { + const request = SUPPORTS_REQUEST_API ? new Request(url.toString(), opts.fetchOpts) : url.toString(); let response: Response; let cache: Cache | undefined; @@ -170,12 +186,13 @@ export async function getRequest(url: RequestInfo | URL, permanent = false, fetc response = cacheResponse; } else { // If no cached response is found, perform a network request - response = (await newRequest(request, { cache, fetchOpts }))!; + response = (await newRequest(request, { cache, fetchOpts: opts.fetchOpts, retry: opts.retry }))!; } // If permanent is false, queue up a network request in the background to update the cache + const permanent = opts?.permanent ?? false; if (!permanent && cacheResponse) { - newRequest(request, { cache, fetchOpts, clone: false }); + newRequest(request, { cache, fetchOpts: opts.fetchOpts, clone: false, retry: opts.retry }); } return response; diff --git a/utils/json-streams.ts b/utils/json-streams.ts new file mode 100644 index 0000000..39f51d2 --- /dev/null +++ b/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/utils/lru.ts b/utils/lru.ts index a4bbd74..ba23e3e 100644 --- a/utils/lru.ts +++ b/utils/lru.ts @@ -202,3 +202,5 @@ export class LRU { } export default LRU; + +export * from "@std/cache" diff --git a/utils/mod.ts b/utils/mod.ts index a427970..b19f0fa 100644 --- a/utils/mod.ts +++ b/utils/mod.ts @@ -17,10 +17,14 @@ export * from "./archive-detect.ts"; export * from "./archive-spec.ts"; export * from "./npm-deps-spec.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"; @@ -28,6 +32,7 @@ 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"; diff --git a/utils/npm-search.ts b/utils/npm-search.ts index 4ab5656..163132e 100644 --- a/utils/npm-search.ts +++ b/utils/npm-search.ts @@ -70,7 +70,7 @@ export async function getPackages(input: string): Promise { let result: SearchInfo; try { - const response = await getRequest(searchURL, false); + const response = await getRequest(searchURL); result = await response.json(); } catch (e) { console.warn(e); @@ -113,7 +113,7 @@ export async function getPackage(input: string): Promise { const { packageURL } = getRegistryURL(input); try { - const response = await getRequest(packageURL, false); + const response = await getRequest(packageURL); return await response.json() as FullPackage; } catch (e) { console.warn(e); @@ -221,7 +221,7 @@ export async function getPackageOfVersion(input: string): Promise