diff --git a/.changeset/cool-jars-matter.md b/.changeset/cool-jars-matter.md new file mode 100644 index 000000000..ff0c934f3 --- /dev/null +++ b/.changeset/cool-jars-matter.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +fix href being empty in TOC diff --git a/.changeset/cool-seas-approve.md b/.changeset/cool-seas-approve.md new file mode 100644 index 000000000..c97f692e6 --- /dev/null +++ b/.changeset/cool-seas-approve.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix navigation between sections/variants when previewing a site in v2 diff --git a/.changeset/fast-trees-battle.md b/.changeset/fast-trees-battle.md new file mode 100644 index 000000000..0e75fbbed --- /dev/null +++ b/.changeset/fast-trees-battle.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Add authorization header for OAuth2 diff --git a/.changeset/gorgeous-cycles-cheat.md b/.changeset/gorgeous-cycles-cheat.md new file mode 100644 index 000000000..d13c27765 --- /dev/null +++ b/.changeset/gorgeous-cycles-cheat.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +add a force-revalidate api route to force bust the cache in case of errors diff --git a/.changeset/rotten-seals-rush.md b/.changeset/rotten-seals-rush.md new file mode 100644 index 000000000..950e25880 --- /dev/null +++ b/.changeset/rotten-seals-rush.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Indent JSON python code sample diff --git a/.changeset/stupid-plums-perform.md b/.changeset/stupid-plums-perform.md new file mode 100644 index 000000000..34707627e --- /dev/null +++ b/.changeset/stupid-plums-perform.md @@ -0,0 +1,6 @@ +--- +"gitbook": patch +"gitbook-v2": patch +--- + +cache fonts and static image used in OGImage in memory diff --git a/.changeset/thick-chefs-repeat.md b/.changeset/thick-chefs-repeat.md new file mode 100644 index 000000000..8b08d50e3 --- /dev/null +++ b/.changeset/thick-chefs-repeat.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Handle nested deprecated properties in generateSchemaExample diff --git a/.changeset/violet-schools-care.md b/.changeset/violet-schools-care.md new file mode 100644 index 000000000..e77a98b5c --- /dev/null +++ b/.changeset/violet-schools-care.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Deduplicate path parameters from OpenAPI spec diff --git a/.changeset/wise-gifts-smash.md b/.changeset/wise-gifts-smash.md new file mode 100644 index 000000000..32a85eecb --- /dev/null +++ b/.changeset/wise-gifts-smash.md @@ -0,0 +1,5 @@ +--- +"gitbook-v2": patch +--- + +remove trailing slash from linker diff --git a/.github/actions/gradual-deploy-cloudflare/action.yaml b/.github/actions/gradual-deploy-cloudflare/action.yaml new file mode 100644 index 000000000..eff064a75 --- /dev/null +++ b/.github/actions/gradual-deploy-cloudflare/action.yaml @@ -0,0 +1,83 @@ +name: Gradual Deploy to Cloudflare +description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together +inputs: + apiToken: + description: 'Cloudflare API token' + required: true + accountId: + description: 'Cloudflare account ID' + required: true + environment: + description: 'Cloudflare environment to deploy to (staging, production, preview)' + required: true + middlewareVersionId: + description: 'Middleware version ID to deploy' + required: true + serverVersionId: + description: 'Server version ID to deploy' + required: true +outputs: + deployment-url: + description: "Deployment URL" + value: ${{ steps.deploy_middleware.outputs.deployment-url }} +runs: + using: 'composite' + steps: + - id: wrangler_status + name: Check wrangler deployment status + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: deployments status --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + # This step is used to get the version ID that is currently deployed to Cloudflare. + - id: extract_current_version + name: Extract current version + shell: bash + run: | + version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT + + - id: deploy_server + name: Deploy server to Cloudflare at 0% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + # Since we use version overrides headers, we can directly deploy the middleware to 100%. + - id: deploy_middleware + name: Deploy middleware to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc + + - name: Deploy server to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.10.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc + + - name: Outputs + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} + run: | + echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" \ No newline at end of file diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index fbc98fc82..7e66a8bf3 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -28,7 +28,7 @@ inputs: outputs: deployment-url: description: "Deployment URL" - value: ${{ steps.deploy.outputs.deployment-url }} + value: ${{ steps.upload_middleware.outputs.deployment-url }} runs: using: 'composite' steps: @@ -63,8 +63,8 @@ runs: env: GITBOOK_RUNTIME: cloudflare shell: bash - - id: deploy - name: Deploy to Cloudflare + + - name: Upload the DO worker uses: cloudflare/wrangler-action@v3.14.0 with: apiToken: ${{ inputs.apiToken }} @@ -72,10 +72,67 @@ runs: workingDirectory: ./ wranglerVersion: '4.10.0' environment: ${{ inputs.environment }} - command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc + command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc + + - 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: upload_middleware + name: Upload middleware 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/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: Outputs shell: bash env: - DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }} run: | - echo "URL: ${{ steps.deploy.outputs.deployment-url }}" \ No newline at end of file + echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}" + echo "Output server: ${{ steps.upload_server.outputs.command-output }}" \ No newline at end of file diff --git a/packages/gitbook-v2/next-env.d.ts b/packages/gitbook-v2/next-env.d.ts index 1b3be0840..3cd7048ed 100644 --- a/packages/gitbook-v2/next-env.d.ts +++ b/packages/gitbook-v2/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/gitbook-v2/open-next.config.ts b/packages/gitbook-v2/open-next.config.ts index d35c9ef03..959834739 100644 --- a/packages/gitbook-v2/open-next.config.ts +++ b/packages/gitbook-v2/open-next.config.ts @@ -1,26 +1,29 @@ -import { defineCloudflareConfig } from '@opennextjs/cloudflare'; -import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache'; -import { - softTagFilter, - withFilter, -} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter'; +import type { OpenNextConfig } from '@opennextjs/cloudflare'; -export default defineCloudflareConfig({ - incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), - tagCache: withFilter({ - tagCache: doShardedTagCache({ - baseShardSize: 12, - regionalCache: true, - shardReplication: { - numberOfSoftReplicas: 2, - numberOfHardReplicas: 1, - }, - }), - // We don't use `revalidatePath`, so we filter out soft tags - filterFn: softTagFilter, - }), - queue: () => import('./openNext/queue').then((m) => m.default), - - // Performance improvements as we don't use PPR - enableCacheInterception: true, -}); +export default { + default: { + override: { + wrapper: 'cloudflare-node', + converter: 'edge', + proxyExternalRequest: 'fetch', + queue: () => import('./openNext/queue/server').then((m) => m.default), + incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), + tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), + }, + }, + middleware: { + external: true, + override: { + wrapper: 'cloudflare-edge', + 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), + }, + }, + dangerous: { + enableCacheInterception: true, + }, + edgeExternals: ['node:crypto'], +} satisfies OpenNextConfig; diff --git a/packages/gitbook-v2/openNext/customWorkers/default.js b/packages/gitbook-v2/openNext/customWorkers/default.js new file mode 100644 index 000000000..535c21816 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/default.js @@ -0,0 +1,36 @@ +import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; + +import { DurableObject } from 'cloudflare:workers'; + +// 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 { + async fetch(request, env, ctx) { + return runWithCloudflareRequestContext(request, env, ctx, async () => { + // We can't move the handler import to the top level, otherwise the runtime will not be properly initialized + const { handler } = await import( + '../../.open-next/server-functions/default/handler.mjs' + ); + + // - `Request`s are handled by the Next server + return handler(request, env, ctx); + }); + }, +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc new file mode 100644 index 000000000..e036db1c4 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc @@ -0,0 +1,163 @@ +{ + "main": "default.js", + "name": "gitbook-open-v2-server", + "compatibility_date": "2025-04-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], + "observability": { + "enabled": true + }, + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "env": { + "dev": { + "vars": { + "STAGE": "dev" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-dev" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["R2WriteBuffer"] + } + ] + }, + "preview": { + "vars": { + "STAGE": "preview", + // Just as a test for the preview environment to check that everything works + "NEXT_PRIVATE_DEBUG_CACHE": "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-server-preview" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-preview" + } + ] + } + }, + "staging": { + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-staging" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-staging" + } + ] + }, + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail" + } + ] + }, + "production": { + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-server-production" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-production" + } + ] + }, + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail" + } + ] + } + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/do.js b/packages/gitbook-v2/openNext/customWorkers/do.js new file mode 100644 index 000000000..04f3cf3be --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/do.js @@ -0,0 +1,38 @@ +// 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', + }, + }); + }, +}; diff --git a/packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc b/packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc new file mode 100644 index 000000000..f5d2f726b --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc @@ -0,0 +1,127 @@ +{ + "main": "do.js", + "name": "gitbook-open-v2-do", + "compatibility_date": "2025-04-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], + "observability": { + "enabled": true + }, + "env": { + "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" + } + ], + "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"] + } + ] + }, + "staging": { + "vars": { + "STAGE": "staging", + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail" + } + ], + "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"] + } + ] + }, + "production": { + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true", + "STAGE": "production" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail" + } + ], + "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"] + } + ] + } + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/middleware.js b/packages/gitbook-v2/openNext/customWorkers/middleware.js new file mode 100644 index 000000000..78a84a976 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/middleware.js @@ -0,0 +1,42 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; +import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js'; + +import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs'; + +export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js'; + +export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js'; + +export default class extends WorkerEntrypoint { + async fetch(request) { + return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => { + // - `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 + 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 new file mode 100644 index 000000000..09e48afcc --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc @@ -0,0 +1,216 @@ +{ + "main": "middleware.js", + "name": "gitbook-open-v2", + "compatibility_date": "2025-04-14", + "compatibility_flags": [ + "nodejs_compat", + "allow_importable_env", + "global_fetch_strictly_public" + ], + "assets": { + "directory": "../../.open-next/assets", + "binding": "ASSETS" + }, + "observability": { + "enabled": true + }, + "vars": { + "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" + }, + "env": { + "dev": { + "vars": { + "STAGE": "dev", + "NEXT_PRIVATE_DEBUG_CACHE": "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-dev" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-dev" + } + ] + }, + "preview": { + "vars": { + "STAGE": "preview", + "PREVIEW_HOSTNAME": "TO_REPLACE", + "WORKER_VERSION_ID": "TO_REPLACE" + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-preview" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-preview" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-preview" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-preview" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-preview" + } + ] + } + }, + "staging": { + "vars": { + "STAGE": "staging", + "WORKER_VERSION_ID": "TO_REPLACE" + }, + "routes": [ + { + "pattern": "open-2c.gitbook-staging.com/*", + "zone_name": "gitbook-staging.com" + }, + { + "pattern": "static-2c.gitbook-staging.com/*", + "zone_name": "gitbook-staging.com" + } + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-staging" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-staging" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-staging" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-staging" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-staging" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"] + } + ] + }, + "production": { + "vars": { + // This is a bit misleading, but it means that we can have 500 concurrent revalidations + // This means that we'll have up to 100 durable objects instance running at the same time + "MAX_REVALIDATE_CONCURRENCY": "100", + // Temporary variable to find the issue once deployed + // TODO: remove this once the issue is fixed + "DEBUG_CLOUDFLARE": "true", + "WORKER_VERSION_ID": "TO_REPLACE", + "STAGE": "production" + }, + "routes": [ + { + "pattern": "open-2c.gitbook.com/*", + "zone_name": "gitbook.com" + }, + { + "pattern": "static-2c.gitbook.com/*", + "zone_name": "gitbook.com" + } + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "gitbook-open-v2-cache-production" + } + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "gitbook-open-v2-production" + }, + { + "binding": "DEFAULT_WORKER", + "service": "gitbook-open-v2-server-production" + } + ], + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "WRITE_BUFFER", + "class_name": "R2WriteBuffer", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_TAG_CACHE_DO_SHARDED", + "class_name": "DOShardedTagCache", + "script_name": "gitbook-open-v2-do-production" + }, + { + "name": "NEXT_CACHE_DO_QUEUE", + "class_name": "DOQueueHandler", + "script_name": "gitbook-open-v2-do-production" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"] + } + ] + } + } +} diff --git a/packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts b/packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts new file mode 100644 index 000000000..0fdbf6cc7 --- /dev/null +++ b/packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts @@ -0,0 +1,26 @@ +// In this script, we use the args from the cli to update the PREVIEW_URL vars in the wrangler config file for the middleware +import fs from 'node:fs'; +import path from 'node:path'; + +const wranglerConfigPath = path.join(__dirname, '../middlewareWrangler.jsonc'); + +const file = fs.readFileSync(wranglerConfigPath, 'utf-8'); + +const args = process.argv.slice(2); +// The versionId is in the format xxx-xxx-xxx-xxx, we need the first part to reconstruct the preview URL +const versionId = args[0]; + +// The preview URL is in the format https://-gitbook-open-v2-server-preview.gitbook.workers.dev +const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`; + +let updatedFile = file.replace( + /"PREVIEW_HOSTNAME": "TO_REPLACE"/, + `"PREVIEW_HOSTNAME": "${previewHostname}"` +); + +updatedFile = updatedFile.replaceAll( + /"WORKER_VERSION_ID": "TO_REPLACE"/g, + `"WORKER_VERSION_ID": "${versionId}"` +); + +fs.writeFileSync(wranglerConfigPath, updatedFile); diff --git a/packages/gitbook-v2/openNext/incrementalCache.ts b/packages/gitbook-v2/openNext/incrementalCache.ts index d4a662af0..28d1d6b8a 100644 --- a/packages/gitbook-v2/openNext/incrementalCache.ts +++ b/packages/gitbook-v2/openNext/incrementalCache.ts @@ -9,6 +9,8 @@ import type { } from '@opennextjs/aws/types/overrides.js'; import { getCloudflareContext } from '@opennextjs/cloudflare'; +import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; + export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; export const DEFAULT_PREFIX = 'incremental-cache'; @@ -79,12 +81,10 @@ class GitbookIncrementalCache implements IncrementalCache { }, async (span) => { span.setAttribute('cacheType', cacheType ?? 'cache'); - const r2 = getCloudflareContext().env[BINDING_NAME]; const localCache = await this.getCacheInstance(); - if (!r2) throw new Error('No R2 bucket'); try { - await r2.put(cacheKey, JSON.stringify(value)); + await this.writeToR2(cacheKey, JSON.stringify(value)); //TODO: Check if there is any places where we don't have tags // Ideally we should always have tags, but in case we don't, we need to decide how to handle it @@ -145,6 +145,22 @@ class GitbookIncrementalCache implements IncrementalCache { ); } + async writeToR2(key: string, value: string): Promise { + const env = getCloudflareContext().env as { + WRITE_BUFFER: DurableObjectNamespace< + Rpc.DurableObjectBranded & { + write: (key: string, value: string) => Promise; + } + >; + }; + const id = env.WRITE_BUFFER.idFromName(key); + + // A stub is a client used to invoke methods on the Durable Object + const stub = env.WRITE_BUFFER.get(id); + + await stub.write(key, value); + } + async getCacheInstance(): Promise { if (this.localCache) return this.localCache; this.localCache = await caches.open('incremental-cache'); diff --git a/packages/gitbook-v2/openNext/queue.ts b/packages/gitbook-v2/openNext/queue.ts deleted file mode 100644 index ab33c479d..000000000 --- a/packages/gitbook-v2/openNext/queue.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Queue } from '@opennextjs/aws/types/overrides.js'; -import { getCloudflareContext } from '@opennextjs/cloudflare'; -import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; -import memoryQueue from '@opennextjs/cloudflare/overrides/queue/memory-queue'; - -interface Env { - IS_PREVIEW?: string; -} - -export default { - name: 'GitbookISRQueue', - send: async (msg) => { - const { ctx, env } = getCloudflareContext(); - const isPreview = (env as Env).IS_PREVIEW === 'true'; - ctx.waitUntil(isPreview ? memoryQueue.send(msg) : doQueue.send(msg)); - }, -} satisfies Queue; diff --git a/packages/gitbook-v2/openNext/queue/middleware.ts b/packages/gitbook-v2/openNext/queue/middleware.ts new file mode 100644 index 000000000..2a14dc1d8 --- /dev/null +++ b/packages/gitbook-v2/openNext/queue/middleware.ts @@ -0,0 +1,21 @@ +import { trace } from '@/lib/tracing'; +import type { Queue } from '@opennextjs/aws/types/overrides.js'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; +import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; +import memoryQueue from '@opennextjs/cloudflare/overrides/queue/memory-queue'; + +interface Env { + STAGE?: string; +} + +export default { + name: 'GitbookISRQueue', + send: async (msg) => { + return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => { + const { ctx, env } = getCloudflareContext(); + const hasDurableObject = + (env as Env).STAGE !== 'dev' && (env as Env).STAGE !== 'preview'; + ctx.waitUntil(hasDurableObject ? memoryQueue.send(msg) : doQueue.send(msg)); + }); + }, +} satisfies Queue; diff --git a/packages/gitbook-v2/openNext/queue/server.ts b/packages/gitbook-v2/openNext/queue/server.ts new file mode 100644 index 000000000..9a5b3b689 --- /dev/null +++ b/packages/gitbook-v2/openNext/queue/server.ts @@ -0,0 +1,9 @@ +import type { Queue } from '@opennextjs/aws/types/overrides.js'; + +export default { + name: 'GitbookISRQueue', + send: async (msg) => { + // We should never reach this point in the server. If that's the case, we should log it. + console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg); + }, +} satisfies Queue; diff --git a/packages/gitbook-v2/openNext/tagCache/middleware.ts b/packages/gitbook-v2/openNext/tagCache/middleware.ts new file mode 100644 index 000000000..398ceee0d --- /dev/null +++ b/packages/gitbook-v2/openNext/tagCache/middleware.ts @@ -0,0 +1,78 @@ +import { trace } from '@/lib/tracing'; +import type { NextModeTagCache } 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'; + +const originalTagCache = doShardedTagCache({ + baseShardSize: 12, + regionalCache: true, + // We can probably increase this value even further + regionalCacheTtlSec: 60, + shardReplication: { + numberOfSoftReplicas: 2, + numberOfHardReplicas: 1, + }, +}); + +export default { + name: 'GitbookTagCache', + mode: 'nextMode', + getLastRevalidated: async (tags: string[]) => { + const tagsToCheck = tags.filter(softTagFilter); + if (tagsToCheck.length === 0) { + // If we reach here, it probably means that there is an issue that we'll need to address. + console.warn( + 'getLastRevalidated - No valid tags to check for last revalidation, original tags:', + tags + ); + return 0; // If no tags to check, return 0 + } + return trace( + { + operation: 'gitbookTagCacheGetLastRevalidated', + name: tagsToCheck.join(', '), + }, + async () => { + return await originalTagCache.getLastRevalidated(tagsToCheck); + } + ); + }, + hasBeenRevalidated: async (tags: string[], lastModified?: number) => { + const tagsToCheck = tags.filter(softTagFilter); + if (tagsToCheck.length === 0) { + // If we reach here, it probably means that there is an issue that we'll need to address. + console.warn( + 'hasBeenRevalidated - No valid tags to check for revalidation, original tags:', + tags + ); + return false; // If no tags to check, return false + } + return trace( + { + operation: 'gitbookTagCacheHasBeenRevalidated', + name: tagsToCheck.join(', '), + }, + async () => { + const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified); + return result; + } + ); + }, + writeTags: async (tags: string[]) => { + return trace( + { + operation: 'gitbookTagCacheWriteTags', + name: tags.join(', '), + }, + async () => { + const tagsToWrite = tags.filter(softTagFilter); + if (tagsToWrite.length === 0) { + console.warn('writeTags - No valid tags to write'); + return; // If no tags to write, exit early + } + // Write only the filtered tags + await originalTagCache.writeTags(tagsToWrite); + } + ); + }, +} satisfies NextModeTagCache; diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 5561b7f8d..224d55e30 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -30,6 +30,8 @@ "start": "next start", "build:v2:cloudflare": "opennextjs-cloudflare 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", "unit": "bun test", "typecheck": "tsc --noEmit" } diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 7a2d03d16..f8ae6d271 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -2,14 +2,13 @@ import { trace } from '@/lib/tracing'; import { type ComputedContentSource, GitBookAPI, - type GitBookAPIServiceBinding, type HttpResponse, type RenderIntegrationUI, } from '@gitbook/api'; import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env'; import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; -import { getCloudflareContext, getCloudflareRequestGlobal } from './cloudflare'; +import { getCloudflareRequestGlobal } from './cloudflare'; import { DataFetcherError, wrapDataFetcherError } from './errors'; import { withCacheKey, withoutConcurrentExecution } from './memoize'; import type { GitBookDataFetcher } from './types'; @@ -828,36 +827,16 @@ async function* streamAIResponse( } } -let loggedServiceBinding = false; - /** * Create a new API client. */ export function apiClient(input: DataFetcherInput = { apiToken: null }) { const { apiToken } = input; - let serviceBinding: GitBookAPIServiceBinding | undefined; - - const cloudflareContext = getCloudflareContext(); - if (cloudflareContext) { - // @ts-expect-error - serviceBinding = cloudflareContext.env.GITBOOK_API as GitBookAPIServiceBinding | undefined; - if (!loggedServiceBinding) { - loggedServiceBinding = true; - if (serviceBinding) { - // biome-ignore lint/suspicious/noConsole: we want to log here - console.log(`using service binding for the API (${GITBOOK_API_URL})`); - } else { - // biome-ignore lint/suspicious/noConsole: we want to log here - console.warn(`no service binding for the API (${GITBOOK_API_URL})`); - } - } - } const api = new GitBookAPI({ authToken: apiToken || GITBOOK_API_TOKEN || undefined, endpoint: GITBOOK_API_URL, userAgent: GITBOOK_USER_AGENT, - serviceBinding, }); return api; diff --git a/packages/gitbook-v2/src/lib/data/index.ts b/packages/gitbook-v2/src/lib/data/index.ts index d79049684..2e37e2fbb 100644 --- a/packages/gitbook-v2/src/lib/data/index.ts +++ b/packages/gitbook-v2/src/lib/data/index.ts @@ -4,5 +4,4 @@ export * from './pages'; export * from './urls'; export * from './errors'; export * from './lookup'; -export * from './proxy'; export * from './visitor'; diff --git a/packages/gitbook-v2/src/lib/data/visitor.ts b/packages/gitbook-v2/src/lib/data/visitor.ts index f93f0afe8..e59e32365 100644 --- a/packages/gitbook-v2/src/lib/data/visitor.ts +++ b/packages/gitbook-v2/src/lib/data/visitor.ts @@ -1,6 +1,6 @@ import { withLeadingSlash, withTrailingSlash } from '@/lib/paths'; import type { PublishedSiteContent } from '@gitbook/api'; -import { getProxyRequestIdentifier, isProxyRequest } from './proxy'; +import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy'; /** * Get the appropriate base path for the visitor authentication cookie. diff --git a/packages/gitbook-v2/src/lib/images/getImageResizingContextId.test.ts b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.test.ts new file mode 100644 index 000000000..eaba01663 --- /dev/null +++ b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'bun:test'; +import { getImageResizingContextId } from './getImageResizingContextId'; + +describe('getImageResizingContextId', () => { + it('should return proxy identifier for proxy requests', () => { + const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world'); + expect(getImageResizingContextId(proxyRequestURL)).toBe('sites/site_foo'); + }); + + it('should return preview identifier for preview requests', () => { + const previewRequestURL = new URL('https://preview/site_foo/hello/world'); + expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo'); + }); + + it('should return host for regular requests', () => { + const regularRequestURL = new URL('https://example.com/docs/foo/hello/world'); + expect(getImageResizingContextId(regularRequestURL)).toBe('example.com'); + }); +}); diff --git a/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts index 82f922526..40594e2ae 100644 --- a/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts +++ b/packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts @@ -1,4 +1,5 @@ -import { getProxyRequestIdentifier, isProxyRequest } from '../data'; +import { getPreviewRequestIdentifier, isPreviewRequest } from '@v2/lib/preview'; +import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy'; /** * Get the site identifier to use for image resizing for an incoming request. @@ -8,6 +9,9 @@ export function getImageResizingContextId(url: URL): string { if (isProxyRequest(url)) { return getProxyRequestIdentifier(url); } + if (isPreviewRequest(url)) { + return getPreviewRequestIdentifier(url); + } return url.host; } diff --git a/packages/gitbook-v2/src/lib/links.test.ts b/packages/gitbook-v2/src/lib/links.test.ts index 26a2464f5..f73ec5ee8 100644 --- a/packages/gitbook-v2/src/lib/links.test.ts +++ b/packages/gitbook-v2/src/lib/links.test.ts @@ -19,7 +19,7 @@ const siteGitBookIO = createLinker({ siteBasePath: '/sitename/', }); -describe('toPathInContent', () => { +describe('toPathInSpace', () => { it('should return the correct path', () => { expect(root.toPathInSpace('some/path')).toBe('/some/path'); expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path'); @@ -29,6 +29,16 @@ describe('toPathInContent', () => { expect(root.toPathInSpace('/some/path')).toBe('/some/path'); expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path'); }); + + it('should remove the trailing slash', () => { + expect(root.toPathInSpace('some/path/')).toBe('/some/path'); + expect(variantInSection.toPathInSpace('some/path/')).toBe('/section/variant/some/path'); + }); + + it('should not add a trailing slash', () => { + expect(root.toPathInSpace('')).toBe(''); + expect(variantInSection.toPathInSpace('')).toBe('/section/variant'); + }); }); describe('toPathInSite', () => { @@ -36,6 +46,16 @@ describe('toPathInSite', () => { expect(root.toPathInSite('some/path')).toBe('/some/path'); expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path'); }); + + it('should remove the trailing slash', () => { + expect(root.toPathInSite('some/path/')).toBe('/some/path'); + expect(siteGitBookIO.toPathInSite('some/path/')).toBe('/sitename/some/path'); + }); + + it('should not add a trailing slash', () => { + expect(root.toPathInSite('')).toBe(''); + expect(siteGitBookIO.toPathInSite('')).toBe('/sitename'); + }); }); describe('toRelativePathInSite', () => { diff --git a/packages/gitbook-v2/src/lib/links.ts b/packages/gitbook-v2/src/lib/links.ts index b84565d1e..a64bda541 100644 --- a/packages/gitbook-v2/src/lib/links.ts +++ b/packages/gitbook-v2/src/lib/links.ts @@ -128,5 +128,9 @@ export function createLinker( function joinPaths(prefix: string, path: string): string { const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`; const suffixPath = path.startsWith('/') ? path.slice(1) : path; - return prefixPath + suffixPath; + return removeTrailingSlash(prefixPath + suffixPath); +} + +function removeTrailingSlash(path: string): string { + return path.endsWith('/') ? path.slice(0, -1) : path; } diff --git a/packages/gitbook-v2/src/lib/preview.test.ts b/packages/gitbook-v2/src/lib/preview.test.ts new file mode 100644 index 000000000..bbaf0402b --- /dev/null +++ b/packages/gitbook-v2/src/lib/preview.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'bun:test'; +import { getPreviewRequestIdentifier, isPreviewRequest } from './preview'; + +describe('isPreviewRequest', () => { + it('should return true for preview requests', () => { + const previewRequestURL = new URL('https://preview/site_foo/hello/world'); + expect(isPreviewRequest(previewRequestURL)).toBe(true); + }); + + it('should return false for non-preview requests', () => { + const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world'); + expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false); + }); +}); + +describe('getPreviewRequestIdentifier', () => { + it('should return the correct identifier for preview requests', () => { + const previewRequestURL = new URL('https://preview/site_foo/hello/world'); + expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo'); + }); +}); diff --git a/packages/gitbook-v2/src/lib/preview.ts b/packages/gitbook-v2/src/lib/preview.ts new file mode 100644 index 000000000..7094d1197 --- /dev/null +++ b/packages/gitbook-v2/src/lib/preview.ts @@ -0,0 +1,13 @@ +/** + * Check if the request to the site is a preview request. + */ +export function isPreviewRequest(requestURL: URL): boolean { + return requestURL.host === 'preview'; +} + +export function getPreviewRequestIdentifier(requestURL: URL): string { + // For preview requests, we extract the site ID from the pathname + // e.g. https://preview/site_id/... + const pathname = requestURL.pathname.slice(1).split('/'); + return pathname[0]; +} diff --git a/packages/gitbook-v2/src/lib/data/proxy.test.ts b/packages/gitbook-v2/src/lib/proxy.test.ts similarity index 100% rename from packages/gitbook-v2/src/lib/data/proxy.test.ts rename to packages/gitbook-v2/src/lib/proxy.test.ts diff --git a/packages/gitbook-v2/src/lib/data/proxy.ts b/packages/gitbook-v2/src/lib/proxy.ts similarity index 100% rename from packages/gitbook-v2/src/lib/data/proxy.ts rename to packages/gitbook-v2/src/lib/proxy.ts diff --git a/packages/gitbook-v2/src/pages/api/~gitbook/force-revalidate.ts b/packages/gitbook-v2/src/pages/api/~gitbook/force-revalidate.ts new file mode 100644 index 000000000..45e6c7eca --- /dev/null +++ b/packages/gitbook-v2/src/pages/api/~gitbook/force-revalidate.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +interface JsonBody { + // The paths need to be the rewritten one, `res.revalidate` call don't go through the middleware + paths: string[]; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Only allow POST requests + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + const signatureHeader = req.headers['x-gitbook-signature'] as string | undefined; + if (!signatureHeader) { + return res.status(400).json({ error: 'Missing signature header' }); + } + // We cannot use env from `@/v2/lib/env` here as it make it crash because of the import "server-only" in the file. + if (process.env.GITBOOK_SECRET) { + try { + const computedSignature = crypto + .createHmac('sha256', process.env.GITBOOK_SECRET) + .update(JSON.stringify(req.body)) + .digest('hex'); + + if (computedSignature === signatureHeader) { + const results = await Promise.allSettled( + (req.body as JsonBody).paths.map((path) => { + // biome-ignore lint/suspicious/noConsole: we want to log here + console.log(`Revalidating path: ${path}`); + return res.revalidate(path); + }) + ); + return res.status(200).json({ + success: results.every((result) => result.status === 'fulfilled'), + errors: results + .filter((result) => result.status === 'rejected') + .map((result) => (result as PromiseRejectedResult).reason), + }); + } + return res.status(401).json({ error: 'Invalid signature' }); + } catch (error) { + console.error('Error during revalidation:', error); + return res.status(400).json({ error: 'Invalid request or unable to parse JSON' }); + } + } + // If no secret is set, we do not allow revalidation + return res.status(403).json({ error: 'Revalidation is disabled' }); +} diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx index aa1bf45b8..50dc0d827 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx @@ -115,7 +115,8 @@ export async function RecordColumnValue( return {''}; } - const horizontalAlignment = `[&_*]:${getColumnAlignment(definition)} ${getColumnAlignment(definition)}`; + const horizontalAlignment = getColumnAlignment(definition); + const childrenHorizontalAlignment = `[&_*]:${horizontalAlignment}`; return ( ( 'leading-normal', verticalAlignment, horizontalAlignment, + childrenHorizontalAlignment, ]} context={context} blockStyle={['w-full', 'max-w-[unset]']} diff --git a/packages/gitbook/src/components/Header/SpacesDropdown.tsx b/packages/gitbook/src/components/Header/SpacesDropdown.tsx index 9728216b7..3fc133523 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdown.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdown.tsx @@ -2,6 +2,7 @@ import type { SiteSpace } from '@gitbook/api'; import { tcls } from '@/lib/tailwind'; +import { joinPath } from '@/lib/paths'; import type { GitBookSiteContext } from '@v2/lib/context'; import { DropdownChevron, DropdownMenu } from './DropdownMenu'; import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem'; @@ -74,7 +75,7 @@ export function SpacesDropdown(props: { title: otherSiteSpace.title, url: otherSiteSpace.urls.published ? linker.toLinkForContent(otherSiteSpace.urls.published) - : otherSiteSpace.space.urls.app, + : getFallbackSiteSpaceURL(otherSiteSpace, context), }} active={otherSiteSpace.id === siteSpace.id} /> @@ -82,3 +83,14 @@ export function SpacesDropdown(props: { ); } + +/** + * When the site is not published yet, `urls.published` is not available. + * To ensure navigation works in preview, we compute a relative URL from the siteSpace path. + */ +function getFallbackSiteSpaceURL(siteSpace: SiteSpace, context: GitBookSiteContext) { + const { linker, sections } = context; + return linker.toPathInSite( + sections?.current ? joinPath(sections.current.path, siteSpace.path) : siteSpace.path + ); +} diff --git a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts index 79a007ecc..49b73a8e1 100644 --- a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts +++ b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts @@ -53,6 +53,8 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection) { description: section.description, icon: section.icon, object: section.object, - url: section.urls.published ? linker.toLinkForContent(section.urls.published) : '', + url: section.urls.published + ? linker.toLinkForContent(section.urls.published) + : linker.toPathInSite(section.path), }; } diff --git a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx index 2ff3289d7..4875633ac 100644 --- a/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageDocumentItem.tsx @@ -17,7 +17,11 @@ export async function PageDocumentItem(props: { context: GitBookSiteContext; }) { const { rootPages, page, context } = props; - const href = context.linker.toPathForPage({ pages: rootPages, page }); + let href = context.linker.toPathForPage({ pages: rootPages, page }); + // toPathForPage can returns an empty path, this will cause all links to point to the current page. + if (href === '') { + href = '/'; + } return (
  • diff --git a/packages/gitbook/src/routes/ogimage.tsx b/packages/gitbook/src/routes/ogimage.tsx index c1cfd9772..b086ffd1c 100644 --- a/packages/gitbook/src/routes/ogimage.tsx +++ b/packages/gitbook/src/routes/ogimage.tsx @@ -9,7 +9,6 @@ import { getAssetURL } from '@/lib/assets'; import { filterOutNullable } from '@/lib/typescript'; import { getCacheTag } from '@gitbook/cache-tags'; import type { GitBookSiteContext } from '@v2/lib/context'; -import { getCloudflareContext } from '@v2/lib/data/cloudflare'; import { getResizedImageURL } from '@v2/lib/images'; const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = { @@ -73,8 +72,12 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page const fonts = ( await Promise.all([ - loadGoogleFont({ fontFamily, text: regularText, weight: 400 }), - loadGoogleFont({ fontFamily, text: boldText, weight: 700 }), + getWithCache(`google-font:${fontFamily}:400`, () => + loadGoogleFont({ fontFamily, text: regularText, weight: 400 }) + ), + getWithCache(`google-font:${fontFamily}:700`, () => + loadGoogleFont({ fontFamily, text: boldText, weight: 700 }) + ), ]) ).filter(filterOutNullable); @@ -322,24 +325,6 @@ function logOnCloudflareOnly(message: string) { } } -/** - * Fetch a resource from the function itself. - * To avoid error with worker to worker requests in the same zone, we use the `WORKER_SELF_REFERENCE` binding. - */ -async function fetchSelf(url: string) { - const cloudflare = getCloudflareContext(); - if (cloudflare?.env.WORKER_SELF_REFERENCE) { - logOnCloudflareOnly(`Fetching self: ${url}`); - return await cloudflare.env.WORKER_SELF_REFERENCE.fetch( - // `getAssetURL` can return a relative URL, so we need to make it absolute - // the URL doesn't matter, as we're using the worker-self-reference binding - new URL(url, 'https://worker-self-reference/') - ); - } - - return await fetch(url); -} - /** * Read an image from a response as a base64 encoded string. */ @@ -357,27 +342,34 @@ async function readImage(response: Response) { return `data:${contentType};base64,${base64}`; } -const staticImagesCache = new Map(); +// biome-ignore lint/suspicious/noExplicitAny: +const staticCache = new Map(); + +// Do we need to limit the in-memory cache size? I think given the usage, we should be fine. +async function getWithCache(key: string, fn: () => Promise) { + const cached = staticCache.get(key) as T; + if (cached) { + return Promise.resolve(cached); + } + + const result = await fn(); + staticCache.set(key, result); + return result; +} /** * Read a static image and cache it in memory. */ async function readStaticImage(url: string) { - const cached = staticImagesCache.get(url); - if (cached) { - return cached; - } - - const image = await readSelfImage(url); - staticImagesCache.set(url, image); - return image; + logOnCloudflareOnly(`Reading static image: ${url}, cache size: ${staticCache.size}`); + return getWithCache(`static-image:${url}`, () => readSelfImage(url)); } /** * Read an image from GitBook itself. */ async function readSelfImage(url: string) { - const response = await fetchSelf(url); + const response = await fetch(url); const image = await readImage(response); return image; } diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 8b67bbedc..8bffb6d9a 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -312,6 +312,11 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { [name]: 'YOUR_API_KEY', }; } + case 'oauth2': { + return { + Authorization: 'Bearer YOUR_OAUTH2_TOKEN', + }; + } default: { return {}; } diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx index 49c41cda4..1e6e56269 100644 --- a/packages/react-openapi/src/OpenAPISpec.tsx +++ b/packages/react-openapi/src/OpenAPISpec.tsx @@ -18,7 +18,7 @@ export function OpenAPISpec(props: { const { operation } = data; - const parameters = operation.parameters ?? []; + const parameters = deduplicateParameters(operation.parameters ?? []); const parameterGroups = groupParameters(parameters, context); const securities = 'securities' in data ? data.securities : []; @@ -113,3 +113,23 @@ function getParameterGroupName(paramIn: string, context: OpenAPIClientContext): return paramIn; } } + +/** Deduplicate parameters by name and in. + * Some specs have both parameters define at path and operation level. + * We only want to display one of them. + */ +function deduplicateParameters(parameters: OpenAPI.Parameters): OpenAPI.Parameters { + const seen = new Set(); + + return parameters.filter((param) => { + const key = `${param.name}:${param.in}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + + return true; + }); +} diff --git a/packages/react-openapi/src/code-samples.test.ts b/packages/react-openapi/src/code-samples.test.ts index 7fd24bb3f..375cee84b 100644 --- a/packages/react-openapi/src/code-samples.test.ts +++ b/packages/react-openapi/src/code-samples.test.ts @@ -400,7 +400,7 @@ describe('python code sample generator', () => { const output = generator?.generate(input); expect(output).toBe( - 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={"key":"value"}\n)\n\ndata = response.json()' + 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={\n "key": "value"\n }\n)\n\ndata = response.json()' ); }); @@ -422,7 +422,7 @@ describe('python code sample generator', () => { const output = generator?.generate(input); expect(output).toBe( - 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({"key":"value","truethy":True,"falsey":False,"nullish":None})\n)\n\ndata = response.json()' + 'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({\n "key": "value",\n "truethy": True,\n "falsey": False,\n "nullish": None\n })\n)\n\ndata = response.json()' ); }); diff --git a/packages/react-openapi/src/code-samples.ts b/packages/react-openapi/src/code-samples.ts index 50c6a9204..8d855bbb6 100644 --- a/packages/react-openapi/src/code-samples.ts +++ b/packages/react-openapi/src/code-samples.ts @@ -356,18 +356,22 @@ const BodyGenerators = { // Convert JSON to XML if needed body = JSON.stringify(convertBodyToXML(body)); } else { - body = stringifyOpenAPI(body, (_key, value) => { - switch (value) { - case true: - return '$$__TRUE__$$'; - case false: - return '$$__FALSE__$$'; - case null: - return '$$__NULL__$$'; - default: - return value; - } - }) + body = stringifyOpenAPI( + body, + (_key, value) => { + switch (value) { + case true: + return '$$__TRUE__$$'; + case false: + return '$$__FALSE__$$'; + case null: + return '$$__NULL__$$'; + default: + return value; + } + }, + 2 + ) .replaceAll('"$$__TRUE__$$"', 'True') .replaceAll('"$$__FALSE__$$"', 'False') .replaceAll('"$$__NULL__$$"', 'None'); diff --git a/packages/react-openapi/src/generateSchemaExample.test.ts b/packages/react-openapi/src/generateSchemaExample.test.ts index 3181881b6..682c5f2f3 100644 --- a/packages/react-openapi/src/generateSchemaExample.test.ts +++ b/packages/react-openapi/src/generateSchemaExample.test.ts @@ -1017,4 +1017,24 @@ describe('generateSchemaExample', () => { }, }); }); + + it('handles deprecated properties', () => { + expect( + generateSchemaExample({ + type: 'object', + deprecated: true, + }) + ).toBeUndefined(); + }); + + it('handle nested deprecated properties', () => { + expect( + generateSchemaExample({ + type: 'array', + items: { + deprecated: true, + }, + }) + ).toBeUndefined(); + }); }); diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts index 5a038db06..595f723d9 100644 --- a/packages/react-openapi/src/generateSchemaExample.ts +++ b/packages/react-openapi/src/generateSchemaExample.ts @@ -167,7 +167,7 @@ const getExampleFromSchema = ( const makeUpRandomData = !!options?.emptyString; // If the property is deprecated we don't show it in examples. - if (schema.deprecated) { + if (schema.deprecated || (schema.type === 'array' && schema.items?.deprecated)) { return undefined; }