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
JavaScript
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
;