Compare commits

...

25 Commits

Author SHA1 Message Date
Nicolas Dorseuil 00b5606e8d Update memory allocation for Cloudflare's container instances to meet minimum requirements 2026-08-31 20:07:09 +02:00
Nicolas Dorseuil 6499329758 use similar sized to vercel ones 2026-08-31 19:59:34 +02:00
Nicolas Dorseuil f6e2581c0c Add headSha input to Cloudflare deployment action for improved deployment ID handling 2026-08-31 19:44:31 +02:00
Nicolas Dorseuil 460d08f906 debug log 2026-08-31 18:21:51 +02:00
Nicolas Dorseuil 4a02f872f8 Add build ID handling to cache operations and update related tests
- Introduced build ID management in cache set and delete operations to ensure entries are namespaced correctly.
- Updated the `getReadUrl` function to include build ID in the request URL.
- Enhanced tests to validate build ID inclusion in cache operations.
2026-08-31 17:58:02 +02:00
Nicolas Dorseuil ee8762e065 Send the server tier's build ID to the cache worker to ensure incremental cache entries are namespaced correctly. 2026-08-31 17:44:07 +02:00
Nicolas Dorseuil 82ab9bb091 Update instance type from standard-1 to standard-2 in container configuration 2026-08-31 17:13:49 +02:00
Nicolas Dorseuil f36c3aead3 Update instance type from standard-1 to standard-2 in container configuration 2026-08-31 16:19:21 +02:00
Nicolas Dorseuil b7c5dd3130 Add --x-provision=false to container worker deployment command to prevent unnecessary resource provisioning 2026-08-31 15:57:51 +02:00
Nicolas Dorseuil 72bf155251 Move incremental cache tier from DO worker to container worker, refactor related configurations and tests. 2026-08-31 15:39:40 +02:00
Nicolas Dorseuil 60bc6914f9 Deploy container server tier with CI integration for preview, staging, and production environments 2026-08-31 14:43:04 +02:00
Nicolas Dorseuil 4e93131235 Add container server tier with cache integration and build configuration 2026-08-31 13:33:08 +02:00
Nicolas Dorseuil f80f81032f Refactor action.yaml for improved readability and consistency in step formatting 2026-08-20 10:42:28 +02:00
Nicolas Dorseuil 5356cd9e10 fix rebase 2026-08-19 12:55:56 +02:00
Nicolas Dorseuil 0af066bed8 temporarily add observability for debugging 2026-08-19 11:16:37 +02:00
Nicolas Dorseuil 26b2a6917d Update wrangler version to 4.122.0 in action.yaml, bun.lock, and package.json 2026-08-19 11:16:14 +02:00
Nicolas Dorseuil 82cf22449f script to remove 2026-08-19 11:15:26 +02:00
Nicolas Dorseuil 13eed34ff7 Add tests for IncrementalCacheWorker and enhance cache header management 2026-08-19 11:15:26 +02:00
Nicolas Dorseuil 6733ac3fa0 Enhance deployment process by extracting deployment ID and updating cache key logic; add tests for GitbookIncrementalCache 2026-08-19 11:15:04 +02:00
Nicolas Dorseuil 497c5d325a workaround for opennext config 2026-08-19 11:14:24 +02:00
Nicolas Dorseuil 264f4cc939 Refactor tag cache implementation to use cacheWorkerClient; add tests for tagCache functionality 2026-08-19 11:14:24 +02:00
Nicolas Dorseuil 528d4a2475 Update @cloudflare/workers-types dependency to version 5.20260716.1 2026-08-19 11:13:55 +02:00
Nicolas Dorseuil ad1e0e52af Enhance IncrementalCacheWorker to include Cache-Control and Cache-Tag headers; update compatibility date in doWrangler.jsonc 2026-08-19 11:12:59 +02:00
Nicolas Dorseuil d7b3aa97cd Update wrangler dependency to version 4.110.0 2026-08-19 11:12:59 +02:00
Nicolas Dorseuil 4acb8538f8 Refactor cache worker implementation and update configurations for incremental caching 2026-08-19 11:12:59 +02:00
45 changed files with 2496 additions and 123 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Move the incremental cache tier from the DO worker into the container worker.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Build it with `bun run build:all` and run it locally with `bun run dev:cf:container`.
+5
View File
@@ -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.
@@ -30,7 +30,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -49,7 +49,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
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
@@ -61,7 +61,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -71,7 +71,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -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,23 +66,59 @@ 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' || '' }}
shell: bash
- id: extract_deployment_id
name: Extract Next deployment ID
shell: bash
run: |
deployment_id=$(bun -e '
const buildOutput = await Bun.file("packages/gitbook/.open-next/server-functions/default/packages/gitbook/.next/required-server-files.json").json();
const deploymentId = buildOutput.config?.deploymentId;
if (!deploymentId) throw new Error("Next build did not emit a deployment ID");
console.log(deploymentId);
')
echo "deployment_id=$deployment_id" >> $GITHUB_OUTPUT
- name: Upload the DO worker
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
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
@@ -88,7 +127,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -111,7 +150,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+1
View File
@@ -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 }}
+84 -15
View File
@@ -113,7 +113,8 @@
"version": "0.27.2",
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
"@cloudflare/containers": "^0.3.7",
"@cloudflare/workers-types": "^5.20260716.1",
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
@@ -223,7 +224,7 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.79.0",
"wrangler": "^4.122.0",
},
},
"packages/icons": {
@@ -580,21 +581,23 @@
"@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
"@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260401.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw=="],
"@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=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260401.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260811.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260401.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MDWUH/0bvL/l9aauN8zEddyYOXId1OueqrUCXXENNJ95R/lSmF6OgGVuXaYhoIhxQkNiEJ/0NOlnVYj9mJq4dw=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260811.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260401.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UgkzpMzVWM/bwbo3vjCTg2aoKfGcUhiEoQoDdo6RGWvbHRJyLVZ4VQCG9ZcISiztkiS2ICCoYOtPy6M/lV6Gcw=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260811.1", "", { "os": "linux", "cpu": "x64" }, "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260401.1", "", { "os": "win32", "cpu": "x64" }, "sha512-HBLzcQF5iF4Qv20tQ++pG7xs3OsCnaIbc+GAi6fmhUKZhvmzvml/jwrQzLJ+MPm0cQo41K5OO/U3T4S8tvJetQ=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260811.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251011.0", "", {}, "sha512-gQpih+pbq3sP4uXltUeCSbPgZxTNp2gQd8639SaIbQMwgA6oJNHLhIART1fWy6DQACngiRzDVULA2x0ohmkGTQ=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260811.1", "", { "os": "win32", "cpu": "x64" }, "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260716.1", "", {}, "sha512-LqQPmGAvdpQxzZGAMlDI6fnCsTlr8nRQibWsREaERqD0PucwFl25aXpUskSob70uSY2n6K1sp6Te9xkmzSFgaw=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-sFAphGQIqyQZfP2ZBsSHV7xQvo9Py0rV0dW7W3IMRdS+zDuNb2l3no78CvUaWKGfzFjI4FTrLdUSj86IGb2hRA=="],
@@ -2612,7 +2615,7 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"miniflare": ["miniflare@4.20260401.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260401.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-lngHPzZFN9sxYG/mhzvnWiBMNVAN5MsO/7g32ttJ07rymtiK/ZBalODTKb8Od+BQdlU5DOR4CjVt9NydjnUyYg=="],
"miniflare": ["miniflare@5.20260811.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260811.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-sypXsD5fjY88fZNedPqnwrwR1dwfnfbfW7MfvMyIfPJdtRiCCOpUnjWGeFVYYZ+0fQVICye6Juu+vZgzTEx8XA=="],
"minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
@@ -3260,9 +3263,9 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"workerd": ["workerd@1.20260401.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260401.1", "@cloudflare/workerd-darwin-arm64": "1.20260401.1", "@cloudflare/workerd-linux-64": "1.20260401.1", "@cloudflare/workerd-linux-arm64": "1.20260401.1", "@cloudflare/workerd-windows-64": "1.20260401.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-mUYCd+ohaWJWF5nhDzxugWaAD/DM8Dw0ze3B7bu8JaA7S70+XQJXcvcvwE8C4qGcxSdCyqjsrFzqxKubECDwzg=="],
"workerd": ["workerd@1.20260811.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260811.1", "@cloudflare/workerd-darwin-arm64": "1.20260811.1", "@cloudflare/workerd-linux-64": "1.20260811.1", "@cloudflare/workerd-linux-arm64": "1.20260811.1", "@cloudflare/workerd-windows-64": "1.20260811.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw=="],
"wrangler": ["wrangler@4.80.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260401.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260401.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260401.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-2ZKF7uPeOZy65BGk3YfvqBCPo/xH1MrAlMmH9mVP+tCNBrTUMnwOHSj1HrZHgR8LttkAqhko0fGz+I4ax1rzyQ=="],
"wrangler": ["wrangler@4.122.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260811.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260811.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260811.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-qkskzgQ76Y1qvVe5JARgvc3RISq6BC2rPoxQhFoKH1dKIwQc3GDFttQ/7m2OfeQ+tmQRzynv2dy/DXnxFCj2Lw=="],
"wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="],
@@ -3272,7 +3275,7 @@
"write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"xdg-app-paths": ["xdg-app-paths@5.1.0", "", { "dependencies": { "xdg-portable": "^7.0.0" } }, "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA=="],
@@ -3830,7 +3833,9 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"miniflare/sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
"miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -4308,6 +4313,62 @@
"micro/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"miniflare/sharp/@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
"miniflare/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
"miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
"miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
"miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
"miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
"miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
"miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
"miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
"miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
"miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
"miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
"miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
"miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
"miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
"miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
"miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
"miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
"miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
"miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
"miniflare/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
"miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
"miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
"miniflare/sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"miniflare/sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"next/postcss/nanoid": ["nanoid@3.3.7", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="],
"next/postcss/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
@@ -4440,6 +4501,10 @@
"micro/raw-body/http-errors/toidentifier": ["toidentifier@1.0.0", "", {}, "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="],
"miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
"miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -4474,6 +4539,10 @@
"@zenuml/core/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@opennextjs/aws/express/body-parser/raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+4
View File
@@ -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
View File
@@ -35,5 +35,6 @@ screenshots/
# cloudflare
.open-next
.open-next-container
.wrangler
worker-configuration.d.ts
+1 -5
View File
@@ -21,11 +21,7 @@ const allowedDevOrigins =
]
: undefined;
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
let deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
? undefined
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
let deploymentId = process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
const { VERCEL_TARGET_ENV } = process.env;
+4 -4
View File
@@ -8,8 +8,8 @@ export default {
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/server').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
import('./openNext/incrementalCache/cacheWorkerClient').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/cacheWorkerClient').then((m) => m.default),
},
},
middleware: {
@@ -20,8 +20,8 @@ export default {
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/middleware').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
import('./openNext/incrementalCache/cacheWorkerClient').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/cacheWorkerClient').then((m) => m.default),
},
},
dangerous: {
@@ -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);
}
}
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
const runWithCloudflareRequestContext = mock(
async <T>(_: Request, __: unknown, ___: unknown, operation: () => Promise<T>) => operation()
);
const get = mock();
const getTagsFromValue = mock();
const hasBeenRevalidated = mock();
mock.module('cloudflare:workers', () => ({
DurableObject: class {},
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 }));
mock.module('../tagCache/middleware', () => ({
default: { hasBeenRevalidated },
}));
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';
const cacheValue = {
type: 'page' as const,
html: '<p>cached</p>',
json: {},
revalidate: 60,
};
describe('IncrementalCacheWorker fetch', () => {
const selfFetch = mock();
beforeEach(() => {
selfFetch.mockReset();
get.mockReset();
cacheConstructor.mockReset();
getTagsFromValue.mockReset();
hasBeenRevalidated.mockReset();
getTagsFromValue.mockReturnValue(['space:1']);
hasBeenRevalidated.mockResolvedValue(false);
});
const fetch = (request: Request) =>
IncrementalCacheWorker.prototype.fetch.call(
{
env: { WORKER_SELF_REFERENCE: { fetch: selfFetch } },
ctx: {},
},
request
);
it('forwards cache reads to the internal endpoint and restores cache metadata', async () => {
selfFetch.mockResolvedValue(
Response.json(
{ value: cacheValue, lastModified: 123 },
{
headers: {
'x-gitbook-cache-control': CACHE_CONTROL,
'x-gitbook-cache-tag': 'incremental-cache:entry,space:1',
},
}
)
);
const response = await fetch(
new Request('https://incremental-cache.internal/?key=entry&cacheType=cache')
);
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
const forwardedRequest = selfFetch.mock.calls[0]?.[0] as Request;
const forwardedURL = new URL(forwardedRequest.url);
expect(forwardedURL.pathname).toBe('/internal');
expect(forwardedURL.searchParams.get('key')).toBe('entry');
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() });
const response = await fetch(
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=cache')
);
expect(get).toHaveBeenCalledWith('entry', 'cache');
expect(selfFetch).not.toHaveBeenCalled();
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('x-gitbook-cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
expect(response.headers.get('x-gitbook-cache-tag')).toBe('incremental-cache:entry,space:1');
});
it('keeps cache misses out of the worker cache', async () => {
get.mockResolvedValue(null);
const response = await fetch(
new Request('https://incremental-cache.internal/internal?key=missing')
);
expect(response.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(response.headers.get('x-gitbook-cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(await response.json()).toBeNull();
});
it('keeps revalidated and stale entries out of the worker cache', async () => {
get.mockResolvedValueOnce({ value: cacheValue, lastModified: Date.now() });
hasBeenRevalidated.mockResolvedValueOnce(true);
const revalidatedResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=revalidated')
);
get.mockResolvedValueOnce({
value: { ...cacheValue, revalidate: 0 },
lastModified: Date.now() - 1_000,
});
const staleResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=stale')
);
expect(revalidatedResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(revalidatedResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
expect(staleResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
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')
);
const methodResponse = await fetch(
new Request('https://incremental-cache.internal/?key=entry', { method: 'POST' })
);
expect(invalidResponse.status).toBe(400);
expect(methodResponse.status).toBe(405);
expect(selfFetch).not.toHaveBeenCalled();
});
});
@@ -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",
},
],
},
},
}
@@ -33,6 +33,11 @@
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-dev",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-container-dev",
"entrypoint": "IncrementalCacheWorker",
},
],
"durable_objects": {
"bindings": [
@@ -56,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",
},
@@ -70,6 +77,11 @@
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-preview",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-container-preview",
"entrypoint": "IncrementalCacheWorker",
},
],
"durable_objects": {
"bindings": [
@@ -94,6 +106,8 @@
"staging": {
"vars": {
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
// TODO: temporary, traces incremental cache key resolution.
"DEBUG_CACHE_KEYS": "true",
},
"r2_buckets": [
{
@@ -106,6 +120,11 @@
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-staging",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-container-staging",
"entrypoint": "IncrementalCacheWorker",
},
],
"durable_objects": {
"bindings": [
@@ -150,6 +169,11 @@
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-server-production",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-container-production",
"entrypoint": "IncrementalCacheWorker",
},
],
"durable_objects": {
"bindings": [
@@ -1,38 +0,0 @@
// This worker only purposes it to host the different DO that we will need in the other workers.
import { DurableObject } from 'cloudflare:workers';
// `use cache` could cause multiple writes to the same key to happen concurrently, there is a limit of 1 write per key/second
// so we need to buffer writes to the R2 bucket to avoid hitting this limit.
export class R2WriteBuffer extends DurableObject {
writePromise;
async write(cacheKey, value) {
// We are already writing to this key
if (this.writePromise) {
return;
}
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.ctx.waitUntil(
this.writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
export default {
async fetch() {
// This worker does not handle any requests, it only provides Durable Objects
return new Response('This worker is not meant to handle requests directly', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
});
},
};
@@ -0,0 +1,43 @@
import { DurableObject } from 'cloudflare:workers';
type CacheWorkerEnv = {
NEXT_INC_CACHE_R2_BUCKET: {
put(key: string, value: string): Promise<unknown>;
};
};
// `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;
async write(cacheKey: string, value: string): Promise<void> {
if (this.writePromise) {
return;
}
const writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
this.writePromise = writePromise;
this.ctx.waitUntil(
writePromise.finally(() => {
this.writePromise = undefined;
})
);
}
}
// @ts-ignore Generated by the Cloudflare build.
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';
/**
* 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 });
},
};
@@ -1,7 +1,7 @@
{
"main": "do.js",
"main": "do.ts",
"name": "gitbook-open-v2-do",
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-06-14",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
@@ -11,10 +11,14 @@
"observability": {
"enabled": false,
},
"placement": {
"region": "gcp:us-central1",
},
"env": {
"preview": {
"dev": {
"vars": {
"STAGE": "preview",
"STAGE": "dev",
"OPEN_NEXT_BUILD_ID": "local",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
},
"r2_buckets": [
@@ -26,7 +30,7 @@
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview",
"service": "gitbook-open-v2-do-dev",
},
],
"durable_objects": {
@@ -52,6 +56,56 @@
},
],
},
"preview": {
"vars": {
"STAGE": "preview",
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
},
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview",
},
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-do-preview",
},
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler",
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache",
},
{
"name": "WRITE_BUFFER",
"class_name": "R2WriteBuffer",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"],
},
],
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 1,
},
"logs": {
"enabled": true,
"head_sampling_rate": 1,
},
},
},
"staging": {
"vars": {
"STAGE": "staging",
@@ -71,7 +125,7 @@
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging",
"service": "gitbook-open-v2-do-staging",
},
],
"durable_objects": {
@@ -120,7 +174,7 @@
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production",
"service": "gitbook-open-v2-do-production",
},
],
"durable_objects": {
@@ -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,
@@ -23,11 +23,10 @@
"vars": {
"STAGE": "dev",
"NEXT_PRIVATE_DEBUG_CACHE": "true",
// When deployed locally, we don't have access to the tag cache here,
// we should just bypass the cache to go to the server directly
"SHOULD_BYPASS_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": [
{
@@ -44,13 +43,25 @@
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-dev",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"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": [
{
@@ -67,6 +78,15 @@
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-preview",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-container-preview",
"entrypoint": "IncrementalCacheWorker",
},
{
"binding": "CONTAINER_WORKER",
"service": "gitbook-open-v2-container-preview",
},
],
"durable_objects": {
"bindings": [
@@ -87,12 +107,25 @@
},
],
},
"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",
"WORKER_VERSION_ID": "TO_REPLACE",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"SERVER_TIER": "container",
},
"routes": [
{
@@ -119,6 +152,15 @@
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-staging",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-container-staging",
"entrypoint": "IncrementalCacheWorker",
},
{
"binding": "CONTAINER_WORKER",
"service": "gitbook-open-v2-container-staging",
},
],
"tail_consumers": [
{
@@ -188,6 +230,17 @@
"binding": "DEFAULT_WORKER",
"service": "gitbook-open-v2-server-production",
},
{
"binding": "NEXT_INC_CACHE_WORKER",
"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": [
{
@@ -0,0 +1,125 @@
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,
html: '<p>cached</p>',
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();
getCloudflareContext.mockReturnValue({
env: {
NEXT_INC_CACHE_WORKER: { fetch, set, delete: remove },
},
});
console.error = mock();
});
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 () => {
fetch.mockResolvedValue(
Response.json({
value: cacheValue,
lastModified: 123,
})
);
const result = await new GitbookIncrementalCache().get('key with / characters', 'cache');
expect(result).toEqual({ value: cacheValue, lastModified: 123 });
const request = fetch.mock.calls[0]?.[0] as Request;
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 () => {
fetch.mockResolvedValue(Response.json(null));
expect(await new GitbookIncrementalCache().get('missing')).toBeNull();
fetch.mockResolvedValueOnce(new Response(null, { status: 503 }));
expect(await new GitbookIncrementalCache().get('unavailable-response')).toBeNull();
fetch.mockRejectedValueOnce(new Error('service unavailable'));
expect(await new GitbookIncrementalCache().get('unavailable')).toBeNull();
});
it('uses RPC for writes and deletes', async () => {
set.mockResolvedValue(undefined);
remove.mockResolvedValue(undefined);
const cache = new GitbookIncrementalCache();
await cache.set('entry', cacheValue, 'cache');
await cache.delete('entry');
expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache', BUILD_ID);
expect(remove).toHaveBeenCalledWith('entry', BUILD_ID);
});
it('contains mutation failures', async () => {
set.mockRejectedValue(new Error('service unavailable'));
remove.mockRejectedValue(new Error('service unavailable'));
const cache = new GitbookIncrementalCache();
await expect(cache.set('entry', cacheValue, 'cache')).resolves.toBeUndefined();
await expect(cache.delete('entry')).resolves.toBeUndefined();
});
});
@@ -0,0 +1,98 @@
import type {
CacheEntryType,
CacheValue,
IncrementalCache,
WithLastModified,
} 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 = {
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>;
};
export class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache';
async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
try {
const url = getReadUrl(key, cacheType, CACHE_ORIGIN_SECURE);
logCacheDebug('workerd.get', {
cacheType: cacheType ?? 'cache',
sentBuildId: url.searchParams.get('buildId'),
envOpenNextBuildId: process.env.OPEN_NEXT_BUILD_ID,
envDeploymentId: process.env.DEPLOYMENT_ID,
url: url.toString(),
});
const response = await this.getWorker().fetch(new Request(url));
logCacheDebug('workerd.get.response', {
status: response.status,
// Set by the cache worker's response cache: a HIT here means the answer never
// reached R2, so the R2 key's build namespace was bypassed entirely.
cfCacheStatus: response.headers.get('cf-cache-status'),
age: response.headers.get('age'),
revalidated: response.headers.get('x-gitbook-cache-revalidated'),
});
if (!response.ok) {
console.error('Failed to get from cache worker', response.status);
return null;
}
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> {
try {
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);
}
}
async delete(key: string): Promise<void> {
try {
await this.getWorker().delete(key, getBuildId());
} catch (error) {
console.error('Failed to delete from cache worker', error);
}
}
private getWorker(): CacheWorker {
const env = getCloudflareContext().env as Record<string, unknown>;
const worker = env[BINDING_NAME] as CacheWorker | undefined;
if (!worker) {
throw new Error(`Missing ${BINDING_NAME} service binding`);
}
return worker;
}
}
export default new GitbookIncrementalCache();
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { createHash } from 'node:crypto';
import { DEFAULT_PREFIX, GitbookIncrementalCache } from './incrementalCache';
const environmentKeys = ['OPEN_NEXT_BUILD_ID', 'DEPLOYMENT_ID', 'NEXT_BUILD_ID'] as const;
const originalEnvironment = Object.fromEntries(
environmentKeys.map((key) => [key, process.env[key]])
);
const hash = (key: string) => createHash('sha256').update(key).digest('hex');
afterEach(() => {
for (const key of environmentKeys) {
const value = originalEnvironment[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe('GitbookIncrementalCache cache keys', () => {
it('uses the deployment-aware OpenNext build ID', () => {
process.env.OPEN_NEXT_BUILD_ID = 'deployment-id';
process.env.NEXT_BUILD_ID = 'legacy-build-id';
expect(new GitbookIncrementalCache().getR2Key('entry')).toBe(
`${DEFAULT_PREFIX}/deployment-id/${hash('entry')}.cache`
);
});
it('uses the Cloudflare deployment ID when the worker build ID is unavailable', () => {
delete process.env.OPEN_NEXT_BUILD_ID;
process.env.DEPLOYMENT_ID = 'runtime-deployment-id';
expect(new GitbookIncrementalCache().getR2Key('entry')).toBe(
`${DEFAULT_PREFIX}/runtime-deployment-id/${hash('entry')}.cache`
);
});
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']);
expect(new GitbookIncrementalCache().getR2Key(key, 'composable')).toBe(
`${DEFAULT_PREFIX}/dataCache/${hash(JSON.stringify(['cache-key']))}.composable`
);
});
});
@@ -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,9 +136,22 @@ export class GitbookIncrementalCache implements IncrementalCache {
}
const hash = createHash('sha256').update(key).digest('hex');
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : '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;
}
}
@@ -1,13 +1,14 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
// import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './cacheWorkerClient';
import { GitbookIncrementalCache } from './incrementalCache';
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // We can do it because we use our own logic to invalidate the cache
// bypassTagCacheOnCacheHit: true,
// //TODO: bump it again once I figured out the race condition
// defaultLongLivedTtlSec: 5 * 60, // 5 minutes
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
//TODO: bump it again once I figured out the race condition
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
export default new GitbookIncrementalCache();
@@ -1,16 +1,17 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
// import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './cacheWorkerClient';
import { GitbookIncrementalCache } from './incrementalCache';
// // We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
// // and cause race conditions. This will be fixed in a future release of OpenNext
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
// bypassTagCacheOnCacheHit: false,
// //TODO: remove, reducing cache ttl of regional cache to help debugging
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
// and cause race conditions. This will be fixed in a future release of OpenNext
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
//TODO: remove, reducing cache ttl of regional cache to help debugging
defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
export default new GitbookIncrementalCache();
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const getCloudflareContext = mock();
mock.module('@opennextjs/cloudflare', () => ({ getCloudflareContext }));
const { default: tagCache } = await import('./cacheWorkerClient');
describe('GitbookTagCache cache worker client', () => {
const writeTags = mock();
beforeEach(() => {
writeTags.mockReset();
getCloudflareContext.mockReturnValue({
env: {
NEXT_INC_CACHE_WORKER: { writeTags },
},
});
});
afterEach(() => {
getCloudflareContext.mockReset();
});
it('writes hard tags through the cache worker service binding', async () => {
writeTags.mockResolvedValue(undefined);
await tagCache.writeTags([
'content',
{ tag: 'with-duration', stale: 100, expire: 200 },
'_N_T_/soft-tag',
]);
expect(writeTags).toHaveBeenCalledWith([
'content',
{ tag: 'with-duration', stale: 100, expire: 200 },
]);
});
it('does not call the worker when all tags are soft tags', async () => {
await tagCache.writeTags(['_N_T_/soft-tag']);
expect(writeTags).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,39 @@
import type { NextModeTagCache, NextModeTagCacheWriteInput } from '@opennextjs/aws/types/overrides';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
const BINDING_NAME = 'NEXT_INC_CACHE_WORKER';
type CacheWorker = {
writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
};
const getWorker = (): CacheWorker => {
const env = getCloudflareContext().env as Record<string, unknown>;
const worker = env[BINDING_NAME] as CacheWorker | undefined;
if (!worker) {
throw new Error(`Missing ${BINDING_NAME} service binding`);
}
return worker;
};
export default {
name: 'GitbookTagCache',
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;
}
await getWorker().writeTags(tagsToWrite);
},
} satisfies NextModeTagCache;
@@ -1,4 +1,7 @@
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
import type {
NextModeTagCache,
NextModeTagCacheWriteInput,
} from '@opennextjs/aws/types/overrides.js';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
@@ -48,7 +51,7 @@ export default {
return false; // In case of error, return false
}
},
writeTags: async (tags: string[]) => {
writeTags: async (tags: NextModeTagCacheWriteInput[]) => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
const logger = getLogger().subLogger('gitbookTagCache');
+8 -3
View File
@@ -4,7 +4,8 @@
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
"@cloudflare/workers-types": "^4.20251011.0",
"@cloudflare/containers": "^0.3.7",
"@cloudflare/workers-types": "^5.20260716.1",
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*",
@@ -113,7 +114,7 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.79.0",
"wrangler": "^4.122.0",
"rss-parser": "^3.13.0"
},
"scripts": {
@@ -127,10 +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",
@@ -0,0 +1,205 @@
/**
* Usage:
* bun packages/gitbook/scripts/test-cache-revalidation.ts benchmark
* bun packages/gitbook/scripts/test-cache-revalidation.ts revalidate
*/
import { createHmac } from 'node:crypto';
// Edit this section for the local site under test. `bun dev` exposes published
// sites at /url/<published-site-host>/<path>.
const PAGE_URLS = [
'http://localhost:3000/url/example.gitbook.io/docs',
'http://localhost:3000/url/example.gitbook.io/docs/getting-started',
];
// The revalidation route must use the same /url/<published-site-host>/ prefix.
// Leave REVALIDATE_SECRET empty when the deployment does not set GITBOOK_SECRET.
const REVALIDATE_URL = 'http://localhost:3000/url/example.gitbook.io/~gitbook/revalidate';
const REVALIDATE_SECRET = '';
const REVALIDATE_BODY = '{"tags":["space:YOUR_SPACE_ID"]}';
// Next sets x-nextjs-cache to HIT or MISS. Change this when testing through a
// proxy that exposes the result under a different header.
const CACHE_STATUS_HEADER = 'x-nextjs-cache';
const BENCHMARK_RUNS = 3;
const WARMUP_REQUESTS = 2;
const WAIT_AFTER_REVALIDATION_MS = 1_000;
type RequestResult = {
cacheStatus: string | null;
status: number;
totalMs: number;
ttfbMs: number;
url: string;
};
function printTableHeader() {
console.log(
`${'request'.padEnd(13)} ${'HTTP'.padEnd(4)} ${CACHE_STATUS_HEADER.padEnd(16)} ${'TTFB / total'.padEnd(22)} URL`
);
}
function printResult(label: string, result: RequestResult) {
const cacheStatus = result.cacheStatus ?? '<missing>';
const timings = `${result.ttfbMs.toFixed(1)} / ${result.totalMs.toFixed(1)} ms`;
console.log(
`${label.padEnd(13)} ${String(result.status).padEnd(4)} ${cacheStatus.padEnd(16)} ${timings.padEnd(22)} ${result.url}`
);
}
async function requestPage(url: string): Promise<RequestResult> {
const start = performance.now();
const response = await fetch(url);
const ttfbMs = performance.now() - start;
// Consume the response body so totalMs measures the entire transfer.
await response.arrayBuffer();
return {
cacheStatus: response.headers.get(CACHE_STATUS_HEADER),
status: response.status,
totalMs: performance.now() - start,
ttfbMs,
url: response.url,
};
}
function isSuccessful(result: RequestResult) {
return result.status >= 200 && result.status < 400;
}
async function benchmark(): Promise<number> {
let failures = 0;
printTableHeader();
for (let run = 1; run <= BENCHMARK_RUNS; run += 1) {
for (const url of PAGE_URLS) {
try {
const result = await requestPage(url);
printResult(`benchmark-${run}`, result);
if (!isSuccessful(result)) {
failures += 1;
}
} catch (error) {
console.error(`benchmark-${run} ERR ${url}: ${String(error)}`);
failures += 1;
}
}
}
return failures;
}
function validateRevalidationConfig() {
if (
REVALIDATE_URL.includes('example.gitbook.io') ||
REVALIDATE_BODY.includes('YOUR_') ||
PAGE_URLS.some((url) => url.includes('example.gitbook.io'))
) {
throw new Error(
'Set PAGE_URLS, REVALIDATE_URL, and REVALIDATE_BODY before revalidate mode.'
);
}
}
function createSignature() {
if (!REVALIDATE_SECRET) {
return undefined;
}
return createHmac('sha256', REVALIDATE_SECRET).update(REVALIDATE_BODY).digest('hex');
}
async function revalidate(): Promise<number> {
validateRevalidationConfig();
let failures = 0;
console.log(`Warming each page (${WARMUP_REQUESTS} request(s) each)...`);
printTableHeader();
for (let run = 1; run <= WARMUP_REQUESTS; run += 1) {
for (const url of PAGE_URLS) {
try {
const result = await requestPage(url);
printResult(`warmup-${run}`, result);
if (!isSuccessful(result)) {
failures += 1;
}
} catch (error) {
console.error(`warmup-${run} ERR ${url}: ${String(error)}`);
failures += 1;
}
}
}
const headers = new Headers({ 'content-type': 'application/json' });
const signature = createSignature();
if (signature) {
headers.set('x-gitbook-signature', signature);
}
console.log(`Revalidating ${REVALIDATE_BODY}...`);
const revalidationResponse = await fetch(REVALIDATE_URL, {
body: REVALIDATE_BODY,
headers,
method: 'POST',
});
const revalidationResponseBody = await revalidationResponse.text();
if (!revalidationResponse.ok) {
throw new Error(
`Revalidation failed with ${revalidationResponse.status}: ${revalidationResponseBody}`
);
}
console.log(revalidationResponseBody);
await Bun.sleep(WAIT_AFTER_REVALIDATION_MS);
console.log('Checking that the next response is a MISS...');
printTableHeader();
for (const url of PAGE_URLS) {
try {
const result = await requestPage(url);
printResult('revalidated', result);
if (!isSuccessful(result) || result.cacheStatus?.toUpperCase() !== 'MISS') {
failures += 1;
}
} catch (error) {
console.error(`revalidated ERR ${url}: ${String(error)}`);
failures += 1;
}
}
return failures;
}
async function main() {
const mode = process.argv[2] ?? 'benchmark';
let failures: number;
try {
switch (mode) {
case 'benchmark':
failures = await benchmark();
break;
case 'revalidate':
failures = await revalidate();
break;
default:
throw new Error(`Usage: bun ${process.argv[1]} {benchmark|revalidate}`);
}
} catch (error) {
console.error(String(error));
return 1;
}
if (failures > 0) {
console.error(`${failures} request(s) failed.`);
return 1;
}
return 0;
}
void main().then((exitCode) => {
process.exitCode = exitCode;
});
+6 -1
View File
@@ -10,5 +10,10 @@ export function getCloudflareContext() {
return null;
}
return getCloudflareContextOpenNext();
try {
return getCloudflareContextOpenNext();
} catch {
// The container tier shares the Cloudflare build, but runs on plain Node with no bindings.
return null;
}
}
+7
View File
@@ -23,6 +23,13 @@ export async function waitUntil(promise: Promise<unknown>) {
context.ctx.waitUntil(promise);
return;
}
// The container tier shares the Cloudflare build but runs as a long-lived Node server,
// where a detached promise keeps running after the response is sent.
promise.catch((error) => {
console.error('Ignored error in waitUntil', error);
});
return;
}
await promise.catch((error) => {
+16
View File
@@ -25,6 +25,22 @@
"dependsOn": ["^build", "generate"],
"outputs": [".next/**", "!.next/cache/**", "dist", ".open-next/**"]
},
// Build the package for the Cloudflare container tier
"build:container": {
"dependsOn": ["^build", "generate"],
"outputs": [".next/**", "!.next/cache/**", "dist", ".open-next-container/**"]
},
// Build both the Cloudflare workers and the container, sharing a single `next build`
"build:all": {
"dependsOn": ["^build", "generate"],
"outputs": [
".next/**",
"!.next/cache/**",
"dist",
".open-next/**",
".open-next-container/**"
]
},
// Check the package for type errors
"typecheck": {
"dependsOn": ["^typecheck", "build"]