diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 01fe92523..63119f340 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -19,6 +19,12 @@ inputs: deploy: description: 'Deploy as main version for all traffic instead of uploading versions' required: true + commitTag: + description: 'Commit branch to associate with the deployment' + required: true + commitMessage: + description: 'Commit message to associate with the deployment' + required: true outputs: deployment-url: description: "Deployment URL" @@ -65,7 +71,7 @@ runs: workingDirectory: ./ wranglerVersion: '3.112.0' environment: ${{ inputs.environment }} - command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.jsonc + command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc - name: Outputs shell: bash env: diff --git a/.github/workflows/deploy-preview.yaml b/.github/workflows/deploy-preview.yaml index e7ce2a6a7..4926cf07f 100644 --- a/.github/workflows/deploy-preview.yaml +++ b/.github/workflows/deploy-preview.yaml @@ -40,6 +40,7 @@ jobs: run: bun run turbo gitbook#build:cloudflare env: NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }} + GITBOOK_RUNTIME: cloudflare - id: deploy name: Deploy to Cloudflare uses: cloudflare/wrangler-action@v3.14.0 @@ -95,6 +96,8 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} opItem: op://gitbook-open/2c-preview opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }} + commitMessage: ${{ github.sha }} - name: Outputs run: | echo "URL: ${{ steps.deploy.outputs.deployment-url }}" diff --git a/.github/workflows/deploy-production.yaml b/.github/workflows/deploy-production.yaml index 5b6fc8b09..9d5bb3abb 100644 --- a/.github/workflows/deploy-production.yaml +++ b/.github/workflows/deploy-production.yaml @@ -48,6 +48,8 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} opItem: op://gitbook-open/2c-production opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + commitTag: main + commitMessage: ${{ github.sha }} - name: Outputs run: | echo "URL: ${{ steps.deploy.outputs.deployment-url }}" \ No newline at end of file diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 1a77ebc3a..ed2f290c7 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -48,6 +48,8 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} opItem: op://gitbook-open/2c-staging opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + commitTag: main + commitMessage: ${{ github.sha }} - name: Outputs run: | echo "URL: ${{ steps.deploy.outputs.deployment-url }}" \ No newline at end of file diff --git a/packages/gitbook-v2/src/lib/images/signatures.ts b/packages/gitbook-v2/src/lib/images/signatures.ts index a56b4a7ce..21834a69c 100644 --- a/packages/gitbook-v2/src/lib/images/signatures.ts +++ b/packages/gitbook-v2/src/lib/images/signatures.ts @@ -34,7 +34,9 @@ export async function verifyImageSignature( const generated = await generator(input); // biome-ignore lint/suspicious/noConsole: we want to log the signature comparison - console.log(`comparing image signature "${generated}" (expected) === "${signature}" (actual)`); + console.log( + `comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)` + ); return generated === signature; } @@ -69,7 +71,8 @@ const generateSignatureV2: SignFn = async (input) => { .filter(Boolean) .join(':'); - return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16); + const signature = fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16); + return signature; }; // Reused buffer for FNV-1a hashing in the v1 algorithm diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index ae5aa4a36..ef46c17fb 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -167,6 +167,23 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // (customization override, theme, etc) let routeType: 'dynamic' | 'static' = 'static'; + // We pick only stable data from the siteURL data to prevent re-rendering of + // the root layout when changing pages.. + const stableSiteURLData: SiteURLData = { + site: siteURLData.site, + siteSection: siteURLData.siteSection, + siteSpace: siteURLData.siteSpace, + siteBasePath: siteURLData.siteBasePath, + basePath: siteURLData.basePath, + space: siteURLData.space, + organization: siteURLData.organization, + changeRequest: siteURLData.changeRequest, + revision: siteURLData.revision, + shareKey: siteURLData.shareKey, + apiToken: siteURLData.apiToken, + imagesContextId: imagesContextId, + }; + const requestHeaders = new Headers(request.headers); requestHeaders.set(MiddlewareHeaders.RouteType, routeType); requestHeaders.set(MiddlewareHeaders.URLMode, mode); @@ -174,7 +191,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { MiddlewareHeaders.SiteURL, `${siteCanonicalURL.origin}${siteURLData.basePath}` ); - requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData)); + requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData)); // Preview of customization/theme const customization = siteRequestURL.searchParams.get('customization'); @@ -204,23 +221,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { ); routeType = routeTypeFromPathname ?? routeType; - // We pick only stable data from the siteURL data to prevent re-rendering of - // the root layout when changing pages.. - const stableSiteURLData: SiteURLData = { - site: siteURLData.site, - siteSection: siteURLData.siteSection, - siteSpace: siteURLData.siteSpace, - siteBasePath: siteURLData.siteBasePath, - basePath: siteURLData.basePath, - space: siteURLData.space, - organization: siteURLData.organization, - changeRequest: siteURLData.changeRequest, - revision: siteURLData.revision, - shareKey: siteURLData.shareKey, - apiToken: siteURLData.apiToken, - imagesContextId: imagesContextId, - }; - const route = [ 'sites', routeType, diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts index 7b3b0ce26..73d5df162 100644 --- a/packages/gitbook/e2e/customers.spec.ts +++ b/packages/gitbook/e2e/customers.spec.ts @@ -92,11 +92,6 @@ const testCases: TestsCase[] = [ contentBaseURL: 'https://book.character.ai', tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], }, - { - name: 'docs.tradeonnova.io', - contentBaseURL: 'https://docs.tradeonnova.io', - tests: [{ name: 'Home', url: '/' }], - }, { name: 'azcoiner.gitbook.io', contentBaseURL: 'https://azcoiner.gitbook.io', diff --git a/packages/gitbook/next.config.js b/packages/gitbook/next.config.js index 8aec4f2e7..23c575ec1 100644 --- a/packages/gitbook/next.config.js +++ b/packages/gitbook/next.config.js @@ -5,6 +5,7 @@ module.exports = { GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL, GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, + GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME, }, webpack(config) { diff --git a/packages/gitbook/src/routes/icon.tsx b/packages/gitbook/src/routes/icon.tsx index 425bb4b84..03a6f3522 100644 --- a/packages/gitbook/src/routes/icon.tsx +++ b/packages/gitbook/src/routes/icon.tsx @@ -3,6 +3,7 @@ import { ImageResponse } from 'next/og'; import { getEmojiForCode } from '@/lib/emojis'; import { tcls } from '@/lib/tailwind'; +import { getCacheTag } from '@gitbook/cache-tags'; import type { GitBookSiteContext } from '@v2/lib/context'; import { getResizedImageURL } from '@v2/lib/images'; @@ -73,6 +74,14 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) { { width: size.width, height: size.height, + headers: { + 'cache-tag': [ + getCacheTag({ + tag: 'site', + site: context.site.id, + }), + ].join(','), + }, } ); } diff --git a/packages/gitbook/src/routes/ogimage.tsx b/packages/gitbook/src/routes/ogimage.tsx index 59a7a66f1..f6f04db6f 100644 --- a/packages/gitbook/src/routes/ogimage.tsx +++ b/packages/gitbook/src/routes/ogimage.tsx @@ -7,7 +7,9 @@ import { type PageParams, fetchPageData } from '@/components/SitePage'; import { getFontSourcesToPreload } from '@/fonts/custom'; 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 } = { @@ -169,8 +171,12 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page {String.fromCodePoint(Number.parseInt(`0x${customization.favicon.emoji}`))} ); - const src = linker.toAbsoluteURL( - linker.toPathInSpace(`~gitbook/icon?size=medium&theme=${customization.themes.default}`) + const src = await readSelfImage( + linker.toAbsoluteURL( + linker.toPathInSpace( + `~gitbook/icon?size=medium&theme=${customization.themes.default}` + ) + ) ); return Icon; })(); @@ -192,7 +198,11 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page /> {/* Grid */} - Grid + Grid {/* Logo */} {customization.header.logo ? ( @@ -228,6 +238,18 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page width: 1200, height: 630, fonts: fonts.length ? fonts : undefined, + headers: { + 'cache-tag': [ + getCacheTag({ + tag: 'site', + site: baseContext.site.id, + }), + getCacheTag({ + tag: 'space', + space: baseContext.space.id, + }), + ].join(','), + }, } ); } @@ -285,3 +307,55 @@ async function loadCustomFont(input: { url: string; weight: 400 | 700 }) { weight, }; } + +/** + * 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) { + return await cloudflare.env.WORKER_SELF_REFERENCE.fetch(url); + } + + return await fetch(url); +} + +/** + * Read an image from a response as a base64 encoded string. + */ +async function readImage(response: Response) { + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.startsWith('image/')) { + throw new Error(`Invalid content type: ${contentType}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString('base64'); + return `data:${contentType};base64,${base64}`; +} + +const staticImagesCache = new Map(); + +/** + * 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; +} + +/** + * Read an image from GitBook itself. + */ +async function readSelfImage(url: string) { + const response = await fetchSelf(url); + const image = await readImage(response); + return image; +}