UNPKG

cached-middleware-fetch-next

Version:

A Next.js fetch wrapper for edge middleware that uses Vercel Runtime Cache as its caching backend

372 lines (371 loc) 13.7 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { cachedFetch: () => cachedFetch, default: () => index_default, fetch: () => cachedFetch }); module.exports = __toCommonJS(index_exports); var import_functions = require("@vercel/functions"); async function processBodyForCacheKey(body) { if (!body) return { chunks: [] }; if (body instanceof Uint8Array) { const decoder = new TextDecoder(); const decoded = decoder.decode(body); return { chunks: [decoded], ogBody: body }; } if (body instanceof ReadableStream) { const reader = body.getReader(); const chunks = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) chunks.push(value); } } finally { reader.releaseLock(); } const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const combined = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } const decoder = new TextDecoder(); const decoded = decoder.decode(combined); return { chunks: [decoded], ogBody: combined }; } if (typeof FormData !== "undefined" && body instanceof FormData) { const serialized = []; body.forEach((value, key) => { if (typeof value === "string") { serialized.push(`${key}=${value}`); } }); return { chunks: [serialized.join(",")] }; } if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) { const serialized = []; body.forEach((value, key) => { serialized.push(`${key}=${value}`); }); return { chunks: [serialized.join(",")] }; } if (typeof Blob !== "undefined" && body instanceof Blob) { const text = await body.text(); const newBlob = new Blob([text], { type: body.type }); return { chunks: [text], ogBody: newBlob }; } if (typeof body === "string") { return { chunks: [body] }; } return { chunks: [JSON.stringify(body)] }; } function processHeadersForCacheKey(headers) { const headerObj = {}; if (!headers) return headerObj; if (headers instanceof Headers) { headers.forEach((value, key) => { const lowerKey = key.toLowerCase(); if (lowerKey !== "traceparent" && lowerKey !== "tracestate") { headerObj[lowerKey] = value; } }); } else if (Array.isArray(headers)) { headers.forEach(([key, value]) => { const lowerKey = key.toLowerCase(); if (lowerKey !== "traceparent" && lowerKey !== "tracestate") { headerObj[lowerKey] = value; } }); } else { Object.entries(headers).forEach(([key, value]) => { const lowerKey = key.toLowerCase(); if (lowerKey !== "traceparent" && lowerKey !== "tracestate") { headerObj[lowerKey] = value; } }); } return headerObj; } async function sha256(message) { if (typeof crypto !== "undefined" && crypto.subtle && crypto.subtle.digest) { const encoder = new TextEncoder(); const data = encoder.encode(message); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } const { createHash } = await import("crypto"); return createHash("sha256").update(message).digest("hex"); } async function generateCacheKey(input, init, fetchCacheKeyPrefix, preprocessedBodyChunks) { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const request = new Request(url, init); const bodyChunks = preprocessedBodyChunks ?? (await processBodyForCacheKey(init?.body)).chunks; const headers = processHeadersForCacheKey(init?.headers); const keyComponents = [ "v1", // Version prefix fetchCacheKeyPrefix || "", url, request.method, headers, request.mode || "", request.redirect || "", request.credentials || "", request.referrer || "", request.referrerPolicy || "", request.integrity || "", request.cache || "", bodyChunks ]; const jsonString = JSON.stringify(keyComponents); return sha256(jsonString); } function isCacheEntryExpired(entry) { if (!entry.expiresAt) { return false; } return Date.now() > entry.expiresAt; } function needsRevalidation(entry) { if (!entry.revalidateAfter) { return false; } return Date.now() > entry.revalidateAfter; } async function responseToCache(response, options) { const headers = {}; response.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); const contentType = response.headers.get("content-type") || ""; const shouldTreatAsText = /^(text\/|application\/(json|javascript|xml|x-www-form-urlencoded)|image\/svg\+xml)/i.test(contentType); let data; let isBinary = false; if (shouldTreatAsText) { data = await response.text(); } else { const buffer = await response.arrayBuffer(); const bytes = new Uint8Array(buffer); data = toBase64(bytes); isBinary = true; } const now = Date.now(); let revalidateAfter; let expiresAt; const revalidate = options?.next?.revalidate; const expires = options?.next?.expires; if (revalidate === false) { expiresAt = now + 365 * 24 * 60 * 60 * 1e3; } else if (typeof revalidate === "number" && revalidate > 0) { revalidateAfter = now + revalidate * 1e3; if (expires && expires > revalidate) { expiresAt = now + expires * 1e3; } else { const defaultExpiry = Math.max(86400, revalidate * 10); expiresAt = now + defaultExpiry * 1e3; } } return { data, headers, status: response.status, statusText: response.statusText, timestamp: now, revalidateAfter, expiresAt, tags: options?.next?.tags, isBinary, contentType: contentType || void 0 }; } function cacheToResponse(entry, cacheStatus = "HIT") { const headers = new Headers(entry.headers); headers.delete("content-length"); if (entry.contentType && !headers.get("content-type")) { headers.set("content-type", entry.contentType); } const now = Date.now(); const cacheAge = Math.floor((now - entry.timestamp) / 1e3); headers.set("X-Cache-Status", cacheStatus); headers.set("X-Cache-Age", cacheAge.toString()); if (entry.expiresAt) { const expiresIn = Math.max(0, Math.floor((entry.expiresAt - now) / 1e3)); headers.set("X-Cache-Expires-In", expiresIn.toString()); } let body = null; if (entry.isBinary) { const bytes = fromBase64(entry.data); const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); body = ab; } else { body = entry.data; } return new Response(body, { status: entry.status, statusText: entry.statusText, headers }); } function cleanFetchOptions(options) { if (!options) return void 0; const { cache, next, ...rest } = options; const cleanOptions = { ...rest }; if (cache === "no-store") { cleanOptions.cache = "no-store"; } else if (cache === "force-cache") { cleanOptions.cache = "force-cache"; } return cleanOptions; } function toBase64(bytes) { if (typeof Buffer !== "undefined") { return Buffer.from(bytes).toString("base64"); } let binary = ""; const chunkSize = 32768; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); binary += String.fromCharCode(...chunk); } return btoa(binary); } function fromBase64(b64) { if (typeof Buffer !== "undefined") { return new Uint8Array(Buffer.from(b64, "base64")); } const binary = atob(b64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function computeTTL(expiresAt) { if (!expiresAt) return 86400; const ttl = Math.floor((expiresAt - Date.now()) / 1e3); return Math.max(60, ttl); } async function cachedFetch(input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const cleanOptions = cleanFetchOptions(init) || {}; const method = (cleanOptions.method ? String(cleanOptions.method) : "GET").toUpperCase(); cleanOptions.method = method; const cacheOption = init?.cache || "auto no cache"; const revalidate = init?.next?.revalidate; if (cacheOption === "no-store" || revalidate === 0) { const response = await fetch(input, cleanOptions); const responseClone = response.clone(); const responseWithCacheHeaders = new Response(responseClone.body, { status: response.status, statusText: response.statusText, headers: new Headers(response.headers) }); responseWithCacheHeaders.headers.set("X-Cache-Status", "MISS"); responseWithCacheHeaders.headers.set("X-Cache-Age", "0"); return responseWithCacheHeaders; } const { chunks: bodyChunks, ogBody } = await processBodyForCacheKey(init?.body); if (ogBody !== void 0) { cleanOptions.body = ogBody; } const cacheKey = await generateCacheKey(input, cleanOptions, init?.next?.fetchCacheKeyPrefix, bodyChunks); const cache = (0, import_functions.getCache)(); try { if (cacheOption === "force-cache" || cacheOption === "auto no cache") { const cachedEntry = await cache.get(cacheKey); if (cachedEntry && typeof cachedEntry.status === "number" && cachedEntry.data !== void 0 && cachedEntry.headers && !isCacheEntryExpired(cachedEntry)) { const isStale = needsRevalidation(cachedEntry); if (isStale) { const backgroundRefresh = async () => { try { const freshResponse = await fetch(input, cleanOptions); if (freshResponse.ok && (method === "GET" || method === "POST" || method === "PUT")) { const freshCacheEntry = await responseToCache(freshResponse.clone(), init); const cacheTTL = computeTTL(freshCacheEntry.expiresAt); await cache.set(cacheKey, freshCacheEntry, { ttl: cacheTTL }); } } catch (error) { console.error("[cached-middleware-fetch] Background refresh failed:", error); } }; if (typeof import_functions.waitUntil === "function") { (0, import_functions.waitUntil)(backgroundRefresh()); } else { backgroundRefresh().catch(() => { }); } } return cacheToResponse(cachedEntry, isStale ? "STALE" : "HIT"); } } const response = await fetch(input, cleanOptions); const responseForCaching = response.clone(); const responseForReturn = response.clone(); const responseWithCacheHeaders = new Response(responseForReturn.body, { status: response.status, statusText: response.statusText, headers: new Headers(response.headers) }); responseWithCacheHeaders.headers.set("X-Cache-Status", "MISS"); responseWithCacheHeaders.headers.set("X-Cache-Age", "0"); if (response.ok && (method === "GET" || method === "POST" || method === "PUT")) { const cacheEntry = await responseToCache(responseForCaching, init); const cacheTTL = computeTTL(cacheEntry.expiresAt); cache.set(cacheKey, cacheEntry, { ttl: cacheTTL }).catch((error) => { console.error("[cached-middleware-fetch] Failed to cache response:", error); }); } return responseWithCacheHeaders; } catch (error) { console.error("[cached-middleware-fetch] Cache operation failed:", error); const fallbackResponse = await fetch(input, cleanOptions); const fallbackResponseClone = fallbackResponse.clone(); const responseWithCacheHeaders = new Response(fallbackResponseClone.body, { status: fallbackResponse.status, statusText: fallbackResponse.statusText, headers: new Headers(fallbackResponse.headers) }); responseWithCacheHeaders.headers.set("X-Cache-Status", "MISS"); responseWithCacheHeaders.headers.set("X-Cache-Age", "0"); return responseWithCacheHeaders; } } var index_default = cachedFetch; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { cachedFetch, fetch }); //# sourceMappingURL=index.js.map