mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +00:00
Add tests for IncrementalCacheWorker and enhance cache header management
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const runWithCloudflareRequestContext = mock(
|
||||
async <T>(_: Request, __: unknown, ___: unknown, operation: () => Promise<T>) => operation()
|
||||
);
|
||||
const get = mock();
|
||||
const getTagsFromValue = mock();
|
||||
const hasBeenRevalidated = mock();
|
||||
|
||||
mock.module('cloudflare:workers', () => ({
|
||||
DurableObject: class {},
|
||||
WorkerEntrypoint: class {},
|
||||
}));
|
||||
mock.module('../../.open-next/cloudflare/init.js', () => ({ runWithCloudflareRequestContext }));
|
||||
mock.module('../incrementalCache/incrementalCache', () => ({
|
||||
GitbookIncrementalCache: class {
|
||||
get = get;
|
||||
},
|
||||
}));
|
||||
mock.module('@opennextjs/aws/utils/cache.js', () => ({ getTagsFromValue }));
|
||||
mock.module('../tagCache/middleware', () => ({
|
||||
default: { hasBeenRevalidated },
|
||||
}));
|
||||
|
||||
const { default: IncrementalCacheWorker } = await import('./do');
|
||||
|
||||
const CACHE_CONTROL = 'public, s-maxage=3600, stale-while-revalidate=86400';
|
||||
const NO_STORE_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate';
|
||||
|
||||
const cacheValue = {
|
||||
type: 'page' as const,
|
||||
html: '<p>cached</p>',
|
||||
json: {},
|
||||
revalidate: 60,
|
||||
};
|
||||
|
||||
describe('IncrementalCacheWorker fetch', () => {
|
||||
const selfFetch = mock();
|
||||
|
||||
beforeEach(() => {
|
||||
selfFetch.mockReset();
|
||||
get.mockReset();
|
||||
getTagsFromValue.mockReset();
|
||||
hasBeenRevalidated.mockReset();
|
||||
getTagsFromValue.mockReturnValue(['space:1']);
|
||||
hasBeenRevalidated.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
const fetch = (request: Request) =>
|
||||
IncrementalCacheWorker.prototype.fetch.call(
|
||||
{
|
||||
env: { WORKER_SELF_REFERENCE: { fetch: selfFetch } },
|
||||
ctx: {},
|
||||
},
|
||||
request
|
||||
);
|
||||
|
||||
it('forwards cache reads to the internal endpoint and restores cache metadata', async () => {
|
||||
selfFetch.mockResolvedValue(
|
||||
Response.json(
|
||||
{ value: cacheValue, lastModified: 123 },
|
||||
{
|
||||
headers: {
|
||||
'x-gitbook-cache-control': CACHE_CONTROL,
|
||||
'x-gitbook-cache-tag': 'incremental-cache:entry,space:1',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
new Request('https://incremental-cache.internal/?key=entry&cacheType=cache')
|
||||
);
|
||||
|
||||
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
|
||||
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
|
||||
const forwardedRequest = selfFetch.mock.calls[0]?.[0] as Request;
|
||||
const forwardedURL = new URL(forwardedRequest.url);
|
||||
expect(forwardedURL.pathname).toBe('/internal');
|
||||
expect(forwardedURL.searchParams.get('key')).toBe('entry');
|
||||
expect(forwardedURL.searchParams.get('cacheType')).toBe('cache');
|
||||
});
|
||||
|
||||
it('reads and annotates a cache hit only on the internal endpoint', async () => {
|
||||
get.mockResolvedValue({ value: cacheValue, lastModified: Date.now() });
|
||||
|
||||
const response = await fetch(
|
||||
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=cache')
|
||||
);
|
||||
|
||||
expect(get).toHaveBeenCalledWith('entry', 'cache');
|
||||
expect(selfFetch).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
|
||||
expect(response.headers.get('x-gitbook-cache-control')).toBe(CACHE_CONTROL);
|
||||
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
|
||||
expect(response.headers.get('x-gitbook-cache-tag')).toBe('incremental-cache:entry,space:1');
|
||||
});
|
||||
|
||||
it('keeps cache misses out of the worker cache', async () => {
|
||||
get.mockResolvedValue(null);
|
||||
|
||||
const response = await fetch(
|
||||
new Request('https://incremental-cache.internal/internal?key=missing')
|
||||
);
|
||||
|
||||
expect(response.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
|
||||
expect(response.headers.get('x-gitbook-cache-control')).toBe(NO_STORE_CACHE_CONTROL);
|
||||
expect(await response.json()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps revalidated and stale entries out of the worker cache', async () => {
|
||||
get.mockResolvedValueOnce({ value: cacheValue, lastModified: Date.now() });
|
||||
hasBeenRevalidated.mockResolvedValueOnce(true);
|
||||
const revalidatedResponse = await fetch(
|
||||
new Request('https://incremental-cache.internal/internal?key=revalidated')
|
||||
);
|
||||
|
||||
get.mockResolvedValueOnce({
|
||||
value: { ...cacheValue, revalidate: 0 },
|
||||
lastModified: Date.now() - 1_000,
|
||||
});
|
||||
const staleResponse = await fetch(
|
||||
new Request('https://incremental-cache.internal/internal?key=stale')
|
||||
);
|
||||
|
||||
expect(revalidatedResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
|
||||
expect(revalidatedResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
|
||||
expect(staleResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
|
||||
expect(staleResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
|
||||
});
|
||||
|
||||
it('rejects invalid internal requests and does not forward non-GET requests', async () => {
|
||||
const invalidResponse = await fetch(
|
||||
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=invalid')
|
||||
);
|
||||
const methodResponse = await fetch(
|
||||
new Request('https://incremental-cache.internal/?key=entry', { method: 'POST' })
|
||||
);
|
||||
|
||||
expect(invalidResponse.status).toBe(400);
|
||||
expect(methodResponse.status).toBe(405);
|
||||
expect(selfFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,9 @@ type CacheWorkerEnv = {
|
||||
NEXT_INC_CACHE_R2_BUCKET: {
|
||||
put(key: string, value: string): Promise<unknown>;
|
||||
};
|
||||
WORKER_SELF_REFERENCE: {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
};
|
||||
};
|
||||
|
||||
//@ts-ignore - Just to avoid tag cache crashing
|
||||
@@ -31,15 +34,48 @@ const isCacheEntryType = (value: string | null): value is CacheEntryType =>
|
||||
value !== null && cacheEntryTypes.has(value as CacheEntryType);
|
||||
|
||||
const NO_STORE_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate';
|
||||
const INTERNAL_PATH = '/internal';
|
||||
const CACHE_CONTROL_HEADER = 'x-gitbook-cache-control';
|
||||
const CACHE_TAG_HEADER = 'x-gitbook-cache-tag';
|
||||
|
||||
const getCacheHeaders = (cacheControl: string, cacheTag?: string): HeadersInit => ({
|
||||
'Cache-Control': cacheControl,
|
||||
[CACHE_CONTROL_HEADER]: cacheControl,
|
||||
...(cacheTag
|
||||
? {
|
||||
'Cache-Tag': cacheTag,
|
||||
[CACHE_TAG_HEADER]: cacheTag,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const nullCacheResponse = (hasBeenRevalidated = false): Response =>
|
||||
Response.json(null, {
|
||||
headers: {
|
||||
'Cache-Control': NO_STORE_CACHE_CONTROL,
|
||||
...getCacheHeaders(NO_STORE_CACHE_CONTROL),
|
||||
...(hasBeenRevalidated ? { 'x-gitbook-cache-revalidated': 'true' } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const restoreCacheHeaders = (response: Response): Response => {
|
||||
const headers = new Headers(response.headers);
|
||||
const cacheControl = headers.get(CACHE_CONTROL_HEADER);
|
||||
const cacheTag = headers.get(CACHE_TAG_HEADER);
|
||||
|
||||
if (cacheControl) {
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
}
|
||||
if (cacheTag) {
|
||||
headers.set('Cache-Tag', cacheTag);
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
const isTimeStale = (value: CacheValue<CacheEntryType>, lastModified?: number): boolean => {
|
||||
const revalidate = value.revalidate;
|
||||
if (typeof revalidate !== 'number') {
|
||||
@@ -84,6 +120,12 @@ export default class IncrementalCacheWorker extends WorkerEntrypoint<CacheWorker
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== INTERNAL_PATH) {
|
||||
url.pathname = INTERNAL_PATH;
|
||||
const response = await this.env.WORKER_SELF_REFERENCE.fetch(new Request(url, request));
|
||||
return restoreCacheHeaders(response);
|
||||
}
|
||||
|
||||
const key = url.searchParams.get('key');
|
||||
const cacheType = url.searchParams.get('cacheType');
|
||||
if (!key || (cacheType !== null && !isCacheEntryType(cacheType))) {
|
||||
@@ -106,10 +148,11 @@ export default class IncrementalCacheWorker extends WorkerEntrypoint<CacheWorker
|
||||
}
|
||||
|
||||
return Response.json(value, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=3600',
|
||||
'Cache-Tag': [`incremental-cache:${key}`, ...tags].join(','),
|
||||
},
|
||||
headers: getCacheHeaders(
|
||||
// 1 hour cache, with a 1 day stale-while-revalidate.
|
||||
'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
[`incremental-cache:${key}`, ...tags].join(',')
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,52 +12,52 @@
|
||||
"enabled": false,
|
||||
},
|
||||
"cache": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
},
|
||||
"placement": {
|
||||
"region": "gcp:us-central1"
|
||||
"region": "gcp:us-central1",
|
||||
},
|
||||
"env": {
|
||||
"dev": {
|
||||
"vars": {
|
||||
"STAGE": "dev",
|
||||
"OPEN_NEXT_BUILD_ID": "local",
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||
}
|
||||
"bucket_name": "gitbook-open-v2-cache-preview",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-dev"
|
||||
}
|
||||
"service": "gitbook-open-v2-do-dev",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "NEXT_CACHE_DO_QUEUE",
|
||||
"class_name": "DOQueueHandler"
|
||||
"class_name": "DOQueueHandler",
|
||||
},
|
||||
{
|
||||
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||
"class_name": "DOShardedTagCache"
|
||||
"class_name": "DOShardedTagCache",
|
||||
},
|
||||
{
|
||||
"name": "WRITE_BUFFER",
|
||||
"class_name": "R2WriteBuffer"
|
||||
}
|
||||
]
|
||||
"class_name": "R2WriteBuffer",
|
||||
},
|
||||
],
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||
}
|
||||
]
|
||||
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"preview": {
|
||||
"vars": {
|
||||
@@ -73,7 +73,7 @@
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-preview",
|
||||
"service": "gitbook-open-v2-do-preview",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
@@ -118,7 +118,7 @@
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-staging",
|
||||
"service": "gitbook-open-v2-do-staging",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
@@ -167,7 +167,7 @@
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "gitbook-open-v2-production",
|
||||
"service": "gitbook-open-v2-do-production",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
|
||||
Reference in New Issue
Block a user