From f80f81032f4d19558736f3f2430799c0cfb36521 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Thu, 20 Aug 2026 10:42:28 +0200 Subject: [PATCH 01/13] Refactor action.yaml for improved readability and consistency in step formatting --- .../gradual-deploy-cloudflare/action.yaml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/actions/gradual-deploy-cloudflare/action.yaml b/.github/actions/gradual-deploy-cloudflare/action.yaml index 69d9773861..ee9301286f 100644 --- a/.github/actions/gradual-deploy-cloudflare/action.yaml +++ b/.github/actions/gradual-deploy-cloudflare/action.yaml @@ -34,13 +34,13 @@ runs: environment: ${{ inputs.environment }} command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - # This step is used to get the version ID that is currently deployed to Cloudflare. + # This step is used to get the version ID that is currently deployed to Cloudflare. - id: extract_current_version - name: Extract current version - shell: bash - run: | - version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') - echo "version_id=$version_id" >> $GITHUB_OUTPUT + name: Extract current version + shell: bash + run: | + version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT - id: deploy_server name: Deploy server to Cloudflare at 0% @@ -75,9 +75,9 @@ runs: environment: ${{ inputs.environment }} command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - - name: Outputs - shell: bash - env: - DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} - run: | - echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" + - name: Outputs + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} + run: | + echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" From 4e93131235347b21049776f70ca603e4244792b5 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 13:33:08 +0200 Subject: [PATCH 02/13] Add container server tier with cache integration and build configuration --- .changeset/container-server-tier.md | 5 + bun.lock | 3 + packages/gitbook/.gitignore | 1 + .../gitbook/open-next.container.config.ts | 30 +++++ .../gitbook/openNext/container/client.test.ts | 111 ++++++++++++++++++ packages/gitbook/openNext/container/fetch.ts | 12 ++ .../openNext/container/incrementalCache.ts | 83 +++++++++++++ .../gitbook/openNext/container/protocol.ts | 59 ++++++++++ packages/gitbook/openNext/container/queue.ts | 25 ++++ .../gitbook/openNext/container/tagCache.ts | 36 ++++++ .../gitbook/openNext/customWorkers/Dockerfile | 15 +++ .../openNext/customWorkers/container.ts | 59 ++++++++++ .../customWorkers/containerOutbound.test.ts | 105 +++++++++++++++++ .../customWorkers/containerOutbound.ts | 85 ++++++++++++++ .../customWorkers/containerWrangler.jsonc | 58 +++++++++ packages/gitbook/openNext/customWorkers/do.ts | 14 ++- .../openNext/customWorkers/middleware.js | 9 +- .../customWorkers/middlewareWrangler.jsonc | 6 + packages/gitbook/package.json | 6 +- packages/gitbook/src/lib/data/cloudflare.ts | 7 +- packages/gitbook/src/lib/waitUntil.ts | 7 ++ turbo.json | 16 +++ 22 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 .changeset/container-server-tier.md create mode 100644 packages/gitbook/open-next.container.config.ts create mode 100644 packages/gitbook/openNext/container/client.test.ts create mode 100644 packages/gitbook/openNext/container/fetch.ts create mode 100644 packages/gitbook/openNext/container/incrementalCache.ts create mode 100644 packages/gitbook/openNext/container/protocol.ts create mode 100644 packages/gitbook/openNext/container/queue.ts create mode 100644 packages/gitbook/openNext/container/tagCache.ts create mode 100644 packages/gitbook/openNext/customWorkers/Dockerfile create mode 100644 packages/gitbook/openNext/customWorkers/container.ts create mode 100644 packages/gitbook/openNext/customWorkers/containerOutbound.test.ts create mode 100644 packages/gitbook/openNext/customWorkers/containerOutbound.ts create mode 100644 packages/gitbook/openNext/customWorkers/containerWrangler.jsonc diff --git a/.changeset/container-server-tier.md b/.changeset/container-server-tier.md new file mode 100644 index 0000000000..0e377ddae4 --- /dev/null +++ b/.changeset/container-server-tier.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Local dev only for now (`bun run build:all`, `bun run dev:cf:container`). diff --git a/bun.lock b/bun.lock index 0d86f55ffb..2b09ca8477 100644 --- a/bun.lock +++ b/bun.lock @@ -113,6 +113,7 @@ "version": "0.27.2", "dependencies": { "@base-ui/react": "catalog:", + "@cloudflare/containers": "^0.3.7", "@cloudflare/workers-types": "^5.20260716.1", "@gitbook/api": "catalog:", "@gitbook/browser-types": "workspace:*", @@ -580,6 +581,8 @@ "@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="], + "@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore index f7a6b34753..3047cd1e5e 100644 --- a/packages/gitbook/.gitignore +++ b/packages/gitbook/.gitignore @@ -35,5 +35,6 @@ screenshots/ # cloudflare .open-next +.open-next-container .wrangler worker-configuration.d.ts \ No newline at end of file diff --git a/packages/gitbook/open-next.container.config.ts b/packages/gitbook/open-next.container.config.ts new file mode 100644 index 0000000000..c6ec62d911 --- /dev/null +++ b/packages/gitbook/open-next.container.config.ts @@ -0,0 +1,30 @@ +import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js'; + +/** + * Build config for the container server tier: the same Next.js app packaged by `@opennextjs/aws` + * as a plain Node server, run inside a Cloudflare Container. + * + * The Cloudflare middleware worker stays the front door, so `middleware.external` mirrors + * `open-next.config.ts` and the middleware bundle emitted here is unused. + */ +export default { + default: { + override: { + wrapper: 'node', + converter: 'node', + // We ship our own Dockerfile (openNext/customWorkers/Dockerfile). + generateDockerfile: false, + queue: () => import('./openNext/container/queue').then((m) => m.default), + incrementalCache: () => + import('./openNext/container/incrementalCache').then((m) => m.default), + tagCache: () => import('./openNext/container/tagCache').then((m) => m.default), + }, + }, + middleware: { + external: true, + }, + dangerous: { + enableCacheInterception: true, + }, + edgeExternals: ['node:crypto'], +} satisfies OpenNextConfig; diff --git a/packages/gitbook/openNext/container/client.test.ts b/packages/gitbook/openNext/container/client.test.ts new file mode 100644 index 0000000000..99bfe88c81 --- /dev/null +++ b/packages/gitbook/openNext/container/client.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +const { GitbookContainerIncrementalCache } = await import('./incrementalCache'); +const { default: tagCache } = await import('./tagCache'); +const { default: queue } = await import('./queue'); + +const cacheValue = { + type: 'page' as const, + html: '

cached

