Skip to content

Commit

Permalink
feat: further compatibility with Cloudflare Workers Cache API (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
aui authored Jul 9, 2024
1 parent 809b5cc commit 98bdedb
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 110 deletions.
6 changes: 6 additions & 0 deletions .changeset/khaki-dots-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@web-widget/shared-cache': patch
---

- The stale-while-revalidate and stale-if-error directives are not supported when using the cache.put or cache.match methods.
- Support HEAD requests.
48 changes: 1 addition & 47 deletions src/cache-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,12 @@ test('should support built-in rules', async () => {
include: ['x-id'],
},
host: true,
method: true,
pathname: true,
search: true,
},
}
);
expect(key).toBe('localhost/?a=1#a=356a19:desktop:x-id=a9993e:GET');
expect(key).toBe('localhost/?a=1#a=356a19:desktop:x-id=a9993e');
});

test('should support filtering', async () => {
Expand Down Expand Up @@ -410,51 +409,6 @@ describe('should support host', () => {
});
});

describe('should support method', () => {
test('basic', async () => {
const keyGenerator = createCacheKeyGenerator();
const key = await keyGenerator(new Request('http://localhost/'), {
cacheKeyRules: {
method: true,
},
});
expect(key).toBe('#GET');
});

test('should support filtering', async () => {
const keyGenerator = createCacheKeyGenerator();
const key = await keyGenerator(
new Request('http://localhost/', { method: 'POST' }),
{
cacheKeyRules: {
method: { include: ['GET'] },
},
}
);
expect(key).toBe('');
});

test('the body of the POST, PATCH and PUT methods should be used as part of the key', async () => {
await Promise.all(
['POST', 'PATCH', 'PUT'].map(async (method) => {
const keyGenerator = createCacheKeyGenerator();
const key = await keyGenerator(
new Request('http://localhost/', {
method,
body: 'hello',
}),
{
cacheKeyRules: {
method: true,
},
}
);
expect(key).toBe(`#${method}=aaf4c6`);
})
);
});
});

