diff --git a/core/plugins/cdn.ts b/core/plugins/cdn.ts index a97f10f..9d8b859 100644 --- a/core/plugins/cdn.ts +++ b/core/plugins/cdn.ts @@ -72,7 +72,7 @@ import { } from "@bundle/utils/npm-deps-spec"; import { extname, isBareImport, join } from "@bundle/utils/path"; -import { getRequest } from "@bundle/utils/fetch-and-cache"; +import { fetchWithCache } from "@bundle/utils/fetch-and-cache"; import { deepMerge } from "@bundle/utils/deep-object"; import { determineExtension, HTTP_NAMESPACE } from "./http.ts"; @@ -339,7 +339,7 @@ export function CdnResolution(StateContext: Context>) { try { // Strongly cache package.json files - const res = await getRequest(url, { permanent: true }); + const { response: res } = await fetchWithCache(url.href, { cacheMode: "reload" }); if (!res.ok) throw new Error(await res.text()); resolvedManifest = await res.json(); @@ -463,11 +463,11 @@ export function CdnResolution(StateContext: Context>) { const { url } = getCDNUrl(`${effectiveName}${cdnVersionFormat}${resultSubpath}`, origin); // Store the package.json manifest of the dependencies fetched in the cache - if (!packageManifestsMap.get(`${effectiveName}${knownVersion}`)) { + if (!packageManifestsMap.get(`${effectiveName}@${knownVersion}`)) { try { - const _manifest = await getPackageOfVersion(`${effectiveName}${knownVersion}`); + const _manifest = await getPackageOfVersion(`${effectiveName}@${knownVersion}`); if (_manifest) - packageManifestsMap.set(`${effectiveName}${knownVersion}`, _manifest); + packageManifestsMap.set(`${effectiveName}@${knownVersion}`, _manifest); } catch (e) { console.warn(e); } diff --git a/core/plugins/http.ts b/core/plugins/http.ts index 70cfd7c..00d5547 100644 --- a/core/plugins/http.ts +++ b/core/plugins/http.ts @@ -61,21 +61,17 @@ export interface HttpResolutionState extends LocalState { */ export async function fetchPkg( url: string, - opts: { fetchOpts?: RequestInit; retry?: number } = {} + opts: { fetchOpts?: RequestInit; retry?: number; cacheMode?: 'normal' | 'force' | 'reload' | 'no-store' } = {} ): Promise<{ url: string; content: Uint8Array; contentType: string | null }> { - const { fetchOpts, retry } = opts; + const { fetchOpts, retry, cacheMode = 'normal' } = opts; try { const result = await fetchContent(url, { init: fetchOpts, retries: retry, + cacheMode, }); - // Reject HTML responses (common error: hitting a 404 page or package listing) - if (result.contentType && /text\/html/i.test(result.contentType)) { - throw new Error(`Received HTML instead of expected content for ${result.url}`); - } - // Build descriptive log message const flags = [ result.fromCache && 'cached', @@ -106,13 +102,10 @@ export async function fetchPkg( */ export async function fetchPkgHeaders( url: string, - opts: { retry?: number } = {} + opts: { retry?: number; cacheMode?: 'normal' | 'force' | 'reload' | 'no-store' } = {} ): Promise<{ url: string; contentType: string | null }> { try { - const result = await fetchHeaders(url, { retries: opts.retry }); - if (result.contentType && /text\/html/i.test(result.contentType)) { - throw new Error(`Received HTML instead of expected content for ${result.url}`); - } + const result = await fetchHeaders(url, { retries: opts.retry, cacheMode: opts.cacheMode }); return { url: result.url, contentType: result.contentType, @@ -231,11 +224,14 @@ export async function determineExtension( if (failedSet.has(testUrl)) continue; try { + // Use 'force' cacheMode to prevent background refresh during extension probing. + // Background refresh with extensionless URLs can 404 if the CDN doesn't consistently + // redirect/resolve extensionless paths. if (headersOnly) { - const { url, contentType } = await fetchPkgHeaders(testUrl); + const { url, contentType } = await fetchPkgHeaders(testUrl, { cacheMode: 'reload' }); return { url, contentType }; } else { - const { url, contentType, content } = await fetchPkg(testUrl); + const { url, contentType, content } = await fetchPkg(testUrl, { cacheMode: 'normal' }); return { url, contentType, content }; } } catch (e) { diff --git a/core/plugins/tar.ts b/core/plugins/tar.ts index 96bcfe4..e3f1d58 100644 --- a/core/plugins/tar.ts +++ b/core/plugins/tar.ts @@ -26,7 +26,7 @@ import { UntarStream } from "@bundle/utils/tar"; import { resolve, legacy } from "@bundle/utils/resolve-exports-imports"; import { normalize, join } from "@bundle/utils/path"; -import { getRequest } from "@bundle/utils/fetch-and-cache"; +import { fetchWithCache } from "@bundle/utils/fetch-and-cache"; import { VIRTUAL_FILESYSTEM_NAMESPACE } from "./fs.ts"; import { dispatchEvent, LOGGER_INFO, LOGGER_WARN, LOGGER_ERROR } from "../configs/events.ts"; @@ -335,7 +335,7 @@ export async function fetchAndExtractTarball( dispatchEvent(LOGGER_INFO, `Fetching tarball candidate: ${url}`); - const response = await getRequest(url); + const { response } = await fetchWithCache(url); if (!response.ok) { throw new Error(`Failed to fetch tarball: ${response.status} ${response.statusText}`); } diff --git a/edge/mod.ts b/edge/mod.ts index ed8925e..333cd57 100644 --- a/edge/mod.ts +++ b/edge/mod.ts @@ -34,6 +34,7 @@ export type Config = BuildConfig & { let WASM_MODULE: Uint8Array; let wasmModule: WebAssembly.Module; +let redis: Redis | undefined | null; const encoder = new TextEncoder(); @@ -74,6 +75,9 @@ export default { if (url.pathname === "/favicon.ico") return Response.redirect("https://bundlejs.com/favicon/favicon-api.ico"); + if (url.pathname === "/sw.js") + return Response.json({ "message": "no" }); + if (url.pathname.startsWith("/.well-known/")) { const ext = extname(url.pathname); const fileName = basename(url.pathname); @@ -92,12 +96,13 @@ export default { return Response.redirect("https://blog.okikio.dev/documenting-an-online-bundler-bundlejs#heading-configuration"); } - let redis: Redis | undefined | null; try { - redis = new Redis({ - url: Deno.env.get('UPSTASH_URL') ?? "", - token: Deno.env.get('UPSTASH_TOKEN') ?? "", - }) + if (!redis) { + redis = new Redis({ + url: Deno.env.get('UPSTASH_URL') ?? "", + token: Deno.env.get('UPSTASH_TOKEN') ?? "", + }) + } } catch (e) { console.warn(e) } diff --git a/utils/fetch-and-cache.ts b/utils/fetch-and-cache.ts index a16cdd2..e9ff233 100644 --- a/utils/fetch-and-cache.ts +++ b/utils/fetch-and-cache.ts @@ -206,6 +206,49 @@ function doFetch( 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 { + try { + // Try original URL first - this allows version discovery for @latest etc. + const response = await doFetch(originalUrl, init, retries); + const resolvedUrl = response.url || originalUrl; + await storeInCache(originalUrl, resolvedUrl, response, cacheApi); + } catch (err) { + // 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 { + const response = await doFetch(finalUrl, init, retries); + const resolvedUrl = response.url || finalUrl; + await storeInCache(finalUrl, resolvedUrl, response, cacheApi); + } catch (fallbackErr) { + // 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. * @@ -234,7 +277,7 @@ export async function fetchWithCache( cacheMode = 'normal', } = options; - // Only cache GET requests + // Only cache GET requests (not HEAD, POST, etc.) const method = init.method?.toUpperCase() ?? 'GET'; const canCache = method === 'GET' && cacheMode !== 'no-store'; @@ -249,9 +292,7 @@ export async function fetchWithCache( // 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)); + backgroundRefresh(url, finalUrl, init, retries, cacheApi); } return { @@ -397,36 +438,15 @@ export async function invalidate(url: string): Promise { */ export async function getRequest( url: RequestInfo | URL, - opts: { permanent?: boolean; retry?: number; fetchOpts?: RequestInit } = {} + opts: { cacheMode?: FetchOptions['cacheMode']; 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, + cacheMode: opts.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 diff --git a/utils/npm-search.ts b/utils/npm-search.ts index 163132e..9879e52 100644 --- a/utils/npm-search.ts +++ b/utils/npm-search.ts @@ -1,6 +1,6 @@ import type { FullPackage, FullPackageVersion, PackageInfo, PackageSearchResult, RegistryURLs, SearchInfo } from "./types.ts"; -import { getRequest } from "./fetch-and-cache.ts"; +import { fetchWithCache } from "./fetch-and-cache.ts"; import { parsePackageName } from "./parse-package-name.ts"; import { maxSatisfying, parse, parseRange, format } from "./semver.ts"; @@ -70,7 +70,7 @@ export async function getPackages(input: string): Promise { let result: SearchInfo; try { - const response = await getRequest(searchURL); + const { response } = await fetchWithCache(searchURL, { cacheMode: "reload" }); 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); + const { response } = await fetchWithCache(packageURL, { cacheMode: "reload" }); return await response.json() as FullPackage; } catch (e) { console.warn(e); @@ -221,7 +221,7 @@ export async function getPackageOfVersion(input: string): Promise