From 4e93131235347b21049776f70ca603e4244792b5 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 13:33:08 +0200 Subject: [PATCH] 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 000000000..0e377ddae --- /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 0d86f55ff..2b09ca847 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 f7a6b3475..3047cd1e5 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 000000000..c6ec62d91 --- /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 000000000..99bfe88c8 --- /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 000000000..43a4f3933 --- /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 000000000..d707f485d --- /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 000000000..93ef230d3 --- /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 000000000..7d4ce1890 --- /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 000000000..e8ac08a05 --- /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 000000000..9ccdc283b --- /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 000000000..ea0c837a6 --- /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 000000000..76fb5629e --- /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 000000000..2b7185df8 --- /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 000000000..bffa3971f --- /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 dbe062862..87301a1e9 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 0a519d278..48b0958ff 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 5323b8a39..cd4060648 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 b738635c0..feb531057 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 333bc25dd..6f53e87c7 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 359e0100e..a3c3e47f3 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 d9031e75c..3d57eed25 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"]