describe('should support pathname', () => {
test('basic', async () => {
const keyGenerator = createCacheKeyGenerator();
Expand Down
22 changes: 1 addition & 21 deletions src/cache-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ export interface SharedCacheKeyRules {
header?: FilterOptions | boolean;
/** Use host as part of cache key. */
host?: FilterOptions | boolean;
/** Use method as part of cache key. */
method?: FilterOptions | boolean;
/** Use pathname as part of cache key. */
pathname?: FilterOptions | boolean;
/** Use search as part of cache key. */
Expand Down Expand Up @@ -121,18 +119,6 @@ export function host(url: URL, options?: FilterOptions) {
.join('');
}

export async function method(request: Request, options?: FilterOptions) {
const hasBody =
request.body && ['POST', 'PATCH', 'PUT'].includes(request.method);
return (
await Promise.all(
filter([[request.method, '']], options).map(async ([key]) =>
hasBody ? `${key}=${await shortHash(request.body)}` : key
)
)
).join('');
}

export function pathname(url: URL, options?: FilterOptions) {
const pathname = url.pathname;
return filter([[pathname, '']], options)
Expand Down Expand Up @@ -218,12 +204,10 @@ const BUILT_IN_EXPANDED_PART_DEFINERS: BuiltInExpandedCacheKeyPartDefiners = {
cookie,
device,
header,
method,
};

export const DEFAULT_CACHE_KEY_RULES: SharedCacheKeyRules = {
host: true,
method: true,
pathname: true,
search: true,
};
Expand Down Expand Up @@ -251,10 +235,6 @@ export function createCacheKeyGenerator(
const urlRules: SharedCacheKeyRules = { host, pathname, search };
const url = new URL(request.url);

if (options.ignoreMethod) {
fragmentRules.method = false;
}

const urlPart: string[] = BUILT_IN_URL_PART_KEYS.filter(
(name) => urlRules[name]
).map((name) => {
Expand Down Expand Up @@ -306,6 +286,6 @@ export function createCacheKeyGenerator(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function notImplemented(options: any, name: string) {
if (name in options) {
throw new Error(`Not Implemented: "${name}" option.`);
throw new Error(`Not implemented: "${name}" option.`);
}
}
8 changes: 4 additions & 4 deletions src/cache-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,25 @@ export class SharedCacheStorage implements CacheStorage {

/** @private */
async delete(_cacheName: string): Promise<boolean> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/** @private */
async has(_cacheName: string): Promise<boolean> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/** @private */
async keys(): Promise<string[]> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/** @private */
async match(
_request: RequestInfo,
_options?: MultiCacheQueryOptions
): Promise<Response | undefined> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/**
Expand Down
46 changes: 26 additions & 20 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ import {
STALE,
} from './constants';

const ORIGINAL_FETCH = globalThis.fetch;

export class SharedCache implements Cache {
#cacheKeyGenerator: (
request: Request,
options?: SharedCacheQueryOptions
) => Promise<string>;
#cacheKeyRules?: SharedCacheKeyRules;
#fetch: typeof fetch;
#fetch?: typeof fetch;
#logger?: Logger;
#storage: KVStorage;
#waitUntil: (promise: Promise<unknown>) => void;
Expand All @@ -48,20 +46,20 @@ export class SharedCache implements Cache {
resolveOptions.cacheKeyPartDefiners
);
this.#cacheKeyRules = resolveOptions.cacheKeyRules;
this.#fetch = resolveOptions.fetch ?? ORIGINAL_FETCH;
this.#fetch = resolveOptions.fetch;
this.#logger = resolveOptions.logger;
this.#storage = storage;
this.#waitUntil = resolveOptions.waitUntil;
}

/** @private */
async add(_request: RequestInfo): Promise<void> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/** @private */
async addAll(_requests: RequestInfo[]): Promise<void> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/**
Expand Down Expand Up @@ -111,7 +109,7 @@ export class SharedCache implements Cache {
_request?: RequestInfo,
_options?: SharedCacheQueryOptions
): Promise<readonly Request[]> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/**
Expand Down Expand Up @@ -160,23 +158,29 @@ export class SharedCache implements Cache {
}

const fetch = options?._fetch ?? this.#fetch;
const forceCache = options?.forceCache;
const { body, status, statusText } = cacheItem.response;
const policy = CachePolicy.fromObject(cacheItem.policy);

const { body, status, statusText } = cacheItem.response;
const headers = policy.responseHeaders();
let response = new Response(body, {
const stale = policy.stale();
const response = new Response(body, {
status,
statusText,
headers,
});

if (
!forceCache &&
!policy.satisfiesWithoutRevalidation(r, {
ignoreRequestCacheControl: options?.ignoreRequestCacheControl,
ignoreMethod: true,
ignoreSearch: true,
})
ignoreVary: true,
}) ||
stale
) {
if (policy.stale() && policy.useStaleWhileRevalidate()) {
if (!fetch) {
return;
} else if (stale && policy.useStaleWhileRevalidate()) {
// Well actually, in this case it's fine to return the stale response.
// But we'll update the cache in the background.
this.#waitUntil(
Expand All @@ -192,8 +196,9 @@ export class SharedCache implements Cache {
)
);
this.#setCacheStatus(response, STALE);
return response;
} else {
response = await this.#revalidate(
return this.#revalidate(
r,
{
response,
Expand All @@ -204,10 +209,9 @@ export class SharedCache implements Cache {
options
);
}
} else {
this.#setCacheStatus(response, HIT);
}

this.#setCacheStatus(response, HIT);
return response;
}

Expand All @@ -216,7 +220,7 @@ export class SharedCache implements Cache {
_request?: RequestInfo,
_options?: SharedCacheQueryOptions
): Promise<readonly Response[]> {
throw new Error('Not Implemented.');
throw new Error('Not implemented.');
}

/**
Expand Down Expand Up @@ -263,7 +267,7 @@ export class SharedCache implements Cache {
!urlIsHttpHttpsScheme(innerRequest.url) ||
innerRequest.method !== 'GET'
) {
new TypeError(
throw new TypeError(
`Cache.put: Expected an http/s scheme when method is not GET.`
);
}
Expand Down Expand Up @@ -301,6 +305,8 @@ export class SharedCache implements Cache {
// 9.
const clonedResponse = innerResponse.clone();

// TODO: 10. - 19.

const policy = new CachePolicy(innerRequest, clonedResponse);
const ttl = policy.timeToLive();

Expand Down Expand Up @@ -343,10 +349,10 @@ export class SharedCache implements Cache {
): Promise<Response> {
const revalidationRequest = new Request(request, {
headers: resolveCacheItem.policy.revalidationHeaders(request, {
ignoreRequestCacheControl: options?.ignoreRequestCacheControl ?? true,
ignoreRequestCacheControl: options?.ignoreRequestCacheControl,
ignoreMethod: true,
ignoreSearch: true,
ignoreVary: false,
ignoreVary: true,
}),
});
let revalidationResponse: Response;
Expand Down
40 changes: 37 additions & 3 deletions src/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ describe('multiple duplicate requests', () => {
});
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/lol; charset=utf-8');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
expect(res.headers.get('etag')).toBe('"v1"');
expect(await res.text()).toBe('lol');
});
Expand Down Expand Up @@ -194,7 +194,7 @@ test('when no cache control is set the latest content should be loaded', async (
expect(await res.text()).toBe('lol');
});

test('should respect cache control directives from requests', async () => {
test.only('should respect cache control directives from requests', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const fetch = createSharedCacheFetch(cache, {
Expand All @@ -210,6 +210,9 @@ test('should respect cache control directives from requests', async () => {
headers: {
'cache-control': 'no-cache',
},
sharedCache: {
ignoreRequestCacheControl: false,
},
});

expect(res.status).toBe(200);
Expand All @@ -222,6 +225,9 @@ test('should respect cache control directives from requests', async () => {
headers: {
'cache-control': 'no-cache',
},
sharedCache: {
ignoreRequestCacheControl: false,
},
});

expect(res.status).toBe(200);
Expand Down Expand Up @@ -251,6 +257,34 @@ test('when body is a string it should cache the response', async () => {
expect(await cachedRes?.text()).toBe('lol');
});

test('when the method is HEAD, it should read the cache of the GET request', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
const fetch = createSharedCacheFetch(cache, {
async fetch(input, init) {
const req = new Request(input, init);
return new Response(req.method, {
headers: {
'cache-control': 'max-age=300',
},
});
},
});
const get = new Request(TEST_URL, {
method: 'GET',
});
await fetch(get);
const head = new Request(TEST_URL, {
method: 'HEAD',
});
const res = await fetch(head);

expect(res.status).toBe(200);
expect(await res.text()).toBe('GET');
expect(res.headers.get('x-cache-status')).toBe(HIT);
expect(await cache.match(head)).toBeUndefined();
});

test('when the method is POST it should not cache the response', async () => {
const store = createCacheStore();
const cache = new SharedCache(store);
Expand All @@ -275,7 +309,7 @@ test('when the method is POST it should not cache the response', async () => {

expect(res.status).toBe(200);
expect(await res.text()).toBe('POST');
expect(res.headers.get('x-cache-status')).toBe(MISS);
expect(res.headers.get('x-cache-status')).toBe(DYNAMIC);
expect(await cache.match(post)).toBeUndefined();
});

Expand Down
Loading

0 comments on commit 98bdedb

Please sign in to comment.