mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 23:25:16 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00b5606e8d | |||
| 6499329758 | |||
| f6e2581c0c | |||
| 460d08f906 | |||
| 4a02f872f8 | |||
| ee8762e065 | |||
| 82ab9bb091 | |||
| f36c3aead3 | |||
| b7c5dd3130 | |||
| 72bf155251 | |||
| 60bc6914f9 | |||
| 4e93131235 | |||
| f80f81032f |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Move the incremental cache tier from the DO worker into the container worker.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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' || '' }}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
@@ -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,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 });
|
||||
});
|
||||
});
|
||||
@@ -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,85 @@
|
||||
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,
|
||||
getBuildId,
|
||||
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,
|
||||
buildId: getBuildId(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, buildId: getBuildId() };
|
||||
|
||||
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,101 @@
|
||||
import type {
|
||||
CacheEntryType,
|
||||
CacheValue,
|
||||
NextModeTagCacheWriteInput,
|
||||
QueueMessage,
|
||||
} from '@opennextjs/aws/types/overrides.js';
|
||||
|
||||
/**
|
||||
* Protocol spoken to the cache worker, by both server tiers.
|
||||
*
|
||||
* 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';
|
||||
|
||||
// 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}`;
|
||||
|
||||
// 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',
|
||||
delete: '/delete',
|
||||
writeTags: '/write-tags',
|
||||
queue: '/queue',
|
||||
} as const;
|
||||
|
||||
export type SetPayload = {
|
||||
key: string;
|
||||
value: CacheValue<CacheEntryType>;
|
||||
cacheType?: CacheEntryType;
|
||||
buildId?: string;
|
||||
};
|
||||
|
||||
export type DeletePayload = {
|
||||
key: string;
|
||||
buildId?: string;
|
||||
};
|
||||
|
||||
export type WriteTagsPayload = {
|
||||
tags: NextModeTagCacheWriteInput[];
|
||||
};
|
||||
|
||||
export type QueuePayload = {
|
||||
msg: QueueMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>): void {
|
||||
if (process.env.DEBUG_CACHE_KEYS !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[cache-keys] ${scope} ${JSON.stringify(fields)}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,63 @@
|
||||
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';
|
||||
|
||||
// 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<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);
|
||||
}
|
||||
}
|
||||
+35
-1
@@ -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 }));
|
||||
@@ -22,7 +26,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';
|
||||
@@ -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() });
|
||||
|
||||
@@ -129,6 +146,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 <T>(_: Request, __: unknown, ___: unknown, operation: () => Promise<T>) => {
|
||||
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')
|
||||
@@ -0,0 +1,230 @@
|
||||
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 { logCacheDebug } from '../container/protocol';
|
||||
import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache';
|
||||
import queue from '../queue/middleware';
|
||||
import tagCache from '../tagCache/middleware';
|
||||
|
||||
type CacheWorkerEnv = {
|
||||
WORKER_SELF_REFERENCE: {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
};
|
||||
};
|
||||
|
||||
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<T>(
|
||||
request: Request,
|
||||
env: CacheWorkerEnv,
|
||||
ctx: ExecutionContext,
|
||||
handler: () => Promise<T>
|
||||
): Promise<T> {
|
||||
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<CacheEntryType>(['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<CacheEntryType>, 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<CacheWorkerEnv> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
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));
|
||||
logCacheDebug('worker.responseCache', {
|
||||
url: url.toString(),
|
||||
cfCacheStatus: response.headers.get('cf-cache-status'),
|
||||
age: response.headers.get('age'),
|
||||
});
|
||||
return restoreCacheHeaders(response);
|
||||
}
|
||||
|
||||
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(buildId).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<CacheType extends CacheEntryType>(
|
||||
key: string,
|
||||
value: CacheValue<CacheType>,
|
||||
cacheType?: CacheType,
|
||||
buildId?: string
|
||||
): Promise<void> {
|
||||
await this.#runRpcOperation(async () => {
|
||||
await new GitbookIncrementalCache(buildId).set(key, value, cacheType);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string, buildId?: string): Promise<void> {
|
||||
await this.#runRpcOperation(async () => {
|
||||
await new GitbookIncrementalCache(buildId).delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
async writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void> {
|
||||
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<void> {
|
||||
await this.#runRpcOperation(async () => {
|
||||
await queue.send(msg);
|
||||
});
|
||||
}
|
||||
|
||||
async #runRpcOperation(operation: () => Promise<void>): Promise<void> {
|
||||
await runWithCacheContext(
|
||||
new Request('https://incremental-cache.internal'),
|
||||
this.env,
|
||||
this.ctx,
|
||||
async () => {
|
||||
await operation();
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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',
|
||||
buildId: 'caller-build-id',
|
||||
}),
|
||||
env()
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache', 'caller-build-id');
|
||||
|
||||
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']);
|
||||
|
||||
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,86 @@
|
||||
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,
|
||||
buildId?: string
|
||||
): Promise<void>;
|
||||
delete(key: string, buildId?: 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, buildId } = (await request.json()) as SetPayload;
|
||||
await worker.set(key, value, cacheType, buildId);
|
||||
return noContent();
|
||||
}
|
||||
case CACHE_PATH.delete: {
|
||||
const { key, buildId } = (await request.json()) as DeletePayload;
|
||||
await worker.delete(key, buildId);
|
||||
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,356 @@
|
||||
{
|
||||
"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,
|
||||
},
|
||||
// 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
|
||||
// (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": {
|
||||
// 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": 6144,
|
||||
"disk_mb": 8192,
|
||||
},
|
||||
"max_instances": 1,
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["NextServerContainer"],
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"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",
|
||||
},
|
||||
],
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"class_name": "NextServerContainer",
|
||||
"image": "./Dockerfile",
|
||||
"image_build_context": "../..",
|
||||
"instance_type": {
|
||||
// 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": 6144,
|
||||
"disk_mb": 8192,
|
||||
},
|
||||
"max_instances": 3,
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["NextServerContainer"],
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"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": {
|
||||
"traces": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1,
|
||||
},
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"class_name": "NextServerContainer",
|
||||
"image": "./Dockerfile",
|
||||
"image_build_context": "../..",
|
||||
"instance_type": {
|
||||
// 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": 6144,
|
||||
"disk_mb": 8192,
|
||||
},
|
||||
"max_instances": 5,
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["NextServerContainer"],
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"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": [
|
||||
{
|
||||
"service": "gitbook-x-staging-tail",
|
||||
},
|
||||
],
|
||||
},
|
||||
"production": {
|
||||
"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": [
|
||||
{
|
||||
"class_name": "NextServerContainer",
|
||||
"image": "./Dockerfile",
|
||||
"image_build_context": "../..",
|
||||
"instance_type": {
|
||||
// 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": 6144,
|
||||
"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,
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["NextServerContainer"],
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"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": [
|
||||
{
|
||||
"service": "gitbook-x-prod-tail",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -60,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",
|
||||
},
|
||||
@@ -76,7 +79,8 @@
|
||||
},
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"service": "gitbook-open-v2-do-preview",
|
||||
"service": "gitbook-open-v2-container-preview",
|
||||
"entrypoint": "IncrementalCacheWorker",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
@@ -102,6 +106,8 @@
|
||||
"staging": {
|
||||
"vars": {
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
// TODO: temporary, traces incremental cache key resolution.
|
||||
"DEBUG_CACHE_KEYS": "true",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -116,7 +122,8 @@
|
||||
},
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"service": "gitbook-open-v2-do-staging",
|
||||
"service": "gitbook-open-v2-container-staging",
|
||||
"entrypoint": "IncrementalCacheWorker",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
@@ -164,7 +171,8 @@
|
||||
},
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"service": "gitbook-open-v2-do-production",
|
||||
"service": "gitbook-open-v2-container-production",
|
||||
"entrypoint": "IncrementalCacheWorker",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
|
||||
import type {
|
||||
CacheEntryType,
|
||||
CacheValue,
|
||||
NextModeTagCacheWriteInput,
|
||||
} 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';
|
||||
|
||||
// @ts-ignore Generated by the Cloudflare build.
|
||||
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||
import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache';
|
||||
import tagCache from '../tagCache/middleware';
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
|
||||
type CacheWorkerEnv = {
|
||||
NEXT_INC_CACHE_R2_BUCKET: {
|
||||
put(key: string, value: string): Promise<unknown>;
|
||||
};
|
||||
WORKER_SELF_REFERENCE: {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
};
|
||||
};
|
||||
|
||||
//@ts-ignore - Just to avoid tag cache crashing
|
||||
globalThis.openNextConfig = {
|
||||
dangerous: {
|
||||
enableCacheInterception: true,
|
||||
},
|
||||
};
|
||||
|
||||
const cacheEntryTypes = new Set<CacheEntryType>(['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<CacheEntryType>, 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<CacheWorkerEnv> {
|
||||
private writePromise: Promise<unknown> | undefined;
|
||||
@@ -113,87 +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<CacheWorkerEnv> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
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<CacheType extends CacheEntryType>(
|
||||
key: string,
|
||||
value: CacheValue<CacheType>,
|
||||
cacheType?: CacheType
|
||||
): Promise<void> {
|
||||
await this.#runRpcOperation(async () => {
|
||||
await new GitbookIncrementalCache().set(key, value, cacheType);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.#runRpcOperation(async () => {
|
||||
await new GitbookIncrementalCache().delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
async writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void> {
|
||||
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) });
|
||||
});
|
||||
}
|
||||
|
||||
async #runRpcOperation(operation: () => Promise<void>): Promise<void> {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
"observability": {
|
||||
"enabled": false,
|
||||
},
|
||||
"cache": {
|
||||
"enabled": true,
|
||||
},
|
||||
"placement": {
|
||||
"region": "gcp:us-central1",
|
||||
},
|
||||
|
||||
@@ -43,13 +43,24 @@ export default class extends WorkerEntrypoint {
|
||||
return reqOrResp;
|
||||
}
|
||||
|
||||
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, {
|
||||
// `container` routes to the Next server running inside a Cloudflare Container,
|
||||
// `worker` (the default) to the workerd server.
|
||||
const isContainerTier = this.env.SERVER_TIER === 'container';
|
||||
const serverWorker = isContainerTier
|
||||
? this.env.CONTAINER_WORKER
|
||||
: this.env.DEFAULT_WORKER;
|
||||
|
||||
// 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: {
|
||||
cacheEverything: false,
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"GITBOOK_URL": "http://localhost:8771",
|
||||
// Unset (or any other value) routes to DEFAULT_WORKER instead.
|
||||
"SERVER_TIER": "container",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -43,15 +45,23 @@
|
||||
},
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"service": "gitbook-open-v2-do-dev",
|
||||
"service": "gitbook-open-v2-container-dev",
|
||||
"entrypoint": "IncrementalCacheWorker",
|
||||
},
|
||||
{
|
||||
"binding": "CONTAINER_WORKER",
|
||||
"service": "gitbook-open-v2-container-dev",
|
||||
},
|
||||
],
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -70,7 +80,12 @@
|
||||
},
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"service": "gitbook-open-v2-do-preview",
|
||||
"service": "gitbook-open-v2-container-preview",
|
||||
"entrypoint": "IncrementalCacheWorker",
|
||||
},
|
||||
{
|
||||
"binding": "CONTAINER_WORKER",
|
||||
"service": "gitbook-open-v2-container-preview",
|
||||
},
|
||||
],
|
||||
"durable_objects": {
|
||||
@@ -106,8 +121,11 @@
|
||||
"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",
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
@@ -136,7 +154,12 @@
|
||||
},
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_WORKER",
|
||||
"service": "gitbook-open-v2-do-staging",
|
||||
"service": "gitbook-open-v2-container-staging",
|
||||
"entrypoint": "IncrementalCacheWorker",
|
||||
},
|
||||
{
|
||||
"binding": "CONTAINER_WORKER",
|
||||
"service": "gitbook-open-v2-container-staging",
|
||||
},
|
||||
],
|
||||
"tail_consumers": [
|
||||
@@ -209,7 +232,14 @@
|
||||
},
|
||||
{
|
||||
"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.
|
||||
{
|
||||
"binding": "CONTAINER_WORKER",
|
||||
"service": "gitbook-open-v2-container-production",
|
||||
},
|
||||
],
|
||||
"tail_consumers": [
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const BUILD_ID = 'caller-build-id';
|
||||
|
||||
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,
|
||||
@@ -11,13 +14,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 +42,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 +64,31 @@ 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);
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
@@ -69,8 +110,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 () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
} from '@opennextjs/aws/types/overrides.js';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
|
||||
import { CACHE_ORIGIN_SECURE, getBuildId, getReadUrl, logCacheDebug } from '../container/protocol';
|
||||
|
||||
export const BINDING_NAME = 'NEXT_INC_CACHE_WORKER';
|
||||
|
||||
type CacheWorker = {
|
||||
@@ -13,9 +15,10 @@ type CacheWorker = {
|
||||
set<CacheType extends CacheEntryType>(
|
||||
key: string,
|
||||
value: CacheValue<CacheType>,
|
||||
cacheType?: CacheType
|
||||
cacheType?: CacheType,
|
||||
buildId?: string
|
||||
): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
delete(key: string, buildId?: string): Promise<void>;
|
||||
};
|
||||
|
||||
export class GitbookIncrementalCache implements IncrementalCache {
|
||||
@@ -26,13 +29,24 @@ export class GitbookIncrementalCache implements IncrementalCache {
|
||||
cacheType?: CacheType
|
||||
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||
try {
|
||||
const url = new URL('https://incremental-cache.internal');
|
||||
url.searchParams.set('key', key);
|
||||
if (cacheType) {
|
||||
url.searchParams.set('cacheType', cacheType);
|
||||
}
|
||||
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;
|
||||
@@ -51,7 +65,13 @@ export class GitbookIncrementalCache implements IncrementalCache {
|
||||
cacheType?: CacheType
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.getWorker().set(key, value, 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);
|
||||
}
|
||||
@@ -59,7 +79,7 @@ export class GitbookIncrementalCache implements IncrementalCache {
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.getWorker().delete(key);
|
||||
await this.getWorker().delete(key, getBuildId());
|
||||
} catch (error) {
|
||||
console.error('Failed to delete from cache worker', error);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -23,6 +25,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<CacheType extends CacheEntryType = 'cache'>(
|
||||
key: string,
|
||||
cacheType?: CacheType
|
||||
@@ -128,10 +136,22 @@ 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;
|
||||
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? buildId : 'dataCache'}/${hash}.${cacheType}`.replace(
|
||||
/\/+/g,
|
||||
'/'
|
||||
);
|
||||
const buildId = this.buildId ?? process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user