diff --git a/core/_repl.ts b/core/_repl.ts index 5eefae0..b8d4a92 100644 --- a/core/_repl.ts +++ b/core/_repl.ts @@ -2,7 +2,7 @@ import type { TarStreamEntry } from "@bundle/utils/tar"; import { UntarStream } from "@bundle/utils/tar"; import { normalize, join } from "@bundle/utils/path"; -import { getFile, setFile, PLATFORM_AUTO, TheFileSystem } from "./mod.ts"; +import { getFile, setFile, PLATFORM_AUTO, TheFileSystem, build } from "./mod.ts"; import { context, cancel, dispose, rebuild, } from "./mod.ts"; const fs = await TheFileSystem; @@ -87,6 +87,7 @@ console.log("\n"); // export * from "@okikio/animate";`); // await setFile(fs, "/new.tsx", "export * from \"@okikio/native\";"); await setFile(fs, "/new.tsx", "export * from \"https://pkg.pr.new/@tanstack/react-query@7988\"") +// await setFile(fs, "/new.tsx", "export * from 'iconv-lite';\nexport { default } from 'iconv-lite';") // await setFile(fs, "/other.tsx", `\ // export * as Other from "/index.tsx"; // export * from "@okikio/emitter";`); @@ -96,7 +97,24 @@ await setFile(fs, "/new.tsx", "export * from \"https://pkg.pr.new/@tanstack/reac console.log(await getFile(fs, "/new.tsx", "string") ) console.log(fs) -const ctx = await context({ +// const ctx = await context({ +// // "/index.tsx", +// entryPoints: ["/new.tsx"], +// esbuild: { +// treeShaking: true, +// splitting: true, +// format: "esm" +// }, +// init: { +// // platform: "node", +// // version: "0.17", +// // wasm +// } +// }); +// const result = await rebuild(ctx); + + +const result = await build({ // "/index.tsx", entryPoints: ["/new.tsx"], esbuild: { @@ -110,7 +128,6 @@ const ctx = await context({ // wasm } }); -const result = await rebuild(ctx); console.log({ result, @@ -137,8 +154,8 @@ console.log({ // // // ) // }); -await cancel(ctx); -await dispose(ctx); +// await cancel(ctx); +// await dispose(ctx); if (PLATFORM_AUTO === "deno") { globalThis?.Deno?.exit?.(); } else { diff --git a/core/plugins/cdn.ts b/core/plugins/cdn.ts index e172a95..a97f10f 100644 --- a/core/plugins/cdn.ts +++ b/core/plugins/cdn.ts @@ -491,9 +491,10 @@ export function CdnResolution(StateContext: Context>) { inheritPeerDependencies[name] = initialDeps[name] ?? version; } + const pathWithExt = (await determineExtension(url.toString())); return { namespace: HTTP_NAMESPACE, - path: (await determineExtension(url.toString())).url, + path: pathWithExt.url, sideEffects: typeof resolvedManifest?.sideEffects === "boolean" ? resolvedManifest.sideEffects : undefined, diff --git a/core/plugins/http.ts b/core/plugins/http.ts index d549b3b..d3b1589 100644 --- a/core/plugins/http.ts +++ b/core/plugins/http.ts @@ -30,7 +30,7 @@ import type { CdnResolutionState } from "./cdn.ts"; import { fromContext, toContext } from "../context/context.ts"; import { CdnResolution } from "./cdn.ts"; -import { getRequest } from "@bundle/utils/fetch-and-cache"; +import { fetchContent, fetchHeaders } from "@bundle/utils/fetch-and-cache"; import { decode } from "@bundle/utils/encode-decode"; import { LOGGER_ERROR, LOGGER_INFO, LOGGER_WARN, dispatchEvent } from "../configs/events.ts"; @@ -49,52 +49,92 @@ export interface HttpResolutionState extends LocalState { build: ESBUILD.PluginBuild } +// ============================================================================ +// Fetch Wrappers +// ============================================================================ + /** - * Fetches packages from HTTP/HTTPS URLs - * - * @param url Package URL to fetch - * @param fetchOpts Optional fetch configuration (e.g., { method: "HEAD" }) - * @returns Object with url, contentType, and content as Uint8Array - * @throws Error if fetch fails or returns HTML + * Fetches package content from a URL. + * + * Returns the **final URL** after any redirects, which is critical for + * resolving relative imports within the fetched content. */ -export async function fetchPkg(url: string, fetchOpts?: RequestInit) { +export async function fetchPkg( + url: string, + opts: { fetchOpts?: RequestInit; retry?: number } = {} +): Promise<{ url: string; content: Uint8Array; contentType: string | null }> { + const { fetchOpts, retry } = opts; + try { - const response = await getRequest(url, { fetchOpts, retry: 1 }); - if (!response.ok) - throw new Error(`Couldn't load ${response.url || url} (${response.status} code)`); + const result = await fetchContent(url, { + init: fetchOpts, + retries: retry, + }); - if (response?.headers && /text\/html/.test(response.headers.get("content-type") ?? "")) - throw new Error("Can't load HTML as a package"); + // Build descriptive log message + const flags = [ + result.fromCache && 'cached', + result.redirected && 'redirected', + ].filter(Boolean).join(', '); + + const flagStr = flags ? ` (${flags})` : ''; + const redirectStr = result.redirected ? ` → ${result.url}` : ''; + + dispatchEvent(LOGGER_INFO, `Fetch${flagStr} ${url}${redirectStr}`); - if (fetchOpts?.method !== "HEAD") - dispatchEvent(LOGGER_INFO, `Fetch ${fetchOpts?.method === "HEAD" ? `(test)` : ""} ${response.url || url}`); + return { + url: result.url, + content: result.content, + contentType: result.contentType, + }; + } catch (e) { + const err = e as Error; + throw new Error(`[fetchPkg] Failed to fetch ${url}\n${err.message}`, { cause: err }); + } +} +/** + * Fetches only headers from a URL (for extension probing). + * Uses HEAD request with GET fallback for servers that don't support HEAD. + * + * Returns the **final URL** after any redirects. + */ +export async function fetchPkgHeaders( + url: string, + opts: { retry?: number } = {} +): Promise<{ url: string; contentType: string | null }> { + try { + const result = await fetchHeaders(url, { retries: opts.retry }); return { - // Deno doesn't have a `response.url` which is odd but whatever - url: response.url || url, - contentType: response?.headers?.get?.("content-type"), - content: new Uint8Array(await response.arrayBuffer()), + url: result.url, + contentType: result.contentType, }; } catch (e) { - const err = e as Error | unknown; - throw new Error(`[getRequest] Failed at request (${url})\n${err?.toString()}`, { - cause: err - }); + const err = e as Error; + throw new Error(`[fetchPkgHeaders] Failed to probe ${url}\n${err.message}`, { cause: err }); } } +// ============================================================================ +// Asset Discovery +// ============================================================================ + /** * Fetches assets referenced in JS files via `new URL("...", import.meta.url)` * * External assets like WASM files and Workers are discovered and fetched. * These are stored in the virtual file system for later bundling. * - * @param path URL path for the original JS file + * @param path URL path for the original JS file (must be final URL after redirects) * @param content Content of the original JS file * @param StateContext Context with filesystem access * @returns Promise of settled results for each discovered asset */ -export async function fetchAssets(path: string, content: Uint8Array, StateContext: Context>) { +export async function fetchAssets( + path: string, + content: Uint8Array, + StateContext: Context> +) { // Regex for `new URL("./path.js", import.meta.url)`, // Supports comments so you can add comments and the regex will ignore them const rgx = /new(?:\s|\n?)+URL\((?:\s*(?:\/\*(?:.*\n)*\*\/)?(?:\/\/.*\n)?)*(?:(?!\`.*\$\{)['"`](.*)['"`]),(?:\s*(?:\/\*(?:.*\n)*\*\/)?(?:\/\/.*\n)?)*import\.meta\.url(?:\s*(?:\/\*(?:.*\n)*\*\/)?(?:\/\/.*\n)?)*\)/g; @@ -108,15 +148,14 @@ export async function fetchAssets(path: string, content: Uint8Array { const { content: asset, url } = await fetchPkg(urlJoin(parentURL, assetURL)); - // Create a virtual file system for storing assets - // This is for building a package bundle analyzer + // Store asset in virtual file system for bundle analyzer if (FileSystem) { - const path = toURLPath(url); - await setFile(FileSystem, path, content); + const filePath = toURLPath(url); + await setFile(FileSystem, filePath, asset); } - const hashBuffer = await crypto.subtle.digest("SHA-256", asset); - const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashBuffer = await crypto.subtle.digest("SHA-256", asset as BufferSource); + const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray .map(b => b.toString(16).padStart(2, "0")) .join(""); @@ -124,7 +163,7 @@ export async function fetchAssets(path: string, content: Uint8Array(path: string, content: Uint8Array { - return FileEndings.map(extension => ending + extension) - }) - .flat() + FilePaths.flatMap(path => FileEndings.map(ext => path + ext)) ) ); export const EndingVariantsLength = AllEndingVariants.length; /** - * Probes for the correct file extension when not explicitly provided + * Probes for the correct file extension when not explicitly provided. * * TypeScript files often don't have file extensions in imports, but servers * require the full path. This function tries multiple extensions until one works. * * @param path Base path to probe - * @param headersOnly If true, only fetch headers (HEAD request) + * @param headersOnly If true, only fetch headers (faster for probing) * @param StateContext Optional context for caching failed probes * @returns Object with resolved url, contentType, and optionally content */ export async function determineExtension( path: string, - headersOnly: boolean = true, - StateContext: Context> | null = null -) { - // Some typescript files don't have file extensions but you can't fetch a file without their file extension - // so bundlejs tries to solve for that - const argPath = (suffix = "") => path + suffix; + { headersOnly = true, StateContext = null }: { + headersOnly?: boolean; + StateContext?: Context> | null; + } = {} +): Promise<{ url: string; contentType: string | null; content?: Uint8Array }> { const failedExtChecks = StateContext ? fromContext("failedExtensionChecks", StateContext) : null; const failedSet = failedExtChecks ?? new Set(); - let url = path; - let content: Uint8Array | undefined; - let contentType: string | null = null; + let firstError: Error | undefined; - let err: Error | undefined; for (let i = 0; i < EndingVariantsLength; i++) { - const endings = AllEndingVariants[i]; - const testingUrl = argPath(endings); + const suffix = AllEndingVariants[i]; + const testUrl = path + suffix; - try { - if (failedSet?.has?.(testingUrl)) continue; + // Skip URLs we've already tried and failed + if (failedSet.has(testUrl)) continue; - ({ url, contentType, content } = await fetchPkg( - testingUrl, - headersOnly ? { method: "HEAD" } : undefined - )); - break; + try { + if (headersOnly) { + const { url, contentType } = await fetchPkgHeaders(testUrl); + return { url, contentType }; + } else { + const { url, contentType, content } = await fetchPkg(testUrl); + return { url, contentType, content }; + } } catch (e) { - failedSet?.add?.(testingUrl); - if (i === 0) err = e as Error; + failedSet.add(testUrl); + + // Keep the first error as it's usually most accurate + if (i === 0) firstError = e as Error; - // If after checking all the different file extensions none of them are valid - // Throw the first fetch error encountered, as that is generally the most accurate error + // If we've exhausted all variants, throw if (i >= EndingVariantsLength - 1) { - dispatchEvent(LOGGER_ERROR, err ?? e as Error); - throw err ?? e; + const error = firstError ?? e; + dispatchEvent(LOGGER_ERROR, error as Error); + throw error; } } } - return headersOnly ? { url, contentType } : { url, contentType, content }; + // TypeScript: unreachable, but needed for type safety + throw new Error(`[determineExtension] Failed to resolve ${path}`); } +// ============================================================================ +// esbuild Resolution +// ============================================================================ + /** * Resolution algorithm for the esbuild HTTP plugin * @@ -215,102 +261,88 @@ export async function determineExtension( * 2. Bare imports - delegate to CdnResolution * 3. Relative/absolute imports - resolve against parent URL * - * @param StateContext Context with host and config - * @param build Optional esbuild PluginBuild for URL routing (passed to CdnResolution) + * **Important**: Uses `pluginData.url` (the final URL after redirects) as the + * base for resolving relative imports. This ensures correct resolution when + * CDN aliases like `@latest` redirect to specific versions. */ export function HttpResolution(StateContext: Context>) { const host = fromContext("host", StateContext)!; const build = fromContext("build", StateContext)!; return async function (args: ESBUILD.OnResolveArgs): Promise { - // Some packages use "../../" with the assumption that "/" is equal to "/index.js", this is supposed to fix that bug - const argPath = args.path; //.replace(/\/$/, "/index"); + const argPath = args.path; - // If the import path isn't relative do this... + // Non-relative imports if (!argPath.startsWith(".") && !isAbsolute(argPath)) { - // If the import is an http import load the content via the http plugins loader + // Direct HTTP URL if (/^https?:\/\//.test(argPath)) { return { path: argPath, namespace: HTTP_NAMESPACE, - sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? - args.pluginData?.manifest?.sideEffects : - undefined, + sideEffects: args.pluginData?.manifest?.sideEffects, pluginData: args.pluginData, }; } + // Determine origin for resolution const pathOrigin = new URL( - // Use the parent files URL as a host - urlJoin(args.pluginData?.url ? args.pluginData?.url : host, "../", argPath) + urlJoin(args.pluginData?.url ?? host, "../", argPath) ).origin; - // npm standard CDNs, e.g. unpkg, skypack, esm.sh, etc... const NPM_CDN = getCDNStyle(pathOrigin) === "npm"; const origin = NPM_CDN ? pathOrigin : host; - // If the import is a bare import, use the CDN plugins resolution algorithm + // Bare import (e.g., "lodash") → delegate to CDN resolution if (isBareImport(argPath)) { const ctx = StateContext.with({ build }) as Context>; return await CdnResolution(ctx)(args); - } else { - /** - * If the import is neither an http import or a bare import (module import), then it is an absolute import. - * Therefore, load the content via the http plugins loader, but make sure that the absolute URL doesn't go past the root URL - * - * e.g. - * To load `jquery` from jsdelivr, the CDN root needs to `https://cdn.jsdelivr.net/npm`, - * thus the final URL is https://cdn.jsdelivr.net/npm/jquery - * - * The problem is that if a user using absolute URL's aims for the root domain, - * the result should be `https://cdn.jsdelivr.net`, but what we really want is for our use case is - * a root of `https://cdn.jsdelivr.net/npm` - * - * So, we treat the path as a CDN and force all URLs to use CDN origins as the root domain - */ - return { - path: getCDNUrl(argPath, origin).url.toString(), - namespace: HTTP_NAMESPACE, - sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? - args.pluginData?.manifest?.sideEffects : - undefined, - pluginData: args.pluginData, - }; } + + // Absolute import (e.g., "/lib/foo") → resolve against CDN origin + return { + path: getCDNUrl(argPath, origin).url.toString(), + namespace: HTTP_NAMESPACE, + sideEffects: args.pluginData?.manifest?.sideEffects, + pluginData: args.pluginData, + }; } - // For relative imports - let path = urlJoin(args.pluginData?.url, "../", argPath); + // Relative imports - resolve against parent's final URL + let resolvedPath: string; + if (isAbsolute(argPath)) { - const _url = new URL(args.pluginData?.url); - _url.pathname = argPath; - path = _url.toString(); + const parentUrl = new URL(args.pluginData?.url); + parentUrl.pathname = argPath; + resolvedPath = parentUrl.toString(); + } else { + // Relative: "./foo" resolved against parent URL + resolvedPath = urlJoin(args.pluginData?.url, "../", argPath); } return { - path, + path: resolvedPath, namespace: HTTP_NAMESPACE, - sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? - args.pluginData?.manifest?.sideEffects : - undefined, + sideEffects: args.pluginData?.manifest?.sideEffects, pluginData: args.pluginData, }; }; } +// ============================================================================ +// esbuild Plugin +// ============================================================================ + /** - * Esbuild HTTP plugin - * - * @param assets Array to store fetched assets - * @param host The default host origin to use if an import doesn't already have one - * @param logger Console log + * esbuild HTTP plugin for loading modules from URLs + * + * @param StateContext Context with config, assets, and filesystem */ export function HttpPlugin(StateContext: Context>): ESBUILD.Plugin { - // Convert CDN values to URL origins + // Resolve CDN host const LocalConfig = fromContext("config", StateContext)!; - const { origin: host } = LocalConfig?.cdn && !/:/.test(LocalConfig?.cdn) ? - getCDNUrl(LocalConfig?.cdn + ":") : - getCDNUrl(LocalConfig?.cdn ?? DEFAULT_CDN_HOST); + const { origin: host } = LocalConfig?.cdn && !/:/.test(LocalConfig?.cdn) + ? getCDNUrl(LocalConfig?.cdn + ":") + : getCDNUrl(LocalConfig?.cdn ?? DEFAULT_CDN_HOST); toContext("host", host ?? DEFAULT_CDN_HOST, StateContext); @@ -322,73 +354,55 @@ export function HttpPlugin(StateContext: Context>): ESBUILD.Plu setup(build) { const ctx = StateContext.with({ build }) as Context>; - // Intercept import paths starting with "http:" and "https:" so - // esbuild doesn't attempt to map them to a file system location. - // Tag them with the "http-url" namespace to associate them with - // this plugin. - build.onResolve({ filter: /^https?:\/\// }, args => { - return { - path: args.path, - namespace: HTTP_NAMESPACE, - sideEffects: typeof args.pluginData?.manifest?.sideEffects === "boolean" ? - args.pluginData?.manifest?.sideEffects : - undefined, - pluginData: args.pluginData, - }; - }); + // Route HTTP/HTTPS URLs to this plugin + build.onResolve({ filter: /^https?:\/\// }, args => ({ + path: args.path, + namespace: HTTP_NAMESPACE, + sideEffects: args.pluginData?.manifest?.sideEffects, + pluginData: args.pluginData, + })); - // We also want to intercept all import paths inside downloaded - // files and resolve them against the original URL. All of these - // files will be in the "http-url" namespace. Make sure to keep - // the newly resolved URL in the "http-url" namespace so imports - // inside it will also be resolved as URLs recursively. + // Route all imports within HTTP namespace through HttpResolution build.onResolve({ filter: /.*/, namespace: HTTP_NAMESPACE }, HttpResolution(ctx)); - // When a URL is loaded, we want to actually download the content - // from the internet. This has just enough logic to be able to - // handle the example import from https://cdn.esm.sh/ but in reality this - // would probably need to be more complex. + // Load content from HTTP URLs build.onLoad({ filter: /.*/, namespace: HTTP_NAMESPACE }, async (args) => { - // Some typescript files don't have file extensions but you can't fetch a file without their file extension - // so bundle tries to solve for that - let content: Uint8Array | undefined, url: string; - let contentType: string | null = null; - ({ content, contentType, url } = await determineExtension(args.path, false, StateContext)); - - // Create a virtual file system for storing node modules - // This is for building a package bundle analyzer - // await FileSystem.set(args.namespace + ":" + args.path, content); - - if (content) { - // Create a virtual file system for storing assets - // This is for building a package bundle analyzer - if (FileSystem) { - const path = toURLPath(url); - await setFile(FileSystem, path, content); - } - - const _assetResults = - (await fetchAssets(url, content as Uint8Array, StateContext)) - .filter((result) => { - if (result.status === "rejected") { - dispatchEvent(LOGGER_WARN, `Asset '${url}' fetch failed.\n` + result?.reason?.toString()) - return false; - } else return true; - }) - .map((result) => { - if (result.status === "fulfilled") - return result.value; - return null - }) - .filter(value => value) as ESBUILD.OutputFile[]; - - toContext("assets", Assets.concat(_assetResults), StateContext); - return { - contents: content, - loader: inferLoader(url, contentType), - pluginData: { url, manifest: args.pluginData?.manifest }, - }; + // Probe for correct extension and fetch content + const { url, content, contentType } = await determineExtension(args.path, { + headersOnly: false, + StateContext, + }); + + if (!content) return; + + // Store in virtual filesystem for bundle analyzer + if (FileSystem) { + const filePath = toURLPath(url); + await setFile(FileSystem, filePath, content); } + + // Discover and fetch assets (WASM, Workers, etc.) + const assetResults = await fetchAssets(url, content as Uint8Array, StateContext); + + const resolvedAssets = assetResults + .filter((result): result is PromiseFulfilledResult => { + if (result.status === "rejected") { + dispatchEvent(LOGGER_WARN, `Asset fetch failed for '${url}':\n${result.reason}`); + return false; + } + return true; + }) + .map(result => result.value); + + toContext("assets", Assets.concat(resolvedAssets), StateContext); + + return { + contents: content, + loader: inferLoader(url, contentType), + // CRITICAL: Pass the final URL (after redirects) in pluginData + // This is used as the base URL for resolving relative imports + pluginData: { ...args.pluginData, url }, + }; }); }, }; diff --git a/core/plugins/tar.ts b/core/plugins/tar.ts index b8c5d8c..96bcfe4 100644 --- a/core/plugins/tar.ts +++ b/core/plugins/tar.ts @@ -671,14 +671,6 @@ export function TarResolution(StateContext: Context>) { const mount = await getOrCreateMount(packageUrl.toString(), StateContext); const entryPath = resolvePackageEntry(mount.manifest, subpath, conditions); const resolvedPath = join(mount.packageRoot, entryPath); - - console.log({ - argPath: args.path, - entryPath, - resolvedPath, - name: mount?.manifest?.name, - deps: mount?.manifest?.dependencies, - }) return { path: resolvedPath, diff --git a/utils/fetch-and-cache.ts b/utils/fetch-and-cache.ts index 84debe0..e0cf1fb 100644 --- a/utils/fetch-and-cache.ts +++ b/utils/fetch-and-cache.ts @@ -2,198 +2,423 @@ import { retry } from "./async.ts"; import { LruCache } from "./lru.ts"; /** - * An LRU cache to store network responses with a capacity of 300 entries. + * 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. */ -export const CACHE = new LruCache(300); -/** - * The name of the cache used for storing external fetches. +// ============================================================================ +// Configuration +// ============================================================================ + +/** LRU cache capacity for responses */ +const CACHE_CAPACITY = 300; + +/** LRU cache capacity for redirect mappings */ +const REDIRECT_MAP_CAPACITY = 500; + +/** Default retry attempts for failed requests */ +const DEFAULT_RETRIES = 2; + +// ============================================================================ +// 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; +} + +export interface FetchOptions { + /** Additional fetch options (headers, method, etc.) */ + init?: RequestInit; + /** Number of retry attempts on failure. Default: 2 */ + retries?: number; + /** Whether to clone the response before returning. Default: true */ + clone?: boolean; + /** + * 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'; +} + +// ============================================================================ +// Cache API wrapper +// ============================================================================ + +let openCachePromise: Promise | null = null; + /** - * Boolean flag indicating if the Cache API is supported in the current environment. + * Opens the Cache API storage (singleton pattern) */ -export const SUPPORTS_CACHE_API = "caches" in globalThis; +export async 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; +} + +// ============================================================================ +// Core fetch implementation +// ============================================================================ /** - * Boolean flag indicating if the Request API is supported in the current environment. + * Resolves a URL through the redirect map to find the final cached URL. + * Returns the original URL if no redirect mapping exists. */ -export const SUPPORTS_REQUEST_API = "Request" in globalThis; +function resolveRedirect(url: string): string { + return redirectMap.get(url) ?? url; +} /** - * Generates a unique key for a given request URL. - * - * @param request - The request info (URL or Request object). - * @returns A unique string key for the request. + * Looks up a URL in cache, checking both the direct URL and any redirect mappings. */ -export function requestKey(request: RequestInfo) { - return SUPPORTS_REQUEST_API && request instanceof Request ? request.url.toString() : request.toString() +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); } /** - * Performs a network request and handles retries, caching, and response cloning. + * 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 +): 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) return; + + try { + const cloned = response.clone(); + + if (SUPPORTS_CACHE_API && cacheApi) { + // Cache API: store under final URL + await cacheApi.put(new Request(finalUrl), cloned); + } else { + // In-memory fallback: store under final URL + responseCache.set(finalUrl, cloned); + } + + // Track redirect mapping if URLs differ + if (originalUrl !== finalUrl) { + redirectMap.set(originalUrl, finalUrl); + } + } catch (err) { + console.error(`[cache] Failed to store response for ${finalUrl}:`, err); + } +} + +/** + * Performs the actual network fetch with retry logic. + */ +async function doFetch( + url: string, + init: RequestInit = {}, + retries: number +): Promise { + const fetchWithRedirect = async () => { + const response = await fetch(url, { + redirect: "follow", + ...init, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText} for ${url}`); + } + + return response; + }; + + if (retries > 0) { + return retry(fetchWithRedirect, { maxAttempts: retries + 1 }); + } + + return fetchWithRedirect(); +} + +/** + * Main fetch function with caching and proper redirect handling. * - * @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 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). - * @returns The network response, possibly cloned, or an error response if all retries fail. + * **Important**: The returned `url` is the *final* URL after redirects. + * Use this for resolving relative imports in fetched content. * - * Example usage: + * @example * ```ts - * const response = await newRequest('https://example.com/api/data', { retry: 5 }); - * if (response.ok) { - * const data = await response.json(); - * console.log(data); - * } else { - * console.error('Failed to fetch data:', response.statusText); - * } + * // 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 newRequest( - request: RequestInfo, - { 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 attempt = 0; - const networkResponse = await retry(async () => { - attempt++; - const networkResponse = await fetch(url, fetchOpts); +export async function fetchWithCache( + url: string, + options: FetchOptions = {} +): Promise { + const { + init = {}, + retries = DEFAULT_RETRIES, + clone = true, + cacheMode = 'normal', + } = options; - // 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}`); - } + // Only cache GET requests + const method = init.method?.toUpperCase() ?? 'GET'; + const canCache = method === 'GET' && cacheMode !== 'no-store'; + + const cacheApi = SUPPORTS_CACHE_API ? await openCache() : undefined; - return networkResponse - }, { maxAttempts }) - - // 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")) { - try { - // Check if the environment supports the Cache API and a cache object is provided - if (SUPPORTS_CACHE_API && cache) { - // Store the response in the provided cache object - await cache.put(request, networkResponse!.clone()); - } else { - // Fallback to a simple in-memory cache if Cache API is not supported - CACHE.set(url, networkResponse!.clone()); + // 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 + if (cacheMode === 'normal') { + doFetch(url, init, retries) + .then(response => storeInCache(url, response.url || url, response, cacheApi)) + .catch(err => console.error(`[cache] Background refresh failed for ${url}:`, err)); } - } catch (err) { - const cacheError = err as Error; - console.error(`Failed to cache response for ${url}: ${cacheError?.message}`); + + return { + url: finalUrl, + response: clone ? cached.clone() : cached, + fromCache: true, + redirected: url !== finalUrl, + }; } } - // Clone the response if the clone option is set to true - if (clone) { - try { - return networkResponse!.clone(); - } catch (err) { - const cloneError = err as Error; - console.error(`Failed to clone response for ${url}: ${cloneError?.message}`); - } + // Fetch from network + const response = await doFetch(url, init, retries); + const finalUrl = response.url || url; + const redirected = response.redirected || url !== finalUrl; + + // Store in cache + if (canCache) { + await storeInCache(url, finalUrl, response, cacheApi); } - // Return the original network response if cloning is not required or cloning fails - return networkResponse!; -}; + return { + url: finalUrl, + response: clone ? response.clone() : response, + fromCache: false, + redirected, + }; +} -/** - * A reference to an opened Cache object. - */ -export let OPEN_CACHE: Cache; +// ============================================================================ +// Convenience functions +// ============================================================================ /** - * Opens the named cache and returns the Cache object. - * - * @returns The opened Cache object. + * Fetches content and returns it as a Uint8Array along with metadata. + * This is the most common pattern for bundlers. */ -export async function openCache(): Promise { - if (OPEN_CACHE) return OPEN_CACHE; - OPEN_CACHE = await caches.open(CACHE_NAME); - return (OPEN_CACHE); +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"); + + // Reject HTML responses (common error: hitting a 404 page or package listing) + if (contentType && /text\/html/i.test(contentType)) { + throw new Error(`Received HTML instead of expected content for ${finalUrl}`); + } + + const content = new Uint8Array(await response.arrayBuffer()); + + return { url: finalUrl, content, contentType, fromCache, redirected }; } /** - * Retrieves a response from the cache or network, with optional permanent caching. - * - * @param url - The URL or Request object for the resource. - * @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', { permanent: true }); - * if (response.ok) { - * const data = await response.json(); - * console.log(data); - * } else { - * console.error('Failed to fetch data:', response.statusText); - * } - * ``` + * 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 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; +export async function fetchHeaders( + url: string, + options: Omit = {} +): Promise<{ + url: string; + contentType: string | null; + fromCache: boolean; +}> { + const { init = {}, retries = 0, cacheMode = 'normal' } = options; + + // Try HEAD first + try { + const { url: finalUrl, response, fromCache } = await fetchWithCache(url, { + init: { ...init, method: 'HEAD' }, + retries: 0, + clone: false, + cacheMode, + }); + + // Cancel the body if any (shouldn't be one for HEAD, but defensive) + try { await response.body?.cancel(); } catch { /* ignore */ } + + const contentType = response.headers.get("content-type"); + if (contentType && /text\/html/i.test(contentType)) { + throw new Error(`Received HTML instead of expected content for ${finalUrl}`); + } + + 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, + clone: false, + cacheMode: 'no-store', // Don't cache partial responses + }); + + // Cancel body immediately - we only wanted headers + try { await response.body?.cancel(); } catch { /* ignore */ } + + const contentType = response.headers.get("content-type"); + if (contentType && /text\/html/i.test(contentType)) { + throw new Error(`Received HTML instead of expected content for ${finalUrl}`); + } + + return { url: finalUrl, contentType, fromCache }; + } +} - let cache: Cache | undefined; - let cacheResponse: Response | undefined; +// ============================================================================ +// Cache management +// ============================================================================ - // In specific situations the browser will sometimes disable access to cache storage, - // so, we create our own in-memory cache as a fallback +/** + * Clears all cached data (both in-memory and Cache API) + */ +export async function clearCache(): Promise { + responseCache.clear(); + redirectMap.clear(); + if (SUPPORTS_CACHE_API) { - cache = await openCache(); - cacheResponse = await cache.match(request); - } else { - const reqKey = requestKey(request); - cacheResponse = CACHE.get(reqKey); + await caches.delete(CACHE_NAME); + openCachePromise = null; } +} - // If a cached response is found, use it - if (cacheResponse) { - response = cacheResponse; - } else { - // If no cached response is found, perform a network request - response = (await newRequest(request, { cache, fetchOpts: opts.fetchOpts, retry: opts.retry }))!; +/** + * 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); + + if (SUPPORTS_CACHE_API) { + const cache = await openCache(); + await cache.delete(finalUrl); } +} - // 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: opts.fetchOpts, clone: false, retry: opts.retry }); - } +// ============================================================================ +// Legacy API compatibility (deprecated, use new functions) +// ============================================================================ +/** + * @deprecated Use `fetchWithCache` instead + */ +export async function getRequest( + url: RequestInfo | URL, + opts: { permanent?: boolean; retry?: number; fetchOpts?: RequestInit } = {} +): Promise { + const urlString = url.toString(); + const cacheMode = opts.permanent ? 'force' : 'normal'; + + const { response } = await fetchWithCache(urlString, { + init: opts.fetchOpts, + retries: opts.retry, + cacheMode, + }); + + return response; +} + +/** + * @deprecated Use `fetchContent` instead + */ +export async function newRequest( + request: RequestInfo, + opts: { retry?: number; cache?: Cache; fetchOpts?: RequestInit; clone?: boolean } = {} +): Promise { + const url = SUPPORTS_REQUEST_API && request instanceof Request + ? request.url + : request.toString(); + + const { response } = await fetchWithCache(url, { + init: opts.fetchOpts, + retries: opts.retry, + clone: opts.clone ?? true, + }); + return response; } \ No newline at end of file