diff --git a/packages/gitbook/openNext/customWorkers/do.test.ts b/packages/gitbook/openNext/customWorkers/do.test.ts new file mode 100644 index 000000000..191f3703f --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/do.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test'; + +const runWithCloudflareRequestContext = mock( + async (_: Request, __: unknown, ___: unknown, operation: () => Promise) => 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: '

cached

', + 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(); + }); +}); diff --git a/packages/gitbook/openNext/customWorkers/do.ts b/packages/gitbook/openNext/customWorkers/do.ts index ba2389128..dbe062862 100644 --- a/packages/gitbook/openNext/customWorkers/do.ts +++ b/packages/gitbook/openNext/customWorkers/do.ts @@ -16,6 +16,9 @@ type CacheWorkerEnv = { NEXT_INC_CACHE_R2_BUCKET: { put(key: string, value: string): Promise; }; + WORKER_SELF_REFERENCE: { + fetch(request: Request): Promise; + }; }; //@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, lastModified?: number): boolean => { const revalidate = value.revalidate; if (typeof revalidate !== 'number') { @@ -84,6 +120,12 @@ export default class IncrementalCacheWorker extends WorkerEntrypoint