From 5051e78166da87e2b335235e4b07ddd31819c3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B3=96=E9=A5=BC?= Date: Wed, 17 Jun 2026 14:58:55 +0800 Subject: [PATCH 1/3] feat: add createCacheHandler for middleware-style cache origins Extract resolveWithCache as the shared caching core, add phase-aware origin invocation for in-process handlers, and refactor createFetch to use the same path. --- .changeset/middleware-cache-handler.md | 5 + README.md | 55 +++-- src/cache.test.ts | 11 +- src/fetch.test.ts | 21 +- src/fetch.ts | 289 ++----------------------- src/index.ts | 8 + src/origin.test.ts | 191 ++++++++++++++++ src/origin.ts | 107 +++++++++ src/resolve.test.ts | 202 +++++++++++++++++ src/resolve.ts | 281 ++++++++++++++++++++++++ src/types.ts | 44 ++++ 11 files changed, 918 insertions(+), 296 deletions(-) create mode 100644 .changeset/middleware-cache-handler.md create mode 100644 src/origin.test.ts create mode 100644 src/origin.ts create mode 100644 src/resolve.test.ts create mode 100644 src/resolve.ts diff --git a/.changeset/middleware-cache-handler.md b/.changeset/middleware-cache-handler.md new file mode 100644 index 0000000..aee5882 --- /dev/null +++ b/.changeset/middleware-cache-handler.md @@ -0,0 +1,5 @@ +--- +"@web-widget/shared-cache": minor +--- + +Add middleware-friendly `resolveWithCache` and `createCacheHandler` APIs with phase-aware origin error handling. diff --git a/README.md b/README.md index 11b44e8..71b8bd4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ SharedCache is an HTTP caching library that follows Web Standards and HTTP speci - **🎯 Smart Caching**: Handles complex HTTP scenarios including `Vary` headers, proxy revalidation, and authenticated responses - **🔧 Flexible Storage**: Pluggable storage backend supporting memory, Redis, or any custom key-value store - **🚀 Enhanced Fetch**: Extends the standard `fetch` API with caching capabilities while maintaining full compatibility +- **🔌 Middleware Origin**: `createCacheHandler` for in-process handlers (e.g. middleware `next()`) - **🎛️ Custom Cache Keys**: Cache key customization supporting device types, cookies, headers, and URL components - **⚡ Shared Cache Optimization**: Prioritizes `s-maxage` over `max-age` for shared cache performance - **🌍 Universal Runtime**: Compatible with [WinterCG](https://wintercg.org/) environments including Node.js, Deno, Bun, and Edge Runtime @@ -104,7 +105,14 @@ export const handler = { }; ``` -**Integration Requirements**: This pattern requires web framework integration with SharedCache middleware or custom cache implementation in your SSR pipeline. +**Integration Requirements**: Use `createCacheHandler` in framework middleware, or integrate SharedCache manually in your SSR pipeline. + +```typescript +const handler = createCacheHandler(cache, { + cacheControlOverride: 's-maxage=60', +}); +return handler.resolve(request, () => next(), { waitUntil }); +``` #### **Cross-Runtime Applications** @@ -206,7 +214,8 @@ This package exports a comprehensive set of APIs for HTTP caching functionality: ```typescript import { - createFetch, // Main fetch function with caching + createFetch, // Outbound HTTP fetch with caching + createCacheHandler, // In-process origin (middleware) Cache, // SharedCache class CacheStorage, // SharedCacheStorage class } from '@web-widget/shared-cache'; @@ -766,16 +775,16 @@ SharedCache provides comprehensive monitoring through the `x-cache-status` heade ### Cache Status Types -| Status | Description | When It Occurs | -| ----------------- | ----------------------------------------------- | ------------------------------------------------------------ | -| **`HIT`** | Response served from cache | Fresh cache hit, or conditional `304` from `Cache.match()` | -| **`MISS`** | Response fetched from origin | The requested resource was not found in cache | -| **`EXPIRED`** | Cached response expired, fresh response fetched | The cached response exceeded its TTL | -| **`UPDATING`** | Stale response served during background revalidate | `stale-while-revalidate` via `createFetch` | -| **`STALE`** | Stale response served when origin is unreachable | `stale-if-error` or revalidation failure | -| **`BYPASS`** | Cache bypassed | Bypassed due to cache control directives like `no-store` | -| **`REVALIDATED`** | Cached response revalidated with origin | Synchronous revalidation; origin returned 304 Not Modified | -| **`DYNAMIC`** | Response cannot be cached | Cannot be cached due to HTTP method or status code | +| Status | Description | When It Occurs | +| ----------------- | -------------------------------------------------- | ---------------------------------------------------------- | +| **`HIT`** | Response served from cache | Fresh cache hit, or conditional `304` from `Cache.match()` | +| **`MISS`** | Response fetched from origin | The requested resource was not found in cache | +| **`EXPIRED`** | Cached response expired, fresh response fetched | The cached response exceeded its TTL | +| **`UPDATING`** | Stale response served during background revalidate | `stale-while-revalidate` via `createFetch` | +| **`STALE`** | Stale response served when origin is unreachable | `stale-if-error` or revalidation failure | +| **`BYPASS`** | Cache bypassed | Bypassed due to cache control directives like `no-store` | +| **`REVALIDATED`** | Cached response revalidated with origin | Synchronous revalidation; origin returned 304 Not Modified | +| **`DYNAMIC`** | Response cannot be cached | Cannot be cached due to HTTP method or status code | ### Cache Status Header Details @@ -1048,7 +1057,9 @@ const alertingLogger = { **Main Functions:** -- `createFetch(cache?, options?)` - Create cached fetch function +- `createFetch(cache?, options?)` - Cached fetch for outbound HTTP requests +- `createCacheHandler(cache, defaults?)` - Cached resolver for in-process origin handlers +- `resolveWithCache(cache, request, origin, options?)` - Low-level cache resolution (used by both APIs above) - `createLogger(logger?, logLevel?, prefix?)` - Create logger with level filtering **Classes:** @@ -1095,6 +1106,19 @@ const fetch = createFetch(cache, { }); ``` +### createCacheHandler / resolveWithCache + +For in-process origins (e.g. middleware `next()`). Same cache options as `createFetch`. On cache miss, origin throws propagate; during revalidation, throws become 5xx for `stale-if-error`. + +```typescript +const handler = createCacheHandler(cache, { + cacheControlOverride: 's-maxage=60', +}); +await handler.resolve(request, () => next(), { waitUntil }); +``` + +Use `createFetch` for outbound HTTP; use `createCacheHandler` for in-process handlers. + ### Key Interfaces #### SharedCacheRequestInitProperties @@ -1228,6 +1252,8 @@ interface Cache { **`createFetch` vs `Cache`:** `createFetch` adds SWR, origin revalidation, and status headers. Bare `cache.match()` / `cache.put()` are storage-style APIs (expired entries return `undefined` without `createFetch`). +**`createFetch` vs `createCacheHandler`:** Same caching core. `createFetch` wraps outbound `fetch`; `createCacheHandler` accepts an in-process origin callback. + **Options Parameter Differences:** SharedCache's `CacheQueryOptions` interface differs from the standard Web Cache API: @@ -1332,11 +1358,13 @@ When using SharedCache with meta-frameworks, you can develop with a consistent c 2. Second query: Get actual response from variant cache key **Performance Impact:** + - **Local Redis**: Minimal impact (0.2-1ms additional latency) - **Remote Redis**: Significant impact (4-20ms additional latency) - **Database storage**: High impact (10-50ms additional latency) **Recommendation for slow storage:** + ```typescript // Disable Vary processing for better performance const fetch = createFetch(cache, { @@ -1347,6 +1375,7 @@ const fetch = createFetch(cache, { ``` **Trade-offs:** + - **With Vary**: RFC compliant, supports content negotiation, but slower - **Without Vary**: Faster performance, but may serve incorrect content for requests with different headers diff --git a/src/cache.test.ts b/src/cache.test.ts index e939021..86f2f70 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -317,7 +317,9 @@ describe('SharedCache', () => { }); it('should omit representation headers on conditional 304 responses', async () => { - const request = new Request('https://example.com/conditional-304-headers'); + const request = new Request( + 'https://example.com/conditional-304-headers' + ); await cache.put( request, createTestResponse('cached data', 200, { @@ -379,7 +381,9 @@ describe('SharedCache', () => { }); it('should return 200 when If-Modified-Since is invalid', async () => { - const request = new Request('https://example.com/conditional-invalid-ims'); + const request = new Request( + 'https://example.com/conditional-invalid-ims' + ); await cache.put( request, createTestResponse('cached data', 200, { @@ -545,7 +549,8 @@ describe('SharedCache', () => { await new Promise((resolve) => setTimeout(resolve, 1100)); const matched = await cache.match(request, { - _fetch: async () => new Response('Internal Server Error', { status: 500 }), + _fetch: async () => + new Response('Internal Server Error', { status: 500 }), }); expect(matched).toBeDefined(); diff --git a/src/fetch.test.ts b/src/fetch.test.ts index 0502a8c..d7e3236 100644 --- a/src/fetch.test.ts +++ b/src/fetch.test.ts @@ -1,16 +1,13 @@ /** - * Comprehensive test suite for the shared cache fetch implementation. - * Tests HTTP caching semantics, error handling, and edge cases according to RFC 7234. + * Integration tests for the `createFetch` HTTP client API. * - * Test Coverage: - * - HTTP cache control directives and semantics - * - Vary header handling and cache variations - * - Stale-while-revalidate and stale-if-error behavior - * - HTTP method caching rules (GET, HEAD, POST, etc.) - * - Status code caching policies - * - Edge cases and error handling - * - Performance and concurrent request handling - * - Standards compliance with HTTP/1.1 and related RFCs + * Test division: + * - `fetch.test.ts` (this file): `createFetch` end-to-end HTTP caching semantics (RFC 7234). + * - `resolve.test.ts`: `resolveWithCache` / `createCacheHandler` middleware orchestration. + * - `origin.test.ts`: phase-aware origin invocation (`invokeOrigin`) unit tests. + * + * Miss-phase error propagation through `createFetch` is covered here at the integration + * level. Phase-specific throw vs 5xx conversion is unit-tested in `origin.test.ts`. */ import { LRUCache } from 'lru-cache'; @@ -1874,6 +1871,8 @@ describe('Vary Header Handling', () => { }); describe('Error Handling', () => { + // Integration coverage for createFetch miss propagation. See origin.test.ts + // for phase-specific throw vs 5xx conversion details. it('should handle network errors gracefully', async () => { const store = createCacheStore(); const cache = new SharedCache(store); diff --git a/src/fetch.ts b/src/fetch.ts index 43f296f..818b715 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,23 +1,7 @@ -import { vary } from './utils/vary'; -import { vary as getVaryCachePart } from './cache-key'; -import { cacheControl } from './utils/cache-control'; -import { - encodeCacheKeyHeaderValue, - modifyResponseHeaders, - setResponseHeader, -} from './utils/response'; import { SharedCache } from './cache'; import { SharedCacheStorage } from './cache-storage'; +import { resolveWithCache } from './resolve'; import { - CACHE_KEY_HEADER_NAME, - BYPASS, - CACHE_STATUS_HEADER_NAME, - DYNAMIC, - HIT, - MISS, -} from './constants'; -import { - SharedCacheStatus, SharedCacheFetch, SharedCacheRequestInitProperties, SharedCacheRequest, @@ -57,7 +41,7 @@ const ORIGINAL_FETCH = globalThis.fetch; * const caches = new CacheStorage(createLRUStorage()); * const cache = await caches.open('api-cache'); * - * // Create cached fetch with default options + * // Create cached fetch with default configuration * const fetch = createFetch(cache, { * defaults: { * cacheControlOverride: 's-maxage=300', @@ -101,8 +85,15 @@ export function createSharedCacheFetch( // Extract and validate cache mode const requestCache = getRequestCacheMode(request, init?.cache); + // Validate unsupported cache modes + if (requestCache && requestCache !== 'default') { + throw new Error( + `Cache mode "${requestCache}" is not implemented. Only "default" mode is supported.` + ); + } + // Configure shared cache options with defaults merged with request options - const sharedCacheOptions = (request.sharedCache = { + const sharedCacheOptions = { // Start with global defaults ignoreRequestCacheControl: true, ignoreVary: false, @@ -112,96 +103,17 @@ export function createSharedCacheFetch( ...request.sharedCache, // Finally apply init options (highest priority) ...init?.sharedCache, - }); - const debugCacheKey = sharedCacheOptions.debugCacheKey - ? await cache.getCacheKey(request) - : undefined; - - // Create interceptor for response header manipulation - const interceptor = createInterceptor( - fetcher, - sharedCacheOptions.cacheControlOverride, - sharedCacheOptions.varyOverride - ); - - // Validate unsupported cache modes - if (requestCache && requestCache !== 'default') { - throw new Error( - `Cache mode "${requestCache}" is not implemented. Only "default" mode is supported.` - ); - } - - // Create event from waitUntil function if event is not provided but waitUntil is - const event = - sharedCacheOptions.event || - (sharedCacheOptions.waitUntil - ? ({ - waitUntil: sharedCacheOptions.waitUntil, - } as ExtendableEvent) - : undefined); - - // Attempt to serve from cache - const cachedResponse = await cache.match(request, { - _fetch: interceptor, - _ignoreRequestCacheControl: sharedCacheOptions.ignoreRequestCacheControl, - _event: event, - ignoreMethod: request.method === 'HEAD', // HEAD requests can match GET - }); - - // Return cached response if available - if (cachedResponse) { - const effectiveCacheKey = await getEffectiveCacheKey( - request, - cachedResponse, - debugCacheKey, - sharedCacheOptions.ignoreVary - ); - return setCacheKey( - setCacheStatus(cachedResponse, HIT), - effectiveCacheKey - ); - } - - // Fetch from network and attempt to cache - const fetchedResponse = await interceptor(request); - // Process response caching based on Cache-Control directives - const cacheControl = fetchedResponse.headers.get('cache-control'); - - if (cacheControl) { - // Check if response should bypass cache - if (bypassCache(cacheControl)) { - return setCacheKey( - setCacheStatus(fetchedResponse, BYPASS), - debugCacheKey - ); - } else { - // Attempt to store in cache - const cacheSuccess = await cache.put(request, fetchedResponse).then( - () => true, - () => { - return false; - } - ); - const effectiveCacheKey = cacheSuccess - ? await getEffectiveCacheKey( - request, - fetchedResponse, - debugCacheKey, - sharedCacheOptions.ignoreVary - ) - : debugCacheKey; - return setCacheKey( - setCacheStatus(fetchedResponse, cacheSuccess ? MISS : DYNAMIC), - effectiveCacheKey - ); + }; + + return resolveWithCache( + cache, + request, + (originRequest) => fetcher(originRequest, init), + { + ...sharedCacheOptions, + signal: init?.signal ?? undefined, } - } else { - // No Cache-Control header - mark as dynamic content - return setCacheKey( - setCacheStatus(fetchedResponse, DYNAMIC), - debugCacheKey - ); - } + ); }; } @@ -216,161 +128,6 @@ export function createSharedCacheFetch( */ export const sharedCacheFetch = createSharedCacheFetch(); -/** - * Sets cache status header on a response if not already present. - * - * This function adds diagnostic information about cache behavior by setting - * a custom header. The header is only set if it doesn't already exist, - * preserving any existing cache status information. - * - * @param response - The response to modify - * @param status - The cache status to set - * @returns The response with cache status header set - * @internal - */ -function setCacheStatus( - response: Response, - status: SharedCacheStatus -): Response { - if (!response.headers.has(CACHE_STATUS_HEADER_NAME)) { - return setResponseHeader(response, CACHE_STATUS_HEADER_NAME, status); - } - return response; -} - -/** - * Sets cache key header on a response for debugging. - * - * @param response - The response to modify - * @param cacheKey - The computed cache key - * @returns The response with cache key header set when enabled - * @internal - */ -function setCacheKey(response: Response, cacheKey?: string): Response { - if (cacheKey) { - return setResponseHeader( - response, - CACHE_KEY_HEADER_NAME, - encodeCacheKeyHeaderValue(cacheKey) - ); - } - return response; -} - -/** - * Resolves effective cache key by applying response Vary rules. - * - * @param request - The original request - * @param response - The response used for cache lookup/store - * @param cacheKey - The base cache key - * @param ignoreVary - Whether vary handling is disabled - * @returns The effective cache key used by storage - * @internal - */ -async function getEffectiveCacheKey( - request: Request, - response: Response, - cacheKey: string | undefined, - ignoreVary: boolean | undefined -): Promise { - if (!cacheKey || ignoreVary) { - return cacheKey; - } - - const varyHeader = response.headers.get('vary'); - if (!varyHeader || varyHeader === '*') { - return cacheKey; - } - - const include = varyHeader - .split(',') - .map((field) => field.trim().toLowerCase()) - .filter(Boolean); - if (!include.length) { - return cacheKey; - } - - const varyPart = await getVaryCachePart(request, { include }); - return varyPart ? `${cacheKey}:${varyPart}` : cacheKey; -} - -/** - * Creates an interceptor function that can modify response headers. - * - * This function creates a wrapper around the fetch function that allows - * overriding Cache-Control and Vary headers on successful responses. - * This is useful for: - * - Enforcing consistent caching policies - * - Adding cache directives to responses that lack them - * - Customizing vary behavior for specific applications - * - * Header overrides are only applied to successful responses (response.ok = true) - * to avoid interfering with error handling. - * - * @param fetcher - The underlying fetch function to wrap - * @param cacheControlOverride - Optional Cache-Control header value to set - * @param varyOverride - Optional Vary header value to set - * @returns A fetch function with header modification capabilities - * @internal - */ -function createInterceptor( - fetcher: typeof fetch, - cacheControlOverride: string | undefined, - varyOverride: string | undefined -): typeof fetch { - return async function fetch(...args) { - const response = await fetcher(...args); - - // Only modify headers on successful responses - if (response.ok && (cacheControlOverride || varyOverride)) { - return modifyResponseHeaders(response, (headers) => { - // Override Cache-Control header if specified - if (cacheControlOverride) { - cacheControl(headers, cacheControlOverride); - } - - // Override Vary header if specified - if (varyOverride) { - vary(headers, varyOverride); - } - }); - } - - return response; - }; -} - -/** - * Determines if a response should bypass the cache based on Cache-Control directives. - * - * This function implements cache bypass logic according to HTTP caching specifications. - * A response bypasses the cache if it contains any of the following directives: - * - * - `no-store`: Response must not be stored in any cache - * - `no-cache`: Response must not be served from cache without revalidation - * - `private`: Response is intended for a single user and shouldn't be stored in shared caches - * - `s-maxage=0`: Response expires immediately for shared caches - * - `max-age=0` (without s-maxage): Response expires immediately for all caches - * - * This follows RFC 7234 Section 5.2 and best practices for shared cache implementations. - * - * @param cacheControlHeader - The Cache-Control header value to analyze - * @returns True if the response should bypass the cache, false otherwise - * @internal - */ -function bypassCache(cacheControlHeader: string): boolean { - const cacheControl = cacheControlHeader.toLowerCase(); - - return ( - cacheControl.includes('no-store') || // Must not store - cacheControl.includes('no-cache') || // Must revalidate - cacheControl.includes('private') || // Not for shared caches - cacheControl.includes('s-maxage=0') || // Shared cache max-age is 0 - // max-age=0 only if no s-maxage directive exists (shared cache priority) - (!cacheControl.includes('s-maxage') && cacheControl.includes('max-age=0')) - ); -} - /** * Safely extracts the cache mode from a request object. * @@ -378,12 +135,6 @@ function bypassCache(cacheControlHeader: string): boolean { * may not be implemented (e.g., some server-side environments) by falling * back to a default cache mode. * - * The cache property is part of the Fetch API specification but may not - * be available in all JavaScript environments. - * - * @param request - The request object to extract cache mode from - * @param defaultCacheMode - Fallback cache mode if request.cache is not available - * @returns The request's cache mode or the default if not available * @internal */ function getRequestCacheMode( diff --git a/src/index.ts b/src/index.ts index 3eee081..f68ae37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,9 @@ export { sharedCacheFetch as fetch, } from './fetch'; +// Middleware-friendly cache resolution +export { createCacheHandler, resolveWithCache } from './resolve'; + // Cache key utilities export { createCacheKeyGenerator, @@ -74,6 +77,11 @@ export type { SharedCacheQueryOptions, SharedCacheRequestInitProperties, SharedCacheStatus, + CacheOriginPhase, + CacheOriginContext, + CacheOriginHandler, + CacheResolveOptions, + CacheHandler, } from './types'; export type { Logger } from './utils/logger'; diff --git a/src/origin.test.ts b/src/origin.test.ts new file mode 100644 index 0000000..d562e1d --- /dev/null +++ b/src/origin.test.ts @@ -0,0 +1,191 @@ +/** + * Unit tests for phase-aware cache origin invocation. + * + * Test division: + * - `origin.test.ts` (this file): `invokeOrigin` / `toOriginFailureResponse` contracts. + * - `resolve.test.ts`: `resolveWithCache` / `createCacheHandler` orchestration. + * - `fetch.test.ts`: `createFetch` HTTP client integration and RFC 7234 end-to-end flows. + */ + +import { invokeOrigin, toOriginFailureResponse } from './origin'; +import type { CacheOriginContext, CacheOriginHandler } from './types'; + +const TEST_URL = 'http://localhost/'; +const testRequest = new Request(TEST_URL); + +function createContext( + phase: CacheOriginContext['phase'], + signal?: AbortSignal +): CacheOriginContext { + return { phase, signal }; +} + +describe('toOriginFailureResponse', () => { + it('should convert Error instances to 500 responses', async () => { + const response = toOriginFailureResponse(new Error('origin failed')); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('origin failed'); + }); + + it('should convert non-Error values to a generic 500 response', async () => { + const response = toOriginFailureResponse('boom'); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('Internal Server Error'); + }); +}); + +describe('invokeOrigin', () => { + describe('without signal', () => { + it('should return the origin response on cache miss', async () => { + const origin: CacheOriginHandler = () => + new Response('ok', { status: 200 }); + + const response = await invokeOrigin( + origin, + testRequest, + createContext('miss') + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('ok'); + }); + + it('should propagate synchronous throws on cache miss', async () => { + const origin: CacheOriginHandler = () => { + throw new Error('sync miss failure'); + }; + + await expect( + invokeOrigin(origin, testRequest, createContext('miss')) + ).rejects.toThrow('sync miss failure'); + }); + + it('should propagate asynchronous rejections on cache miss', async () => { + const origin: CacheOriginHandler = async () => { + throw new Error('async miss failure'); + }; + + await expect( + invokeOrigin(origin, testRequest, createContext('miss')) + ).rejects.toThrow('async miss failure'); + }); + + it('should convert synchronous throws to 500 responses during revalidation', async () => { + const origin: CacheOriginHandler = () => { + throw new Error('sync revalidate failure'); + }; + + const response = await invokeOrigin( + origin, + testRequest, + createContext('revalidate') + ); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('sync revalidate failure'); + }); + + it('should convert asynchronous rejections to 500 responses during revalidation', async () => { + const origin: CacheOriginHandler = async () => { + throw new Error('async revalidate failure'); + }; + + const response = await invokeOrigin( + origin, + testRequest, + createContext('revalidate') + ); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('async revalidate failure'); + }); + + it('should pass the origin context to the handler', async () => { + const origin: CacheOriginHandler = (_request, context) => { + expect(context.phase).toBe('revalidate'); + expect(context.revalidationRequest).toBeInstanceOf(Request); + return new Response('revalidated'); + }; + + const revalidationRequest = new Request(TEST_URL, { + headers: { 'if-none-match': '"v1"' }, + }); + + const response = await invokeOrigin(origin, revalidationRequest, { + phase: 'revalidate', + revalidationRequest, + }); + + expect(await response.text()).toBe('revalidated'); + }); + }); + + describe('with signal', () => { + it('should throw on cache miss when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(new Error('already aborted')); + + await expect( + invokeOrigin(() => new Response('ok'), testRequest, { + phase: 'miss', + signal: controller.signal, + }) + ).rejects.toThrow('already aborted'); + }); + + it('should return 500 during revalidation when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(new Error('already aborted')); + + const response = await invokeOrigin( + () => new Response('ok'), + testRequest, + { + phase: 'revalidate', + signal: controller.signal, + } + ); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('already aborted'); + }); + + it('should reject cache miss when the signal aborts before the origin settles', async () => { + const controller = new AbortController(); + const origin: CacheOriginHandler = () => + new Promise((resolve) => { + setTimeout(() => resolve(new Response('late')), 50); + }); + + const pending = invokeOrigin(origin, testRequest, { + phase: 'miss', + signal: controller.signal, + }); + + controller.abort(new Error('aborted while pending')); + + await expect(pending).rejects.toThrow('aborted while pending'); + }); + + it('should return 500 during revalidation when the signal aborts before the origin settles', async () => { + const controller = new AbortController(); + const origin: CacheOriginHandler = () => + new Promise((resolve) => { + setTimeout(() => resolve(new Response('late')), 50); + }); + + const pending = invokeOrigin(origin, testRequest, { + phase: 'revalidate', + signal: controller.signal, + }); + + controller.abort(new Error('aborted while pending')); + + const response = await pending; + expect(response.status).toBe(500); + expect(await response.text()).toBe('aborted while pending'); + }); + }); +}); diff --git a/src/origin.ts b/src/origin.ts new file mode 100644 index 0000000..e306087 --- /dev/null +++ b/src/origin.ts @@ -0,0 +1,107 @@ +import type { CacheOriginContext, CacheOriginHandler } from './types'; + +function toAbortError(reason?: unknown): Error { + if (reason instanceof Error) { + return reason; + } + return new DOMException( + reason != null ? String(reason) : 'Aborted', + 'AbortError' + ); +} + +/** + * Converts origin failures during revalidation into HTTP 5xx responses. + * + * NOTE: Revalidation failures must remain HTTP responses so `stale-if-error` + * and conditional revalidation can run. Miss-phase failures propagate as throws. + * + * @internal + */ +export function toOriginFailureResponse(error: unknown): Response { + const message = + error instanceof Error ? error.message : 'Internal Server Error'; + return new Response(message, { status: 500 }); +} + +function settleOriginFailure( + phase: CacheOriginContext['phase'], + error: unknown, + resolve: (response: Response) => void, + reject: (error: unknown) => void +): void { + // NOTE: Miss-phase errors are application failures and must reach framework handlers. + if (phase === 'miss') { + reject(error); + return; + } + resolve(toOriginFailureResponse(error)); +} + +/** + * Invokes a cache origin with phase-aware error and abort handling. + * + * @internal + */ +export async function invokeOrigin( + origin: CacheOriginHandler, + request: Request, + context: CacheOriginContext +): Promise { + const { signal, phase } = context; + + if (!signal) { + try { + return await origin(request, context); + } catch (error) { + if (phase === 'miss') { + throw error; + } + return toOriginFailureResponse(error); + } + } + + if (signal.aborted) { + if (phase === 'miss') { + throw toAbortError(signal.reason); + } + return toOriginFailureResponse(signal.reason); + } + + return new Promise((resolve, reject) => { + const onAbort = () => { + settleOriginFailure(phase, toAbortError(signal.reason), resolve, reject); + }; + + signal.addEventListener('abort', onAbort); + + let result: Response | Promise; + + try { + result = origin(request, context); + } catch (error) { + signal.removeEventListener('abort', onAbort); + settleOriginFailure(phase, error, resolve, reject); + return; + } + + Promise.resolve(result) + .then((response) => { + signal.removeEventListener('abort', onAbort); + if (signal.aborted) { + settleOriginFailure( + phase, + toAbortError(signal.reason), + resolve, + reject + ); + return; + } + resolve(response); + }) + .catch((error) => { + signal.removeEventListener('abort', onAbort); + settleOriginFailure(phase, error, resolve, reject); + }); + }); +} diff --git a/src/resolve.test.ts b/src/resolve.test.ts new file mode 100644 index 0000000..ec90e6e --- /dev/null +++ b/src/resolve.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for middleware-friendly cache resolution APIs. + * + * Test division: + * - `resolve.test.ts` (this file): `resolveWithCache` / `createCacheHandler`. + * - `origin.test.ts`: phase-aware origin invocation (`invokeOrigin`). + * - `fetch.test.ts`: `createFetch` HTTP client integration and RFC 7234 end-to-end flows. + */ + +import { LRUCache } from 'lru-cache'; +import { SharedCache } from './cache'; +import { BYPASS, DYNAMIC, HIT, MISS, STALE } from './constants'; +import { createCacheHandler, resolveWithCache } from './resolve'; +import type { + CacheOriginHandler, + KVStorage, + SharedCacheRequest, +} from './types'; + +const TEST_URL = 'http://localhost/'; +const timeout = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const createCacheStore = (): KVStorage => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = new LRUCache({ max: 1024 }); + + return { + async get(cacheKey) { + return store.get(cacheKey); + }, + async set(cacheKey, value, ttl) { + store.set(cacheKey, value, { ttl }); + }, + async delete(cacheKey) { + return store.delete(cacheKey); + }, + }; +}; + +function createRequest(url: string = TEST_URL): SharedCacheRequest { + return new Request(url) as SharedCacheRequest; +} + +describe('resolveWithCache', () => { + let cache: SharedCache; + + beforeEach(() => { + cache = new SharedCache(createCacheStore()); + }); + + it('should cache a successful miss-phase origin response', async () => { + const origin: CacheOriginHandler = () => + new Response('cached-body', { + headers: { 'cache-control': 'max-age=60' }, + }); + + const first = await resolveWithCache(cache, createRequest(), origin); + const second = await resolveWithCache(cache, createRequest(), origin); + + expect(first.headers.get('x-cache-status')).toBe(MISS); + expect(second.headers.get('x-cache-status')).toBe(HIT); + expect(await first.text()).toBe('cached-body'); + expect(await second.text()).toBe('cached-body'); + }); + + it('should propagate miss-phase origin throws', async () => { + const origin: CacheOriginHandler = () => { + throw new Error('miss failure'); + }; + + await expect( + resolveWithCache(cache, createRequest(), origin) + ).rejects.toThrow('miss failure'); + }); + + it('should mark responses without cache-control as dynamic', async () => { + const origin: CacheOriginHandler = () => new Response('dynamic-body'); + + const response = await resolveWithCache(cache, createRequest(), origin); + + expect(response.headers.get('x-cache-status')).toBe(DYNAMIC); + expect(await response.text()).toBe('dynamic-body'); + }); + + it('should bypass cache storage for no-store responses', async () => { + const origin: CacheOriginHandler = () => + new Response('bypass-body', { + headers: { 'cache-control': 'no-store' }, + }); + + const first = await resolveWithCache(cache, createRequest(), origin); + const second = await resolveWithCache(cache, createRequest(), origin); + + expect(first.headers.get('x-cache-status')).toBe(BYPASS); + expect(second.headers.get('x-cache-status')).toBe(BYPASS); + }); + + it('should apply cache-control overrides only on successful responses', async () => { + const origin: CacheOriginHandler = () => + new Response('error-body', { status: 500 }); + + const response = await resolveWithCache(cache, createRequest(), origin, { + cacheControlOverride: 's-maxage=120', + }); + + expect(response.status).toBe(500); + expect(response.headers.get('cache-control')).toBeNull(); + }); + + it('should apply cache-control overrides on successful miss responses', async () => { + const origin: CacheOriginHandler = () => + new Response('ok', { + headers: { 'cache-control': 'max-age=60' }, + }); + + const response = await resolveWithCache(cache, createRequest(), origin, { + cacheControlOverride: 's-maxage=120', + }); + + expect(response.headers.get('cache-control')).toBe( + 'max-age=60, s-maxage=120' + ); + }); + + it('should pass miss and revalidate phases to the origin handler', async () => { + const phases: string[] = []; + const origin: CacheOriginHandler = (_request, context) => { + phases.push(context.phase); + return new Response(`phase:${context.phase}`, { + headers: { 'cache-control': 'max-age=1, stale-if-error=60' }, + }); + }; + + await resolveWithCache(cache, createRequest(), origin); + await timeout(1100); + await resolveWithCache(cache, createRequest(), origin); + + expect(phases).toEqual(['miss', 'revalidate']); + }); + + it('should serve stale content when revalidation throws and stale-if-error applies', async () => { + const origin: CacheOriginHandler = (_request, context) => { + if (context.phase === 'revalidate') { + throw new Error('revalidate failed'); + } + return new Response('stale-body', { + headers: { 'cache-control': 'max-age=1, stale-if-error=60' }, + }); + }; + + await resolveWithCache(cache, createRequest(), origin); + await timeout(1100); + + const response = await resolveWithCache(cache, createRequest(), origin); + + expect(response.status).toBe(200); + expect(response.headers.get('x-cache-status')).toBe(STALE); + expect(await response.text()).toBe('stale-body'); + }); + + it('should propagate the outer abort signal during miss-phase origin calls', async () => { + const controller = new AbortController(); + const origin: CacheOriginHandler = () => + new Promise((resolve) => { + setTimeout(() => resolve(new Response('late')), 50); + }); + + const pending = resolveWithCache(cache, createRequest(), origin, { + signal: controller.signal, + }); + + controller.abort(new Error('miss aborted')); + + await expect(pending).rejects.toThrow('miss aborted'); + }); +}); + +describe('createCacheHandler', () => { + it('should merge defaults with per-call options', async () => { + const cache = new SharedCache(createCacheStore()); + const handler = createCacheHandler(cache, { + cacheControlOverride: 's-maxage=30', + }); + + const response = await handler.resolve( + createRequest(), + () => + new Response('handler-body', { + headers: { 'cache-control': 'max-age=10' }, + }), + { + debugCacheKey: true, + } + ); + + expect(response.headers.get('cache-control')).toBe( + 'max-age=10, s-maxage=30' + ); + expect(response.headers.get('x-cache-key')).toBe('localhost/'); + }); +}); diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..68c8d91 --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,281 @@ +import { vary as getVaryCachePart } from './cache-key'; +import { vary } from './utils/vary'; +import { cacheControl } from './utils/cache-control'; +import { + encodeCacheKeyHeaderValue, + modifyResponseHeaders, + setResponseHeader, +} from './utils/response'; +import { invokeOrigin } from './origin'; +import { SharedCache } from './cache'; +import { + CACHE_KEY_HEADER_NAME, + BYPASS, + CACHE_STATUS_HEADER_NAME, + DYNAMIC, + HIT, + MISS, +} from './constants'; +import type { + CacheHandler, + CacheOriginHandler, + CacheResolveOptions, + SharedCacheRequest, + SharedCacheStatus, +} from './types'; + +/** + * Sets cache status header on a response if not already present. + * + * @internal + */ +function setCacheStatus( + response: Response, + status: SharedCacheStatus +): Response { + if (!response.headers.has(CACHE_STATUS_HEADER_NAME)) { + return setResponseHeader(response, CACHE_STATUS_HEADER_NAME, status); + } + return response; +} + +/** + * Sets cache key header on a response for debugging. + * + * @internal + */ +function setCacheKey(response: Response, cacheKey?: string): Response { + if (cacheKey) { + return setResponseHeader( + response, + CACHE_KEY_HEADER_NAME, + encodeCacheKeyHeaderValue(cacheKey) + ); + } + return response; +} + +/** + * Resolves effective cache key by applying response Vary rules. + * + * @internal + */ +async function getEffectiveCacheKey( + request: Request, + response: Response, + cacheKey: string | undefined, + ignoreVary: boolean | undefined +): Promise { + if (!cacheKey || ignoreVary) { + return cacheKey; + } + + const varyHeader = response.headers.get('vary'); + if (!varyHeader || varyHeader === '*') { + return cacheKey; + } + + const include = varyHeader + .split(',') + .map((field) => field.trim().toLowerCase()) + .filter(Boolean); + if (!include.length) { + return cacheKey; + } + + const varyPart = await getVaryCachePart(request, { include }); + return varyPart ? `${cacheKey}:${varyPart}` : cacheKey; +} + +/** + * Applies Cache-Control and Vary overrides on successful origin responses. + * + * Header overrides are only applied when `response.ok` is true to avoid + * interfering with error handling. + * + * @internal + */ +function applyResponseHeaderOverrides( + response: Response, + cacheControlOverride: string | undefined, + varyOverride: string | undefined +): Response { + if (response.ok && (cacheControlOverride || varyOverride)) { + return modifyResponseHeaders(response, (headers) => { + // Override Cache-Control header if specified + if (cacheControlOverride) { + cacheControl(headers, cacheControlOverride); + } + + // Override Vary header if specified + if (varyOverride) { + vary(headers, varyOverride); + } + }); + } + + return response; +} + +/** + * Determines if a response should bypass the cache based on Cache-Control directives. + * + * This follows RFC 7234 Section 5.2 and best practices for shared cache implementations. + * + * @internal + */ +function bypassCache(cacheControlHeader: string): boolean { + const normalized = cacheControlHeader.toLowerCase(); + + return ( + normalized.includes('no-store') || // Must not store + normalized.includes('no-cache') || // Must revalidate + normalized.includes('private') || // Not for shared caches + normalized.includes('s-maxage=0') || // Shared cache max-age is 0 + // max-age=0 only if no s-maxage directive exists (shared cache priority) + (!normalized.includes('s-maxage') && normalized.includes('max-age=0')) + ); +} + +/** + * Resolves a request through shared cache using an in-process origin handler. + * + * @remarks + * Origin error contract: + * - **miss**: throws propagate to the caller (framework `onError`). + * - **revalidate**: throws are converted to 5xx responses for `stale-if-error`. + * + * @throws When the origin throws during a cache miss. + */ +export async function resolveWithCache( + cache: SharedCache, + request: SharedCacheRequest, + origin: CacheOriginHandler, + options: CacheResolveOptions = {} +): Promise { + const sharedCacheOptions = (request.sharedCache = { + ignoreRequestCacheControl: true, + ignoreVary: false, + ...options, + ...request.sharedCache, + }); + + const debugCacheKey = sharedCacheOptions.debugCacheKey + ? await cache.getCacheKey(request) + : undefined; + + const cacheControlOverride = sharedCacheOptions.cacheControlOverride; + const varyOverride = sharedCacheOptions.varyOverride; + const outerSignal = options.signal; + + // Origin fetcher used by cache.match during revalidation and stale-while-revalidate. + const revalidateFetch = async ( + input: RequestInfo | URL, + init?: RequestInit + ) => { + const revalidationRequest = new Request(input, init); + const response = await invokeOrigin(origin, revalidationRequest, { + phase: 'revalidate', + // NOTE: Propagate the outer abort signal so middleware can terminate revalidation. + signal: revalidationRequest.signal ?? outerSignal, + revalidationRequest, + }); + return applyResponseHeaderOverrides( + response, + cacheControlOverride, + varyOverride + ); + }; + + // Create event from waitUntil function if event is not provided but waitUntil is + const event = + sharedCacheOptions.event || + (sharedCacheOptions.waitUntil + ? ({ + waitUntil: sharedCacheOptions.waitUntil, + } as ExtendableEvent) + : undefined); + + // Attempt to serve from cache + const cachedResponse = await cache.match(request, { + _fetch: revalidateFetch, + _ignoreRequestCacheControl: sharedCacheOptions.ignoreRequestCacheControl, + _event: event, + ignoreMethod: request.method === 'HEAD', // HEAD requests can match GET + }); + + // Return cached response if available + if (cachedResponse) { + const effectiveCacheKey = await getEffectiveCacheKey( + request, + cachedResponse, + debugCacheKey, + sharedCacheOptions.ignoreVary + ); + return setCacheKey(setCacheStatus(cachedResponse, HIT), effectiveCacheKey); + } + + // Fetch from origin on cache miss and attempt to cache + const fetchedResponse = applyResponseHeaderOverrides( + await invokeOrigin(origin, request, { + phase: 'miss', + signal: outerSignal ?? request.signal, + }), + cacheControlOverride, + varyOverride + ); + + // Process response caching based on Cache-Control directives + const responseCacheControl = fetchedResponse.headers.get('cache-control'); + + if (responseCacheControl) { + // Check if response should bypass cache + if (bypassCache(responseCacheControl)) { + return setCacheKey( + setCacheStatus(fetchedResponse, BYPASS), + debugCacheKey + ); + } + + // Attempt to store in cache + const cacheSuccess = await cache.put(request, fetchedResponse).then( + () => true, + () => false + ); + const effectiveCacheKey = cacheSuccess + ? await getEffectiveCacheKey( + request, + fetchedResponse, + debugCacheKey, + sharedCacheOptions.ignoreVary + ) + : debugCacheKey; + return setCacheKey( + setCacheStatus(fetchedResponse, cacheSuccess ? MISS : DYNAMIC), + effectiveCacheKey + ); + } + + // No Cache-Control header - mark as dynamic content + return setCacheKey(setCacheStatus(fetchedResponse, DYNAMIC), debugCacheKey); +} + +/** + * Creates a reusable cache resolver for middleware-style origin handlers. + * + * Prefer this over `createFetch` when the origin is an in-process handler such as + * middleware `next()` rather than an outbound HTTP `fetch`. + */ +export function createCacheHandler( + cache: SharedCache, + defaults: CacheResolveOptions = {} +): CacheHandler { + return { + resolve(request, origin, options = {}) { + return resolveWithCache(cache, request as SharedCacheRequest, origin, { + ...defaults, + ...options, + }); + }, + }; +} diff --git a/src/types.ts b/src/types.ts index bf0d824..7876470 100644 --- a/src/types.ts +++ b/src/types.ts @@ -253,4 +253,48 @@ export interface SharedCacheRequestInitProperties { */ waitUntil?: (promise: Promise) => void; } + +/** + * Phase indicating why a cache origin handler is invoked. + */ +export type CacheOriginPhase = 'miss' | 'revalidate'; + +/** + * Context passed to middleware-friendly cache origin handlers. + */ +export interface CacheOriginContext { + /** Why the origin is being invoked. */ + phase: CacheOriginPhase; + /** Present when invoked from a conditional revalidation request. */ + revalidationRequest?: Request; + /** Abort signal from the outer resolve call or request. */ + signal?: AbortSignal; +} + +/** + * In-process origin handler for middleware integrations. + * + * @remarks + * - **miss**: throws propagate to the caller (framework error handling). + * - **revalidate**: throws are converted to 5xx responses for stale-if-error. + */ +export type CacheOriginHandler = ( + request: Request, + context: CacheOriginContext +) => Response | Promise; + +/** + * Options for {@link resolveWithCache} and {@link createCacheHandler}. + */ +export type CacheResolveOptions = SharedCacheRequestInitProperties & { + signal?: AbortSignal; +}; + +export interface CacheHandler { + resolve( + request: Request, + origin: CacheOriginHandler, + options?: CacheResolveOptions + ): Promise; +} /* c8 ignore stop */ From 54325a97fc68a400ad297aa895a57cf98f10f116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B3=96=E9=A5=BC?= Date: Wed, 17 Jun 2026 15:03:01 +0800 Subject: [PATCH 2/3] chore: remove internal test division comments from test files --- src/fetch.test.ts | 13 ++----------- src/origin.test.ts | 9 --------- src/resolve.test.ts | 9 --------- 3 files changed, 2 insertions(+), 29 deletions(-) diff --git a/src/fetch.test.ts b/src/fetch.test.ts index d7e3236..8c421c9 100644 --- a/src/fetch.test.ts +++ b/src/fetch.test.ts @@ -1,13 +1,6 @@ /** - * Integration tests for the `createFetch` HTTP client API. - * - * Test division: - * - `fetch.test.ts` (this file): `createFetch` end-to-end HTTP caching semantics (RFC 7234). - * - `resolve.test.ts`: `resolveWithCache` / `createCacheHandler` middleware orchestration. - * - `origin.test.ts`: phase-aware origin invocation (`invokeOrigin`) unit tests. - * - * Miss-phase error propagation through `createFetch` is covered here at the integration - * level. Phase-specific throw vs 5xx conversion is unit-tested in `origin.test.ts`. + * Integration tests for the shared cache fetch implementation. + * Tests HTTP caching semantics, error handling, and edge cases according to RFC 7234. */ import { LRUCache } from 'lru-cache'; @@ -1871,8 +1864,6 @@ describe('Vary Header Handling', () => { }); describe('Error Handling', () => { - // Integration coverage for createFetch miss propagation. See origin.test.ts - // for phase-specific throw vs 5xx conversion details. it('should handle network errors gracefully', async () => { const store = createCacheStore(); const cache = new SharedCache(store); diff --git a/src/origin.test.ts b/src/origin.test.ts index d562e1d..02ffeeb 100644 --- a/src/origin.test.ts +++ b/src/origin.test.ts @@ -1,12 +1,3 @@ -/** - * Unit tests for phase-aware cache origin invocation. - * - * Test division: - * - `origin.test.ts` (this file): `invokeOrigin` / `toOriginFailureResponse` contracts. - * - `resolve.test.ts`: `resolveWithCache` / `createCacheHandler` orchestration. - * - `fetch.test.ts`: `createFetch` HTTP client integration and RFC 7234 end-to-end flows. - */ - import { invokeOrigin, toOriginFailureResponse } from './origin'; import type { CacheOriginContext, CacheOriginHandler } from './types'; diff --git a/src/resolve.test.ts b/src/resolve.test.ts index ec90e6e..b2f69ce 100644 --- a/src/resolve.test.ts +++ b/src/resolve.test.ts @@ -1,12 +1,3 @@ -/** - * Tests for middleware-friendly cache resolution APIs. - * - * Test division: - * - `resolve.test.ts` (this file): `resolveWithCache` / `createCacheHandler`. - * - `origin.test.ts`: phase-aware origin invocation (`invokeOrigin`). - * - `fetch.test.ts`: `createFetch` HTTP client integration and RFC 7234 end-to-end flows. - */ - import { LRUCache } from 'lru-cache'; import { SharedCache } from './cache'; import { BYPASS, DYNAMIC, HIT, MISS, STALE } from './constants'; From 6a63c2305affd69e62e5e5f3ece8f18cf150111d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B3=96=E9=A5=BC?= Date: Wed, 17 Jun 2026 15:15:49 +0800 Subject: [PATCH 3/3] test: improve coverage for origin, resolve, and fetch bootstrap paths --- src/fetch.test.ts | 35 ++++++++++++++++ src/origin.test.ts | 41 ++++++++++++++++++ src/resolve.test.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/src/fetch.test.ts b/src/fetch.test.ts index 8c421c9..3bfbb47 100644 --- a/src/fetch.test.ts +++ b/src/fetch.test.ts @@ -7,6 +7,7 @@ import { LRUCache } from 'lru-cache'; import { KVStorage } from './types'; import { createSharedCacheFetch } from './fetch'; import { SharedCache } from './cache'; +import { SharedCacheStorage } from './cache-storage'; import { BYPASS, CACHE_KEY_HEADER_NAME, @@ -2384,3 +2385,37 @@ describe('Vary Header Handling', () => { }); }); }); + +describe('createSharedCacheFetch bootstrap', () => { + const originalCaches = globalThis.caches; + + afterEach(() => { + globalThis.caches = originalCaches; + }); + + it('should throw when no cache instance is available', async () => { + globalThis.caches = undefined as unknown as SharedCacheStorage; + const fetch = createSharedCacheFetch(); + + await expect(fetch(TEST_URL)).rejects.toThrow( + 'Cache is required. Provide a cache instance or ensure globalThis.caches is available.' + ); + }); + + it('should auto-discover cache from global caches when none is provided', async () => { + const caches = new SharedCacheStorage(createCacheStore()); + globalThis.caches = caches; + const fetch = createSharedCacheFetch(undefined, { + async fetch() { + return new Response('global-cache', { + headers: { 'cache-control': 'max-age=60' }, + }); + }, + }); + + const response = await fetch(TEST_URL); + + expect(response.headers.get('x-cache-status')).toBe(MISS); + expect(await response.text()).toBe('global-cache'); + }); +}); diff --git a/src/origin.test.ts b/src/origin.test.ts index 02ffeeb..2502bf4 100644 --- a/src/origin.test.ts +++ b/src/origin.test.ts @@ -178,5 +178,46 @@ describe('invokeOrigin', () => { expect(response.status).toBe(500); expect(await response.text()).toBe('aborted while pending'); }); + + it('should use a default AbortError when abort has no reason', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + invokeOrigin(() => new Response('ok'), testRequest, { + phase: 'miss', + signal: controller.signal, + }) + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('should convert synchronous throws to 500 when a signal is present during revalidation', async () => { + const controller = new AbortController(); + const origin: CacheOriginHandler = () => { + throw new Error('sync with signal'); + }; + + const response = await invokeOrigin(origin, testRequest, { + phase: 'revalidate', + signal: controller.signal, + }); + + expect(response.status).toBe(500); + expect(await response.text()).toBe('sync with signal'); + }); + + it('should reject cache miss when the origin rejects and a signal is present', async () => { + const controller = new AbortController(); + const origin: CacheOriginHandler = async () => { + throw new Error('async with signal'); + }; + + await expect( + invokeOrigin(origin, testRequest, { + phase: 'miss', + signal: controller.signal, + }) + ).rejects.toThrow('async with signal'); + }); }); }); diff --git a/src/resolve.test.ts b/src/resolve.test.ts index b2f69ce..96625f8 100644 --- a/src/resolve.test.ts +++ b/src/resolve.test.ts @@ -165,6 +165,106 @@ describe('resolveWithCache', () => { await expect(pending).rejects.toThrow('miss aborted'); }); + + it('should apply vary override on successful miss responses', async () => { + const origin: CacheOriginHandler = () => + new Response('ok', { + headers: { 'cache-control': 'max-age=60' }, + }); + + const response = await resolveWithCache(cache, createRequest(), origin, { + varyOverride: 'accept-language', + }); + + expect(response.headers.get('vary')).toBe('accept-language'); + }); + + it('should expose an effective cache key on cache hit when debugCacheKey is enabled', async () => { + const origin: CacheOriginHandler = () => + new Response('vary-body', { + headers: { + 'cache-control': 'max-age=60', + vary: 'accept-language', + }, + }); + + await resolveWithCache(cache, createRequest(), origin, { + debugCacheKey: true, + }); + + const response = await resolveWithCache(cache, createRequest(), origin, { + debugCacheKey: true, + }); + + expect(response.headers.get('x-cache-status')).toBe(HIT); + expect(response.headers.get('x-cache-key')).toContain('localhost/'); + }); + + it('should skip vary-specific cache key suffixes when ignoreVary is enabled', async () => { + const origin: CacheOriginHandler = () => + new Response('vary-body', { + headers: { + 'cache-control': 'max-age=60', + vary: 'accept-language', + }, + }); + + await resolveWithCache(cache, createRequest(), origin, { + debugCacheKey: true, + ignoreVary: true, + }); + + const response = await resolveWithCache(cache, createRequest(), origin, { + debugCacheKey: true, + ignoreVary: true, + }); + + expect(response.headers.get('x-cache-key')).toBe('localhost/'); + }); + + it('should keep the base cache key when vary is wildcard', async () => { + const origin: CacheOriginHandler = () => + new Response('wildcard-vary', { + headers: { + 'cache-control': 'max-age=60', + vary: '*', + }, + }); + + await resolveWithCache(cache, createRequest(), origin, { + debugCacheKey: true, + }); + + const response = await resolveWithCache(cache, createRequest(), origin, { + debugCacheKey: true, + }); + + expect(response.headers.get('x-cache-key')).toBe('localhost/'); + }); + + it('should serve stale content when revalidation aborts via outer signal', async () => { + const controller = new AbortController(); + const origin: CacheOriginHandler = (_request, context) => { + if (context.phase === 'revalidate') { + return new Response('should-not-be-used', { status: 500 }); + } + return new Response('stale-body', { + headers: { 'cache-control': 'max-age=1, stale-if-error=60' }, + }); + }; + + await resolveWithCache(cache, createRequest(), origin); + await timeout(1100); + + controller.abort(new Error('revalidate aborted')); + + const response = await resolveWithCache(cache, createRequest(), origin, { + signal: controller.signal, + }); + + expect(response.headers.get('x-cache-status')).toBe(STALE); + expect(await response.text()).toBe('stale-body'); + }); }); describe('createCacheHandler', () => {