Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/middleware-cache-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@web-widget/shared-cache": minor
---

Add middleware-friendly `resolveWithCache` and `createCacheHandler` APIs with phase-aware origin error handling.
55 changes: 42 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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, {
Expand All @@ -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

Expand Down
11 changes: 8 additions & 3 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 36 additions & 11 deletions src/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
/**
* Comprehensive test suite for the shared cache fetch implementation.
* Integration tests for the shared cache fetch implementation.
* Tests HTTP caching semantics, error handling, and edge cases according to RFC 7234.
*
* 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
*/

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,
Expand Down Expand Up @@ -2394,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');
});
});
Loading
Loading