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
7 changes: 7 additions & 0 deletions .changeset/bright-spoons-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@web-widget/shared-cache': minor
---

Add `debugCacheKey` support to expose the computed cache key via `x-cache-key` for diagnostics.

Unify cache status constant naming to `CACHE_STATUS_HEADER_NAME` and export `CACHE_KEY_HEADER_NAME` for downstream integrations.
5 changes: 3 additions & 2 deletions src/cache-key.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { sha1 } from './utils/crypto';
import { deviceType as getDeviceType } from './utils/user-agent';
import { CACHE_STATUS_HEADERS_NAME } from './constants';
import { CACHE_KEY_HEADER_NAME, CACHE_STATUS_HEADER_NAME } from './constants';
import { RequestCookies } from './utils/cookies';

/**
Expand Down Expand Up @@ -317,7 +317,8 @@ export const CANNOT_INCLUDE_HEADERS = [
'host',
'vary',
// Headers that contain cache status information
CACHE_STATUS_HEADERS_NAME,
CACHE_STATUS_HEADER_NAME,
CACHE_KEY_HEADER_NAME,
] as const;

/**
Expand Down
10 changes: 5 additions & 5 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SharedCache } from './cache';
import { KVStorage, CacheItem, SharedCacheOptions } from './types';
import type { Logger } from './utils/logger';
import {
CACHE_STATUS_HEADERS_NAME,
CACHE_STATUS_HEADER_NAME,
HIT,
STALE,
REVALIDATED,
Expand Down Expand Up @@ -285,7 +285,7 @@ describe('SharedCache', () => {

expect(matched).toBeDefined();
expect(await matched!.text()).toBe('cached data');
expect(matched!.headers.get(CACHE_STATUS_HEADERS_NAME)).toBe(HIT);
expect(matched!.headers.get(CACHE_STATUS_HEADER_NAME)).toBe(HIT);
});

it('should accept string URL', async () => {
Expand Down Expand Up @@ -361,7 +361,7 @@ describe('SharedCache', () => {

expect(matched).toBeDefined();
expect(await matched!.text()).toBe('stale data');
expect(matched!.headers.get(CACHE_STATUS_HEADERS_NAME)).toBe(STALE);
expect(matched!.headers.get(CACHE_STATUS_HEADER_NAME)).toBe(STALE);
expect(backgroundPromise).not.toBeNull();
});

Expand Down Expand Up @@ -398,7 +398,7 @@ describe('SharedCache', () => {

expect(matched).toBeDefined();
expect(await matched!.text()).toBe('stale data');
expect(matched!.headers.get(CACHE_STATUS_HEADERS_NAME)).toBe(STALE);
expect(matched!.headers.get(CACHE_STATUS_HEADER_NAME)).toBe(STALE);
expect(backgroundPromise).not.toBeNull();
});

Expand Down Expand Up @@ -594,7 +594,7 @@ describe('SharedCache', () => {
await cache.put(request, response);
const matched = await cache.match(request);

expect(matched!.headers.get(CACHE_STATUS_HEADERS_NAME)).toBe(HIT);
expect(matched!.headers.get(CACHE_STATUS_HEADER_NAME)).toBe(HIT);
});
});
});
17 changes: 15 additions & 2 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from './cache-key';
import type { FilterOptions } from './cache-key';
import {
CACHE_STATUS_HEADERS_NAME,
CACHE_STATUS_HEADER_NAME,
EXPIRED,
HIT,
REVALIDATED,
Expand Down Expand Up @@ -85,6 +85,19 @@ export class SharedCache implements WebCache {
this.#storage = storage;
}

/**
* Computes the cache key for a request using the current cache key rules.
* Useful for debugging and diagnostics in callers that need to surface the key.
*
* @param request - Request to compute key for
* @returns Promise resolving to the computed cache key
*/
async getCacheKey(request: SharedCacheRequestInfo): Promise<string> {
const resolvedRequest =
request instanceof Request ? request : new Request(request);
return this.#cacheKeyGenerator(resolvedRequest);
}

