diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 7e66a8bf33..72b9e816e1 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -59,7 +59,7 @@ runs: GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL - name: Build worker - run: bun run turbo build:v2:cloudflare + run: bun run turbo build:v2:container env: GITBOOK_RUNTIME: cloudflare shell: bash @@ -70,32 +70,42 @@ runs: apiToken: ${{ inputs.apiToken }} accountId: ${{ inputs.accountId }} workingDirectory: ./ - wranglerVersion: '4.10.0' + wranglerVersion: '4.21.1' environment: ${{ inputs.environment }} command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc - - id: upload_server - name: Upload server to Cloudflare + # - id: upload_server + # name: Upload server to Cloudflare + # uses: cloudflare/wrangler-action@v3.14.0 + # with: + # apiToken: ${{ inputs.apiToken }} + # accountId: ${{ inputs.accountId }} + # workingDirectory: ./ + # wranglerVersion: '4.10.0' + # environment: ${{ inputs.environment }} + # command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + # - name: Extract server version worker ID + # shell: bash + # id: extract_server_version_id + # run: | + # version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}') + # echo "version_id=$version_id" >> $GITHUB_OUTPUT + + # - name: Run updateWrangler scripts + # shell: bash + # run: | + # bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }} + + - id: deploy_container + name: Deploy container to Cloudflare uses: cloudflare/wrangler-action@v3.14.0 with: apiToken: ${{ inputs.apiToken }} accountId: ${{ inputs.accountId }} workingDirectory: ./ - wranglerVersion: '4.10.0' - environment: ${{ inputs.environment }} - command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc - - - name: Extract server version worker ID - shell: bash - id: extract_server_version_id - run: | - version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}') - echo "version_id=$version_id" >> $GITHUB_OUTPUT - - - name: Run updateWrangler scripts - shell: bash - run: | - bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }} + wranglerVersion: '4.21.1' + command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/containerWrangler.jsonc - id: upload_middleware name: Upload middleware to Cloudflare @@ -104,29 +114,29 @@ runs: apiToken: ${{ inputs.apiToken }} accountId: ${{ inputs.accountId }} workingDirectory: ./ - wranglerVersion: '4.10.0' + wranglerVersion: '4.21.1' environment: ${{ inputs.environment }} command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc - - - name: Extract middleware version worker ID - shell: bash - id: extract_middleware_version_id - run: | - version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}') - echo "version_id=$version_id" >> $GITHUB_OUTPUT - - name: Deploy server and middleware to Cloudflare - if: ${{ inputs.deploy == 'true' }} - uses: ./.github/actions/gradual-deploy-cloudflare - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - opServiceAccount: ${{ inputs.opServiceAccount }} - opItem: ${{ inputs.opItem }} - environment: ${{ inputs.environment }} - serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }} - middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }} - deploy: ${{ inputs.deploy }} + # - name: Extract middleware version worker ID + # shell: bash + # id: extract_middleware_version_id + # run: | + # version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}') + # echo "version_id=$version_id" >> $GITHUB_OUTPUT + + # - name: Deploy server and middleware to Cloudflare + # if: ${{ inputs.deploy == 'true' }} + # uses: ./.github/actions/gradual-deploy-cloudflare + # with: + # apiToken: ${{ inputs.apiToken }} + # accountId: ${{ inputs.accountId }} + # opServiceAccount: ${{ inputs.opServiceAccount }} + # opItem: ${{ inputs.opItem }} + # environment: ${{ inputs.environment }} + # serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }} + # middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }} + # deploy: ${{ inputs.deploy }} - name: Outputs @@ -134,5 +144,4 @@ runs: env: DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }} run: | - echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}" - echo "Output server: ${{ steps.upload_server.outputs.command-output }}" \ No newline at end of file + echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..8034fede21 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-alpine +WORKDIR /app +COPY ./packages/gitbook-v2/.open-next/server-functions/default /app +EXPOSE 3000 +CMD ["node", "index.mjs"] \ No newline at end of file diff --git a/bun.lock b/bun.lock index 096de72ae8..61c27b4bf5 100644 --- a/bun.lock +++ b/bun.lock @@ -164,6 +164,8 @@ "name": "gitbook-v2", "version": "0.3.0", "dependencies": { + "@cloudflare/containers": "^0.0.8", + "@cloudflare/workers-types": "^4.20250620.0", "@gitbook/api": "catalog:", "@gitbook/cache-tags": "workspace:*", "@opennextjs/cloudflare": "1.2.1", @@ -279,6 +281,7 @@ "patchedDependencies": { "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch", + "@opennextjs/aws@3.6.5": "patches/@opennextjs%2Faws@3.6.5.patch", }, "overrides": { "@codemirror/state": "6.4.1", @@ -495,6 +498,8 @@ "@changesets/write": ["@changesets/write@0.3.2", "", { "dependencies": { "@changesets/types": "^6.0.0", "fs-extra": "^7.0.1", "human-id": "^1.0.2", "prettier": "^2.7.1" } }, "sha512-kDxDrPNpUgsjDbWBvUo27PzKX4gqeKOlhibaOXDJA6kuBisGqNHv/HwGJrAu8U/dSf8ZEFIeHIPtvSlZI1kULw=="], + "@cloudflare/containers": ["@cloudflare/containers@0.0.8", "", {}, "sha512-Uaw11AaUN0jVxRAd6PW33shAA4D4u0Yei3Xl/AOSijguYDhDGDMW4Dykx8vYUAl4tCbV9hGxvBgw9mAFCFXoJw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], "@cloudflare/next-on-pages": ["@cloudflare/next-on-pages@1.13.12", "", { "dependencies": { "acorn": "^8.8.0", "ast-types": "^0.14.2", "chalk": "^5.2.0", "chokidar": "^3.5.3", "commander": "^11.1.0", "cookie": "^0.5.0", "esbuild": "^0.15.3", "js-yaml": "^4.1.0", "miniflare": "^3.20231218.1", "package-manager-manager": "^0.2.0", "pcre-to-regexp": "^1.1.0", "semver": "^7.5.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240208.0", "vercel": ">=30.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "next-on-pages": "bin/index.js" } }, "sha512-rPy7x9c2+0RDDdJ5o0TeRUwXJ1b7N1epnqF6qKSp5Wz1r9KHOyvaZh1ACoOC6Vu5k9su5WZOgy+8fPLIyrldMQ=="], @@ -511,7 +516,7 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250409.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dK9I8zBX5rR7MtaaP2AhICQTEw3PVzHcsltN8o46w7JsbYlMvFOj27FfYH5dhs3IahgmIfw2e572QXW2o/dbpg=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20241230.0", "", {}, "sha512-dtLD4jY35Lb750cCVyO1i/eIfdZJg2Z0i+B1RYX6BVeRPlgaHx/H18ImKAkYmy0g09Ow8R2jZy3hIxMgXun0WQ=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250620.0", "", {}, "sha512-EVvRB/DJEm6jhdKg+A4Qm4y/ry1cIvylSgSO3/f/Bv161vldDRxaXM2YoQQWFhLOJOw0qtrHsKOD51KYxV1XCw=="], "@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=="], diff --git a/package.json b/package.json index 7423660b40..9bfd1d8dc0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ }, "patchedDependencies": { "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", - "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch" + "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch", + "@opennextjs/aws@3.6.5": "patches/@opennextjs%2Faws@3.6.5.patch" } } diff --git a/packages/gitbook-v2/open-next.config.ts b/packages/gitbook-v2/open-next.config.ts index 1872309119..3a3376d52f 100644 --- a/packages/gitbook-v2/open-next.config.ts +++ b/packages/gitbook-v2/open-next.config.ts @@ -3,12 +3,13 @@ import type { OpenNextConfig } from '@opennextjs/cloudflare'; export default { default: { override: { - wrapper: 'cloudflare-node', - converter: 'edge', - proxyExternalRequest: 'fetch', - queue: () => import('./openNext/queue/middleware').then((m) => m.default), - incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), - tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), + wrapper: 'node', + converter: 'node', + proxyExternalRequest: 'node', + generateDockerfile: true, + queue: 'dummy', + incrementalCache: () => import('./openNext/serverCache').then((m) => m.default), + tagCache: 'dummy', }, }, middleware: { @@ -26,4 +27,7 @@ export default { enableCacheInterception: true, }, edgeExternals: ['node:crypto'], + cloudflare: { + dangerousDisableConfigValidation: true, + }, } satisfies OpenNextConfig; diff --git a/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/bucket-cache-purge.js b/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/bucket-cache-purge.js new file mode 100644 index 0000000000..e30bcc5b9b --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/bucket-cache-purge.js @@ -0,0 +1,104 @@ +globalThis.openNextDebug = false;globalThis.openNextVersion = "3.6.5"; + +// ../../node_modules/@opennextjs/cloudflare/dist/api/durable-objects/bucket-cache-purge.js +import { DurableObject } from "cloudflare:workers"; + +// ../../node_modules/@opennextjs/cloudflare/dist/api/cloudflare-context.js +var cloudflareContextSymbol = Symbol.for("__cloudflare-context__"); + +// ../../node_modules/@opennextjs/cloudflare/dist/api/overrides/internal.js +var debugCache = (name, ...args) => { + if (process.env.NEXT_PRIVATE_DEBUG_CACHE) { + console.log(`[${name}] `, ...args); + } +}; +async function internalPurgeCacheByTags(env, tags) { + if (!env.CACHE_PURGE_ZONE_ID && !env.CACHE_PURGE_API_TOKEN) { + debugCache("purgeCacheByTags", "No cache zone ID or API token provided. Skipping cache purge."); + return "missing-credentials"; + } + try { + const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${env.CACHE_PURGE_ZONE_ID}/purge_cache`, { + headers: { + Authorization: `Bearer ${env.CACHE_PURGE_API_TOKEN}`, + "Content-Type": "application/json" + }, + method: "POST", + body: JSON.stringify({ + tags + }) + }); + if (response.status === 429) { + debugCache("purgeCacheByTags", "Rate limit exceeded. Skipping cache purge."); + return "rate-limit-exceeded"; + } + const bodyResponse = await response.json(); + if (!bodyResponse.success) { + debugCache("purgeCacheByTags", "Cache purge failed. Errors:", bodyResponse.errors.map((error) => `${error.code}: ${error.message}`)); + return "purge-failed"; + } + debugCache("purgeCacheByTags", "Cache purged successfully for tags:", tags); + return "purge-success"; + } catch (error) { + console.error("Error purging cache by tags:", error); + return "purge-failed"; + } +} + +// ../../node_modules/@opennextjs/cloudflare/dist/api/durable-objects/bucket-cache-purge.js +var DEFAULT_BUFFER_TIME_IN_SECONDS = 5; +var MAX_NUMBER_OF_TAGS_PER_PURGE = 100; +var BucketCachePurge = class extends DurableObject { + bufferTimeInSeconds; + constructor(state, env) { + super(state, env); + this.bufferTimeInSeconds = env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS ? parseInt(env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS) : DEFAULT_BUFFER_TIME_IN_SECONDS; + state.blockConcurrencyWhile(async () => { + state.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS cache_purge ( + tag TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS tag_index ON cache_purge (tag); + `); + }); + } + async purgeCacheByTags(tags) { + for (const tag of tags) { + this.ctx.storage.sql.exec(` + INSERT OR REPLACE INTO cache_purge (tag) + VALUES (?)`, [tag]); + } + const nextAlarm = await this.ctx.storage.getAlarm(); + if (!nextAlarm) { + this.ctx.storage.setAlarm(Date.now() + this.bufferTimeInSeconds * 1e3); + } + } + async alarm() { + let tags = this.ctx.storage.sql.exec(` + SELECT * FROM cache_purge LIMIT ${MAX_NUMBER_OF_TAGS_PER_PURGE} + `).toArray(); + do { + if (tags.length === 0) { + return; + } + const result = await internalPurgeCacheByTags(this.env, tags.map((row) => row.tag)); + if (result === "rate-limit-exceeded") { + throw new Error("Rate limit exceeded"); + } + this.ctx.storage.sql.exec(` + DELETE FROM cache_purge + WHERE tag IN (${tags.map(() => "?").join(",")}) + `, tags.map((row) => row.tag)); + if (tags.length < MAX_NUMBER_OF_TAGS_PER_PURGE) { + tags = []; + } else { + tags = this.ctx.storage.sql.exec(` + SELECT * FROM cache_purge LIMIT ${MAX_NUMBER_OF_TAGS_PER_PURGE} + `).toArray(); + } + } while (tags.length >= 0); + } +}; +export { + BucketCachePurge +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/queue.js b/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/queue.js new file mode 100644 index 0000000000..8168bfa28a --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/queue.js @@ -0,0 +1,278 @@ +globalThis.openNextDebug = false;globalThis.openNextVersion = "3.6.5"; + +// ../../node_modules/@opennextjs/aws/dist/utils/error.js +var IgnorableError = class extends Error { + __openNextInternal = true; + canIgnore = true; + logLevel = 0; + constructor(message) { + super(message); + this.name = "IgnorableError"; + } +}; +var RecoverableError = class extends Error { + __openNextInternal = true; + canIgnore = true; + logLevel = 1; + constructor(message) { + super(message); + this.name = "RecoverableError"; + } +}; +var FatalError = class extends Error { + __openNextInternal = true; + canIgnore = false; + logLevel = 2; + constructor(message) { + super(message); + this.name = "FatalError"; + } +}; +function isOpenNextError(e) { + try { + return "__openNextInternal" in e; + } catch { + return false; + } +} + +// ../../node_modules/@opennextjs/aws/dist/adapters/logger.js +function debug(...args) { + if (globalThis.openNextDebug) { + console.log(...args); + } +} +function warn(...args) { + console.warn(...args); +} +var DOWNPLAYED_ERROR_LOGS = [ + { + clientName: "S3Client", + commandName: "GetObjectCommand", + errorName: "NoSuchKey" + } +]; +var isDownplayedErrorLog = (errorLog) => DOWNPLAYED_ERROR_LOGS.some((downplayedInput) => downplayedInput.clientName === errorLog?.clientName && downplayedInput.commandName === errorLog?.commandName && (downplayedInput.errorName === errorLog?.error?.name || downplayedInput.errorName === errorLog?.error?.Code)); +function error(...args) { + if (args.some((arg) => isDownplayedErrorLog(arg))) { + return debug(...args); + } + if (args.some((arg) => isOpenNextError(arg))) { + const error2 = args.find((arg) => isOpenNextError(arg)); + if (error2.logLevel < getOpenNextErrorLogLevel()) { + return; + } + if (error2.logLevel === 0) { + return console.log(...args.map((arg) => isOpenNextError(arg) ? `${arg.name}: ${arg.message}` : arg)); + } + if (error2.logLevel === 1) { + return warn(...args.map((arg) => isOpenNextError(arg) ? `${arg.name}: ${arg.message}` : arg)); + } + return console.error(...args); + } + console.error(...args); +} +function getOpenNextErrorLogLevel() { + const strLevel = process.env.OPEN_NEXT_ERROR_LOG_LEVEL ?? "1"; + switch (strLevel.toLowerCase()) { + case "debug": + case "0": + return 0; + case "error": + case "2": + return 2; + default: + return 1; + } +} + +// ../../node_modules/@opennextjs/cloudflare/dist/api/durable-objects/queue.js +import { DurableObject } from "cloudflare:workers"; +var DEFAULT_MAX_REVALIDATION = 5; +var DEFAULT_REVALIDATION_TIMEOUT_MS = 1e4; +var DEFAULT_RETRY_INTERVAL_MS = 2e3; +var DEFAULT_MAX_RETRIES = 6; +var DOQueueHandler = class extends DurableObject { + // Ongoing revalidations are deduped by the deduplication id + // Since this is running in waitUntil, we expect the durable object state to persist this during the duration of the revalidation + // TODO: handle incremental cache with only eventual consistency (i.e. KV or R2/D1 with the optional cache layer on top) + ongoingRevalidations = /* @__PURE__ */ new Map(); + sql; + routeInFailedState = /* @__PURE__ */ new Map(); + service; + // Configurable params + maxRevalidations; + revalidationTimeout; + revalidationRetryInterval; + maxRetries; + disableSQLite; + constructor(ctx, env) { + super(ctx, env); + this.service = env.WORKER_SELF_REFERENCE; + if (!this.service) + throw new IgnorableError("No service binding for cache revalidation worker"); + this.sql = ctx.storage.sql; + this.maxRevalidations = env.NEXT_CACHE_DO_QUEUE_MAX_REVALIDATION ? parseInt(env.NEXT_CACHE_DO_QUEUE_MAX_REVALIDATION) : DEFAULT_MAX_REVALIDATION; + this.revalidationTimeout = env.NEXT_CACHE_DO_QUEUE_REVALIDATION_TIMEOUT_MS ? parseInt(env.NEXT_CACHE_DO_QUEUE_REVALIDATION_TIMEOUT_MS) : DEFAULT_REVALIDATION_TIMEOUT_MS; + this.revalidationRetryInterval = env.NEXT_CACHE_DO_QUEUE_RETRY_INTERVAL_MS ? parseInt(env.NEXT_CACHE_DO_QUEUE_RETRY_INTERVAL_MS) : DEFAULT_RETRY_INTERVAL_MS; + this.maxRetries = env.NEXT_CACHE_DO_QUEUE_MAX_RETRIES ? parseInt(env.NEXT_CACHE_DO_QUEUE_MAX_RETRIES) : DEFAULT_MAX_RETRIES; + this.disableSQLite = env.NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE === "true"; + ctx.blockConcurrencyWhile(async () => { + debug(`Restoring the state of the durable object`); + await this.initState(); + }); + debug(`Durable object initialized`); + } + async revalidate(msg) { + if (this.ongoingRevalidations.size > 2 * this.maxRevalidations) { + warn(`Your durable object has 2 times the maximum number of revalidations (${this.maxRevalidations}) in progress. If this happens often, you should consider increasing the NEXT_CACHE_DO_QUEUE_MAX_REVALIDATION or the number of durable objects with the MAX_REVALIDATE_CONCURRENCY env var.`); + } + if (this.ongoingRevalidations.has(msg.MessageDeduplicationId)) + return; + if (this.routeInFailedState.has(msg.MessageDeduplicationId)) + return; + if (this.checkSyncTable(msg)) + return; + if (this.ongoingRevalidations.size >= this.maxRevalidations) { + debug(`The maximum number of revalidations (${this.maxRevalidations}) is reached. Blocking until one of the revalidations finishes.`); + while (this.ongoingRevalidations.size >= this.maxRevalidations) { + const ongoingRevalidations = this.ongoingRevalidations.values(); + debug(`Waiting for one of the revalidations to finish`); + await Promise.race(ongoingRevalidations); + } + } + const revalidationPromise = this.executeRevalidation(msg); + this.ongoingRevalidations.set(msg.MessageDeduplicationId, revalidationPromise); + this.ctx.waitUntil(revalidationPromise); + } + async executeRevalidation(msg) { + try { + debug(`Revalidating ${msg.MessageBody.host}${msg.MessageBody.url}`); + const { MessageBody: { host, url } } = msg; + const protocol = host.includes("localhost") ? "http" : "https"; + const response = await this.service.fetch(`${protocol}://${host}${url}`, { + method: "HEAD", + headers: { + // This is defined during build + "x-prerender-revalidate": "e7d1449a4136a34776a83dfe63a2d4ef", + "x-isr": "1" + }, + // This one is kind of problematic, it will always show the wall time of the revalidation to `this.revalidationTimeout` + signal: AbortSignal.timeout(this.revalidationTimeout) + }); + if (response.status === 200 && response.headers.get("x-nextjs-cache") !== "REVALIDATED") { + this.routeInFailedState.delete(msg.MessageDeduplicationId); + throw new FatalError(`The revalidation for ${host}${url} cannot be done. This error should never happen.`); + } else if (response.status === 404) { + this.routeInFailedState.delete(msg.MessageDeduplicationId); + throw new IgnorableError(`The revalidation for ${host}${url} cannot be done because the page is not found. It's either expected or an error in user code itself`); + } else if (response.status === 500) { + await this.addToFailedState(msg); + throw new IgnorableError(`Something went wrong while revalidating ${host}${url}`); + } else if (response.status !== 200) { + await this.addToFailedState(msg); + throw new RecoverableError(`An unknown error occurred while revalidating ${host}${url}`); + } + if (!this.disableSQLite) { + this.sql.exec( + "INSERT OR REPLACE INTO sync (id, lastSuccess, buildId) VALUES (?, unixepoch(), ?)", + // We cannot use the deduplication id because it's not unique per route - every time a route is revalidated, the deduplication id is different. + `${host}${url}`, + "FsLrCjhoJaCSdOYr51rb3" + ); + } + this.routeInFailedState.delete(msg.MessageDeduplicationId); + } catch (e) { + if (!isOpenNextError(e)) { + await this.addToFailedState(msg); + } + error(e); + } finally { + this.ongoingRevalidations.delete(msg.MessageDeduplicationId); + } + } + async alarm() { + const currentDateTime = Date.now(); + const nextEventToRetry = Array.from(this.routeInFailedState.values()).filter(({ nextAlarmMs }) => nextAlarmMs > currentDateTime).sort(({ nextAlarmMs: a }, { nextAlarmMs: b }) => a - b)[0]; + const expiredEvents = Array.from(this.routeInFailedState.values()).filter(({ nextAlarmMs }) => nextAlarmMs <= currentDateTime); + const allEventsToRetry = nextEventToRetry ? [nextEventToRetry, ...expiredEvents] : expiredEvents; + for (const event of allEventsToRetry) { + debug(`Retrying revalidation for ${event.msg.MessageBody.host}${event.msg.MessageBody.url}`); + await this.executeRevalidation(event.msg); + } + } + async addToFailedState(msg) { + debug(`Adding ${msg.MessageBody.host}${msg.MessageBody.url} to the failed state`); + const existingFailedState = this.routeInFailedState.get(msg.MessageDeduplicationId); + let updatedFailedState; + if (existingFailedState) { + if (existingFailedState.retryCount >= this.maxRetries) { + error(`The revalidation for ${msg.MessageBody.host}${msg.MessageBody.url} has failed after ${this.maxRetries} retries. It will not be tried again, but subsequent ISR requests will retry.`); + this.routeInFailedState.delete(msg.MessageDeduplicationId); + return; + } + const nextAlarmMs = Date.now() + Math.pow(2, existingFailedState.retryCount + 1) * this.revalidationRetryInterval; + updatedFailedState = { + ...existingFailedState, + retryCount: existingFailedState.retryCount + 1, + nextAlarmMs + }; + } else { + updatedFailedState = { + msg, + retryCount: 1, + nextAlarmMs: Date.now() + 2e3 + }; + } + this.routeInFailedState.set(msg.MessageDeduplicationId, updatedFailedState); + if (!this.disableSQLite) { + this.sql.exec("INSERT OR REPLACE INTO failed_state (id, data, buildId) VALUES (?, ?, ?)", msg.MessageDeduplicationId, JSON.stringify(updatedFailedState), "FsLrCjhoJaCSdOYr51rb3"); + } + await this.addAlarm(); + } + async addAlarm() { + const existingAlarm = await this.ctx.storage.getAlarm({ allowConcurrency: false }); + if (existingAlarm) + return; + if (this.routeInFailedState.size === 0) + return; + let nextAlarmToSetup = Math.min(...Array.from(this.routeInFailedState.values()).map(({ nextAlarmMs }) => nextAlarmMs)); + if (nextAlarmToSetup < Date.now()) { + nextAlarmToSetup = Date.now() + this.revalidationRetryInterval; + } + await this.ctx.storage.setAlarm(nextAlarmToSetup); + } + // This function is used to restore the state of the durable object + // We don't restore the ongoing revalidations because we cannot know in which state they are + // We only restore the failed state and the alarm + async initState() { + if (this.disableSQLite) + return; + this.sql.exec("CREATE TABLE IF NOT EXISTS failed_state (id TEXT PRIMARY KEY, data TEXT, buildId TEXT)"); + this.sql.exec("CREATE TABLE IF NOT EXISTS sync (id TEXT PRIMARY KEY, lastSuccess INTEGER, buildId TEXT)"); + this.sql.exec("DELETE FROM failed_state WHERE buildId != ?", "FsLrCjhoJaCSdOYr51rb3"); + this.sql.exec("DELETE FROM sync WHERE buildId != ?", "FsLrCjhoJaCSdOYr51rb3"); + const failedStateCursor = this.sql.exec("SELECT * FROM failed_state"); + for (const row of failedStateCursor) { + this.routeInFailedState.set(row.id, JSON.parse(row.data)); + } + await this.addAlarm(); + } + /** + * + * @param msg + * @returns `true` if the route has been revalidated since the lastModified from the message, `false` otherwise + */ + checkSyncTable(msg) { + try { + if (this.disableSQLite) + return false; + return this.sql.exec("SELECT 1 FROM sync WHERE id = ? AND lastSuccess > ? LIMIT 1", `${msg.MessageBody.host}${msg.MessageBody.url}`, Math.round(msg.MessageBody.lastModified / 1e3)).toArray().length > 0; + } catch { + return false; + } + } +}; +export { + DOQueueHandler +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/sharded-tag-cache.js b/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/sharded-tag-cache.js new file mode 100644 index 0000000000..450910c8f0 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/cloudflare/durable-objects/sharded-tag-cache.js @@ -0,0 +1,36 @@ +globalThis.openNextDebug = false;globalThis.openNextVersion = "3.6.5"; + +// ../../node_modules/@opennextjs/cloudflare/dist/api/durable-objects/sharded-tag-cache.js +import { DurableObject } from "cloudflare:workers"; +var DOShardedTagCache = class extends DurableObject { + sql; + constructor(state, env) { + super(state, env); + this.sql = state.storage.sql; + state.blockConcurrencyWhile(async () => { + this.sql.exec(`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)`); + }); + } + async getLastRevalidated(tags) { + try { + const result = this.sql.exec(`SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags).toArray(); + if (result.length === 0) + return 0; + return result[0]?.time; + } catch (e) { + console.error(e); + return 0; + } + } + async hasBeenRevalidated(tags, lastModified) { + return this.sql.exec(`SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1`, ...tags, lastModified ?? Date.now()).toArray().length > 0; + } + async writeTags(tags, lastModified) { + tags.forEach((tag) => { + this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`, tag, lastModified); + }); + } +}; +export { + DOShardedTagCache +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/cloudflare/init.js b/packages/gitbook-v2/openNext/customWorkers/cloudflare/init.js new file mode 100644 index 0000000000..defe2fd18e --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/cloudflare/init.js @@ -0,0 +1,83 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import process from "node:process"; +import stream from "node:stream"; +import * as nextEnvVars from "./next-env.mjs"; +const cloudflareContextALS = new AsyncLocalStorage(); +Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { + get() { + return cloudflareContextALS.getStore(); + } +}); +async function runWithCloudflareRequestContext(request, env, ctx, handler) { + init(request, env); + return cloudflareContextALS.run({ env, ctx, cf: request.cf }, handler); +} +let initialized = false; +function init(request, env) { + if (initialized) { + return; + } + initialized = true; + const url = new URL(request.url); + initRuntime(); + populateProcessEnv(url, env); +} +function initRuntime() { + Object.assign(process, { version: process.version || "v22.14.0" }); + Object.assign(process.versions, { node: "22.14.0", ...process.versions }); + globalThis.__dirname ??= ""; + globalThis.__filename ??= ""; + import.meta.url ??= "file:///worker.js"; + const __original_fetch = globalThis.fetch; + globalThis.fetch = (input, init2) => { + if (init2) { + delete init2.cache; + } + return __original_fetch(input, init2); + }; + const CustomRequest = class extends globalThis.Request { + constructor(input, init2) { + if (init2) { + delete init2.cache; + Object.defineProperty(init2, "body", { + // @ts-ignore + value: init2.body instanceof stream.Readable ? ReadableStream.from(init2.body) : init2.body + }); + } + super(input, init2); + } + }; + Object.assign(globalThis, { + Request: CustomRequest, + __BUILD_TIMESTAMP_MS__: 1750516662934, + __NEXT_BASE_PATH__: "", + // The external middleware will use the convertTo function of the `edge` converter + // by default it will try to fetch the request, but since we are running everything in the same worker + // we need to use the request as is. + __dangerous_ON_edge_converter_returns_request: true + }); +} +function populateProcessEnv(url, env) { + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + process.env[key] = value; + } + } + const mode = env.NEXTJS_ENV ?? "production"; + if (nextEnvVars[mode]) { + for (const key in nextEnvVars[mode]) { + process.env[key] ??= nextEnvVars[mode][key]; + } + } + process.env.OPEN_NEXT_ORIGIN = JSON.stringify({ + default: { + host: url.hostname, + protocol: url.protocol.slice(0, -1), + port: url.port + } + }); + process.env.__NEXT_PRIVATE_ORIGIN = url.origin; +} +export { + runWithCloudflareRequestContext +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/cloudflare/next-env.mjs b/packages/gitbook-v2/openNext/customWorkers/cloudflare/next-env.mjs new file mode 100644 index 0000000000..52a80b4d92 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/cloudflare/next-env.mjs @@ -0,0 +1,3 @@ +export const production = {}; +export const development = {}; +export const test = {}; diff --git a/packages/gitbook-v2/openNext/customWorkers/container.ts b/packages/gitbook-v2/openNext/customWorkers/container.ts new file mode 100644 index 0000000000..4cc6e5f7e3 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/container.ts @@ -0,0 +1,31 @@ +import { Container } from '@cloudflare/containers'; +import type { DurableObjectNamespace } from '@cloudflare/workers-types'; + +interface Env { + STAGE: string; +} + +export class OpenNextContainer extends Container { + defaultPort = 3000; + sleepAfter = '10s'; + startManually = true; +} + +export default { + async fetch( + request: Request, + env: { ON_CONTAINER: DurableObjectNamespace } + ) { + const idOne = env.ON_CONTAINER.idFromName('foo'); + const containerInstance = env.ON_CONTAINER.get(idOne); + + await containerInstance.start({ + envVars: { + HOST: request.headers.get('x-host') || 'localhost:8771', + }, + }); + + // @ts-ignore + return containerInstance.fetch(request); + }, +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/containerWrangler.jsonc new file mode 100644 index 0000000000..7f1379d729 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/containerWrangler.jsonc @@ -0,0 +1,31 @@ +{ + "name": "on-container-worker", + "main": "container.ts", + "compatibility_date": "2025-05-06", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "containers": [ + { + "class_name": "OpenNextContainer", + "image": "./Dockerfile", + "max_instances": 1, + "name": "container-open-next" + } + ], + "durable_objects": { + "bindings": [ + { + "class_name": "OpenNextContainer", + "name": "ON_CONTAINER" + } + ] + }, + "migrations": [ + { + "new_sqlite_classes": ["OpenNextContainer"], + "tag": "v1" + } + ] +} diff --git a/packages/gitbook-v2/openNext/customWorkers/do.js b/packages/gitbook-v2/openNext/customWorkers/do.js index 04f3cf3bec..aff54dfa3e 100644 --- a/packages/gitbook-v2/openNext/customWorkers/do.js +++ b/packages/gitbook-v2/openNext/customWorkers/do.js @@ -21,9 +21,9 @@ export class R2WriteBuffer extends DurableObject { } } -export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js'; +export { DOQueueHandler } from './cloudflare/durable-objects/queue.js'; -export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js'; +export { DOShardedTagCache } from './cloudflare/durable-objects/sharded-tag-cache.js'; export default { async fetch() { diff --git a/packages/gitbook-v2/openNext/customWorkers/middleware.js b/packages/gitbook-v2/openNext/customWorkers/middleware.js index 78a84a9760..2429a9a1ac 100644 --- a/packages/gitbook-v2/openNext/customWorkers/middleware.js +++ b/packages/gitbook-v2/openNext/customWorkers/middleware.js @@ -1,42 +1,113 @@ -import { WorkerEntrypoint } from 'cloudflare:workers'; -import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; +import { runWithCloudflareRequestContext } from './cloudflare/init.js'; -import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs'; +export { DOQueueHandler } from './cloudflare/durable-objects/queue.js'; -export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js'; +export { DOShardedTagCache } from './cloudflare/durable-objects/sharded-tag-cache.js'; -export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js'; +// Only needed to run locally, in prod we'll use the one from do.js +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 default class extends WorkerEntrypoint { async fetch(request) { return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => { + const { handler: middlewareHandler } = await import( + '../../.open-next/middleware/handler.mjs' + ); + + const url = new URL(request.url); + if (url.pathname.startsWith('/_internal/')) { + // Incremental cache handling + if (url.pathname.startsWith('/_internal/set')) { + const { key, value, cacheType } = await request.json(); + globalThis.incrementalCache.set(key, value, cacheType); + return new Response(null, { + status: 204, + headers: { + 'Content-Type': 'application/json', + }, + }); + // biome-ignore lint/style/noUselessElse: + } else if (url.pathname.startsWith('/_internal/get')) { + const { key, cacheType } = await request.json(); + const value = globalThis.incrementalCache.get(key, cacheType); + return new Response(JSON.stringify(value), { + headers: { + 'Content-Type': 'application/json', + }, + }); + } + } + // - `Request`s are handled by the Next server const reqOrResp = await middlewareHandler(request, this.env, this.ctx); + if (reqOrResp instanceof Response) { return reqOrResp; } - if (this.env.STAGE !== 'preview') { - // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity + if (this.env.STAGE === 'dev') { + const modifiedUrl = new URL(reqOrResp.url); + modifiedUrl.host = this.env.PREVIEW_HOSTNAME; + const nextRequest = new Request(modifiedUrl, reqOrResp); + nextRequest.headers.set('x-host', reqOrResp.host); + return fetch(nextRequest, { + cf: { + cacheEverything: false, + }, + }); + } + + if (this.env.STAGE === 'preview') { + // We just send the request to the container worker reqOrResp.headers.set( - 'Cloudflare-Workers-Version-Overrides', - `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` + 'x-host', + reqOrResp.headers.get('host') || this.env.PREVIEW_HOSTNAME ); - return this.env.DEFAULT_WORKER?.fetch(reqOrResp, { + return this.env.CONTAINER_WORKER?.fetch(reqOrResp, { cf: { cacheEverything: false, }, }); } - // If we are in preview mode, we need to send the request to the preview URL - const modifiedUrl = new URL(reqOrResp.url); - modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME; - const nextRequest = new Request(modifiedUrl, reqOrResp); - return fetch(nextRequest, { - cf: { - cacheEverything: false, - }, - }); + + // 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}"` + // ); + // return this.env.DEFAULT_WORKER?.fetch(reqOrResp, { + // cf: { + // cacheEverything: false, + // }, + // }); + // } + // // If we are in preview mode, we need to send the request to the preview URL + // const modifiedUrl = new URL(reqOrResp.url); + // modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME; + // const nextRequest = new Request(modifiedUrl, reqOrResp); + // return fetch(nextRequest, { + // cf: { + // cacheEverything: false, + // }, + // }); }); } } diff --git a/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc index 09e48afcc0..0d2470b522 100644 --- a/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc @@ -21,7 +21,8 @@ "dev": { "vars": { "STAGE": "dev", - "NEXT_PRIVATE_DEBUG_CACHE": "true" + "NEXT_PRIVATE_DEBUG_CACHE": "true", + "PREVIEW_HOSTNAME": "localhost:3000" }, "r2_buckets": [ { @@ -38,7 +39,15 @@ "binding": "DEFAULT_WORKER", "service": "gitbook-open-v2-server-dev" } - ] + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer" + } + ] + } }, "preview": { "vars": { @@ -60,6 +69,10 @@ { "binding": "DEFAULT_WORKER", "service": "gitbook-open-v2-server-preview" + }, + { + "binding": "CONTAINER_WORKER", + "service": "on-container-worker" } ], "durable_objects": { diff --git a/packages/gitbook-v2/openNext/serverCache.ts b/packages/gitbook-v2/openNext/serverCache.ts new file mode 100644 index 0000000000..6a3e0748a7 --- /dev/null +++ b/packages/gitbook-v2/openNext/serverCache.ts @@ -0,0 +1,47 @@ +import type { + CacheEntryType, + CacheValue, + IncrementalCache, + WithLastModified, +} from '@opennextjs/aws/types/overrides'; + +export default { + get: async ( + key: string, + cacheType?: CacheType + ): Promise> | null> => { + try { + const resp = await fetch( + `http://${process.env.HOST || 'localhost:8771'}/_internal/get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, cacheType }), + } + ); + return resp.ok ? resp.json() : null; + } catch (e) { + console.error('Error fetching cache:', e); + return null; + } + }, + set: async ( + key: string, + value: CacheValue, + cacheType?: CacheType + ): Promise => { + await fetch(`http://${process.env.HOST || 'localhost:8771'}/_internal/set`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, value, cacheType }), + }); + }, + delete: (_key: string): Promise => { + return Promise.resolve(); + }, + name: 'ServerContainerCache', +} satisfies IncrementalCache; diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index cfa6e71b91..69ea1f9872 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -3,6 +3,8 @@ "version": "0.3.0", "private": true, "dependencies": { + "@cloudflare/containers": "^0.0.8", + "@cloudflare/workers-types": "^4.20250620.0", "@gitbook/api": "catalog:", "@gitbook/cache-tags": "workspace:*", "@opennextjs/cloudflare": "1.2.1", @@ -10,12 +12,12 @@ "assert-never": "^1.2.1", "jwt-decode": "^4.0.0", "next": "^15.3.2", + "object-identity": "^0.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", "rison": "^0.1.1", "server-only": "^0.0.1", - "warn-once": "^0.1.1", - "object-identity": "^0.1.2" + "warn-once": "^0.1.1" }, "devDependencies": { "gitbook": "*", @@ -30,9 +32,11 @@ "build:v2": "next build", "start": "next start", "build:v2:cloudflare": "opennextjs-cloudflare build", + "build:v2:container": "open-next build", "dev:v2:cloudflare": "wrangler dev --port 8771 --env preview", "dev:v2:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc", "dev:v2:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc", + "dev:v2:container": "node ./.open-next/server-functions/default/index.mjs", "unit": "bun test", "typecheck": "tsc --noEmit" } diff --git a/patches/@opennextjs%2Faws@3.6.5.patch b/patches/@opennextjs%2Faws@3.6.5.patch new file mode 100644 index 0000000000..e5bf66f652 --- /dev/null +++ b/patches/@opennextjs%2Faws@3.6.5.patch @@ -0,0 +1,13 @@ +diff --git a/dist/build.js b/dist/build.js +index 0c6fee9d01db1ed9f9f0ceaa0a61242f624e7b46..9e727163f7cec8ba50627c2854f5071d9f076ad7 100644 +--- a/dist/build.js ++++ b/dist/build.js +@@ -35,7 +35,7 @@ export async function build(openNextConfigPath, nodeExternals) { + // Compile cache.ts + compileCache(options); + // Compile middleware +- await createMiddleware(options); ++ await createMiddleware(options, { forceOnlyBuildOnce: true }); + createStaticAssets(options); + if (config.dangerous?.disableIncrementalCache !== true) { + const { useTagCache } = createCacheAssets(options); diff --git a/turbo.json b/turbo.json index 2c627db32f..245c00f290 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,10 @@ "dependsOn": ["^build:v2:cloudflare", "^build:v2", "generate"], "outputs": [".next/**", "!.next/cache/**", "dist", ".open-next/**"] }, + "build:v2:container": { + "dependsOn": ["^build:v2:cloudflare", "^build:v2", "generate"], + "outputs": [".next/**", "!.next/cache/**", "dist", ".open-next/**"] + }, // Build the package for Cloudflare Pages "build:cloudflare": { "dependsOn": ["^build", "generate"]