', + json: {}, +}; + +const revalidationMessage = { + MessageDeduplicationId: 'dedup', + MessageBody: { host: 'example.com', url: '/docs', lastModified: 1, eTag: 'etag' }, + MessageGroupId: 'group', +}; + +describe('container cache clients', () => { + const internalFetch = mock(); + const originalInternalFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch; + const originalConsoleError = console.error; + + const lastCall = () => internalFetch.mock.calls[internalFetch.mock.calls.length - 1] ?? []; + const lastUrl = () => new URL(String(lastCall()[0])); + const lastBody = () => JSON.parse((lastCall()[1] as RequestInit).body as string); + + beforeEach(() => { + internalFetch.mockReset(); + internalFetch.mockResolvedValue(new Response(null, { status: 204 })); + (globalThis as { internalFetch?: unknown }).internalFetch = internalFetch; + console.error = mock(); + }); + + afterEach(() => { + (globalThis as { internalFetch?: unknown }).internalFetch = originalInternalFetch; + console.error = originalConsoleError; + }); + + it('reads through the intercepted cache host', async () => { + internalFetch.mockResolvedValue(Response.json({ value: cacheValue, lastModified: 123 })); + + const result = await new GitbookContainerIncrementalCache().get( + 'key with / characters', + 'cache' + ); + + expect(result).toEqual({ value: cacheValue, lastModified: 123 }); + const url = lastUrl(); + expect(url.host).toBe('incremental-cache.internal'); + expect(url.protocol).toBe('http:'); + expect(url.pathname).toBe('/'); + expect(url.searchParams.get('key')).toBe('key with / characters'); + expect(url.searchParams.get('cacheType')).toBe('cache'); + }); + + it('returns null for cache misses and failed reads', async () => { + internalFetch.mockResolvedValue(Response.json(null)); + expect(await new GitbookContainerIncrementalCache().get('missing')).toBeNull(); + + internalFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + expect(await new GitbookContainerIncrementalCache().get('unavailable-response')).toBeNull(); + + internalFetch.mockRejectedValueOnce(new Error('unreachable')); + expect(await new GitbookContainerIncrementalCache().get('unavailable')).toBeNull(); + }); + + it('posts writes and deletes to their own paths', async () => { + const cache = new GitbookContainerIncrementalCache(); + + await cache.set('entry', cacheValue, 'cache'); + expect(lastUrl().pathname).toBe('/set'); + expect(lastBody()).toEqual({ key: 'entry', value: cacheValue, cacheType: 'cache' }); + + await cache.delete('entry'); + expect(lastUrl().pathname).toBe('/delete'); + expect(lastBody()).toEqual({ key: 'entry' }); + }); + + it('contains mutation failures', async () => { + internalFetch.mockRejectedValue(new Error('unreachable')); + const cache = new GitbookContainerIncrementalCache(); + + await expect(cache.set('entry', cacheValue, 'cache')).resolves.toBeUndefined(); + await expect(cache.delete('entry')).resolves.toBeUndefined(); + }); + + it('writes hard tags only', async () => { + await tagCache.writeTags([ + 'content', + { tag: 'with-duration', stale: 100, expire: 200 }, + '_N_T_/soft-tag', + ]); + + expect(lastUrl().pathname).toBe('/write-tags'); + expect(lastBody()).toEqual({ + tags: ['content', { tag: 'with-duration', stale: 100, expire: 200 }], + }); + + internalFetch.mockReset(); + await tagCache.writeTags(['_N_T_/soft-tag']); + expect(internalFetch).not.toHaveBeenCalled(); + }); + + it('sends revalidations to the queue path', async () => { + await queue.send(revalidationMessage); + + expect(lastUrl().pathname).toBe('/queue'); + expect(lastBody()).toEqual({ msg: revalidationMessage }); + }); +}); diff --git a/packages/gitbook/openNext/container/fetch.ts b/packages/gitbook/openNext/container/fetch.ts new file mode 100644 index 0000000000..43a4f39330 --- /dev/null +++ b/packages/gitbook/openNext/container/fetch.ts @@ -0,0 +1,12 @@ +/** + * Next.js monkey-patches the global `fetch` with its own data cache. Cache traffic must not go + * through it, or reading the cache would recurse back into the cache. The OpenNext server adapter + * stashes the pristine `fetch` on `globalThis.internalFetch` before Next loads. + */ +export function internalFetch( + input: Request | URL | string, + init?: RequestInit +): Promise { + const untouchedFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch ?? fetch; + return untouchedFetch(input as RequestInfo, init); +} diff --git a/packages/gitbook/openNext/container/incrementalCache.ts b/packages/gitbook/openNext/container/incrementalCache.ts new file mode 100644 index 0000000000..d707f485de --- /dev/null +++ b/packages/gitbook/openNext/container/incrementalCache.ts @@ -0,0 +1,83 @@ +import type { + CacheEntryType, + CacheValue, + IncrementalCache, + WithLastModified, +} from '@opennextjs/aws/types/overrides.js'; + +import { internalFetch } from './fetch'; +import { + CACHE_ORIGIN, + CACHE_PATH, + type DeletePayload, + type SetPayload, + getReadUrl, +} from './protocol'; + +/** + * Container counterpart of `openNext/incrementalCache/cacheWorkerClient.ts`: same cache worker, + * reached over the outbound handler instead of a service binding. + */ +export class GitbookContainerIncrementalCache implements IncrementalCache { + name = 'GitbookContainerIncrementalCache'; + + async get( + key: string, + cacheType?: CacheType + ): Promise> | null> { + try { + const response = await internalFetch(getReadUrl(key, cacheType)); + if (!response.ok) { + console.error('Failed to get from cache worker', response.status); + return null; + } + + return (await response.json()) as WithLastModified> | null; + } catch (error) { + console.error('Failed to get from cache worker', error); + return null; + } + } + + async set( + key: string, + value: CacheValue, + cacheType?: CacheType + ): Promise { + const payload: SetPayload = { + key, + value: value as CacheValue, + cacheType, + }; + + try { + await this.post(CACHE_PATH.set, payload); + } catch (error) { + console.error('Failed to set to cache worker', error); + } + } + + async delete(key: string): Promise { + const payload: DeletePayload = { key }; + + try { + await this.post(CACHE_PATH.delete, payload); + } catch (error) { + console.error('Failed to delete from cache worker', error); + } + } + + private async post(path: string, payload: unknown): Promise { + const response = await internalFetch(new URL(path, CACHE_ORIGIN), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Cache worker responded with ${response.status} for ${path}`); + } + } +} + +export default new GitbookContainerIncrementalCache(); diff --git a/packages/gitbook/openNext/container/protocol.ts b/packages/gitbook/openNext/container/protocol.ts new file mode 100644 index 0000000000..93ef230d38 --- /dev/null +++ b/packages/gitbook/openNext/container/protocol.ts @@ -0,0 +1,59 @@ +import type { + CacheEntryType, + CacheValue, + NextModeTagCacheWriteInput, + QueueMessage, +} from '@opennextjs/aws/types/overrides.js'; + +/** + * Protocol spoken between the Next.js server running inside the container and the cache worker. + * + * The container has no Cloudflare bindings, so it issues plain `fetch` calls to this virtual host. + * They never reach the network: the container Durable Object registers an outbound handler for the + * host, and that handler runs in the Workers runtime where the `NEXT_INC_CACHE_WORKER` service + * binding is available. + */ +export const CACHE_HOST = 'incremental-cache.internal'; + +// Outbound handlers only see ports 80 and 443, and intercepting HTTPS would require trusting a +// per-instance CA inside the image. The handler restores the `https:` scheme before forwarding. +export const CACHE_ORIGIN = `http://${CACHE_HOST}`; + +/** + * `read` deliberately keeps the URL shape used by the workerd tier so both tiers hit the same + * entry in the cache worker's edge cache. + */ +export const CACHE_PATH = { + read: '/', + set: '/set', + delete: '/delete', + writeTags: '/write-tags', + queue: '/queue', +} as const; + +export type SetPayload = { + key: string; + value: CacheValue; + cacheType?: CacheEntryType; +}; + +export type DeletePayload = { + key: string; +}; + +export type WriteTagsPayload = { + tags: NextModeTagCacheWriteInput[]; +}; + +export type QueuePayload = { + msg: QueueMessage; +}; + +export function getReadUrl(key: string, cacheType?: CacheEntryType): URL { + const url = new URL(CACHE_PATH.read, CACHE_ORIGIN); + url.searchParams.set('key', key); + if (cacheType) { + url.searchParams.set('cacheType', cacheType); + } + return url; +} diff --git a/packages/gitbook/openNext/container/queue.ts b/packages/gitbook/openNext/container/queue.ts new file mode 100644 index 0000000000..7d4ce18909 --- /dev/null +++ b/packages/gitbook/openNext/container/queue.ts @@ -0,0 +1,25 @@ +import type { Queue } from '@opennextjs/aws/types/overrides.js'; + +import { internalFetch } from './fetch'; +import { CACHE_ORIGIN, CACHE_PATH, type QueuePayload } from './protocol'; + +/** + * The ISR queue Durable Object lives in the cache worker, so revalidation messages travel the same + * outbound path as the cache itself. + */ +export default { + name: 'GitbookISRQueue', + send: async (msg) => { + const payload: QueuePayload = { msg }; + + try { + await internalFetch(new URL(CACHE_PATH.queue, CACHE_ORIGIN), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + } catch (error) { + console.error('Failed to send revalidation to cache worker', error); + } + }, +} satisfies Queue; diff --git a/packages/gitbook/openNext/container/tagCache.ts b/packages/gitbook/openNext/container/tagCache.ts new file mode 100644 index 0000000000..e8ac08a056 --- /dev/null +++ b/packages/gitbook/openNext/container/tagCache.ts @@ -0,0 +1,36 @@ +import type { NextModeTagCache, NextModeTagCacheWriteInput } from '@opennextjs/aws/types/overrides'; +import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; + +import { internalFetch } from './fetch'; +import { CACHE_ORIGIN, CACHE_PATH, type WriteTagsPayload } from './protocol'; + +export default { + name: 'GitbookContainerTagCache', + mode: 'nextMode', + // Do nothing. + getLastRevalidated: async () => { + return 0; + }, + // Return false, everything handled at the incremental cache level in the do worker. + hasBeenRevalidated: async () => { + return false; + }, + writeTags: async (tags: NextModeTagCacheWriteInput[]) => { + const tagsToWrite = tags.filter(softTagFilter); + if (tagsToWrite.length === 0) { + return; + } + + const payload: WriteTagsPayload = { tags: tagsToWrite }; + + try { + await internalFetch(new URL(CACHE_PATH.writeTags, CACHE_ORIGIN), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + } catch (error) { + console.error('Failed to write tags to cache worker', error); + } + }, +} satisfies NextModeTagCache; diff --git a/packages/gitbook/openNext/customWorkers/Dockerfile b/packages/gitbook/openNext/customWorkers/Dockerfile new file mode 100644 index 0000000000..9ccdc283b4 --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-slim + +WORKDIR /app + +# Built by `bun run build:container` (@opennextjs/aws, node wrapper). +COPY .open-next-container/server-functions/default /app + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +# In this monorepo the bundle's root index.mjs re-exports packages/gitbook/index.mjs; +# importing it boots the HTTP server. +CMD ["node", "index.mjs"] diff --git a/packages/gitbook/openNext/customWorkers/container.ts b/packages/gitbook/openNext/customWorkers/container.ts new file mode 100644 index 0000000000..ea0c837a61 --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/container.ts @@ -0,0 +1,59 @@ +import { Container, type OutboundHandler, getRandom } from '@cloudflare/containers'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +import { CACHE_HOST } from '../container/protocol'; +import { type ContainerOutboundEnv, handleCacheOutbound } from './containerOutbound'; + +// Required by @cloudflare/containers: the outbound interception proxy is looked up on ctx.exports. +export { ContainerProxy } from '@cloudflare/containers'; + +type ContainerWorkerEnv = ContainerOutboundEnv & { + NEXT_SERVER_CONTAINER: DurableObjectNamespace; + CONTAINER_INSTANCES?: string; +}; + +const DEFAULT_INSTANCES = 3; + +function getStringVars(env: unknown): Record { + return Object.fromEntries( + Object.entries(env as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); +} + +export class NextServerContainer extends Container { + defaultPort = 3000; + sleepAfter = '10m'; + // Host + path: the @opennextjs/aws `node` wrapper answers this without waking Next. + pingEndpoint = 'container/__health'; + // The container is a separate process, so worker `vars` do not reach it on their own. Forward + // them so the app reads the same `process.env` the workerd tier does (GITBOOK_URL, STAGE, ...). + envVars = getStringVars(this.env); +} + +// `Container.outboundByHost` is a static setter that registers the handlers; declaring it as a +// static field on the subclass would shadow it with a plain property and the proxy would never +// find the handler — it would fall through to real internet access instead. +// `Cloudflare.Env` is generated from the root wrangler config and does not describe this worker's +// bindings, hence the cast. +// +// Only the cache host is intercepted; everything else (the GitBook API, the icons CDN) goes out +// normally. Note that loopback traffic cannot be intercepted at all — it never leaves the +// container's network namespace — so anything the app fetches server-side must be a real host. +NextServerContainer.outboundByHost = { + [CACHE_HOST]: (request, env) => + handleCacheOutbound(request, env as unknown as ContainerOutboundEnv), +} satisfies Record; + +export default class extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const instances = Number.parseInt(this.env.CONTAINER_INSTANCES ?? '', 10); + const container = await getRandom( + this.env.NEXT_SERVER_CONTAINER, + Number.isNaN(instances) ? DEFAULT_INSTANCES : instances + ); + + return container.fetch(request); + } +} diff --git a/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts b/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts new file mode 100644 index 0000000000..76fb5629e0 --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +import { handleCacheOutbound } from './containerOutbound'; + +const cacheValue = { + type: 'page' as const, + html: '

cached

', + json: {}, +}; + +const revalidationMessage = { + MessageDeduplicationId: 'dedup', + MessageBody: { host: 'example.com', url: '/docs', lastModified: 1, eTag: 'etag' }, + MessageGroupId: 'group', +}; + +const post = (path: string, payload: unknown) => + new Request(`http://incremental-cache.internal${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + +describe('handleCacheOutbound', () => { + const fetch = mock(); + const set = mock(); + const remove = mock(); + const writeTags = mock(); + const enqueueRevalidation = mock(); + const originalConsoleError = console.error; + + const env = () => ({ + NEXT_INC_CACHE_WORKER: { fetch, set, delete: remove, writeTags, enqueueRevalidation }, + }); + + beforeEach(() => { + for (const m of [fetch, set, remove, writeTags, enqueueRevalidation]) { + m.mockReset(); + } + fetch.mockResolvedValue(new Response(null, { status: 204 })); + console.error = mock(); + }); + + afterEach(() => { + console.error = originalConsoleError; + }); + + it('forwards reads to the cache worker with the https scheme restored', async () => { + fetch.mockResolvedValue(Response.json({ value: cacheValue, lastModified: 123 })); + + const response = await handleCacheOutbound( + new Request('http://incremental-cache.internal/?key=entry&cacheType=cache'), + env() + ); + + expect(await response.json()).toEqual({ value: cacheValue, lastModified: 123 }); + // The workerd tier sends the same URL, so both tiers share one edge cache entry. + expect((fetch.mock.calls[0]?.[0] as Request).url).toBe( + 'https://incremental-cache.internal/?key=entry&cacheType=cache' + ); + }); + + it('maps write paths onto the service binding RPC methods', async () => { + await handleCacheOutbound( + post('/set', { key: 'entry', value: cacheValue, cacheType: 'cache' }), + env() + ); + expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache'); + + await handleCacheOutbound(post('/delete', { key: 'entry' }), env()); + expect(remove).toHaveBeenCalledWith('entry'); + + await handleCacheOutbound(post('/write-tags', { tags: ['content'] }), env()); + expect(writeTags).toHaveBeenCalledWith(['content']); + + await handleCacheOutbound(post('/queue', { msg: revalidationMessage }), env()); + expect(enqueueRevalidation).toHaveBeenCalledWith(revalidationMessage); + }); + + it('returns 404 for unknown paths', async () => { + const response = await handleCacheOutbound(post('/unknown', {}), env()); + + expect(response.status).toBe(404); + }); + + it('returns 502 when the cache worker fails', async () => { + set.mockRejectedValue(new Error('service unavailable')); + + const response = await handleCacheOutbound( + post('/set', { key: 'entry', value: cacheValue }), + env() + ); + + expect(response.status).toBe(502); + }); + + it('returns 503 when the service binding is missing', async () => { + const response = await handleCacheOutbound( + new Request('http://incremental-cache.internal/?key=entry'), + {} as never + ); + + expect(response.status).toBe(503); + }); +}); diff --git a/packages/gitbook/openNext/customWorkers/containerOutbound.ts b/packages/gitbook/openNext/customWorkers/containerOutbound.ts new file mode 100644 index 0000000000..2b7185df81 --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/containerOutbound.ts @@ -0,0 +1,85 @@ +import type { + CacheEntryType, + CacheValue, + NextModeTagCacheWriteInput, + QueueMessage, +} from '@opennextjs/aws/types/overrides.js'; + +import { + CACHE_PATH, + type DeletePayload, + type QueuePayload, + type SetPayload, + type WriteTagsPayload, +} from '../container/protocol'; + +export type CacheWorkerBinding = { + fetch(request: Request): Promise; + set( + key: string, + value: CacheValue, + cacheType?: CacheType + ): Promise; + delete(key: string): Promise; + writeTags(tags: NextModeTagCacheWriteInput[]): Promise; + enqueueRevalidation(msg: QueueMessage): Promise; +}; + +export type ContainerOutboundEnv = { + NEXT_INC_CACHE_WORKER: CacheWorkerBinding; +}; + +const noContent = (): Response => new Response(null, { status: 204 }); + +/** + * Handles the requests the container makes to the virtual cache host. It runs in the Workers + * runtime, so it can reach the cache worker through the service binding the container cannot see. + */ +export async function handleCacheOutbound( + request: Request, + env: ContainerOutboundEnv +): Promise { + const worker = env.NEXT_INC_CACHE_WORKER; + if (!worker) { + console.error('Missing NEXT_INC_CACHE_WORKER service binding'); + return new Response('Cache worker unavailable', { status: 503 }); + } + + const url = new URL(request.url); + + try { + switch (url.pathname) { + case CACHE_PATH.read: { + // Rebuild the request the workerd tier sends, down to the scheme, so both tiers + // share one entry in the cache worker's edge cache. + url.protocol = 'https:'; + return await worker.fetch(new Request(url)); + } + case CACHE_PATH.set: { + const { key, value, cacheType } = (await request.json()) as SetPayload; + await worker.set(key, value, cacheType); + return noContent(); + } + case CACHE_PATH.delete: { + const { key } = (await request.json()) as DeletePayload; + await worker.delete(key); + return noContent(); + } + case CACHE_PATH.writeTags: { + const { tags } = (await request.json()) as WriteTagsPayload; + await worker.writeTags(tags); + return noContent(); + } + case CACHE_PATH.queue: { + const { msg } = (await request.json()) as QueuePayload; + await worker.enqueueRevalidation(msg); + return noContent(); + } + default: + return new Response('Not found', { status: 404 }); + } + } catch (error) { + console.error('Cache outbound handler failed', url.pathname, error); + return new Response('Cache worker error', { status: 502 }); + } +} diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc new file mode 100644 index 0000000000..bffa3971f6 --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -0,0 +1,58 @@ +{ + "main": "container.ts", + "name": "gitbook-open-v2-container", + "compatibility_date": "2026-06-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public", + ], + "observability": { + "enabled": false, + }, + "env": { + "dev": { + "vars": { + "STAGE": "dev", + "CONTAINER_INSTANCES": "1", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + // Forwarded into the container by NextServerContainer.envVars. Note that the + // container cannot reach this host itself: anything the app fetches server-side + // (icons, assets) must resolve to a public URL, so build with the env loaded + // (`env-cmd -f ../../.env.local`) to get an absolute GITBOOK_ICONS_URL. + "GITBOOK_URL": "http://localhost:8771", + }, + // `containers` is not inherited from the top-level config, it has to be repeated per env. + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + // Relative to this config file, so the Dockerfile can COPY .open-next-container. + "image_build_context": "../..", + "instance_type": "standard-1", + "max_instances": 1, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-dev", + }, + ], + }, + }, +} diff --git a/packages/gitbook/openNext/customWorkers/do.ts b/packages/gitbook/openNext/customWorkers/do.ts index dbe0628625..87301a1e93 100644 --- a/packages/gitbook/openNext/customWorkers/do.ts +++ b/packages/gitbook/openNext/customWorkers/do.ts @@ -1,15 +1,17 @@ -import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; import type { CacheEntryType, CacheValue, NextModeTagCacheWriteInput, + QueueMessage, } from '@opennextjs/aws/types/overrides.js'; import { getTagsFromValue } from '@opennextjs/aws/utils/cache.js'; import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; // @ts-ignore Generated by the Cloudflare build. import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache'; +import queue from '../queue/middleware'; import tagCache from '../tagCache/middleware'; type CacheWorkerEnv = { @@ -185,6 +187,16 @@ export default class IncrementalCacheWorker extends WorkerEntrypoint { + await this.#runRpcOperation(async () => { + await queue.send(msg); + }); + } + async #runRpcOperation(operation: () => Promise): Promise { await runWithCloudflareRequestContext( new Request('https://incremental-cache.internal'), diff --git a/packages/gitbook/openNext/customWorkers/middleware.js b/packages/gitbook/openNext/customWorkers/middleware.js index 0a519d2787..48b0958ff7 100644 --- a/packages/gitbook/openNext/customWorkers/middleware.js +++ b/packages/gitbook/openNext/customWorkers/middleware.js @@ -43,13 +43,20 @@ export default class extends WorkerEntrypoint { return reqOrResp; } + // `container` routes to the Next server running inside a Cloudflare Container, + // `worker` (the default) to the workerd server. + const serverWorker = + this.env.SERVER_TIER === 'container' + ? this.env.CONTAINER_WORKER + : this.env.DEFAULT_WORKER; + if (this.env.STAGE !== 'preview') { // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity reqOrResp.headers.set( 'Cloudflare-Workers-Version-Overrides', `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` ); - const response = await this.env.DEFAULT_WORKER?.fetch(reqOrResp, { + const response = await serverWorker?.fetch(reqOrResp, { redirect: 'manual', cf: { cacheEverything: false, diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc index 5323b8a39d..cd4060648d 100644 --- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc @@ -25,6 +25,8 @@ "NEXT_PRIVATE_DEBUG_CACHE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", "GITBOOK_URL": "http://localhost:8771", + // Set to "container" to route to gitbook-open-v2-container-dev instead. + "SERVER_TIER": "container", }, "r2_buckets": [ { @@ -45,6 +47,10 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-dev", }, + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-dev", + }, ], }, "preview": { diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index b738635c01..feb5310573 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@base-ui/react": "catalog:", + "@cloudflare/containers": "^0.3.7", "@cloudflare/workers-types": "^5.20260716.1", "@gitbook/api": "catalog:", "@gitbook/browser-types": "workspace:*", @@ -127,11 +128,14 @@ "check:css-browser-compatibility:local": "bun run check:css-browser-compatibility --local origin/main", "start": "GITBOOK_URL=http://localhost:3000 next start", "build:cloudflare": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build", + "build:container": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare open-next build --config-path ./open-next.container.config.ts && rm -rf .open-next-container && mv .open-next .open-next-container", + "build:all": "bun run build:container && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build --skipNextBuild", "dev:cloudflare": "wrangler dev --port 8771 --env preview", - "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", + "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9231 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", "dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc", "profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts", "dev:cf:cache": "wrangler dev --env dev --config ./openNext/customWorkers/doWrangler.jsonc", + "dev:cf:container": "wrangler dev --port 8773 --env dev --config ./openNext/customWorkers/containerWrangler.jsonc", "e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium", "e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium", "e2e-style-perf": "playwright test e2e/style-perf.spec.ts --project=chromium --reporter=list", diff --git a/packages/gitbook/src/lib/data/cloudflare.ts b/packages/gitbook/src/lib/data/cloudflare.ts index 333bc25ddf..6f53e87c72 100644 --- a/packages/gitbook/src/lib/data/cloudflare.ts +++ b/packages/gitbook/src/lib/data/cloudflare.ts @@ -10,5 +10,10 @@ export function getCloudflareContext() { return null; } - return getCloudflareContextOpenNext(); + try { + return getCloudflareContextOpenNext(); + } catch { + // The container tier shares the Cloudflare build, but runs on plain Node with no bindings. + return null; + } } diff --git a/packages/gitbook/src/lib/waitUntil.ts b/packages/gitbook/src/lib/waitUntil.ts index 359e0100e2..a3c3e47f33 100644 --- a/packages/gitbook/src/lib/waitUntil.ts +++ b/packages/gitbook/src/lib/waitUntil.ts @@ -23,6 +23,13 @@ export async function waitUntil(promise: Promise) { context.ctx.waitUntil(promise); return; } + + // The container tier shares the Cloudflare build but runs as a long-lived Node server, + // where a detached promise keeps running after the response is sent. + promise.catch((error) => { + console.error('Ignored error in waitUntil', error); + }); + return; } await promise.catch((error) => { diff --git a/turbo.json b/turbo.json index d9031e75c3..3d57eed25d 100644 --- a/turbo.json +++ b/turbo.json @@ -25,6 +25,22 @@ "dependsOn": ["^build", "generate"], "outputs": [".next/**", "!.next/cache/**", "dist", ".open-next/**"] }, + // Build the package for the Cloudflare container tier + "build:container": { + "dependsOn": ["^build", "generate"], + "outputs": [".next/**", "!.next/cache/**", "dist", ".open-next-container/**"] + }, + // Build both the Cloudflare workers and the container, sharing a single `next build` + "build:all": { + "dependsOn": ["^build", "generate"], + "outputs": [ + ".next/**", + "!.next/cache/**", + "dist", + ".open-next/**", + ".open-next-container/**" + ] + }, // Check the package for type errors "typecheck": { "dependsOn": ["^typecheck", "build"] From 60bc6914f99d406e4431fce8a42e45e52accf654 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 14:43:04 +0200 Subject: [PATCH 03/13] Deploy container server tier with CI integration for preview, staging, and production environments --- .changeset/container-server-tier.md | 2 +- .changeset/deploy-container-tier.md | 5 + .../gradual-deploy-cloudflare/action.yaml | 110 +++++++-------- .../composite/deploy-cloudflare/action.yaml | 17 ++- packages/gitbook/.dockerignore | 4 + .../customWorkers/containerWrangler.jsonc | 130 ++++++++++++++++++ .../openNext/customWorkers/middleware.js | 24 ++-- .../customWorkers/middlewareWrangler.jsonc | 18 ++- 8 files changed, 242 insertions(+), 68 deletions(-) create mode 100644 .changeset/deploy-container-tier.md create mode 100644 packages/gitbook/.dockerignore diff --git a/.changeset/container-server-tier.md b/.changeset/container-server-tier.md index 0e377ddae4..2b06d7ae52 100644 --- a/.changeset/container-server-tier.md +++ b/.changeset/container-server-tier.md @@ -2,4 +2,4 @@ "gitbook": patch --- -Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Local dev only for now (`bun run build:all`, `bun run dev:cf:container`). +Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Build it with `bun run build:all` and run it locally with `bun run dev:cf:container`. diff --git a/.changeset/deploy-container-tier.md b/.changeset/deploy-container-tier.md new file mode 100644 index 0000000000..800025e14a --- /dev/null +++ b/.changeset/deploy-container-tier.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Deploy the container server tier from CI to preview, staging and production. Preview and staging serve their traffic from the container; production deploys it but keeps serving from the workerd tier. diff --git a/.github/actions/gradual-deploy-cloudflare/action.yaml b/.github/actions/gradual-deploy-cloudflare/action.yaml index ee9301286f..fb1ffe6737 100644 --- a/.github/actions/gradual-deploy-cloudflare/action.yaml +++ b/.github/actions/gradual-deploy-cloudflare/action.yaml @@ -21,63 +21,63 @@ outputs: description: 'Deployment URL' value: ${{ steps.deploy_middleware.outputs.deployment-url }} runs: - using: 'composite' - steps: - - id: wrangler_status - name: Check wrangler deployment status - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc + using: 'composite' + steps: + - id: wrangler_status + name: Check wrangler deployment status + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - # This step is used to get the version ID that is currently deployed to Cloudflare. - - id: extract_current_version - name: Extract current version - shell: bash - run: | - version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') - echo "version_id=$version_id" >> $GITHUB_OUTPUT + # This step is used to get the version ID that is currently deployed to Cloudflare. + - id: extract_current_version + name: Extract current version + shell: bash + run: | + version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT - - id: deploy_server - name: Deploy server to Cloudflare at 0% - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc + - id: deploy_server + name: Deploy server to Cloudflare at 0% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - # Since we use version overrides headers, we can directly deploy the middleware to 100%. - - id: deploy_middleware - name: Deploy middleware to Cloudflare at 100% - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc + # Since we use version overrides headers, we can directly deploy the middleware to 100%. + - id: deploy_middleware + name: Deploy middleware to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc - - name: Deploy server to Cloudflare at 100% - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc + - name: Deploy server to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - - name: Outputs - shell: bash - env: - DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} - run: | - echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" + - name: Outputs + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} + run: | + echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index daa53d734f..e499280393 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -63,8 +63,10 @@ runs: GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL + # `build:all` shares a single `next build` between the workerd tier (.open-next) and the + # container tier (.open-next-container), so both are built from the same bundle. - name: Build worker - run: bun run turbo build:cloudflare + run: bun run turbo build:all env: GITBOOK_RUNTIME: cloudflare VERCEL_TARGET_ENV: ${{ inputs.environment }} @@ -94,6 +96,19 @@ runs: environment: ${{ inputs.environment }} command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }} + # `versions upload` never builds or pushes the container image, so the container tier uses + # `deploy` like the DO worker. It runs after the DO worker (which it binds to) and before + # the middleware (which binds to it). + - name: Deploy the container worker + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: deploy --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc + - id: upload_server name: Upload server to Cloudflare uses: cloudflare/wrangler-action@v3.14.0 diff --git a/packages/gitbook/.dockerignore b/packages/gitbook/.dockerignore new file mode 100644 index 0000000000..1f46e36a1e --- /dev/null +++ b/packages/gitbook/.dockerignore @@ -0,0 +1,4 @@ +# The image only needs the `@opennextjs/aws` node bundle; without this the whole package +# (.next, .open-next, public, node_modules) would be sent to the Docker daemon on every deploy. +* +!.open-next-container/server-functions/default diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index bffa3971f6..ad4c91a11e 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -54,5 +54,135 @@ }, ], }, + "preview": { + "vars": { + "STAGE": "preview", + "CONTAINER_INSTANCES": "3", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + "max_instances": 3, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-preview", + }, + ], + "observability": { + "traces": { + "enabled": true, + "head_sampling_rate": 1, + }, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + }, + }, + }, + "staging": { + "vars": { + "STAGE": "staging", + "CONTAINER_INSTANCES": "5", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + "max_instances": 5, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-staging", + }, + ], + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail", + }, + ], + }, + "production": { + "vars": { + "STAGE": "production", + "CONTAINER_INSTANCES": "10", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + // The middleware does not route production traffic here yet (`SERVER_TIER` is + // unset there), this is headroom for when it does. + "max_instances": 10, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-production", + }, + ], + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail", + }, + ], + }, }, } diff --git a/packages/gitbook/openNext/customWorkers/middleware.js b/packages/gitbook/openNext/customWorkers/middleware.js index 48b0958ff7..a326f9ceb7 100644 --- a/packages/gitbook/openNext/customWorkers/middleware.js +++ b/packages/gitbook/openNext/customWorkers/middleware.js @@ -45,17 +45,21 @@ export default class extends WorkerEntrypoint { // `container` routes to the Next server running inside a Cloudflare Container, // `worker` (the default) to the workerd server. - const serverWorker = - this.env.SERVER_TIER === 'container' - ? this.env.CONTAINER_WORKER - : this.env.DEFAULT_WORKER; + const isContainerTier = this.env.SERVER_TIER === 'container'; + const serverWorker = isContainerTier + ? this.env.CONTAINER_WORKER + : this.env.DEFAULT_WORKER; - if (this.env.STAGE !== 'preview') { - // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity - reqOrResp.headers.set( - 'Cloudflare-Workers-Version-Overrides', - `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` - ); + // The container worker is deployed, not versioned, so it has no per-version preview + // URL and version affinity does not apply to it — always go through the binding. + if (isContainerTier || this.env.STAGE !== 'preview') { + if (!isContainerTier) { + // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity + reqOrResp.headers.set( + 'Cloudflare-Workers-Version-Overrides', + `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` + ); + } const response = await serverWorker?.fetch(reqOrResp, { redirect: 'manual', cf: { diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc index cd4060648d..7018ca1460 100644 --- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc @@ -25,7 +25,7 @@ "NEXT_PRIVATE_DEBUG_CACHE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", "GITBOOK_URL": "http://localhost:8771", - // Set to "container" to route to gitbook-open-v2-container-dev instead. + // Unset (or any other value) routes to DEFAULT_WORKER instead. "SERVER_TIER": "container", }, "r2_buckets": [ @@ -58,6 +58,7 @@ "STAGE": "preview", "PREVIEW_HOSTNAME": "TO_REPLACE", "WORKER_VERSION_ID": "TO_REPLACE", + "SERVER_TIER": "container", }, "r2_buckets": [ { @@ -78,6 +79,10 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-preview", }, + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-preview", + }, ], "durable_objects": { "bindings": [ @@ -114,6 +119,7 @@ "STAGE": "staging", "WORKER_VERSION_ID": "TO_REPLACE", "OPEN_NEXT_REQUEST_ID_HEADER": "true", + "SERVER_TIER": "container", }, "routes": [ { @@ -144,6 +150,10 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-staging", }, + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-staging", + }, ], "tail_consumers": [ { @@ -217,6 +227,12 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-production", }, + // Bound but unused: production keeps `SERVER_TIER` unset so it serves from + // DEFAULT_WORKER. Setting the var here is all it takes to switch. + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-production", + }, ], "tail_consumers": [ { From 72bf1552515784ff72697297f84aa7ff1dc8c217 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 15:39:40 +0200 Subject: [PATCH 04/13] Move incremental cache tier from DO worker to container worker, refactor related configurations and tests. --- .changeset/cache-tier-in-container-worker.md | 5 + .../composite/deploy-cloudflare/action.yaml | 6 +- .../openNext/customWorkers/container.ts | 4 + .../{do.test.ts => containerCache.test.ts} | 19 +- .../openNext/customWorkers/containerCache.ts | 217 ++++++++++++++++++ .../customWorkers/containerWrangler.jsonc | 148 +++++++++++- .../customWorkers/defaultWrangler.jsonc | 12 +- packages/gitbook/openNext/customWorkers/do.ts | 190 +-------------- .../openNext/customWorkers/doWrangler.jsonc | 3 - .../customWorkers/middlewareWrangler.jsonc | 12 +- 10 files changed, 418 insertions(+), 198 deletions(-) create mode 100644 .changeset/cache-tier-in-container-worker.md rename packages/gitbook/openNext/customWorkers/{do.test.ts => containerCache.test.ts} (86%) create mode 100644 packages/gitbook/openNext/customWorkers/containerCache.ts diff --git a/.changeset/cache-tier-in-container-worker.md b/.changeset/cache-tier-in-container-worker.md new file mode 100644 index 0000000000..ad9c4f2d9c --- /dev/null +++ b/.changeset/cache-tier-in-container-worker.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Move the incremental cache tier from the DO worker into the container worker. diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index e499280393..a2c47f7972 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -97,8 +97,8 @@ runs: command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }} # `versions upload` never builds or pushes the container image, so the container tier uses - # `deploy` like the DO worker. It runs after the DO worker (which it binds to) and before - # the middleware (which binds to it). + # `deploy` like the DO worker. It runs after the DO worker (whose Durable Objects it binds) + # and before the server and middleware (which read the cache tier it now hosts). - name: Deploy the container worker uses: cloudflare/wrangler-action@v3.14.0 with: @@ -107,7 +107,7 @@ runs: workingDirectory: ./ wranglerVersion: '4.122.0' environment: ${{ inputs.environment }} - command: deploy --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc + command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }} - id: upload_server name: Upload server to Cloudflare diff --git a/packages/gitbook/openNext/customWorkers/container.ts b/packages/gitbook/openNext/customWorkers/container.ts index ea0c837a61..68e10f5fae 100644 --- a/packages/gitbook/openNext/customWorkers/container.ts +++ b/packages/gitbook/openNext/customWorkers/container.ts @@ -7,6 +7,10 @@ import { type ContainerOutboundEnv, handleCacheOutbound } from './containerOutbo // Required by @cloudflare/containers: the outbound interception proxy is looked up on ctx.exports. export { ContainerProxy } from '@cloudflare/containers'; +// The cache tier for every server tier, served from this worker on a named entrypoint so its +// responses are cached without the default entrypoint (rendered pages) being cached too. +export { IncrementalCacheWorker } from './containerCache'; + type ContainerWorkerEnv = ContainerOutboundEnv & { NEXT_SERVER_CONTAINER: DurableObjectNamespace; CONTAINER_INSTANCES?: string; diff --git a/packages/gitbook/openNext/customWorkers/do.test.ts b/packages/gitbook/openNext/customWorkers/containerCache.test.ts similarity index 86% rename from packages/gitbook/openNext/customWorkers/do.test.ts rename to packages/gitbook/openNext/customWorkers/containerCache.test.ts index 191f3703fd..dddef6091b 100644 --- a/packages/gitbook/openNext/customWorkers/do.test.ts +++ b/packages/gitbook/openNext/customWorkers/containerCache.test.ts @@ -22,7 +22,7 @@ mock.module('../tagCache/middleware', () => ({ default: { hasBeenRevalidated }, })); -const { default: IncrementalCacheWorker } = await import('./do'); +const { IncrementalCacheWorker } = await import('./containerCache'); const CACHE_CONTROL = 'public, s-maxage=3600, stale-while-revalidate=86400'; const NO_STORE_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate'; @@ -129,6 +129,23 @@ describe('IncrementalCacheWorker fetch', () => { expect(staleResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true'); }); + it('restores the native Request after entering the OpenNext context', async () => { + const NativeRequest = globalThis.Request; + // The real `runWithCloudflareRequestContext` swaps the global for a subclass, which breaks + // the `instanceof Request` check in @cloudflare/containers on the container proxy path. + runWithCloudflareRequestContext.mockImplementationOnce( + async (_: Request, __: unknown, ___: unknown, operation: () => Promise) => { + globalThis.Request = class extends NativeRequest {} as typeof Request; + return operation(); + } + ); + get.mockResolvedValue(null); + + await fetch(new Request('https://incremental-cache.internal/internal?key=entry')); + + expect(globalThis.Request).toBe(NativeRequest); + }); + 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') diff --git a/packages/gitbook/openNext/customWorkers/containerCache.ts b/packages/gitbook/openNext/customWorkers/containerCache.ts new file mode 100644 index 0000000000..3bccf6f013 --- /dev/null +++ b/packages/gitbook/openNext/customWorkers/containerCache.ts @@ -0,0 +1,217 @@ +import type { + CacheEntryType, + CacheValue, + NextModeTagCacheWriteInput, + QueueMessage, +} from '@opennextjs/aws/types/overrides.js'; +import { getTagsFromValue } from '@opennextjs/aws/utils/cache.js'; +import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +// @ts-ignore Generated by the Cloudflare build. +import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; +import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache'; +import queue from '../queue/middleware'; +import tagCache from '../tagCache/middleware'; + +type CacheWorkerEnv = { + WORKER_SELF_REFERENCE: { + fetch(request: Request): Promise; + }; +}; + +const NativeRequest = globalThis.Request; + +/** + * Enters the OpenNext request context, then undoes the one global it patches that this worker + * cannot live with. + * + * `init()` swaps `globalThis.Request` for a subclass that strips `cache` from `RequestInit`, for + * the benefit of the Next.js server — which runs in the container, not here. Requests the runtime + * hands us are instances of the native class, so after the swap `request instanceof Request` is + * false, and `@cloudflare/containers` falls back to treating the request as a URL string + * (`Invalid URL: [object Request]`). Restoring the native class is safe because `init()` runs + * synchronously on the way in, so no other task can observe the swapped global. + */ +function runWithCacheContext( + request: Request, + env: CacheWorkerEnv, + ctx: ExecutionContext, + handler: () => Promise +): Promise { + const result = runWithCloudflareRequestContext(request, env, ctx, handler); + globalThis.Request = NativeRequest; + return result; +} + +//@ts-ignore - Just to avoid tag cache crashing +globalThis.openNextConfig = { + dangerous: { + enableCacheInterception: true, + }, +}; + +const cacheEntryTypes = new Set(['cache', 'fetch', 'composable']); + +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: { + ...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') { + return false; + } + + return Date.now() >= (lastModified ?? Date.now()) + revalidate * 1000; +}; + +const getTagName = (tag: NextModeTagCacheWriteInput): string => + typeof tag === 'string' ? tag : tag.tag; + +/** + * The cache tier for every server tier: the middleware and the default worker reach it through the + * `NEXT_INC_CACHE_WORKER` service binding, the container through the outbound handler in + * `containerOutbound.ts`. It lives in the container worker so container traffic — the tier that + * reads the cache most — stays within a single worker. + * + * It is a named entrypoint so the response cache can be enabled for it alone (see `exports` in + * `containerWrangler.jsonc`); the default entrypoint serves rendered pages and must not be cached. + * The Durable Objects it drives stay in the DO worker and are bound here by `script_name`. + */ +export class IncrementalCacheWorker extends WorkerEntrypoint { + async fetch(request: Request): Promise { + if (request.method !== 'GET') { + return new Response('Method not allowed', { status: 405 }); + } + + 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))) { + return new Response('Invalid cache request', { status: 400 }); + } + + return runWithCacheContext(request, this.env, this.ctx, async () => { + const value = await new GitbookIncrementalCache().get(key, cacheType ?? undefined); + if (!value?.value) { + return nullCacheResponse(); + } + + const tags = getTagsFromValue(value?.value as CacheValue<'cache'> | undefined); + if (await tagCache.hasBeenRevalidated(tags, value.lastModified)) { + return nullCacheResponse(true); + } + + if (isTimeStale(value.value, value.lastModified)) { + return nullCacheResponse(true); + } + + return Response.json(value, { + 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(',') + ), + }); + }); + } + + async set( + key: string, + value: CacheValue, + cacheType?: CacheType + ): Promise { + await this.#runRpcOperation(async () => { + await new GitbookIncrementalCache().set(key, value, cacheType); + }); + } + + async delete(key: string): Promise { + await this.#runRpcOperation(async () => { + await new GitbookIncrementalCache().delete(key); + }); + } + + async writeTags(tags: NextModeTagCacheWriteInput[]): Promise { + const tagsToWrite = tags.filter(softTagFilter); + if (tagsToWrite.length === 0) { + return; + } + + await this.#runRpcOperation(async () => { + await tagCache.writeTags(tagsToWrite); + await this.ctx.cache?.purge({ tags: tagsToWrite.map(getTagName) }); + }); + } + + /** + * The ISR queue Durable Object lives in the DO worker, but the container tier — which has no + * Cloudflare bindings of its own — enqueues revalidations through here. + */ + async enqueueRevalidation(msg: QueueMessage): Promise { + await this.#runRpcOperation(async () => { + await queue.send(msg); + }); + } + + async #runRpcOperation(operation: () => Promise): Promise { + await runWithCacheContext( + new Request('https://incremental-cache.internal'), + this.env, + this.ctx, + async () => { + await operation(); + return new Response(null, { status: 204 }); + } + ); + } +} diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index ad4c91a11e..f228023423 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -10,11 +10,28 @@ "observability": { "enabled": false, }, + // Same region as the DO worker: the cache tier reads R2 and drives the Durable Objects hosted + // there, and the container instances follow the Durable Object that owns them. + "placement": { + "region": "gcp:us-central1", + }, + // Enables the response cache for the cache tier alone. The default entrypoint forwards to the + // container and serves rendered pages, which must never be cached here. + "exports": { + "IncrementalCacheWorker": { + "type": "worker", + "cache": { + "enabled": true, + }, + }, + }, "env": { "dev": { "vars": { "STAGE": "dev", "CONTAINER_INSTANCES": "1", + "OPEN_NEXT_BUILD_ID": "local", + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", // Forwarded into the container by NextServerContainer.envVars. Note that the // container cannot reach this host itself: anything the app fetches server-side @@ -39,6 +56,22 @@ "name": "NEXT_SERVER_CONTAINER", "class_name": "NextServerContainer", }, + // Hosted by the DO worker, which owns their stored state. + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-dev", + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-dev", + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-dev", + }, ], }, "migrations": [ @@ -50,7 +83,20 @@ "services": [ { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-dev", + "service": "gitbook-open-v2-container-dev", + "entrypoint": "IncrementalCacheWorker", + }, + // The cache tier re-enters itself so the read goes through the response cache. + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-container-dev", + "entrypoint": "IncrementalCacheWorker", + }, + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview", }, ], }, @@ -58,6 +104,7 @@ "vars": { "STAGE": "preview", "CONTAINER_INSTANCES": "3", + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", }, "containers": [ @@ -75,6 +122,22 @@ "name": "NEXT_SERVER_CONTAINER", "class_name": "NextServerContainer", }, + // Hosted by the DO worker, which owns their stored state. + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-preview", + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-preview", + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-preview", + }, ], }, "migrations": [ @@ -86,7 +149,20 @@ "services": [ { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-preview", + "service": "gitbook-open-v2-container-preview", + "entrypoint": "IncrementalCacheWorker", + }, + // The cache tier re-enters itself so the read goes through the response cache. + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-container-preview", + "entrypoint": "IncrementalCacheWorker", + }, + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview", }, ], "observability": { @@ -104,6 +180,7 @@ "vars": { "STAGE": "staging", "CONTAINER_INSTANCES": "5", + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", }, "containers": [ @@ -121,6 +198,22 @@ "name": "NEXT_SERVER_CONTAINER", "class_name": "NextServerContainer", }, + // Hosted by the DO worker, which owns their stored state. + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-staging", + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-staging", + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-staging", + }, ], }, "migrations": [ @@ -132,7 +225,20 @@ "services": [ { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-staging", + "service": "gitbook-open-v2-container-staging", + "entrypoint": "IncrementalCacheWorker", + }, + // The cache tier re-enters itself so the read goes through the response cache. + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-container-staging", + "entrypoint": "IncrementalCacheWorker", + }, + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging", }, ], "tail_consumers": [ @@ -145,6 +251,11 @@ "vars": { "STAGE": "production", "CONTAINER_INSTANCES": "10", + // R2 is strongly consistent, so we can disable SQLite + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", + // We don't want to pollute the memory with broken cache entries + // Most of the time, those are fake requests. + "NEXT_CACHE_DO_QUEUE_MAX_RETRIES": "1", "OPEN_NEXT_REQUEST_ID_HEADER": "true", }, "containers": [ @@ -164,6 +275,22 @@ "name": "NEXT_SERVER_CONTAINER", "class_name": "NextServerContainer", }, + // Hosted by the DO worker, which owns their stored state. + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-production", + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-production", + }, + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-production", + }, ], }, "migrations": [ @@ -175,7 +302,20 @@ "services": [ { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-production", + "service": "gitbook-open-v2-container-production", + "entrypoint": "IncrementalCacheWorker", + }, + // The cache tier re-enters itself so the read goes through the response cache. + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-container-production", + "entrypoint": "IncrementalCacheWorker", + }, + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production", }, ], "tail_consumers": [ diff --git a/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc b/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc index f9ef855229..b23b053044 100644 --- a/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc @@ -35,7 +35,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-dev", + "service": "gitbook-open-v2-container-dev", + "entrypoint": "IncrementalCacheWorker", }, ], "durable_objects": { @@ -76,7 +77,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-preview", + "service": "gitbook-open-v2-container-preview", + "entrypoint": "IncrementalCacheWorker", }, ], "durable_objects": { @@ -116,7 +118,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-staging", + "service": "gitbook-open-v2-container-staging", + "entrypoint": "IncrementalCacheWorker", }, ], "durable_objects": { @@ -164,7 +167,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-production", + "service": "gitbook-open-v2-container-production", + "entrypoint": "IncrementalCacheWorker", }, ], "durable_objects": { diff --git a/packages/gitbook/openNext/customWorkers/do.ts b/packages/gitbook/openNext/customWorkers/do.ts index 87301a1e93..e765ac5fd9 100644 --- a/packages/gitbook/openNext/customWorkers/do.ts +++ b/packages/gitbook/openNext/customWorkers/do.ts @@ -1,95 +1,11 @@ -import type { - CacheEntryType, - CacheValue, - NextModeTagCacheWriteInput, - QueueMessage, -} from '@opennextjs/aws/types/overrides.js'; -import { getTagsFromValue } from '@opennextjs/aws/utils/cache.js'; -import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; -import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; - -// @ts-ignore Generated by the Cloudflare build. -import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; -import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache'; -import queue from '../queue/middleware'; -import tagCache from '../tagCache/middleware'; +import { DurableObject } from 'cloudflare:workers'; 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 -globalThis.openNextConfig = { - dangerous: { - enableCacheInterception: true, - }, -}; - -const cacheEntryTypes = new Set(['cache', 'fetch', 'composable']); - -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: { - ...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') { - return false; - } - - return Date.now() >= (lastModified ?? Date.now()) + revalidate * 1000; -}; - -const getTagName = (tag: NextModeTagCacheWriteInput): string => - typeof tag === 'string' ? tag : tag.tag; - // `use cache` can write the same key concurrently, but R2 accepts one write per key per second. export class R2WriteBuffer extends DurableObject { private writePromise: Promise | undefined; @@ -115,97 +31,13 @@ export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js // @ts-ignore Generated by the Cloudflare build. export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js'; -export default class IncrementalCacheWorker extends WorkerEntrypoint { - async fetch(request: Request): Promise { - if (request.method !== 'GET') { - return new Response('Method not allowed', { status: 405 }); - } - - 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))) { - return new Response('Invalid cache request', { status: 400 }); - } - - return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => { - const value = await new GitbookIncrementalCache().get(key, cacheType ?? undefined); - if (!value?.value) { - return nullCacheResponse(); - } - - const tags = getTagsFromValue(value?.value as CacheValue<'cache'> | undefined); - if (await tagCache.hasBeenRevalidated(tags, value.lastModified)) { - return nullCacheResponse(true); - } - - if (isTimeStale(value.value, value.lastModified)) { - return nullCacheResponse(true); - } - - return Response.json(value, { - 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(',') - ), - }); - }); - } - - async set( - key: string, - value: CacheValue, - cacheType?: CacheType - ): Promise { - await this.#runRpcOperation(async () => { - await new GitbookIncrementalCache().set(key, value, cacheType); - }); - } - - async delete(key: string): Promise { - await this.#runRpcOperation(async () => { - await new GitbookIncrementalCache().delete(key); - }); - } - - async writeTags(tags: NextModeTagCacheWriteInput[]): Promise { - const tagsToWrite = tags.filter(softTagFilter); - if (tagsToWrite.length === 0) { - return; - } - - await this.#runRpcOperation(async () => { - await tagCache.writeTags(tagsToWrite); - await this.ctx.cache?.purge({ tags: tagsToWrite.map(getTagName) }); - }); - } - - /** - * The ISR queue Durable Object lives in this worker, so the container tier — which has no - * Cloudflare bindings of its own — enqueues revalidations through here. - */ - async enqueueRevalidation(msg: QueueMessage): Promise { - await this.#runRpcOperation(async () => { - await queue.send(msg); - }); - } - - async #runRpcOperation(operation: () => Promise): Promise { - await runWithCloudflareRequestContext( - new Request('https://incremental-cache.internal'), - this.env, - this.ctx, - async () => { - await operation(); - return new Response(null, { status: 204 }); - } - ); - } -} +/** + * This worker only hosts Durable Objects — the cache tier itself now lives in the container worker + * (`containerCache.ts`). The handler stays because `DOQueueHandler` requires a + * `WORKER_SELF_REFERENCE` service binding, which has always pointed back at this worker. + */ +export default { + fetch(): Response { + return new Response('Not found', { status: 404 }); + }, +}; diff --git a/packages/gitbook/openNext/customWorkers/doWrangler.jsonc b/packages/gitbook/openNext/customWorkers/doWrangler.jsonc index 608c0de0c1..62700c0bb3 100644 --- a/packages/gitbook/openNext/customWorkers/doWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/doWrangler.jsonc @@ -11,9 +11,6 @@ "observability": { "enabled": false, }, - "cache": { - "enabled": true, - }, "placement": { "region": "gcp:us-central1", }, diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc index 7018ca1460..4546c68c98 100644 --- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc @@ -45,7 +45,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-dev", + "service": "gitbook-open-v2-container-dev", + "entrypoint": "IncrementalCacheWorker", }, { "binding": "CONTAINER_WORKER", @@ -77,7 +78,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-preview", + "service": "gitbook-open-v2-container-preview", + "entrypoint": "IncrementalCacheWorker", }, { "binding": "CONTAINER_WORKER", @@ -148,7 +150,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-staging", + "service": "gitbook-open-v2-container-staging", + "entrypoint": "IncrementalCacheWorker", }, { "binding": "CONTAINER_WORKER", @@ -225,7 +228,8 @@ }, { "binding": "NEXT_INC_CACHE_WORKER", - "service": "gitbook-open-v2-do-production", + "service": "gitbook-open-v2-container-production", + "entrypoint": "IncrementalCacheWorker", }, // Bound but unused: production keeps `SERVER_TIER` unset so it serves from // DEFAULT_WORKER. Setting the var here is all it takes to switch. From b7c5dd31305338573303416cfcc49cca94b45b8e Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 15:57:51 +0200 Subject: [PATCH 05/13] Add `--x-provision=false` to container worker deployment command to prevent unnecessary resource provisioning --- .github/composite/deploy-cloudflare/action.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index a2c47f7972..1a33c4b583 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -99,6 +99,11 @@ runs: # `versions upload` never builds or pushes the container image, so the container tier uses # `deploy` like the DO worker. It runs after the DO worker (whose Durable Objects it binds) # and before the server and middleware (which read the cache tier it now hosts). + # + # `--x-provision=false` disables Wrangler's resource provisioning. It is on by default and + # probes the R2 API for any binding the deployed Worker does not already have, which our + # API token has no permission for. Every bucket here already exists, so there is nothing to + # provision. - name: Deploy the container worker uses: cloudflare/wrangler-action@v3.14.0 with: @@ -107,7 +112,7 @@ runs: workingDirectory: ./ wranglerVersion: '4.122.0' environment: ${{ inputs.environment }} - command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }} + command: ${{ format('deploy --x-provision=false --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }} - id: upload_server name: Upload server to Cloudflare From f36c3aead3b597d06b9fdc1e704b459ebd5a6b48 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 16:19:21 +0200 Subject: [PATCH 06/13] Update instance type from standard-1 to standard-2 in container configuration --- packages/gitbook/openNext/customWorkers/containerWrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index f228023423..2204ff3eee 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -46,7 +46,7 @@ "image": "./Dockerfile", // Relative to this config file, so the Dockerfile can COPY .open-next-container. "image_build_context": "../..", - "instance_type": "standard-1", + "instance_type": "standard-2", "max_instances": 1, }, ], From 82ab9bb091528d3068a9348d31df2ee858cbce39 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 17:13:49 +0200 Subject: [PATCH 07/13] Update instance type from standard-1 to standard-2 in container configuration --- packages/gitbook/openNext/customWorkers/containerWrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index 2204ff3eee..b425a7f14c 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -112,7 +112,7 @@ "class_name": "NextServerContainer", "image": "./Dockerfile", "image_build_context": "../..", - "instance_type": "standard-1", + "instance_type": "standard-2", "max_instances": 3, }, ], From ee8762e065237993756131c26347ca9d73ac1b6c Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 17:44:07 +0200 Subject: [PATCH 08/13] Send the server tier's build ID to the cache worker to ensure incremental cache entries are namespaced correctly. --- .../customWorkers/containerCache.test.ts | 17 ++++++++++ .../openNext/customWorkers/containerCache.ts | 17 +++++++--- .../cacheWorkerClient.test.ts | 32 +++++++++++++++++-- .../incrementalCache/cacheWorkerClient.ts | 27 +++++++++++++--- .../incrementalCache/incrementalCache.test.ts | 8 +++++ .../incrementalCache/incrementalCache.ts | 8 ++++- 6 files changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/gitbook/openNext/customWorkers/containerCache.test.ts b/packages/gitbook/openNext/customWorkers/containerCache.test.ts index dddef6091b..fcee010d91 100644 --- a/packages/gitbook/openNext/customWorkers/containerCache.test.ts +++ b/packages/gitbook/openNext/customWorkers/containerCache.test.ts @@ -12,9 +12,13 @@ mock.module('cloudflare:workers', () => ({ WorkerEntrypoint: class {}, })); mock.module('../../.open-next/cloudflare/init.js', () => ({ runWithCloudflareRequestContext })); +const cacheConstructor = mock(); mock.module('../incrementalCache/incrementalCache', () => ({ GitbookIncrementalCache: class { get = get; + constructor(buildId?: string) { + cacheConstructor(buildId); + } }, })); mock.module('@opennextjs/aws/utils/cache.js', () => ({ getTagsFromValue })); @@ -40,6 +44,7 @@ describe('IncrementalCacheWorker fetch', () => { beforeEach(() => { selfFetch.mockReset(); get.mockReset(); + cacheConstructor.mockReset(); getTagsFromValue.mockReset(); hasBeenRevalidated.mockReset(); getTagsFromValue.mockReturnValue(['space:1']); @@ -81,6 +86,18 @@ describe('IncrementalCacheWorker fetch', () => { expect(forwardedURL.searchParams.get('cacheType')).toBe('cache'); }); + it('reads the entry under the build ID sent by the caller', async () => { + get.mockResolvedValue(null); + + await fetch( + new Request( + 'https://incremental-cache.internal/internal?key=entry&cacheType=cache&buildId=caller-build-id' + ) + ); + + expect(cacheConstructor).toHaveBeenCalledWith('caller-build-id'); + }); + it('reads and annotates a cache hit only on the internal endpoint', async () => { get.mockResolvedValue({ value: cacheValue, lastModified: Date.now() }); diff --git a/packages/gitbook/openNext/customWorkers/containerCache.ts b/packages/gitbook/openNext/customWorkers/containerCache.ts index 3bccf6f013..3f3f7c41b6 100644 --- a/packages/gitbook/openNext/customWorkers/containerCache.ts +++ b/packages/gitbook/openNext/customWorkers/containerCache.ts @@ -136,12 +136,18 @@ export class IncrementalCacheWorker extends WorkerEntrypoint { const key = url.searchParams.get('key'); const cacheType = url.searchParams.get('cacheType'); + // Sent by the caller: this worker is deployed on its own, so its build ID is not the one + // the entry belongs to. + const buildId = url.searchParams.get('buildId') ?? undefined; if (!key || (cacheType !== null && !isCacheEntryType(cacheType))) { return new Response('Invalid cache request', { status: 400 }); } return runWithCacheContext(request, this.env, this.ctx, async () => { - const value = await new GitbookIncrementalCache().get(key, cacheType ?? undefined); + const value = await new GitbookIncrementalCache(buildId).get( + key, + cacheType ?? undefined + ); if (!value?.value) { return nullCacheResponse(); } @@ -168,16 +174,17 @@ export class IncrementalCacheWorker extends WorkerEntrypoint { async set( key: string, value: CacheValue, - cacheType?: CacheType + cacheType?: CacheType, + buildId?: string ): Promise { await this.#runRpcOperation(async () => { - await new GitbookIncrementalCache().set(key, value, cacheType); + await new GitbookIncrementalCache(buildId).set(key, value, cacheType); }); } - async delete(key: string): Promise { + async delete(key: string, buildId?: string): Promise { await this.#runRpcOperation(async () => { - await new GitbookIncrementalCache().delete(key); + await new GitbookIncrementalCache(buildId).delete(key); }); } diff --git a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts index d3c83f3177..6a97772173 100644 --- a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts +++ b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +const BUILD_ID = 'caller-build-id'; + const getCloudflareContext = mock(); mock.module('@opennextjs/cloudflare', () => ({ getCloudflareContext })); @@ -11,13 +13,21 @@ const cacheValue = { json: {}, }; +const fetchCacheValue = { + kind: 'FETCH' as const, + data: { headers: {}, body: 'body', status: 200, url: 'https://example.com' }, + revalidate: 60, +}; + describe('GitbookIncrementalCache cache worker client', () => { const fetch = mock(); const set = mock(); const remove = mock(); const originalConsoleError = console.error; + const originalBuildId = process.env.OPEN_NEXT_BUILD_ID; beforeEach(() => { + process.env.OPEN_NEXT_BUILD_ID = BUILD_ID; fetch.mockReset(); set.mockReset(); remove.mockReset(); @@ -31,6 +41,11 @@ describe('GitbookIncrementalCache cache worker client', () => { afterEach(() => { console.error = originalConsoleError; + if (originalBuildId === undefined) { + delete process.env.OPEN_NEXT_BUILD_ID; + } else { + process.env.OPEN_NEXT_BUILD_ID = originalBuildId; + } }); it('gets cache entries through the service binding', async () => { @@ -48,6 +63,19 @@ describe('GitbookIncrementalCache cache worker client', () => { const url = new URL(request.url); expect(url.searchParams.get('key')).toBe('key with / characters'); expect(url.searchParams.get('cacheType')).toBe('cache'); + expect(url.searchParams.get('buildId')).toBe(BUILD_ID); + }); + + it('omits the build ID for entries that are not namespaced per build', async () => { + fetch.mockResolvedValue(Response.json(null)); + + await new GitbookIncrementalCache().get('key', 'composable'); + expect(new URL((fetch.mock.calls[0]?.[0] as Request).url).searchParams.has('buildId')).toBe( + false + ); + + await new GitbookIncrementalCache().set('key', fetchCacheValue, 'fetch'); + expect(set).toHaveBeenCalledWith('key', fetchCacheValue, 'fetch', undefined); }); it('returns null for cache misses and failed reads', async () => { @@ -69,8 +97,8 @@ describe('GitbookIncrementalCache cache worker client', () => { await cache.set('entry', cacheValue, 'cache'); await cache.delete('entry'); - expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache'); - expect(remove).toHaveBeenCalledWith('entry'); + expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache', BUILD_ID); + expect(remove).toHaveBeenCalledWith('entry', BUILD_ID); }); it('contains mutation failures', async () => { diff --git a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts index 44f011dd17..13ad71ed51 100644 --- a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts +++ b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts @@ -8,14 +8,29 @@ import { getCloudflareContext } from '@opennextjs/cloudflare'; export const BINDING_NAME = 'NEXT_INC_CACHE_WORKER'; +/** + * The cache worker namespaces `cache` entries per build, but it ships with the container worker and + * is deployed in one go, while this tier rolls out gradually — so during a rollout its build ID is + * not the one our entries belong to and we have to send ours. `fetch` and `composable` entries live + * in the shared `dataCache` namespace, hence the `undefined`. + */ +function getBuildId(cacheType?: CacheEntryType): string | undefined { + if (cacheType && cacheType !== 'cache') { + return undefined; + } + + return process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; +} + type CacheWorker = { fetch(request: Request): Promise; set( key: string, value: CacheValue, - cacheType?: CacheType + cacheType?: CacheType, + buildId?: string ): Promise; - delete(key: string): Promise; + delete(key: string, buildId?: string): Promise; }; export class GitbookIncrementalCache implements IncrementalCache { @@ -31,6 +46,10 @@ export class GitbookIncrementalCache implements IncrementalCache { if (cacheType) { url.searchParams.set('cacheType', cacheType); } + const buildId = getBuildId(cacheType); + if (buildId) { + url.searchParams.set('buildId', buildId); + } const response = await this.getWorker().fetch(new Request(url)); if (!response.ok) { @@ -51,7 +70,7 @@ export class GitbookIncrementalCache implements IncrementalCache { cacheType?: CacheType ): Promise { try { - await this.getWorker().set(key, value, cacheType); + await this.getWorker().set(key, value, cacheType, getBuildId(cacheType)); } catch (error) { console.error('Failed to set to cache worker', error); } @@ -59,7 +78,7 @@ export class GitbookIncrementalCache implements IncrementalCache { async delete(key: string): Promise { try { - await this.getWorker().delete(key); + await this.getWorker().delete(key, getBuildId()); } catch (error) { console.error('Failed to delete from cache worker', error); } diff --git a/packages/gitbook/openNext/incrementalCache/incrementalCache.test.ts b/packages/gitbook/openNext/incrementalCache/incrementalCache.test.ts index 7c58f68ab1..9b9c7a606c 100644 --- a/packages/gitbook/openNext/incrementalCache/incrementalCache.test.ts +++ b/packages/gitbook/openNext/incrementalCache/incrementalCache.test.ts @@ -40,6 +40,14 @@ describe('GitbookIncrementalCache cache keys', () => { ); }); + it('prefers the build ID sent by the caller over the worker environment', () => { + process.env.OPEN_NEXT_BUILD_ID = 'cache-worker-build-id'; + + expect(new GitbookIncrementalCache('caller-build-id').getR2Key('entry')).toBe( + `${DEFAULT_PREFIX}/caller-build-id/${hash('entry')}.cache` + ); + }); + it('normalizes composable cache keys before applying the deployment namespace', () => { process.env.OPEN_NEXT_BUILD_ID = 'deployment-id'; const key = JSON.stringify(['next-build-id', 'cache-key']); diff --git a/packages/gitbook/openNext/incrementalCache/incrementalCache.ts b/packages/gitbook/openNext/incrementalCache/incrementalCache.ts index 27f71e9fa3..1636c3e606 100644 --- a/packages/gitbook/openNext/incrementalCache/incrementalCache.ts +++ b/packages/gitbook/openNext/incrementalCache/incrementalCache.ts @@ -23,6 +23,12 @@ export type KeyOptions = { export class GitbookIncrementalCache implements IncrementalCache { name = 'GitbookIncrementalCache'; + /** + * @param buildId Build ID of the tier the entry belongs to, when the caller sent one. Falls + * back to this worker's own build ID, which is the right one for callers deployed with it. + */ + constructor(private readonly buildId?: string) {} + async get( key: string, cacheType?: CacheType @@ -128,7 +134,7 @@ export class GitbookIncrementalCache implements IncrementalCache { } const hash = createHash('sha256').update(key).digest('hex'); - const buildId = process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; + const buildId = this.buildId ?? process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? buildId : 'dataCache'}/${hash}.${cacheType}`.replace( /\/+/g, '/' From 4a02f872f89afda7d5eb9fd36e6a7975a838a75c Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 17:58:02 +0200 Subject: [PATCH 09/13] Add build ID handling to cache operations and update related tests - Introduced build ID management in cache set and delete operations to ensure entries are namespaced correctly. - Updated the `getReadUrl` function to include build ID in the request URL. - Enhanced tests to validate build ID inclusion in cache operations. --- .../gitbook/openNext/container/client.test.ts | 35 ++++++++++++- .../openNext/container/incrementalCache.ts | 4 +- .../gitbook/openNext/container/protocol.ts | 51 +++++++++++++++---- .../customWorkers/containerOutbound.test.ts | 16 ++++-- .../customWorkers/containerOutbound.ts | 13 ++--- .../cacheWorkerClient.test.ts | 13 +++++ .../incrementalCache/cacheWorkerClient.ts | 26 ++-------- 7 files changed, 111 insertions(+), 47 deletions(-) diff --git a/packages/gitbook/openNext/container/client.test.ts b/packages/gitbook/openNext/container/client.test.ts index 99bfe88c81..0b4d5b8f46 100644 --- a/packages/gitbook/openNext/container/client.test.ts +++ b/packages/gitbook/openNext/container/client.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +const BUILD_ID = 'caller-build-id'; + const { GitbookContainerIncrementalCache } = await import('./incrementalCache'); const { default: tagCache } = await import('./tagCache'); const { default: queue } = await import('./queue'); @@ -10,6 +12,12 @@ const cacheValue = { json: {}, }; +const fetchCacheValue = { + kind: 'FETCH' as const, + data: { headers: {}, body: 'body', status: 200, url: 'https://example.com' }, + revalidate: 60, +}; + const revalidationMessage = { MessageDeduplicationId: 'dedup', MessageBody: { host: 'example.com', url: '/docs', lastModified: 1, eTag: 'etag' }, @@ -20,6 +28,7 @@ describe('container cache clients', () => { const internalFetch = mock(); const originalInternalFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch; const originalConsoleError = console.error; + const originalBuildId = process.env.OPEN_NEXT_BUILD_ID; const lastCall = () => internalFetch.mock.calls[internalFetch.mock.calls.length - 1] ?? []; const lastUrl = () => new URL(String(lastCall()[0])); @@ -30,11 +39,17 @@ describe('container cache clients', () => { internalFetch.mockResolvedValue(new Response(null, { status: 204 })); (globalThis as { internalFetch?: unknown }).internalFetch = internalFetch; console.error = mock(); + process.env.OPEN_NEXT_BUILD_ID = BUILD_ID; }); afterEach(() => { (globalThis as { internalFetch?: unknown }).internalFetch = originalInternalFetch; console.error = originalConsoleError; + if (originalBuildId === undefined) { + delete process.env.OPEN_NEXT_BUILD_ID; + } else { + process.env.OPEN_NEXT_BUILD_ID = originalBuildId; + } }); it('reads through the intercepted cache host', async () => { @@ -52,6 +67,17 @@ describe('container cache clients', () => { expect(url.pathname).toBe('/'); expect(url.searchParams.get('key')).toBe('key with / characters'); expect(url.searchParams.get('cacheType')).toBe('cache'); + expect(url.searchParams.get('buildId')).toBe(BUILD_ID); + }); + + it('omits the build ID for entries that are not namespaced per build', async () => { + const cache = new GitbookContainerIncrementalCache(); + + await cache.get('key', 'composable'); + expect(lastUrl().searchParams.has('buildId')).toBe(false); + + await cache.set('key', fetchCacheValue, 'fetch'); + expect(lastBody().buildId).toBeUndefined(); }); it('returns null for cache misses and failed reads', async () => { @@ -70,11 +96,16 @@ describe('container cache clients', () => { await cache.set('entry', cacheValue, 'cache'); expect(lastUrl().pathname).toBe('/set'); - expect(lastBody()).toEqual({ key: 'entry', value: cacheValue, cacheType: 'cache' }); + expect(lastBody()).toEqual({ + key: 'entry', + value: cacheValue, + cacheType: 'cache', + buildId: BUILD_ID, + }); await cache.delete('entry'); expect(lastUrl().pathname).toBe('/delete'); - expect(lastBody()).toEqual({ key: 'entry' }); + expect(lastBody()).toEqual({ key: 'entry', buildId: BUILD_ID }); }); it('contains mutation failures', async () => { diff --git a/packages/gitbook/openNext/container/incrementalCache.ts b/packages/gitbook/openNext/container/incrementalCache.ts index d707f485de..f115150459 100644 --- a/packages/gitbook/openNext/container/incrementalCache.ts +++ b/packages/gitbook/openNext/container/incrementalCache.ts @@ -11,6 +11,7 @@ import { CACHE_PATH, type DeletePayload, type SetPayload, + getBuildId, getReadUrl, } from './protocol'; @@ -48,6 +49,7 @@ export class GitbookContainerIncrementalCache implements IncrementalCache { key, value: value as CacheValue, cacheType, + buildId: getBuildId(cacheType), }; try { @@ -58,7 +60,7 @@ export class GitbookContainerIncrementalCache implements IncrementalCache { } async delete(key: string): Promise { - const payload: DeletePayload = { key }; + const payload: DeletePayload = { key, buildId: getBuildId() }; try { await this.post(CACHE_PATH.delete, payload); diff --git a/packages/gitbook/openNext/container/protocol.ts b/packages/gitbook/openNext/container/protocol.ts index 93ef230d38..7b0e5100ae 100644 --- a/packages/gitbook/openNext/container/protocol.ts +++ b/packages/gitbook/openNext/container/protocol.ts @@ -6,12 +6,15 @@ import type { } from '@opennextjs/aws/types/overrides.js'; /** - * Protocol spoken between the Next.js server running inside the container and the cache worker. + * Protocol spoken to the cache worker, by both server tiers. * - * The container has no Cloudflare bindings, so it issues plain `fetch` calls to this virtual host. - * They never reach the network: the container Durable Object registers an outbound handler for the - * host, and that handler runs in the Workers runtime where the `NEXT_INC_CACHE_WORKER` service - * binding is available. + * The workerd tier reaches it through the `NEXT_INC_CACHE_WORKER` service binding. The container + * has no Cloudflare bindings, so it issues plain `fetch` calls to this virtual host instead; they + * never reach the network, because the container Durable Object registers an outbound handler for + * the host that runs in the Workers runtime where the binding is available. + * + * Both tiers build their requests here so they address a cache entry identically — the read URL is + * the cache worker's edge cache key, so any divergence would split that entry in two. */ export const CACHE_HOST = 'incremental-cache.internal'; @@ -19,10 +22,10 @@ export const CACHE_HOST = 'incremental-cache.internal'; // per-instance CA inside the image. The handler restores the `https:` scheme before forwarding. export const CACHE_ORIGIN = `http://${CACHE_HOST}`; -/** - * `read` deliberately keeps the URL shape used by the workerd tier so both tiers hit the same - * entry in the cache worker's edge cache. - */ +// What the cache worker actually sees, and so what the workerd tier sends directly. +export const CACHE_ORIGIN_SECURE = `https://${CACHE_HOST}`; + +/** `read` is built by `getReadUrl` on both tiers, so they share one edge cache entry. */ export const CACHE_PATH = { read: '/', set: '/set', @@ -35,10 +38,12 @@ export type SetPayload = { key: string; value: CacheValue; cacheType?: CacheEntryType; + buildId?: string; }; export type DeletePayload = { key: string; + buildId?: string; }; export type WriteTagsPayload = { @@ -49,11 +54,35 @@ export type QueuePayload = { msg: QueueMessage; }; -export function getReadUrl(key: string, cacheType?: CacheEntryType): URL { - const url = new URL(CACHE_PATH.read, CACHE_ORIGIN); +/** + * Build ID the calling tier's entries belong to. + * + * The cache worker namespaces `cache` entries per build but ships with the container worker, so + * during a gradual rollout of the workerd tier its own build ID is not the one the entry belongs + * to — callers have to send theirs. `fetch` and `composable` entries live in the shared `dataCache` + * namespace, so they deliberately resolve to `undefined`. + */ +export function getBuildId(cacheType?: CacheEntryType): string | undefined { + if (cacheType && cacheType !== 'cache') { + return undefined; + } + + return process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; +} + +export function getReadUrl( + key: string, + cacheType?: CacheEntryType, + origin: string = CACHE_ORIGIN +): URL { + const url = new URL(CACHE_PATH.read, origin); url.searchParams.set('key', key); if (cacheType) { url.searchParams.set('cacheType', cacheType); } + const buildId = getBuildId(cacheType); + if (buildId) { + url.searchParams.set('buildId', buildId); + } return url; } diff --git a/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts b/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts index 76fb5629e0..975ca1c31e 100644 --- a/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts +++ b/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts @@ -62,13 +62,21 @@ describe('handleCacheOutbound', () => { it('maps write paths onto the service binding RPC methods', async () => { await handleCacheOutbound( - post('/set', { key: 'entry', value: cacheValue, cacheType: 'cache' }), + post('/set', { + key: 'entry', + value: cacheValue, + cacheType: 'cache', + buildId: 'caller-build-id', + }), env() ); - expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache'); + expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache', 'caller-build-id'); - await handleCacheOutbound(post('/delete', { key: 'entry' }), env()); - expect(remove).toHaveBeenCalledWith('entry'); + await handleCacheOutbound( + post('/delete', { key: 'entry', buildId: 'caller-build-id' }), + env() + ); + expect(remove).toHaveBeenCalledWith('entry', 'caller-build-id'); await handleCacheOutbound(post('/write-tags', { tags: ['content'] }), env()); expect(writeTags).toHaveBeenCalledWith(['content']); diff --git a/packages/gitbook/openNext/customWorkers/containerOutbound.ts b/packages/gitbook/openNext/customWorkers/containerOutbound.ts index 2b7185df81..1a2b99dc3b 100644 --- a/packages/gitbook/openNext/customWorkers/containerOutbound.ts +++ b/packages/gitbook/openNext/customWorkers/containerOutbound.ts @@ -18,9 +18,10 @@ export type CacheWorkerBinding = { set( key: string, value: CacheValue, - cacheType?: CacheType + cacheType?: CacheType, + buildId?: string ): Promise; - delete(key: string): Promise; + delete(key: string, buildId?: string): Promise; writeTags(tags: NextModeTagCacheWriteInput[]): Promise; enqueueRevalidation(msg: QueueMessage): Promise; }; @@ -56,13 +57,13 @@ export async function handleCacheOutbound( return await worker.fetch(new Request(url)); } case CACHE_PATH.set: { - const { key, value, cacheType } = (await request.json()) as SetPayload; - await worker.set(key, value, cacheType); + const { key, value, cacheType, buildId } = (await request.json()) as SetPayload; + await worker.set(key, value, cacheType, buildId); return noContent(); } case CACHE_PATH.delete: { - const { key } = (await request.json()) as DeletePayload; - await worker.delete(key); + const { key, buildId } = (await request.json()) as DeletePayload; + await worker.delete(key, buildId); return noContent(); } case CACHE_PATH.writeTags: { diff --git a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts index 6a97772173..d82561389a 100644 --- a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts +++ b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.test.ts @@ -6,6 +6,7 @@ const getCloudflareContext = mock(); mock.module('@opennextjs/cloudflare', () => ({ getCloudflareContext })); const { GitbookIncrementalCache } = await import('./cacheWorkerClient'); +const { CACHE_ORIGIN_SECURE, getReadUrl } = await import('../container/protocol'); const cacheValue = { type: 'page' as const, @@ -78,6 +79,18 @@ describe('GitbookIncrementalCache cache worker client', () => { expect(set).toHaveBeenCalledWith('key', fetchCacheValue, 'fetch', undefined); }); + // The read URL is the cache worker's edge cache key, so the container tier — which builds it + // through the same `getReadUrl` — has to land on the exact same string. + it('reads through the URL shared with the container tier', async () => { + fetch.mockResolvedValue(Response.json(null)); + + await new GitbookIncrementalCache().get('entry', 'cache'); + + expect((fetch.mock.calls[0]?.[0] as Request).url).toBe( + getReadUrl('entry', 'cache', CACHE_ORIGIN_SECURE).toString() + ); + }); + it('returns null for cache misses and failed reads', async () => { fetch.mockResolvedValue(Response.json(null)); expect(await new GitbookIncrementalCache().get('missing')).toBeNull(); diff --git a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts index 13ad71ed51..e8de74d965 100644 --- a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts +++ b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts @@ -6,21 +6,9 @@ import type { } from '@opennextjs/aws/types/overrides.js'; import { getCloudflareContext } from '@opennextjs/cloudflare'; -export const BINDING_NAME = 'NEXT_INC_CACHE_WORKER'; - -/** - * The cache worker namespaces `cache` entries per build, but it ships with the container worker and - * is deployed in one go, while this tier rolls out gradually — so during a rollout its build ID is - * not the one our entries belong to and we have to send ours. `fetch` and `composable` entries live - * in the shared `dataCache` namespace, hence the `undefined`. - */ -function getBuildId(cacheType?: CacheEntryType): string | undefined { - if (cacheType && cacheType !== 'cache') { - return undefined; - } +import { CACHE_ORIGIN_SECURE, getBuildId, getReadUrl } from '../container/protocol'; - return process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; -} +export const BINDING_NAME = 'NEXT_INC_CACHE_WORKER'; type CacheWorker = { fetch(request: Request): Promise; @@ -41,15 +29,7 @@ export class GitbookIncrementalCache implements IncrementalCache { cacheType?: CacheType ): Promise> | null> { try { - const url = new URL('https://incremental-cache.internal'); - url.searchParams.set('key', key); - if (cacheType) { - url.searchParams.set('cacheType', cacheType); - } - const buildId = getBuildId(cacheType); - if (buildId) { - url.searchParams.set('buildId', buildId); - } + const url = getReadUrl(key, cacheType, CACHE_ORIGIN_SECURE); const response = await this.getWorker().fetch(new Request(url)); if (!response.ok) { From 460d08f9061b6e3c2d0be58658adf4c7abf2c8c3 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 18:21:51 +0200 Subject: [PATCH 10/13] debug log --- .../gitbook/openNext/container/protocol.ts | 13 ++++++++++ .../openNext/customWorkers/containerCache.ts | 6 +++++ .../customWorkers/containerWrangler.jsonc | 4 +++ .../customWorkers/defaultWrangler.jsonc | 4 +++ .../customWorkers/middlewareWrangler.jsonc | 4 +++ .../incrementalCache/cacheWorkerClient.ts | 25 +++++++++++++++++-- .../incrementalCache/incrementalCache.ts | 22 +++++++++++++--- 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/gitbook/openNext/container/protocol.ts b/packages/gitbook/openNext/container/protocol.ts index 7b0e5100ae..a99867b761 100644 --- a/packages/gitbook/openNext/container/protocol.ts +++ b/packages/gitbook/openNext/container/protocol.ts @@ -70,6 +70,19 @@ export function getBuildId(cacheType?: CacheEntryType): string | undefined { return process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; } +/** + * TODO: temporary. Set `DEBUG_CACHE_KEYS=true` on a worker to trace how a cache entry is + * addressed, end to end: what the caller sends, what the cache worker resolves, and whether the + * answer came from the cache worker's own response cache rather than R2. + */ +export function logCacheDebug(scope: string, fields: Record): void { + if (process.env.DEBUG_CACHE_KEYS !== 'true') { + return; + } + + console.log(`[cache-keys] ${scope} ${JSON.stringify(fields)}`); +} + export function getReadUrl( key: string, cacheType?: CacheEntryType, diff --git a/packages/gitbook/openNext/customWorkers/containerCache.ts b/packages/gitbook/openNext/customWorkers/containerCache.ts index 3f3f7c41b6..cdfbba6be3 100644 --- a/packages/gitbook/openNext/customWorkers/containerCache.ts +++ b/packages/gitbook/openNext/customWorkers/containerCache.ts @@ -10,6 +10,7 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; // @ts-ignore Generated by the Cloudflare build. import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; +import { logCacheDebug } from '../container/protocol'; import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache'; import queue from '../queue/middleware'; import tagCache from '../tagCache/middleware'; @@ -131,6 +132,11 @@ export class IncrementalCacheWorker extends WorkerEntrypoint { if (url.pathname !== INTERNAL_PATH) { url.pathname = INTERNAL_PATH; const response = await this.env.WORKER_SELF_REFERENCE.fetch(new Request(url, request)); + logCacheDebug('worker.responseCache', { + url: url.toString(), + cfCacheStatus: response.headers.get('cf-cache-status'), + age: response.headers.get('age'), + }); return restoreCacheHeaders(response); } diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index b425a7f14c..fdb470e6a9 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -103,6 +103,8 @@ "preview": { "vars": { "STAGE": "preview", + // TODO: temporary, traces incremental cache key resolution. + "DEBUG_CACHE_KEYS": "true", "CONTAINER_INSTANCES": "3", "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", @@ -179,6 +181,8 @@ "staging": { "vars": { "STAGE": "staging", + // TODO: temporary, traces incremental cache key resolution. + "DEBUG_CACHE_KEYS": "true", "CONTAINER_INSTANCES": "5", "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", diff --git a/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc b/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc index b23b053044..f59168b0b8 100644 --- a/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc @@ -61,6 +61,8 @@ "preview": { "vars": { "STAGE": "preview", + // TODO: temporary, traces incremental cache key resolution. + "DEBUG_CACHE_KEYS": "true", // Just as a test for the preview environment to check that everything works "NEXT_PRIVATE_DEBUG_CACHE": "true", }, @@ -104,6 +106,8 @@ "staging": { "vars": { "OPEN_NEXT_REQUEST_ID_HEADER": "true", + // TODO: temporary, traces incremental cache key resolution. + "DEBUG_CACHE_KEYS": "true", }, "r2_buckets": [ { diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc index 4546c68c98..e050d6999c 100644 --- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc @@ -57,6 +57,8 @@ "preview": { "vars": { "STAGE": "preview", + // TODO: temporary, traces incremental cache key resolution. + "DEBUG_CACHE_KEYS": "true", "PREVIEW_HOSTNAME": "TO_REPLACE", "WORKER_VERSION_ID": "TO_REPLACE", "SERVER_TIER": "container", @@ -119,6 +121,8 @@ "staging": { "vars": { "STAGE": "staging", + // TODO: temporary, traces incremental cache key resolution. + "DEBUG_CACHE_KEYS": "true", "WORKER_VERSION_ID": "TO_REPLACE", "OPEN_NEXT_REQUEST_ID_HEADER": "true", "SERVER_TIER": "container", diff --git a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts index e8de74d965..576ecb6029 100644 --- a/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts +++ b/packages/gitbook/openNext/incrementalCache/cacheWorkerClient.ts @@ -6,7 +6,7 @@ import type { } from '@opennextjs/aws/types/overrides.js'; import { getCloudflareContext } from '@opennextjs/cloudflare'; -import { CACHE_ORIGIN_SECURE, getBuildId, getReadUrl } from '../container/protocol'; +import { CACHE_ORIGIN_SECURE, getBuildId, getReadUrl, logCacheDebug } from '../container/protocol'; export const BINDING_NAME = 'NEXT_INC_CACHE_WORKER'; @@ -30,8 +30,23 @@ export class GitbookIncrementalCache implements IncrementalCache { ): Promise> | null> { try { const url = getReadUrl(key, cacheType, CACHE_ORIGIN_SECURE); + logCacheDebug('workerd.get', { + cacheType: cacheType ?? 'cache', + sentBuildId: url.searchParams.get('buildId'), + envOpenNextBuildId: process.env.OPEN_NEXT_BUILD_ID, + envDeploymentId: process.env.DEPLOYMENT_ID, + url: url.toString(), + }); const response = await this.getWorker().fetch(new Request(url)); + logCacheDebug('workerd.get.response', { + status: response.status, + // Set by the cache worker's response cache: a HIT here means the answer never + // reached R2, so the R2 key's build namespace was bypassed entirely. + cfCacheStatus: response.headers.get('cf-cache-status'), + age: response.headers.get('age'), + revalidated: response.headers.get('x-gitbook-cache-revalidated'), + }); if (!response.ok) { console.error('Failed to get from cache worker', response.status); return null; @@ -50,7 +65,13 @@ export class GitbookIncrementalCache implements IncrementalCache { cacheType?: CacheType ): Promise { try { - await this.getWorker().set(key, value, cacheType, getBuildId(cacheType)); + const buildId = getBuildId(cacheType); + logCacheDebug('workerd.set', { + cacheType: cacheType ?? 'cache', + sentBuildId: buildId, + envOpenNextBuildId: process.env.OPEN_NEXT_BUILD_ID, + }); + await this.getWorker().set(key, value, cacheType, buildId); } catch (error) { console.error('Failed to set to cache worker', error); } diff --git a/packages/gitbook/openNext/incrementalCache/incrementalCache.ts b/packages/gitbook/openNext/incrementalCache/incrementalCache.ts index 1636c3e606..36170e0643 100644 --- a/packages/gitbook/openNext/incrementalCache/incrementalCache.ts +++ b/packages/gitbook/openNext/incrementalCache/incrementalCache.ts @@ -8,6 +8,8 @@ import type { import { getCloudflareContext } from '@opennextjs/cloudflare'; import { createHash } from 'node:crypto'; +import { logCacheDebug } from '../container/protocol'; + export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; export const DEFAULT_PREFIX = 'incremental-cache'; @@ -135,9 +137,21 @@ export class GitbookIncrementalCache implements IncrementalCache { const hash = createHash('sha256').update(key).digest('hex'); const buildId = this.buildId ?? process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID; - return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? buildId : 'dataCache'}/${hash}.${cacheType}`.replace( - /\/+/g, - '/' - ); + const r2Key = + `${DEFAULT_PREFIX}/${cacheType === 'cache' ? buildId : 'dataCache'}/${hash}.${cacheType}`.replace( + /\/+/g, + '/' + ); + + logCacheDebug('worker.r2Key', { + cacheType, + callerBuildId: this.buildId, + workerEnvBuildId: process.env.OPEN_NEXT_BUILD_ID, + workerEnvDeploymentId: process.env.DEPLOYMENT_ID, + usedBuildId: buildId, + r2Key, + }); + + return r2Key; } } From f6e2581c0c7963852d1ede5758624a70a3b2e5c6 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 19:44:31 +0200 Subject: [PATCH 11/13] Add headSha input to Cloudflare deployment action for improved deployment ID handling --- .github/composite/deploy-cloudflare/action.yaml | 6 ++++++ .github/workflows/deploy-preview.yaml | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 1a33c4b583..eab56c43f3 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -25,6 +25,9 @@ inputs: commitMessage: description: 'Commit message to associate with the deployment' required: true + headSha: + description: 'Git ref being deployed, used for the deploymentId. Falls back to GITHUB_SHA' + required: false outputs: deployment-url: description: 'Deployment URL' @@ -69,6 +72,9 @@ runs: run: bun run turbo build:all env: GITBOOK_RUNTIME: cloudflare + # `pull_request_target` sets GITHUB_SHA to the base branch tip, identical for every + # commit of a PR, which would keep the deployment ID (and its cache) unchanged. + GITBOOK_HEAD_SHA: ${{ inputs.headSha }} VERCEL_TARGET_ENV: ${{ inputs.environment }} GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }} diff --git a/.github/workflows/deploy-preview.yaml b/.github/workflows/deploy-preview.yaml index 4b0cae96c8..87b0061a13 100644 --- a/.github/workflows/deploy-preview.yaml +++ b/.github/workflows/deploy-preview.yaml @@ -68,6 +68,7 @@ jobs: opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }} commitMessage: ${{ github.sha }} + headSha: ${{ github.event.pull_request.head.sha || github.sha }} - name: Extract Worker ID id: extract-worker-id if: ${{ !steps.deploy.outputs.deployment-url }} From 6499329758e747659c944aa1dde880393f369741 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 19:59:34 +0200 Subject: [PATCH 12/13] use similar sized to vercel ones --- .../customWorkers/containerWrangler.jsonc | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index fdb470e6a9..b32d1f39e1 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -46,7 +46,12 @@ "image": "./Dockerfile", // Relative to this config file, so the Dockerfile can COPY .open-next-container. "image_build_context": "../..", - "instance_type": "standard-2", + "instance_type": { + // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + "vcpu": 2, + "memory_mib": 4096, + "disk_mb": 8192, + }, "max_instances": 1, }, ], @@ -114,7 +119,12 @@ "class_name": "NextServerContainer", "image": "./Dockerfile", "image_build_context": "../..", - "instance_type": "standard-2", + "instance_type": { + // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + "vcpu": 2, + "memory_mib": 4096, + "disk_mb": 8192, + }, "max_instances": 3, }, ], @@ -192,7 +202,12 @@ "class_name": "NextServerContainer", "image": "./Dockerfile", "image_build_context": "../..", - "instance_type": "standard-1", + "instance_type": { + // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + "vcpu": 2, + "memory_mib": 4096, + "disk_mb": 8192, + }, "max_instances": 5, }, ], @@ -267,7 +282,12 @@ "class_name": "NextServerContainer", "image": "./Dockerfile", "image_build_context": "../..", - "instance_type": "standard-1", + "instance_type": { + // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + "vcpu": 2, + "memory_mib": 4096, + "disk_mb": 8192, + }, // The middleware does not route production traffic here yet (`SERVER_TIER` is // unset there), this is headroom for when it does. "max_instances": 10, From 00b5606e8da5a94a0b7c7cd2c99f4282df91081b Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 20:07:09 +0200 Subject: [PATCH 13/13] Update memory allocation for Cloudflare's container instances to meet minimum requirements --- .../customWorkers/containerWrangler.jsonc | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index b32d1f39e1..5dc1a33fdc 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -47,9 +47,10 @@ // Relative to this config file, so the Dockerfile can COPY .open-next-container. "image_build_context": "../..", "instance_type": { - // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + // Vercel's "Performance" tier is 2 vCPU / 4 GiB, but Cloudflare requires at + // least 3 GiB per vCPU, so 6 GiB is the floor at 2 vCPU. "vcpu": 2, - "memory_mib": 4096, + "memory_mib": 6144, "disk_mb": 8192, }, "max_instances": 1, @@ -120,9 +121,10 @@ "image": "./Dockerfile", "image_build_context": "../..", "instance_type": { - // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + // Vercel's "Performance" tier is 2 vCPU / 4 GiB, but Cloudflare requires at + // least 3 GiB per vCPU, so 6 GiB is the floor at 2 vCPU. "vcpu": 2, - "memory_mib": 4096, + "memory_mib": 6144, "disk_mb": 8192, }, "max_instances": 3, @@ -203,9 +205,10 @@ "image": "./Dockerfile", "image_build_context": "../..", "instance_type": { - // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + // Vercel's "Performance" tier is 2 vCPU / 4 GiB, but Cloudflare requires at + // least 3 GiB per vCPU, so 6 GiB is the floor at 2 vCPU. "vcpu": 2, - "memory_mib": 4096, + "memory_mib": 6144, "disk_mb": 8192, }, "max_instances": 5, @@ -283,9 +286,10 @@ "image": "./Dockerfile", "image_build_context": "../..", "instance_type": { - // Matches Vercel's "Performance" tier: 2 vCPU, 4 GiB memory. + // Vercel's "Performance" tier is 2 vCPU / 4 GiB, but Cloudflare requires at + // least 3 GiB per vCPU, so 6 GiB is the floor at 2 vCPU. "vcpu": 2, - "memory_mib": 4096, + "memory_mib": 6144, "disk_mb": 8192, }, // The middleware does not route production traffic here yet (`SERVER_TIER` is