/**
* The add() method is not implemented in this cache implementation.
* This method is part of the Cache interface but not commonly used in practice.
Expand Down Expand Up @@ -611,7 +624,7 @@ export class SharedCache implements WebCache {
* @param status - Cache status value to set
*/
#setCacheStatus(response: Response, status: SharedCacheStatus): void {
response.headers.set(CACHE_STATUS_HEADERS_NAME, status);
response.headers.set(CACHE_STATUS_HEADER_NAME, status);
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { SharedCacheStatus } from './types';
* HTTP header name for cache status information.
* This non-standard header is used to communicate cache hit/miss status.
*/
export const CACHE_STATUS_HEADERS_NAME = 'x-cache-status';
export const CACHE_STATUS_HEADER_NAME = 'x-cache-status';

/**
* HTTP header name for debugging cache key information.
* This non-standard header is used to expose the computed cache key.
*/
export const CACHE_KEY_HEADER_NAME = 'x-cache-key';

/**
* Cache status constants as defined in HTTP caching specifications.
Expand Down
80 changes: 79 additions & 1 deletion src/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ import { LRUCache } from 'lru-cache';
import { KVStorage } from './types';
import { createSharedCacheFetch } from './fetch';
import { SharedCache } from './cache';
import { BYPASS, DYNAMIC, EXPIRED, HIT, MISS, STALE } from './constants';
import {
BYPASS,
CACHE_KEY_HEADER_NAME,
DYNAMIC,
EXPIRED,
HIT,
MISS,
STALE,
} from './constants';

const TEST_URL = 'http://localhost/';

Expand Down Expand Up @@ -1833,6 +1841,76 @@ describe('Vary Header Handling', () => {
});

describe('Cache Configuration Options', () => {
it('should expose cache key in response headers when debugCacheKey is enabled', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response('debug key', {
headers: {
'cache-control': 'max-age=300',
},
});
},
});

const first = await fetch(TEST_URL, {
sharedCache: { debugCacheKey: true },
});
expect(first.headers.get('x-cache-status')).toBe(MISS);
expect(first.headers.get(CACHE_KEY_HEADER_NAME)).toBe('localhost/');

const second = await fetch(TEST_URL, {
sharedCache: { debugCacheKey: true },
});
expect(second.headers.get('x-cache-status')).toBe(HIT);
expect(second.headers.get(CACHE_KEY_HEADER_NAME)).toBe('localhost/');
});

it('should not expose cache key when debugCacheKey is disabled', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response('no debug key', {
headers: {
'cache-control': 'max-age=300',
},
});
},
});

const response = await fetch(TEST_URL);
expect(response.headers.get(CACHE_KEY_HEADER_NAME)).toBe(null);
});

it('should expose vary-aware cache key when debugCacheKey is enabled', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const fetch = createSharedCacheFetch(cache, {
async fetch() {
return new Response('vary debug key', {
headers: {
'cache-control': 'max-age=300',
vary: 'accept-language',
},
});
},
});

const response = await fetch(TEST_URL, {
headers: {
'accept-language': 'en-us',
},
sharedCache: { debugCacheKey: true },
});

expect(response.headers.get('x-cache-status')).toBe(MISS);
expect(response.headers.get(CACHE_KEY_HEADER_NAME)).toMatch(
/^localhost\/:accept-language=[a-f0-9]{6}$/
);
});

it('should respect ignoreRequestCacheControl option', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
Expand Down
97 changes: 90 additions & 7 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { vary } from './utils/vary';
import { vary as getVaryCachePart } from './cache-key';
import { cacheControl } from './utils/cache-control';
import { setResponseHeader, modifyResponseHeaders } from './utils/response';
import { SharedCache } from './cache';
import { SharedCacheStorage } from './cache-storage';
import {
CACHE_KEY_HEADER_NAME,
BYPASS,
CACHE_STATUS_HEADERS_NAME,
CACHE_STATUS_HEADER_NAME,
DYNAMIC,
HIT,
MISS,
Expand Down Expand Up @@ -107,6 +109,9 @@
// Finally apply init options (highest priority)
...init?.sharedCache,
});
const debugCacheKey = sharedCacheOptions.debugCacheKey
? await cache.getCacheKey(request)
: undefined;

Check warning on line 114 in src/fetch.ts

View check run for this annotation

Codecov / codecov/patch

src/fetch.ts#L113-L114

Added lines #L113 - L114 were not covered by tests

// Create interceptor for response header manipulation
const interceptor = createInterceptor(
Expand Down Expand Up @@ -141,7 +146,16 @@

// Return cached response if available
if (cachedResponse) {
return setCacheStatus(cachedResponse, HIT);
const effectiveCacheKey = await getEffectiveCacheKey(
request,
cachedResponse,
debugCacheKey,
sharedCacheOptions.ignoreVary
);
return setCacheKey(
setCacheStatus(cachedResponse, HIT),
effectiveCacheKey
);
}

// Fetch from network and attempt to cache
Expand All @@ -152,7 +166,10 @@
if (cacheControl) {
// Check if response should bypass cache
if (bypassCache(cacheControl)) {
return setCacheStatus(fetchedResponse, BYPASS);
return setCacheKey(
setCacheStatus(fetchedResponse, BYPASS),
debugCacheKey
);
} else {
// Attempt to store in cache
const cacheSuccess = await cache.put(request, fetchedResponse).then(
Expand All @@ -161,11 +178,25 @@
return false;
}
);
return setCacheStatus(fetchedResponse, cacheSuccess ? MISS : DYNAMIC);
const effectiveCacheKey = cacheSuccess
? await getEffectiveCacheKey(
request,
fetchedResponse,
debugCacheKey,
sharedCacheOptions.ignoreVary
)
: debugCacheKey;
return setCacheKey(
setCacheStatus(fetchedResponse, cacheSuccess ? MISS : DYNAMIC),
effectiveCacheKey
);
}
} else {
// No Cache-Control header - mark as dynamic content
return setCacheStatus(fetchedResponse, DYNAMIC);
return setCacheKey(
setCacheStatus(fetchedResponse, DYNAMIC),
debugCacheKey
);
}
};
}
Expand Down Expand Up @@ -197,12 +228,64 @@
response: Response,
status: SharedCacheStatus
): Response {
if (!response.headers.has(CACHE_STATUS_HEADERS_NAME)) {
return setResponseHeader(response, CACHE_STATUS_HEADERS_NAME, status);
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, 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
*/

Check warning on line 261 in src/fetch.ts

View check run for this annotation

Codecov / codecov/patch

src/fetch.ts#L260-L261

Added lines #L260 - L261 were not covered by tests
async function getEffectiveCacheKey(
request: Request,
response: Response,
cacheKey: string | undefined,
ignoreVary: boolean | undefined
): Promise<string | undefined> {
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.
*
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
// Constants
export {
BYPASS,
CACHE_STATUS_HEADERS_NAME,
CACHE_KEY_HEADER_NAME,
CACHE_STATUS_HEADER_NAME,

Check warning on line 92 in src/index.ts

View check run for this annotation

Codecov / codecov/patch

src/index.ts#L91-L92

Added lines #L91 - L92 were not covered by tests
DYNAMIC,
EXPIRED,
HIT,
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ export type SharedCacheRequest = WebRequest & {
* These properties control cache behavior on a per-request basis.
*/
export interface SharedCacheRequestInitProperties {
/**
* Whether to expose the computed cache key via response header.
* When true, the response includes the `x-cache-key` header for debugging.
*/
debugCacheKey?: boolean;

/**
* Override the cache-control header for caching decisions.
* This allows forcing specific cache behavior regardless of origin headers.
Expand Down
Loading