Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cache-tier-in-container-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Move the incremental cache tier from the DO worker into the container worker.
5 changes: 5 additions & 0 deletions .changeset/container-server-tier.md
Original file line number Diff line number Diff line change
@@ -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. Build it with `bun run build:all` and run it locally with `bun run dev:cf:container`.
5 changes: 5 additions & 0 deletions .changeset/deploy-container-tier.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 43 additions & 43 deletions .github/actions/gradual-deploy-cloudflare/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,59 +21,59 @@ 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
- 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
Expand Down
28 changes: 27 additions & 1 deletion .github/composite/deploy-cloudflare/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -63,10 +66,15 @@ 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
# `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' || '' }}
Expand Down Expand Up @@ -94,6 +102,24 @@ 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 (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:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
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
uses: cloudflare/wrangler-action@v3.14.0
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-preview.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/gitbook/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/gitbook/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ screenshots/

# cloudflare
.open-next
.open-next-container
.wrangler
worker-configuration.d.ts
30 changes: 30 additions & 0 deletions packages/gitbook/open-next.container.config.ts
Original file line number Diff line number Diff line change
@@ -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;
142 changes: 142 additions & 0 deletions packages/gitbook/openNext/container/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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');

const cacheValue = {
type: 'page' as const,
html: '<p>cached</p>',
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' },
MessageGroupId: 'group',
};

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]));
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();
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 () => {
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');
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 () => {
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',
buildId: BUILD_ID,
});

await cache.delete('entry');
expect(lastUrl().pathname).toBe('/delete');
expect(lastBody()).toEqual({ key: 'entry', buildId: BUILD_ID });
});

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 });
});
});
12 changes: 12 additions & 0 deletions packages/gitbook/openNext/container/fetch.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading