Add container server tier with cache integration and build configuration

This commit is contained in:
Nicolas Dorseuil
2026-08-31 13:33:08 +02:00
parent f80f81032f
commit 4e93131235
22 changed files with 748 additions and 4 deletions
+5
View File
@@ -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`).
+3
View File
@@ -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=="],
+1
View File
@@ -35,5 +35,6 @@ screenshots/
# cloudflare
.open-next
.open-next-container
.wrangler
worker-configuration.d.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;
@@ -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: '<p>cached</p>',
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 });
});
});
@@ -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<Response> {
const untouchedFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch ?? fetch;
return untouchedFetch(input as RequestInfo, init);
}
@@ -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<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | 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<CacheValue<CacheType>> | null;
} catch (error) {
console.error('Failed to get from cache worker', error);
return null;
}
}
async set<CacheType extends CacheEntryType = 'cache'>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void> {
const payload: SetPayload = {
key,
value: value as CacheValue<CacheEntryType>,
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<void> {
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<void> {
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();
@@ -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<CacheEntryType>;
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;
}
@@ -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;
@@ -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;
@@ -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"]
@@ -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<NextServerContainer>;
CONTAINER_INSTANCES?: string;
};
const DEFAULT_INSTANCES = 3;
function getStringVars(env: unknown): Record<string, string> {
return Object.fromEntries(
Object.entries(env as Record<string, unknown>).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<string, OutboundHandler>;
export default class extends WorkerEntrypoint<ContainerWorkerEnv> {
async fetch(request: Request): Promise<Response> {
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);
}
}
@@ -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: '<p>cached</p>',
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<unknown>()).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);
});
});
@@ -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<Response>;
set<CacheType extends CacheEntryType>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void>;
delete(key: string): Promise<void>;
writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
enqueueRevalidation(msg: QueueMessage): Promise<void>;
};
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<Response> {
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 });
}
}
@@ -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",
},
],
},
},
}
+13 -1
View File
@@ -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<CacheWorker
});
}
/**
* 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<void> {
await this.#runRpcOperation(async () => {
await queue.send(msg);
});
}
async #runRpcOperation(operation: () => Promise<void>): Promise<void> {
await runWithCloudflareRequestContext(
new Request('https://incremental-cache.internal'),
@@ -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,
@@ -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": {
+5 -1
View File
@@ -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",
+6 -1
View File
@@ -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;
}
}
+7
View File
@@ -23,6 +23,13 @@ export async function waitUntil(promise: Promise<unknown>) {
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) => {
+16
View File
@@ -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"]