Skip to content

Commit 1634abd

Browse files
author
Nathan Booker
committed
feat: normalize headers/methods, preserve bodies, binary-safe cache, TTL clamp; docs & package metadata updates
1 parent 3c40cbe commit 1634abd

5 files changed

Lines changed: 116 additions & 103 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ interface CachedFetchOptions extends RequestInit {
231231
cache?: 'auto no cache' | 'no-store' | 'force-cache';
232232
next?: {
233233
revalidate?: false | 0 | number;
234+
expires?: number; // absolute expiry in seconds (must be > revalidate)
234235
tags?: string[];
236+
fetchCacheKeyPrefix?: string;
235237
};
236238
}
237239
```
@@ -285,6 +287,11 @@ interface CachedFetchOptions extends RequestInit {
285287
- @vercel/functions 1.4.0 or later
286288
- Deployed on Vercel (Runtime Cache is a Vercel feature)
287289

290+
## Environment behavior
291+
292+
- On Vercel Edge (middleware/edge routes): uses Runtime Cache for persistence and SWR background refresh via `waitUntil()`.
293+
- Outside Vercel (e.g., local dev or Node runtimes without `@vercel/functions` available): falls back to native `fetch` behavior without caching.
294+
288295
## Edge Runtime Compatibility
289296

290297
This package is designed specifically for the Edge Runtime and works in:

example.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
"vercel",
3131
"runtime-cache"
3232
],
33-
"author": "",
33+
"homepage": "https://github.com/bookernath/cached-middleware-fetch-next#readme",
34+
"bugs": {
35+
"url": "https://github.com/bookernath/cached-middleware-fetch-next/issues"
36+
},
37+
"sideEffects": false,
3438
"license": "MIT",
3539
"repository": {
3640
"type": "git",

src/index.ts

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -97,24 +97,27 @@ function processHeadersForCacheKey(headers: HeadersInit | undefined): Record<str
9797

9898
if (headers instanceof Headers) {
9999
headers.forEach((value, key) => {
100+
const lowerKey = key.toLowerCase();
100101
// Skip trace context headers
101-
if (key.toLowerCase() !== 'traceparent' && key.toLowerCase() !== 'tracestate') {
102-
headerObj[key] = value;
102+
if (lowerKey !== 'traceparent' && lowerKey !== 'tracestate') {
103+
headerObj[lowerKey] = value;
103104
}
104105
});
105106
} else if (Array.isArray(headers)) {
106107
headers.forEach(([key, value]) => {
108+
const lowerKey = key.toLowerCase();
107109
// Skip trace context headers
108-
if (key.toLowerCase() !== 'traceparent' && key.toLowerCase() !== 'tracestate') {
109-
headerObj[key] = value;
110+
if (lowerKey !== 'traceparent' && lowerKey !== 'tracestate') {
111+
headerObj[lowerKey] = value;
110112
}
111113
});
112114
} else {
113115
// Plain object
114116
Object.entries(headers).forEach(([key, value]) => {
117+
const lowerKey = key.toLowerCase();
115118
// Skip trace context headers
116-
if (key.toLowerCase() !== 'traceparent' && key.toLowerCase() !== 'tracestate') {
117-
headerObj[key] = value;
119+
if (lowerKey !== 'traceparent' && lowerKey !== 'tracestate') {
120+
headerObj[lowerKey] = value as string;
118121
}
119122
});
120123
}
@@ -137,8 +140,8 @@ async function sha256(message: string): Promise<string> {
137140

138141
// Node.js runtime
139142
// @ts-ignore - crypto module is available in Node.js
140-
const nodeCrypto = await import('crypto');
141-
return nodeCrypto.createHash('sha256').update(message).digest('hex');
143+
const { createHash } = await import('crypto');
144+
return createHash('sha256').update(message).digest('hex');
142145
}
143146

144147
/**
@@ -147,14 +150,15 @@ async function sha256(message: string): Promise<string> {
147150
async function generateCacheKey(
148151
input: RequestInfo | URL,
149152
init?: RequestInit,
150-
fetchCacheKeyPrefix?: string
153+
fetchCacheKeyPrefix?: string,
154+
preprocessedBodyChunks?: any[]
151155
): Promise<string> {
152156
// Extract URL and create Request object for consistent processing
153157
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
154158
const request = new Request(url, init);
155159

156160
// Process body
157-
const { chunks: bodyChunks } = await processBodyForCacheKey(init?.body);
161+
const bodyChunks = preprocessedBodyChunks ?? (await processBodyForCacheKey(init?.body)).chunks;
158162

159163
// Process headers (removing trace context headers)
160164
const headers = processHeadersForCacheKey(init?.headers);
@@ -211,10 +215,21 @@ function needsRevalidation(entry: CacheEntry): boolean {
211215
async function responseToCache(response: Response, options?: CachedFetchOptions): Promise<CacheEntry> {
212216
const headers: Record<string, string> = {};
213217
response.headers.forEach((value, key) => {
214-
headers[key] = value;
218+
headers[key.toLowerCase()] = value;
215219
});
216220

217-
const data = await response.text();
221+
const contentType = response.headers.get('content-type') || '';
222+
const shouldTreatAsText = /^(text\/|application\/(json|javascript|xml|x-www-form-urlencoded)|image\/svg\+xml)/i.test(contentType);
223+
let data: string;
224+
let isBinary = false;
225+
if (shouldTreatAsText) {
226+
data = await response.text();
227+
} else {
228+
const buffer = await response.arrayBuffer();
229+
const bytes = new Uint8Array(buffer);
230+
data = toBase64(bytes);
231+
isBinary = true;
232+
}
218233
const now = Date.now();
219234

220235
// Calculate revalidation and expiry times
@@ -249,17 +264,32 @@ async function responseToCache(response: Response, options?: CachedFetchOptions)
249264
revalidateAfter,
250265
expiresAt,
251266
tags: options?.next?.tags,
267+
isBinary,
268+
contentType: contentType || undefined,
252269
};
253270
}
254271

255272
/**
256273
* Convert a cache entry back to a Response object
257274
*/
258275
function cacheToResponse(entry: CacheEntry): Response {
259-
return new Response(entry.data, {
276+
const headers = new Headers(entry.headers);
277+
headers.delete('content-length');
278+
if (entry.contentType && !headers.get('content-type')) {
279+
headers.set('content-type', entry.contentType);
280+
}
281+
let body: BodyInit | null = null;
282+
if ((entry as any).isBinary) {
283+
const bytes = fromBase64(entry.data as string);
284+
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as unknown as ArrayBuffer;
285+
body = ab;
286+
} else {
287+
body = entry.data as string;
288+
}
289+
return new Response(body, {
260290
status: entry.status,
261291
statusText: entry.statusText,
262-
headers: entry.headers,
292+
headers,
263293
});
264294
}
265295

@@ -284,6 +314,42 @@ function cleanFetchOptions(options?: CachedFetchOptions): RequestInit | undefine
284314
return cleanOptions;
285315
}
286316

317+
function toBase64(bytes: Uint8Array): string {
318+
// @ts-ignore - Buffer may be available in Node runtime
319+
if (typeof Buffer !== 'undefined') {
320+
// @ts-ignore
321+
return Buffer.from(bytes).toString('base64');
322+
}
323+
let binary = '';
324+
const chunkSize = 0x8000;
325+
for (let i = 0; i < bytes.length; i += chunkSize) {
326+
const chunk = bytes.subarray(i, i + chunkSize);
327+
binary += String.fromCharCode(...chunk);
328+
}
329+
// @ts-ignore - btoa available in Edge/Web runtimes
330+
return btoa(binary);
331+
}
332+
333+
function fromBase64(b64: string): Uint8Array {
334+
// @ts-ignore - Buffer may be available in Node runtime
335+
if (typeof Buffer !== 'undefined') {
336+
// @ts-ignore
337+
return new Uint8Array(Buffer.from(b64, 'base64'));
338+
}
339+
// @ts-ignore - atob available in Edge/Web runtimes
340+
const binary = atob(b64);
341+
const len = binary.length;
342+
const bytes = new Uint8Array(len);
343+
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
344+
return bytes;
345+
}
346+
347+
function computeTTL(expiresAt?: number): number {
348+
if (!expiresAt) return 86400;
349+
const ttl = Math.floor((expiresAt - Date.now()) / 1000);
350+
return Math.max(60, ttl);
351+
}
352+
287353
/**
288354
* A fetch wrapper that uses Vercel Runtime Cache for caching
289355
* Mimics Next.js Data Cache API for use in edge middleware
@@ -293,19 +359,25 @@ export async function cachedFetch(
293359
init?: CachedFetchOptions
294360
): Promise<Response> {
295361
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
296-
const method = init?.method || 'GET';
362+
const cleanOptions = cleanFetchOptions(init) || {};
363+
const method = (cleanOptions.method ? String(cleanOptions.method) : 'GET').toUpperCase();
364+
cleanOptions.method = method;
297365

298366
// Determine cache behavior
299367
const cacheOption = init?.cache || 'auto no cache';
300368
const revalidate = init?.next?.revalidate;
301369

302370
// Skip cache for no-store or revalidate: 0
303371
if (cacheOption === 'no-store' || revalidate === 0) {
304-
return fetch(input, cleanFetchOptions(init));
372+
return fetch(input, cleanOptions);
305373
}
306374

307375
// Generate cache key
308-
const cacheKey = await generateCacheKey(input, cleanFetchOptions(init), init?.next?.fetchCacheKeyPrefix);
376+
const { chunks: bodyChunks, ogBody } = await processBodyForCacheKey(init?.body);
377+
if (ogBody !== undefined) {
378+
cleanOptions.body = ogBody;
379+
}
380+
const cacheKey = await generateCacheKey(input, cleanOptions, init?.next?.fetchCacheKeyPrefix, bodyChunks);
309381

310382
// Get Vercel Runtime Cache instance
311383
const cache = getCache();
@@ -315,19 +387,23 @@ export async function cachedFetch(
315387
if (cacheOption === 'force-cache' || cacheOption === 'auto no cache') {
316388
const cachedEntry = await cache.get(cacheKey) as CacheEntry | undefined;
317389

318-
if (cachedEntry && !isCacheEntryExpired(cachedEntry)) {
390+
if (
391+
cachedEntry &&
392+
typeof cachedEntry.status === 'number' &&
393+
cachedEntry.data !== undefined &&
394+
cachedEntry.headers &&
395+
!isCacheEntryExpired(cachedEntry)
396+
) {
319397
// Check if we need to revalidate in the background
320398
if (needsRevalidation(cachedEntry)) {
321399
// Return stale data immediately and refresh in background (SWR)
322400
const backgroundRefresh = async () => {
323401
try {
324-
const freshResponse = await fetch(input, cleanFetchOptions(init));
402+
const freshResponse = await fetch(input, cleanOptions);
325403

326404
if (freshResponse.ok && (method === 'GET' || method === 'POST' || method === 'PUT')) {
327405
const freshCacheEntry = await responseToCache(freshResponse.clone(), init);
328-
const cacheTTL = freshCacheEntry.expiresAt
329-
? Math.floor((freshCacheEntry.expiresAt - Date.now()) / 1000)
330-
: 86400; // Default 24 hours
406+
const cacheTTL = computeTTL(freshCacheEntry.expiresAt);
331407
await cache.set(cacheKey, freshCacheEntry, { ttl: cacheTTL });
332408
}
333409
} catch (error) {
@@ -350,16 +426,14 @@ export async function cachedFetch(
350426
}
351427

352428
// Fetch from origin (cache miss or expired)
353-
const response = await fetch(input, cleanFetchOptions(init));
429+
const response = await fetch(input, cleanOptions);
354430

355431
// Only cache successful responses (2xx) and GET/POST/PUT requests
356432
if (response.ok && (method === 'GET' || method === 'POST' || method === 'PUT')) {
357433
const cacheEntry = await responseToCache(response.clone(), init);
358434

359435
// Store in cache with appropriate TTL
360-
const cacheTTL = cacheEntry.expiresAt
361-
? Math.floor((cacheEntry.expiresAt - Date.now()) / 1000)
362-
: 86400; // Default 24 hours
436+
const cacheTTL = computeTTL(cacheEntry.expiresAt);
363437

364438
cache.set(cacheKey, cacheEntry, { ttl: cacheTTL }).catch((error: unknown) => {
365439
console.error('[cached-middleware-fetch] Failed to cache response:', error);
@@ -370,7 +444,7 @@ export async function cachedFetch(
370444
} catch (error) {
371445
// If cache operations fail, fallback to regular fetch
372446
console.error('[cached-middleware-fetch] Cache operation failed:', error);
373-
return fetch(input, cleanFetchOptions(init));
447+
return fetch(input, cleanOptions);
374448
}
375449
}
376450

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export interface CacheEntry {
5555
revalidateAfter?: number; // Timestamp when revalidation should occur
5656
expiresAt?: number; // Timestamp when cache entry expires
5757
tags?: string[];
58+
// Binary metadata for safe replay
59+
isBinary?: boolean;
60+
contentType?: string;
5861
}
5962

6063

0 commit comments

Comments
 (0)