Compare commits

...

79 Commits

Author SHA1 Message Date
Nicolas Dorseuil 00b5606e8d Update memory allocation for Cloudflare's container instances to meet minimum requirements 2026-08-31 20:07:09 +02:00
Nicolas Dorseuil 6499329758 use similar sized to vercel ones 2026-08-31 19:59:34 +02:00
Nicolas Dorseuil f6e2581c0c Add headSha input to Cloudflare deployment action for improved deployment ID handling 2026-08-31 19:44:31 +02:00
Nicolas Dorseuil 460d08f906 debug log 2026-08-31 18:21:51 +02:00
Nicolas Dorseuil 4a02f872f8 Add build ID handling to cache operations and update related tests
- Introduced build ID management in cache set and delete operations to ensure entries are namespaced correctly.
- Updated the `getReadUrl` function to include build ID in the request URL.
- Enhanced tests to validate build ID inclusion in cache operations.
2026-08-31 17:58:02 +02:00
Nicolas Dorseuil ee8762e065 Send the server tier's build ID to the cache worker to ensure incremental cache entries are namespaced correctly. 2026-08-31 17:44:07 +02:00
Nicolas Dorseuil 82ab9bb091 Update instance type from standard-1 to standard-2 in container configuration 2026-08-31 17:13:49 +02:00
Nicolas Dorseuil f36c3aead3 Update instance type from standard-1 to standard-2 in container configuration 2026-08-31 16:19:21 +02:00
Nicolas Dorseuil b7c5dd3130 Add --x-provision=false to container worker deployment command to prevent unnecessary resource provisioning 2026-08-31 15:57:51 +02:00
Nicolas Dorseuil 72bf155251 Move incremental cache tier from DO worker to container worker, refactor related configurations and tests. 2026-08-31 15:39:40 +02:00
Nicolas Dorseuil 60bc6914f9 Deploy container server tier with CI integration for preview, staging, and production environments 2026-08-31 14:43:04 +02:00
Nicolas Dorseuil 4e93131235 Add container server tier with cache integration and build configuration 2026-08-31 13:33:08 +02:00
Nicolas Dorseuil f80f81032f Refactor action.yaml for improved readability and consistency in step formatting 2026-08-20 10:42:28 +02:00
Nicolas Dorseuil 5356cd9e10 fix rebase 2026-08-19 12:55:56 +02:00
Nicolas Dorseuil 0af066bed8 temporarily add observability for debugging 2026-08-19 11:16:37 +02:00
Nicolas Dorseuil 26b2a6917d Update wrangler version to 4.122.0 in action.yaml, bun.lock, and package.json 2026-08-19 11:16:14 +02:00
Nicolas Dorseuil 82cf22449f script to remove 2026-08-19 11:15:26 +02:00
Nicolas Dorseuil 13eed34ff7 Add tests for IncrementalCacheWorker and enhance cache header management 2026-08-19 11:15:26 +02:00
Nicolas Dorseuil 6733ac3fa0 Enhance deployment process by extracting deployment ID and updating cache key logic; add tests for GitbookIncrementalCache 2026-08-19 11:15:04 +02:00
Nicolas Dorseuil 497c5d325a workaround for opennext config 2026-08-19 11:14:24 +02:00
Nicolas Dorseuil 264f4cc939 Refactor tag cache implementation to use cacheWorkerClient; add tests for tagCache functionality 2026-08-19 11:14:24 +02:00
Nicolas Dorseuil 528d4a2475 Update @cloudflare/workers-types dependency to version 5.20260716.1 2026-08-19 11:13:55 +02:00
Nicolas Dorseuil ad1e0e52af Enhance IncrementalCacheWorker to include Cache-Control and Cache-Tag headers; update compatibility date in doWrangler.jsonc 2026-08-19 11:12:59 +02:00
Nicolas Dorseuil d7b3aa97cd Update wrangler dependency to version 4.110.0 2026-08-19 11:12:59 +02:00
Nicolas Dorseuil 4acb8538f8 Refactor cache worker implementation and update configurations for incremental caching 2026-08-19 11:12:59 +02:00
Zeno Kapitein 195c9e6b84 Apply and remember the ?theme= light/dark override in the docs embed (#4380)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 11:07:23 +02:00
Zeno Kapitein 09f39f8300 Vertically center breadcrumb icons (#4513) 2026-08-19 08:52:10 +00:00
Peter White 9002f6598a Fix embed search suggested questions not opening the assistant (#4514) 2026-08-18 17:19:25 +02:00
Zeno Kapitein 01c9b059c6 Require Ctrl/Cmd to zoom Mermaid diagrams with the wheel (RND-12338) (#4465)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 16:37:49 +02:00
Zeno Kapitein 6d02b8ab72 Fix TS code block comment punctuation color in dark mode (RND-12204) (#4460)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 16:36:07 +02:00
Peter White 177ef8582a Fix Docs Embed host actions not reaching the assistant (#4512) 2026-08-18 16:33:03 +02:00
Zeno Kapitein 78c589ffba Align Previous/Next page navigation to opposite edges (RND-11235) (#4494)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 13:36:08 +02:00
Nolann B. e4b214e6bd Fix large OpenAPI payload section not being scrollable (RND-12291) (#4510) 2026-08-18 13:17:33 +02:00
Tomek 64ce9e180d Render site-space custom home page at root (#4475)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 12:20:52 +02:00
conico974 c64d3a50e8 Refactor CSS compatibility checks (#4504) 2026-08-18 10:30:43 +02:00
spastorelli fd070ce9ca Redirect to the upstream auth logout URL on ~gitbook/auth/logout (#4489) 2026-08-18 10:27:48 +02:00
Tomek 048c4e4c70 Fix tooltip data-popup-open state conflict and AI messages collapsible (#4509)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 10:22:47 +02:00
conico974 fb01dc9ecb Remove unused Cloudflare image resizing function (#4507) 2026-08-17 17:26:32 +02:00
Nolann B. d33e570bc7 Fix the spacebar being swallowed in the search bar (RND-12484) (#4508) 2026-08-17 13:21:21 +00:00
Tomek 87fd234d55 Fix dropdown icon rotation by reading dropdown open state (#4503)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-17 14:55:09 +02:00
Claire Chabas 0f32eb17d1 Fix toggle chevron alignment in ToC (#4505) 2026-08-17 14:41:44 +02:00
Tomek 3e29680792 Stabilize visual CI tests (#4506)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-17 13:14:47 +02:00
Nolann B. b3db1c58c8 Migrate headless UI primitives from Radix and react-aria to Base UI (#4499) 2026-08-15 21:05:32 +02:00
Nolann B. 0f994016f4 Update OpenAPI customer test URL (#4498) 2026-08-13 18:43:33 +02:00
Nolann B. 419094b79d Stop every DOM change from restyling the whole document (#4492) 2026-08-13 17:18:48 +02:00
Greg Bergé 95d23775ce Migrate repository to Oxlint and Oxfmt (#4497) 2026-08-13 13:18:42 +00:00
spastorelli aa98859560 Detect and pass visitor type for site resolution (#4490) 2026-08-13 14:33:10 +02:00
Tomek ea19801cfa feat: add greeting customization to AI chat (#4483) 2026-08-13 11:17:26 +00:00
Peter White 13059b843c Fix Docs Embed being stuck on the Search tab (RND-12435) (#4496) 2026-08-13 06:57:13 +00:00
Johan Preynat 3db14ad950 Fix search result links for page matches (RND-11916) (#4488) 2026-08-12 17:43:57 +02:00
Nolann B. 5fac9e37ee Move the OpenAPI package from react-aria to Base UI (#4473) 2026-08-12 14:53:09 +02:00
Nolann B. 1424c566a0 Load the OpenAPI renderer only on pages that use it (#4472) 2026-08-12 14:53:09 +02:00
conico974 65f99eafe5 Reduce bundle size on the server (#4464) 2026-08-12 13:22:51 +02:00
Tomek 7594a334d8 Show image thumbnails for file attachments (#4486)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-11 13:09:16 +02:00
conico974 827e4f9856 Fix variant switcher URL for non-default variants (#4487) 2026-08-11 13:09:14 +02:00
Peter White a9a1a609c5 Fix sticky search results when switching the search filter RND-12265 (#4485) 2026-08-11 10:15:36 +02:00
conico974 61405a3036 feat: add CSS browser compatibility check for pull requests (#4478) 2026-08-07 15:44:52 +02:00
conico974 634a24ff03 Fix text visibility issues on pages with background covers in Firefox and iOS Safari (#4477) 2026-08-07 12:46:26 +02:00
Nolann B. 2ccd43ee5e Lazy-load the Mermaid code block (#4468) 2026-08-06 08:59:03 +02:00
Nolann B. cd506e3f79 Split the Shiki highlighter from the plain token helpers (#4467) 2026-08-06 08:59:02 +02:00
Brett Jephson a6644074cb Fix: select no longer stored in url (#4466)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:59:56 +01:00
conico974 4be043d96e Add support for visitor token revalidation without redirection (#4463) 2026-08-05 09:50:26 +02:00
Brett Jephson 673f4b6076 RND-11832: button with select slug action (#4462) 2026-08-04 14:12:13 +01:00
Brett Jephson d0a63bab91 Tab - reinstate scroll hash (#4458)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:42:30 +02:00
conico974 ca9a1dd396 Block crawler from using ask endpoint (#4457) 2026-08-03 11:47:17 +02:00
claude[bot] 996d7ec021 Scroll to in-page heading even when the URL hash is unchanged (RND-12290) (#4454)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Tomek Gargula <tomasz@gitbook.com>
2026-07-31 10:36:37 +02:00
Brett Jephson f9ad9b5356 RND-11830: select store URL param (#4439)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 16:05:36 +01:00
Nolann B. 332089eca9 Don't forward cookies through the OpenAPI Scalar proxy (#4451) 2026-07-30 10:06:17 +02:00
Zeno Kapitein 6ac4cf4b76 Align card and column breakpoints (#4449) 2026-07-29 12:13:25 +00:00
Zeno Kapitein 5c74eef602 Make the table search empty state more prominent (#4418)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:13:02 +02:00
Zeno Kapitein 1ee1853995 Add an assistant tool to rate its own previous response (#4425)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:10:41 +02:00
Tomek fa9c9b38b7 Force software rendering in Playwright for consistent images (#4444)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-27 14:01:55 +00:00
conico974 b4d9c6b95e Fix BUILD_VERSION assignment to ensure fallback to current timestamp (#4445) 2026-07-27 15:47:54 +02:00
Tomek 588964279e Remove search value from URL on unfocus (#4443)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:46:02 +00:00
Nolann B. a80d41200a Run the OpenAPI Scalar proxy on GitBook's origin (#4440) 2026-07-24 18:36:27 +02:00
Nolann B. ccb9d7bda4 Submit sendFeedback MCP tool findings through submitSiteAgentFeedback (#4437) 2026-07-24 10:34:50 +02:00
Nolann B. 14562e9e5b Serve noindex on internal search/assistant URLs (?q= / ?ask=) (#4433) 2026-07-23 15:00:13 +02:00
spastorelli 580d186ddd Clean up old forward resume authorize to sites oauth server (#4436) 2026-07-23 14:08:56 +02:00
Tomek 038008c853 Fix heading anchor links on touch devices (#4435)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-23 11:56:21 +00:00
731 changed files with 12407 additions and 5804 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix "View activity" disclosure in AI Chat not opening after the Base UI migration.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Scroll to an in-page heading even when the URL hash is unchanged (e.g. clicking the same anchor again).
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Force software rendering (SwiftShader) in Playwright Chromium to eliminate image downscaling drift between GPU-equipped local runs and headless CI runs in Argos screenshots.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add an assistant tool to rate its own previous response when the user reacts to it.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep the breadcrumbs from covering the page actions' hit area.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Move the incremental cache tier from the DO worker into the container worker.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Build it with `bun run build:all` and run it locally with `bun run dev:cf:container`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Deploy the container server tier from CI to preview, staging and production. Preview and staging serve their traffic from the container; production deploys it but keeps serving from the workerd tier.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix host action buttons in the Docs Embed not reaching the assistant.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix suggested question clicks in the embed search not opening the assistant.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix switching from the Search tab to the Docs or Assistant tab in the embed doing nothing.
+7
View File
@@ -0,0 +1,7 @@
---
"gitbook": patch
---
Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Update the URL hash when a tab is selected, so a copied link scrolls back to that tab
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Lazy-load the Mermaid code block so pages that have code blocks but no diagram no longer ship its rendering dependencies.
+8
View File
@@ -0,0 +1,8 @@
---
"@gitbook/react-openapi": major
"gitbook": patch
---
Lazy load the Scalar API client modal and stop preloading the Scalar runtime. The modal is now code-split into its own chunk, fetched in parallel with the runtime only when a reader clicks "Test it", and a spinner is shown on the button until the client opens.
Breaking: the package no longer ships the modal in its main entry — consumers must serve the emitted `ScalarApiModal` chunk and use a bundler that supports dynamic `import()`, and the Scalar runtime is no longer preloaded on page load. The internal `preloadScalarRuntime` helper is removed.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Submit `sendFeedback` MCP tool findings through the dedicated `submitSiteAgentFeedback` API endpoint. The `pageUrl` is now required and an optional `goal` can be provided.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Mermaid diagrams no longer hijack page scrolling: zooming with the wheel now requires holding Ctrl/Cmd inline, while the fullscreen view keeps free wheel zoom.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Migrate the headless UI primitives from Radix and react-aria to Base UI.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Persist content selection (tabs and other `select` blocks) in localStorage only, dropping the `?select=` query parameter from the URL. A tab click still writes the tab's hash, so a copied URL lands on that tab and reactivates it on load.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve `X-Robots-Tag: noindex` on internal search/assistant URLs (`?q=` / `?ask=`) and stop disallowing them in robots.txt, so Google can crawl the directive and drop them from the index instead of reporting "Indexed, though blocked by robots.txt".
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the variant switcher linking to the wrong URL for non-default variants of the default section
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve cookie handling in the OpenAPI "Test it" request proxy.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Serve the OpenAPI "Test it" request proxy from GitBook's own domain.
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/react-openapi": minor
---
Keep the OpenAPI renderer out of the initial bundle of pages that have no OpenAPI block, by building its context on the client behind a dynamic boundary.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix OpenAPI webhook payload and schema example panels being clipped instead of scrollable.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Align the Previous page navigation button to the left edge and the Next button to the right edge, including when only one of them is present.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix text disappearing in Firefox and iOS Safari on pages with a background cover
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render a site-space custom home page at its placement root while preserving the full space and normal page URLs.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the docs embed not applying `?theme=light`/`?theme=dark`. The theme was dropped when the embed redirected to its default tab, and the embed tabs couldn't read it because they render statically (their request headers are empty). The middleware now threads a forced embed theme through the embed route context (scoped to the embed, not the main site), so the embed tabs honor it while staying statically rendered, and the redirect forwards it to the default tab. The forced theme is also persisted to the embed's own theme storage so it is remembered across tab navigation instead of only applying while the query string is present.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix code block syntax highlighting so comment delimiters (e.g. `//`, `/*`) use the same color as the rest of the comment. Previously the delimiter fell through to the generic punctuation scope, making it a different color from the comment body (most visible in dark mode).
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the spacebar being ignored in the search bar, which made multi-word queries impossible.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep the last search query visible after closing search, and restore it when reopening, without breaking navigation when clicking a search result.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Link page-level search matches to the top of the page while preserving section anchors for section matches.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Keep Shiki out of the initial page bundle by splitting the highlighter from the plain-text token helpers, so client code no longer pulls the engine and language bundles in through a shared import.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show image thumbnails, including SVG previews, for file attachments on published sites.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Make the table search "no results" empty state more prominent with vertical spacing so it no longer blends into the content below.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix heading anchor links being unreachable on touch devices by adding a tap-to-reveal state. The anchor icon now appears after the heading text without wrapping onto an orphan line while retaining its existing desktop placement. Use `pointerup` for the dismiss listener to fix unreliable dismissal on iOS Safari, and enlarge the anchor's touch tap target to a square 24px area (meeting the WCAG 2.5.8 minimum) so the icon stays centered instead of overflowing shorter headings' line height.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search results from a previous scope staying stuck on top of the new results when switching the search filter.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support localized custom AI Assistant greeting subtitles.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix chevrons and open-state styling incorrectly reacting to a tooltip opening on the same trigger, by switching from the shared Base UI `data-popup-open` attribute to `aria-expanded`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add the `select` action to InlineButton. Clicking the button activates its slug, so any block containing that slug switches to it.
+10
View File
@@ -0,0 +1,10 @@
# Stop editors from looking for .editorconfig files in parent directories.
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
max_line_length = 100
+4 -1
View File
@@ -76,7 +76,10 @@ bun dev
```
Additional development commands:
- `bun format`: Format the code using Biome
- `bun lint`: Lint the code using Oxlint
- `bun lint:fix`: Automatically fix lint issues using Oxlint
- `bun format`: Format the code using Oxfmt
- `bun format:check`: Check formatting without changing files
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
@@ -1,83 +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
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 }}
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.43.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
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
# 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.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
+96 -58
View File
@@ -1,34 +1,37 @@
name: 'Deploy cloudflare'
description: 'Deploy GitBook to Cloudflare'
inputs:
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
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
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
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
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
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
headSha:
description: 'Git ref being deployed, used for the deploymentId. Falls back to GITHUB_SHA'
required: false
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
description: 'Deployment URL'
value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
@@ -42,44 +45,80 @@ runs:
- name: Load secret
uses: 1password/load-secrets-action@v2
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
# `build:all` shares a single `next build` between the workerd tier (.open-next) and the
# container tier (.open-next-container), so both are built from the same bundle.
- name: Build worker
run: bun run turbo build:cloudflare
run: bun run turbo build:all
env:
GITBOOK_RUNTIME: cloudflare
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_RUNTIME: cloudflare
# `pull_request_target` sets GITHUB_SHA to the base branch tip, identical for every
# commit of a PR, which would keep the deployment ID (and its cache) unchanged.
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
VERCEL_TARGET_ENV: ${{ inputs.environment }}
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
shell: bash
- id: extract_deployment_id
name: Extract Next deployment ID
shell: bash
run: |
deployment_id=$(bun -e '
const buildOutput = await Bun.file("packages/gitbook/.open-next/server-functions/default/packages/gitbook/.next/required-server-files.json").json();
const deploymentId = buildOutput.config?.deploymentId;
if (!deploymentId) throw new Error("Next build did not emit a deployment ID");
console.log(deploymentId);
')
echo "deployment_id=$deployment_id" >> $GITHUB_OUTPUT
- name: Upload the DO worker
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }}
# `versions upload` never builds or pushes the container image, so the container tier uses
# `deploy` like the DO worker. It runs after the DO worker (whose Durable Objects it binds)
# and before the server and middleware (which read the cache tier it now hosts).
#
# `--x-provision=false` disables Wrangler's resource provisioning. It is on by default and
# probes the R2 API for any binding the deployed Worker does not already have, which our
# API token has no permission for. Every bucket here already exists, so there is nothing to
# provision.
- name: Deploy the container worker
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: ${{ format('deploy --x-provision=false --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }}
- id: upload_server
name: Upload server to Cloudflare
@@ -88,7 +127,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -111,7 +150,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -135,11 +174,10 @@ runs:
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
deploy: ${{ inputs.deploy }}
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
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 }}"
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
+59 -60
View File
@@ -1,31 +1,31 @@
name: 'Deploy vercel'
description: 'Deploy GitBook to Vercel'
inputs:
vercelOrg:
description: 'Vercel organization'
required: true
vercelProject:
description: 'Vercel project'
required: true
vercelToken:
description: 'Vercel token'
required: true
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
vercelOrg:
description: 'Vercel organization'
required: true
vercelProject:
description: 'Vercel project'
required: true
vercelToken:
description: 'Vercel token'
required: true
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
description: 'Deployment URL'
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
using: 'composite'
steps:
@@ -40,51 +40,51 @@ runs:
run: bun run vercel pull --yes --environment=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
- name: Load secret
uses: 1password/load-secrets-action@v2
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL_2
GITBOOK_IMAGE_RESIZE_SALT: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SALT
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Inject build env vars
if: ${{ inputs.environment == 'preview' }}
shell: bash
run: |
HEAD_SHA=$(git rev-parse HEAD)
echo "resolved HEAD_SHA: $HEAD_SHA"
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
HEAD_SHA=$(git rev-parse HEAD)
echo "resolved HEAD_SHA: $HEAD_SHA"
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
@@ -92,10 +92,9 @@ runs:
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }})
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
- name: Outputs
shell: bash
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+14
View File
@@ -20,6 +20,20 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format:check
lint:
runs-on: ubuntu-latest
name: Lint
timeout-minutes: 6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun lint
test:
runs-on: ubuntu-latest
name: Test
@@ -0,0 +1,34 @@
name: CSS browser compatibility
on:
pull_request_target:
types: [opened, reopened, synchronize]
permissions:
contents: read
pull-requests: read
concurrency:
group: css-browser-compatibility-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check newly added CSS declarations
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
# pull_request_target checks out the trusted base branch by default. Never use the PR ref here.
- name: Checkout trusted checker
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Check CSS browser compatibility
working-directory: packages/gitbook
run: bun run check:css-browser-compatibility
env:
GITHUB_TOKEN: ${{ github.token }}
+58 -9
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
environment:
name: preview-approval
name: preview-approval
steps:
- name: Approval gate
run: echo "Preview deployment approved."
@@ -21,8 +21,8 @@ jobs:
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
@@ -47,8 +47,8 @@ jobs:
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
steps:
@@ -68,14 +68,15 @@ jobs:
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
commitMessage: ${{ github.sha }}
headSha: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Extract Worker ID
id: extract-worker-id
if: ${{ !steps.deploy.outputs.deployment-url }}
run: |
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
@@ -187,6 +188,54 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-vercel
# Style recalc depends on the client bundle, not the host, so this runs here only and
# not in the Cloudflare job. Kept out of `e2e-customers`, which is a visual suite.
- name: Run style invalidation tests
if: always()
working-directory: packages/gitbook
run: bun run e2e-style-perf
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
# Runs on failure too: a blown style budget is exactly when the numbers are worth seeing.
- name: Build style invalidation report
if: always() && github.event_name != 'push'
id: style-perf
working-directory: packages/gitbook
run: |
rows=$(cat test-results/style-perf.md 2>/dev/null || true)
if [ -z "$rows" ]; then exit 0; fi
{
echo 'body<<STYLE_PERF_EOF'
echo '### Style invalidation on a large API reference'
echo
echo 'Elements restyled by opening one popup on [the Snyk API reference](https://docs.snyk.io/snyk-api/reference/apps). A share near or above 100% means the insertion restyles the whole document.'
echo
echo '| interaction | restyled | page | share | budget | |'
echo '| --- | ---: | ---: | ---: | ---: | :-: |'
echo "$rows"
echo 'STYLE_PERF_EOF'
} >> "$GITHUB_OUTPUT"
- name: Find style invalidation comment
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/find-comment@v3
id: fc-style-perf
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Style invalidation on a large API reference'
- name: Create or update style invalidation comment
if: always() && github.event_name != 'push' && steps.style-perf.outputs.body != ''
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc-style-perf.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: ${{ steps.style-perf.outputs.body }}
edit-mode: replace
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
+9 -9
View File
@@ -7,7 +7,7 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
runs-on: ubuntu-latest
environment:
environment:
name: 2v-production
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -19,16 +19,16 @@ jobs:
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: production
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
environment: production
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (production)
runs-on: ubuntu-latest
environment:
environment:
name: 2c-production
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -50,4 +50,4 @@ jobs:
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+9 -9
View File
@@ -7,7 +7,7 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
runs-on: ubuntu-latest
environment:
environment:
name: 2v-staging
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -19,16 +19,16 @@ jobs:
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: staging
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
environment: staging
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (staging)
runs-on: ubuntu-latest
environment:
environment:
name: 2c-staging
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -50,4 +50,4 @@ jobs:
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
+70
View File
@@ -0,0 +1,70 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 100,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"sortImports": {
"customGroups": [
{
"groupName": "workspace",
"elementNamePattern": ["@gitbook/**"]
}
],
"groups": [
["value-builtin", "value-external", "type-builtin", "type-external"],
{ "newlinesBetween": true },
"workspace",
{ "newlinesBetween": true },
[
"value-internal",
"type-internal",
"value-parent",
"type-parent",
"value-sibling",
"type-sibling",
"value-index",
"type-index"
],
"type-import",
"unknown"
]
},
"sortPackageJson": false,
"sortTailwindcss": {
"config": "./packages/gitbook/tailwind.config.ts",
"attributes": ["class", "className", "style"],
"functions": ["clsx", "tw"]
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/public/**",
"**/.next/**",
"**/.open-next/**",
"**/.turbo/**",
"**/.vercel/**",
"**/.cache/**",
"**/.wrangler/**",
"**/*.log",
"**/*.MD",
"**/*.md",
"**/*.mdx",
"**/*.html",
"**/*.css",
"packages/embed/standalone/**",
"packages/openapi-parser/src/fixtures/**",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts"
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "warn",
"suspicious": "warn",
"perf": "warn"
},
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y", "vitest"],
"env": {
"builtin": true,
"browser": true,
"node": true,
"shared-node-browser": true,
"worker": true
},
"globals": {
"Bun": "readonly",
"GitBookIntegrationEvent": "readonly",
"React": "readonly"
},
"rules": {
"eslint/no-console": [
"warn",
{
"allow": ["assert", "error", "warn"]
}
],
"eslint/no-control-regex": "warn",
"eslint/no-prototype-builtins": "warn",
"eslint/no-cond-assign": "warn",
"eslint/no-undef": "error",
"eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"ignoreRestSiblings": true,
"varsIgnorePattern": "^_"
}
],
"react/button-has-type": "warn",
"react/exhaustive-deps": "warn",
"react/jsx-key": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/no-array-index-key": "warn",
"react/void-dom-elements-no-children": "warn",
"react/rules-of-hooks": "error",
"jsx-a11y/alt-text": "warn",
"jsx-a11y/anchor-is-valid": "warn",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/iframe-has-title": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
"jsx-a11y/label-has-associated-control": "warn",
"jsx-a11y/no-noninteractive-tabindex": "warn",
"jsx-a11y/role-has-required-aria-props": "warn",
"jsx-a11y/tabindex-no-positive": "warn",
"typescript/array-type": "error",
"typescript/no-explicit-any": "warn",
"typescript/no-non-null-assertion": "warn",
"typescript/no-confusing-void-expression": "warn",
"typescript/only-throw-error": "error",
"vitest/no-focused-tests": "error",
"no-delete-var": "warn",
"no-debugger": "error",
"no-throw-literal": "error",
"use-isnan": "error",
"valid-typeof": "error"
},
"ignorePatterns": [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/public/**",
"**/.next/**",
"**/.open-next/**",
"**/.turbo/**",
"**/.vercel/**",
"**/.cache/**",
"**/.wrangler/**",
"packages/embed/standalone/**",
"packages/openapi-parser/src/fixtures/**",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"**/*.css"
]
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["biomejs.biome"]
"recommendations": ["oxc.oxc-vscode"]
}
+3 -5
View File
@@ -8,14 +8,12 @@
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
"source.fixAll.oxc": "always"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}
+3 -2
View File
@@ -6,7 +6,8 @@
bun install # Install dependencies
bun dev # Start dev server (all packages)
bun run build # Build all packages
bun run format # Format with Biome (run after every change)
bun run lint # Lint with Oxlint
bun run format # Format with Oxfmt (run after every change)
bun run typecheck # Type-check all packages
bun run unit # Run unit tests
```
@@ -74,7 +75,7 @@ Save as `.changeset/<name>.md`, then commit it separately with message: `changes
## Formatting
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
Linting uses Oxlint and formatting uses Oxfmt. Always run `bun run format` before committing.
## Comments
-178
View File
@@ -1,178 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": [
"**/node_modules/**/*",
"**/dist/**/*",
"**/build/**/*",
"**/public/**/*",
"**/.next/**/*",
"**/.open-next/**/*",
"**/.turbo/**/*",
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/embed/standalone/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"gitbook/tsconfig.json",
"**/*.css"
]
},
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noDelete": "warn"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": {
"noForEach": "off",
"noUselessFragments": "warn",
"noBannedTypes": "warn"
},
"correctness": {
"noUndeclaredVariables": "error",
"noUnusedVariables": "error",
"useArrayLiterals": "error",
"useHookAtTopLevel": "error",
"noUnusedImports": "error",
"noVoidElementsWithChildren": "warn",
"useJsxKeyInIterable": "warn",
"useExhaustiveDependencies": "warn",
"noUnknownFunction": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"noParameterAssign": "off",
"useThrowOnlyError": "error"
},
"suspicious": {
"noConsole": {
"level": "warn",
"options": {
"allow": ["assert", "error", "warn"]
}
},
"noExplicitAny": "warn",
"noImplicitAnyLet": "warn",
"noConfusingVoidType": "warn",
"noControlCharactersInRegex": "warn",
"noPrototypeBuiltins": "warn",
"noAssignInExpressions": "warn",
"noArrayIndexKey": "warn"
},
"a11y": {
"useSemanticElements": "warn",
"useKeyWithClickEvents": "warn",
"noSvgWithoutTitle": "warn",
"useButtonType": "warn",
"useIframeTitle": "warn",
"useAltText": "warn",
"noPositiveTabindex": "warn",
"useFocusableInteractive": "warn",
"useAriaPropsForRole": "warn",
"useValidAnchor": "warn",
"noLabelWithoutControl": "warn",
"noNoninteractiveTabindex": "warn"
},
"nursery": {
"useSortedClasses": {
"level": "error",
"fix": "safe",
"options": {
"attributes": ["class", "className", "style"],
"functions": ["clsx", "tw"]
}
}
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": [
"packages/gitbook/**/*",
"packages/react-openapi/**/*",
"packages/react-math/**/*",
"packages/react-contentkit/**/*",
"packages/icons/**/*"
],
"javascript": {
"globals": ["React"]
}
},
{
"include": ["packages/gitbook/**/*"],
"javascript": {
"globals": ["React", "GitBookIntegrationEvent"]
}
},
{
"include": ["*.css"],
"javascript": {
"globals": ["theme"]
}
},
{
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
"javascript": {
"globals": ["Bun"]
}
},
{
"include": [
"packages/cache-do/**/*",
"packages/gitbook/cf-env.d.ts",
"packages/gitbook/src/cloudflare-entrypoint.ts"
],
"javascript": {
"globals": [
"DurableObjectLocationHint",
"DurableObjectNamespace",
"DurableObjectStub",
"ContinentCode",
"Fetcher",
"ExportedHandler"
]
}
}
]
}
+278 -629
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -5,8 +5,9 @@
"node": "^22.3.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
"oxfmt": "^0.62.0",
"oxlint": "^1.77.0",
"turbo": "^2.10.3",
"vercel": "50.37.3"
},
@@ -18,8 +19,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
"axios": "1.8.4"
},
"private": true,
"scripts": {
@@ -27,8 +27,10 @@
"build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck",
"format": "biome check --write ./",
"format:check": "biome check --diagnostic-level=error ./",
"lint": "oxlint --quiet",
"lint:fix": "oxlint --fix --quiet",
"format": "oxfmt",
"format:check": "oxfmt --check",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
@@ -39,11 +41,14 @@
"clean": "turbo run clean"
},
"workspaces": {
"packages": ["packages/*"],
"packages": [
"packages/*"
],
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.190.0",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.195.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+5 -1
View File
@@ -25,7 +25,11 @@
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
+5 -1
View File
@@ -24,7 +24,11 @@
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
+5 -4
View File
@@ -1,14 +1,15 @@
import type { ComputedContentSource } from '@gitbook/api';
import assertNever from 'assert-never';
import type { ComputedContentSource } from '@gitbook/api';
/**
* Get a stringified cache tag for a given object.
*/
export function getCacheTag(
spec: /**
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'user';
user: string;
+5 -1
View File
@@ -21,7 +21,11 @@
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
+5 -1
View File
@@ -39,7 +39,11 @@
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
@@ -1,4 +1,5 @@
import { createChannel } from 'bidc';
import type {
FrameToParentMessage,
GitBookEmbeddableConfiguration,
@@ -76,7 +77,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
channel.send(message);
};
const events = new Map<string, Array<(...args: any[]) => void>>();
const events = new Map<string, ((...args: any[]) => void)[]>();
const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'search', 'docs'],
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
GetFrameURLOptions,
GitBookEmbeddableConfiguration,
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { type CreateGitBookOptions, createGitBook } from '../client';
import { GitBookContext } from './context';
+1
View File
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import type { GitBookClient } from '../client';
export const GitBookContext = React.createContext<GitBookClient | null>(null);
+1 -1
View File
@@ -1,6 +1,6 @@
import emojisRaws from 'emoji-assets/emoji.json';
import fs from 'node:fs';
import path from 'node:path';
import emojisRaws from 'emoji-assets/emoji.json';
interface EmojiData {
code_points: {
+5 -1
View File
@@ -36,7 +36,11 @@
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
@@ -58,10 +58,10 @@ describe('autocomplete', () => {
}),
};
const context = new SymbolsTable(symbols);
const SCENARIOS: Array<{
const SCENARIOS: {
expressionWithCursor: string;
expectedSuggestions: AutocompleteSuggestions;
}> = [
}[] = [
{
expressionWithCursor: 'visit<cur>',
expectedSuggestions: [
+4 -5
View File
@@ -11,7 +11,6 @@ import type {
} from 'acorn';
import { isDummy } from 'acorn-loose';
import * as walk from 'acorn-walk';
import assertNever from 'assert-never';
import { type ExtractSymbolDef, SymbolType, SymbolsTable } from './symbols';
@@ -312,7 +311,7 @@ export class AutoComplete {
node: BinaryExpression,
cursorOffset: number,
context: SymbolsTable
): Array<AutocompleteLiteralValueSuggestion> {
): AutocompleteLiteralValueSuggestion[] {
const { left, right } = node;
if (left.type !== 'MemberExpression' && left.type !== 'Identifier') {
@@ -431,7 +430,7 @@ export class AutoComplete {
node: AnyNode,
cursorOffset: number,
context: SymbolsTable
): Array<AutocompleteOperatorSuggestion> {
): AutocompleteOperatorSuggestion[] {
if (node.type === 'Literal') {
const parent = findParentNode(node, ast);
@@ -496,7 +495,7 @@ export class AutoComplete {
*/
private getOperatorSuggestionsForSymbol(
symbol: ExtractSymbolDef<SymbolType>
): Array<AutocompleteOperatorSuggestion> {
): AutocompleteOperatorSuggestion[] {
switch (symbol.type) {
case SymbolType.Number:
case SymbolType.Boolean:
@@ -526,7 +525,7 @@ export class AutoComplete {
case SymbolType.Function:
return this.getOperatorSuggestionsForSymbol(symbol.returns);
case SymbolType.Union: {
return symbol.members.reduce<Array<AutocompleteOperatorSuggestion>>((prev, cur) => {
return symbol.members.reduce<AutocompleteOperatorSuggestion[]>((prev, cur) => {
prev.push(...this.getOperatorSuggestionsForSymbol(cur));
return prev;
}, []);
+1
View File
@@ -1,4 +1,5 @@
import type { JSONSchema7 } from 'json-schema';
import { filterOutNullable } from './utils';
type InputValuesType =
+3 -3
View File
@@ -20,7 +20,7 @@ export interface ExpressionParserResult {
/**
* The information of the invalid (non-expression) nodes found from the other portions of the parsed expression.
*/
invalidNodes: Array<ExpressionStatement>;
invalidNodes: ExpressionStatement[];
}
export interface ExpressionAutocompleteResults {
@@ -158,11 +158,11 @@ export interface AutocompleteSymbolSuggestion {
symbol: SymbolInfo;
}
export type AutocompleteSuggestions = Array<
export type AutocompleteSuggestions = (
| AutocompleteSymbolSuggestion
| AutocompleteLiteralValueSuggestion
| AutocompleteOperatorSuggestion
>;
)[];
type LoggerFn = (message: string, ...args: any[]) => void;
+1 -2
View File
@@ -1,8 +1,7 @@
import { APIv2 } from 'google-font-metadata';
import fs from 'node:fs/promises';
import path from 'node:path';
import { APIv2 } from 'google-font-metadata';
import { CustomizationDefaultFont } from '@gitbook/api';
import type { FontDefinitions } from '../src/types';
+6 -1
View File
@@ -27,7 +27,12 @@
"unit": "bun test",
"publish-to-npm": "../../scripts/publish-if-new.sh"
},
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
"files": [
"dist",
"bin",
"README.md",
"CHANGELOG.md"
],
"engines": {
"node": ">=20.0.0"
},
+1 -2
View File
@@ -1,5 +1,4 @@
import rawFonts from './data/fonts.json' with { type: 'json' };
import type { FontDefinitions } from './types';
import rawFonts from './data/fonts.json' with { type: 'json' };
export const fonts: FontDefinitions = rawFonts;
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationDefaultFont } from '@gitbook/api';
import { getDefaultFont } from './getDefaultFont';
describe('getDefaultFont', () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
import { fonts } from './fonts';
import type { FontWeight } from './types';
@@ -86,7 +87,7 @@ function getBestUnicodeRange(text: string, ranges: Record<string, string>): stri
// ---------- tally code-point hits ----------
const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0]));
for (let i = 0; i < text.length; ) {
for (let i = 0; i < text.length;) {
const cp = text.codePointAt(i)!;
i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates)
+4
View File
@@ -0,0 +1,4 @@
# The image only needs the `@opennextjs/aws` node bundle; without this the whole package
# (.next, .open-next, public, node_modules) would be sent to the Docker daemon on every deploy.
*
!.open-next-container/server-functions/default
+1
View File
@@ -35,5 +35,6 @@ screenshots/
# cloudflare
.open-next
.open-next-container
.wrangler
worker-configuration.d.ts
+4 -2
View File
@@ -360,7 +360,9 @@ const testCases: TestsCase[] = [
{
name: 'docs.verifone.com',
contentBaseURL: 'https://docs.verifone.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
// Verifone's custom Cookiebot integration races with and suppresses GitBook's built-in
// banner, so waitForCookiesDialog is not a stable invariant here.
tests: [{ name: 'Home', url: '/' }],
},
// Deactivate it because of a custom Ask AI that causes flakiness.
// {
@@ -385,7 +387,7 @@ const testCases: TestsCase[] = [
{ name: 'Home', url: '/docs', run: waitForCookiesDialog },
{
name: 'OpenAPI',
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask',
url: '/docs/developers/gitbook-api/api-reference/docs-sites/site-ai-ask/ask-a-question-in-a-site',
run: waitForCookiesDialog,
},
],
+47 -22
View File
@@ -1,3 +1,6 @@
import { expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
CustomizationAIMode,
CustomizationBackground,
@@ -11,10 +14,6 @@ import {
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
import {
@@ -35,8 +34,10 @@ import {
waitForAdminToolbar,
waitForCookiesDialog,
waitForCoverImages,
waitForHydration,
waitForNotFound,
} from './util';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
// single fixed search, a concise answer, and a fixed number of follow-ups. The
@@ -135,6 +136,23 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeFocused();
},
},
{
// `fill()` bypasses key events, so it can't catch a swallowed key. RND-12484.
name: 'Search - AI Mode: None - Typing multi-word queries',
url: getCustomizationURL({
ai: {
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
await searchInput.pressSequentially('getting started');
await expect(searchInput).toHaveValue('getting started');
},
},
{
name: 'Search - AI Mode: None - URL query (Initial)',
url: `${getCustomizationURL({
@@ -381,6 +399,7 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -408,6 +427,7 @@ const testCases: TestsCase[] = [
name: 'Switch variant with alternate link in metadata',
url: 'rfcs',
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -446,6 +466,7 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -472,6 +493,7 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -513,6 +535,7 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
},
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -548,6 +571,7 @@ const testCases: TestsCase[] = [
url: 'ecosystem/connection-provider',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -578,6 +602,7 @@ const testCases: TestsCase[] = [
url: 'nl/ecosysteem/connection-provider',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
@@ -618,14 +643,14 @@ const testCases: TestsCase[] = [
url: '',
screenshot: false,
run: async (page) => {
const trigger = page.getByRole('button', { name: 'Test Section Group 1' });
// Radix NavigationMenu opens the dropdown on a `pointermove`. A single
// synthetic hover can land before hydration and be lost, so re-hover
// until the dropdown content actually appears.
await expect(async () => {
await trigger.hover();
await expect(page.getByText('Section B')).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 15000 });
// The menu only opens on `mouseenter`, which cannot fire again once the pointer
// is inside: a hover landing before hydration is lost for good.
await waitForHydration(page);
const trigger = page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 1' });
await trigger.hover();
await expect(page.getByText('Section B')).toBeVisible();
await page.getByText('Section B').click();
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
},
@@ -1241,8 +1266,8 @@ const testCases: TestsCase[] = [
},
// New site themes
...allThemes.flatMap((theme) => [
...allTintColors.flatMap((tint) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
...allTintColors.flatMap((tint) =>
allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`,
url: getCustomizationURL({
styling: {
@@ -1262,8 +1287,8 @@ const testCases: TestsCase[] = [
},
}),
run: waitForCookiesDialog,
})),
]),
}))
),
...allSearchStyles.flatMap((searchStyle) => ({
name: `Theme ${theme} – Search ${searchStyle} – Mode ${themeMode}`,
url: getCustomizationURL({
@@ -1283,8 +1308,8 @@ const testCases: TestsCase[] = [
})),
]),
// Deprecated header themes
...allDeprecatedThemePresets.flatMap((preset) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
...allDeprecatedThemePresets.flatMap((preset) =>
allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `With tint - Legacy header preset ${preset} - Sidebar ${sidebarStyle} - Theme mode ${themeMode}`,
url: getCustomizationURL({
styling: {
@@ -1310,8 +1335,8 @@ const testCases: TestsCase[] = [
},
}),
run: waitForCookiesDialog,
})),
]),
}))
),
{
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
url: getCustomizationURL({
@@ -2405,7 +2430,7 @@ const testCases: TestsCase[] = [
actions.nth(1).click(),
]);
// Verify the new page would have opened with the expected URL
expect(newPage.url()).toContain('gitbook.com');
await expect(newPage).toHaveURL(/gitbook\.com/);
// Close it immediately to avoid navigation
await newPage.close();
@@ -2553,7 +2578,7 @@ const testCases: TestsCase[] = [
openInNewTabButton.click(),
]);
// Verify the new page would have opened with the expected URL
expect(newPage.url()).toContain('gitbook.gitbook.io');
await expect(newPage).toHaveURL(/gitbook\.gitbook\.io/);
// Close it immediately to avoid navigation
await newPage.close();
},
+1
View File
@@ -1,5 +1,6 @@
import { argosScreenshot } from '@argos-ci/playwright';
import { expect, test } from '@playwright/test';
import { getContentTestURL } from '../tests/utils';
import { waitForIcons } from './util';
+238
View File
@@ -0,0 +1,238 @@
import { type Page, expect, test } from '@playwright/test';
// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias
// that the store pulls in — Playwright's loader doesn't resolve it.
import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants';
import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS';
/**
* Behaviour tests for the `select` CSS: given a recency-ordered selection applied to `<html>`, a
* group must show exactly the most-recently-activated of its options (its "first ranking"
* selection), falling back to its default when none are active. These run in a real browser against
* the actual generated CSS, so they assert observable visibility — not how the selectors are built.
*/
/** Render a single group of option panes with the generated stylesheet. First slug = default. */
async function renderGroup(page: Page, slugs: string[]) {
const css = generateSelectCSS(slugs);
const scope = selectSetClassName(slugs);
const panes = slugs
.map(
(slug, index) =>
`<div data-testid="pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
await page.setContent(
`<!doctype html><html><head><style>${css}</style></head><body><div class="${scope}" data-select-group>${panes}</div></body></html>`
);
}
/**
* Apply the recency list to `<html>` as `data-sel-*` attributes (most-recent first), via the shared
* attribute-name helper — mirroring what the pre-paint script / store do at runtime.
*/
async function applySelection(page: Page, active: string[]) {
for (const [rank, value] of active.entries()) {
await page.evaluate(
({ attr, value }) => document.documentElement.setAttribute(attr, value),
{ attr: selectRankAttribute(rank), value }
);
}
}
/** Assert exactly one pane is visible, and it is the expected slug. */
async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) {
for (const slug of slugs) {
const pane = page.getByTestId(`pane-${slug}`);
if (slug === expectedSlug) {
await expect(pane).toBeVisible();
} else {
await expect(pane).toBeHidden();
}
}
}
async function setup(page: Page, slugs: string[], active: string[]) {
await renderGroup(page, slugs);
await applySelection(page, active);
}
test.describe('select CSS visibility', () => {
const slugs = ['python', 'go', 'java'];
test('shows the default when nothing is selected', async ({ page }) => {
await setup(page, slugs, []);
await expectOnlyVisible(page, slugs, 'python'); // first pane is the default
});
test('shows the selected option and hides the rest', async ({ page }) => {
await setup(page, slugs, ['go']);
await expectOnlyVisible(page, slugs, 'go');
});
test('shows the most-recently-activated option of the group', async ({ page }) => {
// Recency list is most-recent-first: `go` is more recent than `python`.
await setup(page, slugs, ['go', 'python']);
await expectOnlyVisible(page, slugs, 'go');
await setup(page, slugs, ['python', 'go']);
await expectOnlyVisible(page, slugs, 'python');
});
test('ignores more-recent selections that are not in the group', async ({ page }) => {
// `dark` is more recent but not one of this group's options, so `go` still wins.
await setup(page, slugs, ['dark', 'go', 'python']);
await expectOnlyVisible(page, slugs, 'go');
});
test('falls back to the default when no active slug is in the group', async ({ page }) => {
await setup(page, slugs, ['dark', 'light']);
await expectOnlyVisible(page, slugs, 'python');
});
test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({
page,
}) => {
// Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute
// selectors without collapsing together.
const symbols = ['c', 'c++', 'c#'];
await setup(page, symbols, ['c++']);
await expectOnlyVisible(page, symbols, 'c++');
});
test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({
page,
}) => {
// Two panes share the slug `js`; activating it must reveal only the first, never both.
const scope = selectSetClassName(['js', 'ts']);
await page.setContent(
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default>js 1</div><div data-testid="js-second" data-select-option="js">js 2</div><div data-testid="ts" data-select-option="ts">ts</div></div></body></html>`
);
await applySelection(page, ['js']);
await expect(page.getByTestId('js-first')).toBeVisible();
await expect(page.getByTestId('js-second')).toBeHidden();
await expect(page.getByTestId('ts')).toBeHidden();
});
test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({
page,
}) => {
// The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned;
// the pinned one must win over the first-match default.
const scope = selectSetClassName(['js', 'ts']);
await page.setContent(
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default data-select-unpinned>js 1</div><div data-testid="js-second" data-select-option="js" data-select-pinned>js 2</div></div></body></html>`
);
await applySelection(page, ['js']);
await expect(page.getByTestId('js-second')).toBeVisible();
await expect(page.getByTestId('js-first')).toBeHidden();
});
});
interface GroupSpec {
id: string;
slugs: string[];
}
/**
* Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page
* stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit
* tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on
* `<html>`, most-recent first. This keeps the test focused on the observable behaviour a visitor
* sees — a real click switching every group that offers that option — driven by real browser CSS.
*/
async function renderGroups(page: Page, groups: GroupSpec[]) {
const styles = [
...new Map(
groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)])
).values(),
]
.map((css) => `<style>${css}</style>`)
.join('');
const markup = groups
.map((group) => {
const scope = selectSetClassName(group.slugs);
const buttons = group.slugs
.map(
(slug) =>
`<button data-testid="${group.id}-btn-${slug}" onclick="__select('${slug}')">${slug}</button>`
)
.join('');
const panes = group.slugs
.map(
(slug, index) =>
`<div data-testid="${group.id}-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
return `<div class="${scope}" data-select-group><div role="tablist">${buttons}</div>${panes}</div>`;
})
.join('');
const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i<cur.length;i++){if(cur[i]!==slug)next.push(cur[i]);}next=next.slice(0,${SELECT_LIST_CAP});for(i=0;i<${SELECT_LIST_CAP};i++){if(next[i])el.setAttribute('data-sel-'+i,next[i]);else el.removeAttribute('data-sel-'+i);}};`;
await page.setContent(
`<!doctype html><html><head>${styles}<script>${selectScript}</script></head><body>${markup}</body></html>`
);
}
/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */
async function expectGroupShows(
page: Page,
groupId: string,
slugs: string[],
expectedSlug: string
) {
for (const slug of slugs) {
const pane = page.getByTestId(`${groupId}-pane-${slug}`);
if (slug === expectedSlug) {
await expect(pane).toBeVisible();
} else {
await expect(pane).toBeHidden();
}
}
}
test.describe('select syncing across groups (click-driven)', () => {
test('clicking a tab syncs every group offering that option', async ({ page }) => {
const slugs = ['python', 'go'];
await renderGroups(page, [
{ id: 'a', slugs },
{ id: 'b', slugs },
]);
// Both groups start on their default (first) pane.
await expectGroupShows(page, 'a', slugs, 'python');
await expectGroupShows(page, 'b', slugs, 'python');
// Clicking a tab in group A switches group B too.
await page.getByTestId('a-btn-go').click();
await expectGroupShows(page, 'a', slugs, 'go');
await expectGroupShows(page, 'b', slugs, 'go');
// And the sync works from either group.
await page.getByTestId('b-btn-python').click();
await expectGroupShows(page, 'a', slugs, 'python');
await expectGroupShows(page, 'b', slugs, 'python');
});
test('only groups that share the clicked option follow along', async ({ page }) => {
const shared = ['python', 'go'];
const other = ['go', 'rust'];
await renderGroups(page, [
{ id: 'a', slugs: shared },
{ id: 'b', slugs: other },
]);
// `rust` exists only in group B, so clicking it leaves group A on its default.
await page.getByTestId('b-btn-rust').click();
await expectGroupShows(page, 'b', other, 'rust');
await expectGroupShows(page, 'a', shared, 'python');
// `go` is shared, so clicking it in A moves both groups.
await page.getByTestId('a-btn-go').click();
await expectGroupShows(page, 'a', shared, 'go');
await expectGroupShows(page, 'b', other, 'go');
});
});
+169
View File
@@ -0,0 +1,169 @@
import { type CDPSession, type Locator, type Page, expect, test } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { getContentTestURL } from '../tests/utils';
import { waitForCookiesDialog } from './util';
/** Already covered by `customers.spec.ts`, and among the largest pages we render. */
const LARGE_PAGE_URL = 'https://docs.snyk.io/snyk-api/reference/apps';
/** Table rows the preview workflow pastes into a PR comment. Relative to the package, gitignored. */
const REPORT_FILE = 'test-results/style-perf.md';
// Share of the page one popup may restyle: a ratio so it survives the page growing, a count rather
// than a duration so it does not move with CI machine speed. `search` is above 1 because it still
// restyles the whole document — a ratchet against today's state, to lower as families get fixed.
const RESTYLE_BUDGET_RATIO = {
'openapi-select': 0.25,
search: 1.25,
};
type TraceEvent = { name: string; args?: { elementCount?: number } };
/** Elements Blink restyled while `action` ran — DevTools reports this as "Elements affected". */
async function countRestyledElements(
page: Page,
client: CDPSession,
action: () => Promise<void>
): Promise<number> {
// Style is computed lazily, so settle queued recalcs or earlier work lands in the trace.
await page.evaluate(() => void document.body.offsetHeight);
await client.send('Tracing.start', {
categories: 'disabled-by-default-devtools.timeline',
transferMode: 'ReturnAsStream',
});
await action();
await page.evaluate(() => void document.body.offsetHeight);
const handle = await new Promise<string>((resolve) => {
client.once('Tracing.tracingComplete', (event) => resolve(event.stream as string));
void client.send('Tracing.end');
});
let raw = '';
for (let eof = false; !eof;) {
const chunk = await client.send('IO.read', { handle });
raw += chunk.data;
eof = chunk.eof;
}
await client.send('IO.close', { handle });
const parsed = JSON.parse(raw);
const events: TraceEvent[] = Array.isArray(parsed) ? parsed : parsed.traceEvents;
return events
.filter((event) => event.name === 'UpdateLayoutTree')
.reduce((total, event) => total + (event.args?.elementCount ?? 0), 0);
}
/**
* Measure a *re*open: the first open also pays for mounting the popup, and waiting on anything
* looser than the popup being hidden again undercounts the reopen by ~10x.
*/
async function measureReopen(
page: Page,
client: CDPSession,
open: () => Promise<void>,
popup: Locator
): Promise<number> {
await open();
await expect(popup).toBeVisible();
await page.keyboard.press('Escape');
await expect(popup).toBeHidden();
const restyled = await countRestyledElements(page, client, open);
await expect(popup, 'the measurement is meaningless if the popup did not open').toBeVisible();
// Leave the page as we found it, so the next measurement is not measuring this popup's teardown.
await page.keyboard.press('Escape');
await expect(popup).toBeHidden();
return restyled;
}
type Measurement = { name: keyof typeof RESTYLE_BUDGET_RATIO; restyled: number };
function expectWithinBudgets(measurements: Measurement[], total: number) {
const count = (value: number) => value.toLocaleString('en-US');
// Reported before asserting, so a blown budget still reaches the PR comment.
mkdirSync(dirname(REPORT_FILE), { recursive: true });
writeFileSync(
REPORT_FILE,
measurements
.map(({ name, restyled }) => {
const ratio = restyled / total;
const budget = RESTYLE_BUDGET_RATIO[name];
const status = ratio < budget ? '✅' : '❌';
return `| \`${name}\` | ${count(restyled)} | ${count(total)} | ${(ratio * 100).toFixed(1)}% | ${(budget * 100).toFixed(0)}% | ${status} |\n`;
})
.join('')
);
// Soft, so one breach still reports the other interaction rather than masking it.
for (const { name, restyled } of measurements) {
expect
.soft(restyled / total, `${name} restyled ${restyled} of ${total} elements`)
.toBeLessThan(RESTYLE_BUDGET_RATIO[name]);
}
}
async function openLargePage(page: Page) {
await page.goto(getContentTestURL(LARGE_PAGE_URL));
await waitForCookiesDialog(page);
// Measure a settled page: before hydration the tree is smaller and no popup can open at all.
await page.locator('html.hydrated').waitFor();
await expect(page.getByLabel('OpenAPI Select').first()).toBeAttached();
await page.waitForLoadState('networkidle');
const client = await page.context().newCDPSession(page);
const totalElements = await page.evaluate(() => document.getElementsByTagName('*').length);
return { client, totalElements };
}
// Guards the harness: if an idle window is busy, background work is leaking into the measurements
// and they mean nothing. Closing a popup leaves a few elements of teardown, so this is not zero —
// it only has to sit far below a real measurement (hundreds) and a whole-document restyle (~11k).
const IDLE_RESTYLE_TOLERANCE = 100;
async function expectIdle(page: Page, client: CDPSession) {
const restyled = await countRestyledElements(page, client, async () => {});
expect(restyled, 'an idle page should barely restyle').toBeLessThan(IDLE_RESTYLE_TOLERANCE);
}
test('opening a popup restyles a bounded part of a large API reference', async ({ page }) => {
const { client, totalElements } = await openLargePage(page);
await expectIdle(page, client);
const trigger = page.getByLabel('OpenAPI Select').first();
await trigger.scrollIntoViewIfNeeded();
const select = await measureReopen(
page,
client,
() => trigger.click(),
page.locator('.openapi-select-popover')
);
await expectIdle(page, client);
const searchInput = page.getByTestId('search-input');
const search = await measureReopen(
page,
client,
() => searchInput.focus(),
page.getByTestId('search-results')
);
expectWithinBudgets(
[
{ name: 'openapi-select', restyled: select },
{ name: 'search', restyled: search },
],
totalElements
);
});
+19 -14
View File
@@ -1,4 +1,16 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
type BrowserContext,
type FrameLocator,
type Page,
type Response,
expect,
test,
} from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
import {
CustomizationAIMode,
CustomizationBackground,
@@ -22,17 +34,6 @@ import {
type SiteCustomizationSettings,
SiteExternalLinksTarget,
} from '@gitbook/api';
import {
type BrowserContext,
type FrameLocator,
type Page,
type Response,
expect,
test,
} from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
import { getContentTestURL, getTestURL } from '../tests/utils';
@@ -91,7 +92,7 @@ export interface Test {
export type TestsCase = {
name: string;
skip?: boolean;
tests: Array<Test>;
tests: Test[];
contentBaseURL?: string;
/**
* Whether screenshots in this test case should capture the full scrollable page by default.
@@ -111,10 +112,10 @@ export const allThemeModes: CustomizationDefaultThemeMode[] = [
CustomizationDefaultThemeMode.Dark,
];
export const allTintColors: Array<{
export const allTintColors: {
label: string;
value: CustomizationThemedColor | undefined;
}> = [
}[] = [
{
label: 'Off',
value: undefined,
@@ -172,6 +173,10 @@ export async function waitForCookiesDialog(page: Page) {
});
}
export async function waitForHydration(page: Page) {
await page.locator('html.hydrated').waitFor();
}
/**
* Wait for the GitBook admin toolbar to be present.
*
+32 -6
View File
@@ -1,10 +1,27 @@
// @ts-check
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
let deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
? undefined
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
import { networkInterfaces } from 'node:os';
// Next blocks its dev client and HMR when a physical device opens the server over a LAN address.
// Needed to hydrate the dev client on physical phones/tablets over the internal network
const allowedDevOrigins =
process.env.NODE_ENV === 'development'
? [
...new Set(
Object.values(networkInterfaces())
.flat()
.filter(
(networkInterface) =>
networkInterface &&
!networkInterface.internal &&
networkInterface.family === 'IPv4'
)
.map((networkInterface) => networkInterface?.address)
),
]
: undefined;
let deploymentId = process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
const { VERCEL_TARGET_ENV } = process.env;
@@ -21,6 +38,7 @@ if (VERCEL_TARGET_ENV === 'preview') {
* @type {import('next').NextConfig}
*/
const nextConfig = {
allowedDevOrigins,
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
@@ -37,10 +55,18 @@ const nextConfig = {
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
// Rewrites barrel imports into deep ones: without it, importing a single helper from
// react-openapi drags its whole client renderer into every page's entry.
optimizePackageImports: ['@gitbook/react-openapi'],
},
env: {
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (
process.env.GITBOOK_HEAD_SHA ||
process.env.GITHUB_SHA ||
Date.now().toString()
).slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
+4 -4
View File
@@ -8,8 +8,8 @@ export default {
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/server').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
import('./openNext/incrementalCache/cacheWorkerClient').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/cacheWorkerClient').then((m) => m.default),
},
},
middleware: {
@@ -20,8 +20,8 @@ export default {
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/middleware').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
import('./openNext/incrementalCache/cacheWorkerClient').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/cacheWorkerClient').then((m) => m.default),
},
},
dangerous: {
@@ -0,0 +1,30 @@
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
/**
* Build config for the container server tier: the same Next.js app packaged by `@opennextjs/aws`
* as a plain Node server, run inside a Cloudflare Container.
*
* The Cloudflare middleware worker stays the front door, so `middleware.external` mirrors
* `open-next.config.ts` and the middleware bundle emitted here is unused.
*/
export default {
default: {
override: {
wrapper: 'node',
converter: 'node',
// We ship our own Dockerfile (openNext/customWorkers/Dockerfile).
generateDockerfile: false,
queue: () => import('./openNext/container/queue').then((m) => m.default),
incrementalCache: () =>
import('./openNext/container/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/container/tagCache').then((m) => m.default),
},
},
middleware: {
external: true,
},
dangerous: {
enableCacheInterception: true,
},
edgeExternals: ['node:crypto'],
} satisfies OpenNextConfig;
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const BUILD_ID = 'caller-build-id';
const { GitbookContainerIncrementalCache } = await import('./incrementalCache');
const { default: tagCache } = await import('./tagCache');
const { default: queue } = await import('./queue');
const cacheValue = {
type: 'page' as const,
html: '<p>cached</p>',
json: {},
};
const fetchCacheValue = {
kind: 'FETCH' as const,
data: { headers: {}, body: 'body', status: 200, url: 'https://example.com' },
revalidate: 60,
};
const revalidationMessage = {
MessageDeduplicationId: 'dedup',
MessageBody: { host: 'example.com', url: '/docs', lastModified: 1, eTag: 'etag' },
MessageGroupId: 'group',
};
describe('container cache clients', () => {
const internalFetch = mock();
const originalInternalFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch;
const originalConsoleError = console.error;
const originalBuildId = process.env.OPEN_NEXT_BUILD_ID;
const lastCall = () => internalFetch.mock.calls[internalFetch.mock.calls.length - 1] ?? [];
const lastUrl = () => new URL(String(lastCall()[0]));
const lastBody = () => JSON.parse((lastCall()[1] as RequestInit).body as string);
beforeEach(() => {
internalFetch.mockReset();
internalFetch.mockResolvedValue(new Response(null, { status: 204 }));
(globalThis as { internalFetch?: unknown }).internalFetch = internalFetch;
console.error = mock();
process.env.OPEN_NEXT_BUILD_ID = BUILD_ID;
});
afterEach(() => {
(globalThis as { internalFetch?: unknown }).internalFetch = originalInternalFetch;
console.error = originalConsoleError;
if (originalBuildId === undefined) {
delete process.env.OPEN_NEXT_BUILD_ID;
} else {
process.env.OPEN_NEXT_BUILD_ID = originalBuildId;
}
});
it('reads through the intercepted cache host', async () => {
internalFetch.mockResolvedValue(Response.json({ value: cacheValue, lastModified: 123 }));
const result = await new GitbookContainerIncrementalCache().get(
'key with / characters',
'cache'
);
expect(result).toEqual({ value: cacheValue, lastModified: 123 });
const url = lastUrl();
expect(url.host).toBe('incremental-cache.internal');
expect(url.protocol).toBe('http:');
expect(url.pathname).toBe('/');
expect(url.searchParams.get('key')).toBe('key with / characters');
expect(url.searchParams.get('cacheType')).toBe('cache');
expect(url.searchParams.get('buildId')).toBe(BUILD_ID);
});
it('omits the build ID for entries that are not namespaced per build', async () => {
const cache = new GitbookContainerIncrementalCache();
await cache.get('key', 'composable');
expect(lastUrl().searchParams.has('buildId')).toBe(false);
await cache.set('key', fetchCacheValue, 'fetch');
expect(lastBody().buildId).toBeUndefined();
});
it('returns null for cache misses and failed reads', async () => {
internalFetch.mockResolvedValue(Response.json(null));
expect(await new GitbookContainerIncrementalCache().get('missing')).toBeNull();
internalFetch.mockResolvedValueOnce(new Response(null, { status: 503 }));
expect(await new GitbookContainerIncrementalCache().get('unavailable-response')).toBeNull();
internalFetch.mockRejectedValueOnce(new Error('unreachable'));
expect(await new GitbookContainerIncrementalCache().get('unavailable')).toBeNull();
});
it('posts writes and deletes to their own paths', async () => {
const cache = new GitbookContainerIncrementalCache();
await cache.set('entry', cacheValue, 'cache');
expect(lastUrl().pathname).toBe('/set');
expect(lastBody()).toEqual({
key: 'entry',
value: cacheValue,
cacheType: 'cache',
buildId: BUILD_ID,
});
await cache.delete('entry');
expect(lastUrl().pathname).toBe('/delete');
expect(lastBody()).toEqual({ key: 'entry', buildId: BUILD_ID });
});
it('contains mutation failures', async () => {
internalFetch.mockRejectedValue(new Error('unreachable'));
const cache = new GitbookContainerIncrementalCache();
await expect(cache.set('entry', cacheValue, 'cache')).resolves.toBeUndefined();
await expect(cache.delete('entry')).resolves.toBeUndefined();
});
it('writes hard tags only', async () => {
await tagCache.writeTags([
'content',
{ tag: 'with-duration', stale: 100, expire: 200 },
'_N_T_/soft-tag',
]);
expect(lastUrl().pathname).toBe('/write-tags');
expect(lastBody()).toEqual({
tags: ['content', { tag: 'with-duration', stale: 100, expire: 200 }],
});
internalFetch.mockReset();
await tagCache.writeTags(['_N_T_/soft-tag']);
expect(internalFetch).not.toHaveBeenCalled();
});
it('sends revalidations to the queue path', async () => {
await queue.send(revalidationMessage);
expect(lastUrl().pathname).toBe('/queue');
expect(lastBody()).toEqual({ msg: revalidationMessage });
});
});
@@ -0,0 +1,12 @@
/**
* Next.js monkey-patches the global `fetch` with its own data cache. Cache traffic must not go
* through it, or reading the cache would recurse back into the cache. The OpenNext server adapter
* stashes the pristine `fetch` on `globalThis.internalFetch` before Next loads.
*/
export function internalFetch(
input: Request | URL | string,
init?: RequestInit
): Promise<Response> {
const untouchedFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch ?? fetch;
return untouchedFetch(input as RequestInfo, init);
}
@@ -0,0 +1,85 @@
import type {
CacheEntryType,
CacheValue,
IncrementalCache,
WithLastModified,
} from '@opennextjs/aws/types/overrides.js';
import { internalFetch } from './fetch';
import {
CACHE_ORIGIN,
CACHE_PATH,
type DeletePayload,
type SetPayload,
getBuildId,
getReadUrl,
} from './protocol';
/**
* Container counterpart of `openNext/incrementalCache/cacheWorkerClient.ts`: same cache worker,
* reached over the outbound handler instead of a service binding.
*/
export class GitbookContainerIncrementalCache implements IncrementalCache {
name = 'GitbookContainerIncrementalCache';
async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
try {
const response = await internalFetch(getReadUrl(key, cacheType));
if (!response.ok) {
console.error('Failed to get from cache worker', response.status);
return null;
}
return (await response.json()) as WithLastModified<CacheValue<CacheType>> | null;
} catch (error) {
console.error('Failed to get from cache worker', error);
return null;
}
}
async set<CacheType extends CacheEntryType = 'cache'>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void> {
const payload: SetPayload = {
key,
value: value as CacheValue<CacheEntryType>,
cacheType,
buildId: getBuildId(cacheType),
};
try {
await this.post(CACHE_PATH.set, payload);
} catch (error) {
console.error('Failed to set to cache worker', error);
}
}
async delete(key: string): Promise<void> {
const payload: DeletePayload = { key, buildId: getBuildId() };
try {
await this.post(CACHE_PATH.delete, payload);
} catch (error) {
console.error('Failed to delete from cache worker', error);
}
}
private async post(path: string, payload: unknown): Promise<void> {
const response = await internalFetch(new URL(path, CACHE_ORIGIN), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Cache worker responded with ${response.status} for ${path}`);
}
}
}
export default new GitbookContainerIncrementalCache();
@@ -0,0 +1,101 @@
import type {
CacheEntryType,
CacheValue,
NextModeTagCacheWriteInput,
QueueMessage,
} from '@opennextjs/aws/types/overrides.js';
/**
* Protocol spoken to the cache worker, by both server tiers.
*
* The workerd tier reaches it through the `NEXT_INC_CACHE_WORKER` service binding. The container
* has no Cloudflare bindings, so it issues plain `fetch` calls to this virtual host instead; they
* never reach the network, because the container Durable Object registers an outbound handler for
* the host that runs in the Workers runtime where the binding is available.
*
* Both tiers build their requests here so they address a cache entry identically — the read URL is
* the cache worker's edge cache key, so any divergence would split that entry in two.
*/
export const CACHE_HOST = 'incremental-cache.internal';
// Outbound handlers only see ports 80 and 443, and intercepting HTTPS would require trusting a
// per-instance CA inside the image. The handler restores the `https:` scheme before forwarding.
export const CACHE_ORIGIN = `http://${CACHE_HOST}`;
// What the cache worker actually sees, and so what the workerd tier sends directly.
export const CACHE_ORIGIN_SECURE = `https://${CACHE_HOST}`;
/** `read` is built by `getReadUrl` on both tiers, so they share one edge cache entry. */
export const CACHE_PATH = {
read: '/',
set: '/set',
delete: '/delete',
writeTags: '/write-tags',
queue: '/queue',
} as const;
export type SetPayload = {
key: string;
value: CacheValue<CacheEntryType>;
cacheType?: CacheEntryType;
buildId?: string;
};
export type DeletePayload = {
key: string;
buildId?: string;
};
export type WriteTagsPayload = {
tags: NextModeTagCacheWriteInput[];
};
export type QueuePayload = {
msg: QueueMessage;
};
/**
* Build ID the calling tier's entries belong to.
*
* The cache worker namespaces `cache` entries per build but ships with the container worker, so
* during a gradual rollout of the workerd tier its own build ID is not the one the entry belongs
* to — callers have to send theirs. `fetch` and `composable` entries live in the shared `dataCache`
* namespace, so they deliberately resolve to `undefined`.
*/
export function getBuildId(cacheType?: CacheEntryType): string | undefined {
if (cacheType && cacheType !== 'cache') {
return undefined;
}
return process.env.OPEN_NEXT_BUILD_ID ?? process.env.DEPLOYMENT_ID;
}
/**
* TODO: temporary. Set `DEBUG_CACHE_KEYS=true` on a worker to trace how a cache entry is
* addressed, end to end: what the caller sends, what the cache worker resolves, and whether the
* answer came from the cache worker's own response cache rather than R2.
*/
export function logCacheDebug(scope: string, fields: Record<string, unknown>): void {
if (process.env.DEBUG_CACHE_KEYS !== 'true') {
return;
}
console.log(`[cache-keys] ${scope} ${JSON.stringify(fields)}`);
}
export function getReadUrl(
key: string,
cacheType?: CacheEntryType,
origin: string = CACHE_ORIGIN
): URL {
const url = new URL(CACHE_PATH.read, origin);
url.searchParams.set('key', key);
if (cacheType) {
url.searchParams.set('cacheType', cacheType);
}
const buildId = getBuildId(cacheType);
if (buildId) {
url.searchParams.set('buildId', buildId);
}
return url;
}
@@ -0,0 +1,25 @@
import type { Queue } from '@opennextjs/aws/types/overrides.js';
import { internalFetch } from './fetch';
import { CACHE_ORIGIN, CACHE_PATH, type QueuePayload } from './protocol';
/**
* The ISR queue Durable Object lives in the cache worker, so revalidation messages travel the same
* outbound path as the cache itself.
*/
export default {
name: 'GitbookISRQueue',
send: async (msg) => {
const payload: QueuePayload = { msg };
try {
await internalFetch(new URL(CACHE_PATH.queue, CACHE_ORIGIN), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (error) {
console.error('Failed to send revalidation to cache worker', error);
}
},
} satisfies Queue;
@@ -0,0 +1,36 @@
import type { NextModeTagCache, NextModeTagCacheWriteInput } from '@opennextjs/aws/types/overrides';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
import { internalFetch } from './fetch';
import { CACHE_ORIGIN, CACHE_PATH, type WriteTagsPayload } from './protocol';
export default {
name: 'GitbookContainerTagCache',
mode: 'nextMode',
// Do nothing.
getLastRevalidated: async () => {
return 0;
},
// Return false, everything handled at the incremental cache level in the do worker.
hasBeenRevalidated: async () => {
return false;
},
writeTags: async (tags: NextModeTagCacheWriteInput[]) => {
const tagsToWrite = tags.filter(softTagFilter);
if (tagsToWrite.length === 0) {
return;
}
const payload: WriteTagsPayload = { tags: tagsToWrite };
try {
await internalFetch(new URL(CACHE_PATH.writeTags, CACHE_ORIGIN), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (error) {
console.error('Failed to write tags to cache worker', error);
}
},
} satisfies NextModeTagCache;
@@ -0,0 +1,15 @@
FROM node:22-slim
WORKDIR /app
# Built by `bun run build:container` (@opennextjs/aws, node wrapper).
COPY .open-next-container/server-functions/default /app
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
# In this monorepo the bundle's root index.mjs re-exports packages/gitbook/index.mjs;
# importing it boots the HTTP server.
CMD ["node", "index.mjs"]
@@ -0,0 +1,63 @@
import { Container, type OutboundHandler, getRandom } from '@cloudflare/containers';
import { WorkerEntrypoint } from 'cloudflare:workers';
import { CACHE_HOST } from '../container/protocol';
import { type ContainerOutboundEnv, handleCacheOutbound } from './containerOutbound';
// Required by @cloudflare/containers: the outbound interception proxy is looked up on ctx.exports.
export { ContainerProxy } from '@cloudflare/containers';
// The cache tier for every server tier, served from this worker on a named entrypoint so its
// responses are cached without the default entrypoint (rendered pages) being cached too.
export { IncrementalCacheWorker } from './containerCache';
type ContainerWorkerEnv = ContainerOutboundEnv & {
NEXT_SERVER_CONTAINER: DurableObjectNamespace<NextServerContainer>;
CONTAINER_INSTANCES?: string;
};
const DEFAULT_INSTANCES = 3;
function getStringVars(env: unknown): Record<string, string> {
return Object.fromEntries(
Object.entries(env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
);
}
export class NextServerContainer extends Container {
defaultPort = 3000;
sleepAfter = '10m';
// Host + path: the @opennextjs/aws `node` wrapper answers this without waking Next.
pingEndpoint = 'container/__health';
// The container is a separate process, so worker `vars` do not reach it on their own. Forward
// them so the app reads the same `process.env` the workerd tier does (GITBOOK_URL, STAGE, ...).
envVars = getStringVars(this.env);
}
// `Container.outboundByHost` is a static setter that registers the handlers; declaring it as a
// static field on the subclass would shadow it with a plain property and the proxy would never
// find the handler — it would fall through to real internet access instead.
// `Cloudflare.Env` is generated from the root wrangler config and does not describe this worker's
// bindings, hence the cast.
//
// Only the cache host is intercepted; everything else (the GitBook API, the icons CDN) goes out
// normally. Note that loopback traffic cannot be intercepted at all — it never leaves the
// container's network namespace — so anything the app fetches server-side must be a real host.
NextServerContainer.outboundByHost = {
[CACHE_HOST]: (request, env) =>
handleCacheOutbound(request, env as unknown as ContainerOutboundEnv),
} satisfies Record<string, OutboundHandler>;
export default class extends WorkerEntrypoint<ContainerWorkerEnv> {
async fetch(request: Request): Promise<Response> {
const instances = Number.parseInt(this.env.CONTAINER_INSTANCES ?? '', 10);
const container = await getRandom(
this.env.NEXT_SERVER_CONTAINER,
Number.isNaN(instances) ? DEFAULT_INSTANCES : instances
);
return container.fetch(request);
}
}
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
const runWithCloudflareRequestContext = mock(
async <T>(_: Request, __: unknown, ___: unknown, operation: () => Promise<T>) => operation()
);
const get = mock();
const getTagsFromValue = mock();
const hasBeenRevalidated = mock();
mock.module('cloudflare:workers', () => ({
DurableObject: class {},
WorkerEntrypoint: class {},
}));
mock.module('../../.open-next/cloudflare/init.js', () => ({ runWithCloudflareRequestContext }));
const cacheConstructor = mock();
mock.module('../incrementalCache/incrementalCache', () => ({
GitbookIncrementalCache: class {
get = get;
constructor(buildId?: string) {
cacheConstructor(buildId);
}
},
}));
mock.module('@opennextjs/aws/utils/cache.js', () => ({ getTagsFromValue }));
mock.module('../tagCache/middleware', () => ({
default: { hasBeenRevalidated },
}));
const { IncrementalCacheWorker } = await import('./containerCache');
const CACHE_CONTROL = 'public, s-maxage=3600, stale-while-revalidate=86400';
const NO_STORE_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate';
const cacheValue = {
type: 'page' as const,
html: '<p>cached</p>',
json: {},
revalidate: 60,
};
describe('IncrementalCacheWorker fetch', () => {
const selfFetch = mock();
beforeEach(() => {
selfFetch.mockReset();
get.mockReset();
cacheConstructor.mockReset();
getTagsFromValue.mockReset();
hasBeenRevalidated.mockReset();
getTagsFromValue.mockReturnValue(['space:1']);
hasBeenRevalidated.mockResolvedValue(false);
});
const fetch = (request: Request) =>
IncrementalCacheWorker.prototype.fetch.call(
{
env: { WORKER_SELF_REFERENCE: { fetch: selfFetch } },
ctx: {},
},
request
);
it('forwards cache reads to the internal endpoint and restores cache metadata', async () => {
selfFetch.mockResolvedValue(
Response.json(
{ value: cacheValue, lastModified: 123 },
{
headers: {
'x-gitbook-cache-control': CACHE_CONTROL,
'x-gitbook-cache-tag': 'incremental-cache:entry,space:1',
},
}
)
);
const response = await fetch(
new Request('https://incremental-cache.internal/?key=entry&cacheType=cache')
);
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
const forwardedRequest = selfFetch.mock.calls[0]?.[0] as Request;
const forwardedURL = new URL(forwardedRequest.url);
expect(forwardedURL.pathname).toBe('/internal');
expect(forwardedURL.searchParams.get('key')).toBe('entry');
expect(forwardedURL.searchParams.get('cacheType')).toBe('cache');
});
it('reads the entry under the build ID sent by the caller', async () => {
get.mockResolvedValue(null);
await fetch(
new Request(
'https://incremental-cache.internal/internal?key=entry&cacheType=cache&buildId=caller-build-id'
)
);
expect(cacheConstructor).toHaveBeenCalledWith('caller-build-id');
});
it('reads and annotates a cache hit only on the internal endpoint', async () => {
get.mockResolvedValue({ value: cacheValue, lastModified: Date.now() });
const response = await fetch(
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=cache')
);
expect(get).toHaveBeenCalledWith('entry', 'cache');
expect(selfFetch).not.toHaveBeenCalled();
expect(response.headers.get('cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('x-gitbook-cache-control')).toBe(CACHE_CONTROL);
expect(response.headers.get('cache-tag')).toBe('incremental-cache:entry,space:1');
expect(response.headers.get('x-gitbook-cache-tag')).toBe('incremental-cache:entry,space:1');
});
it('keeps cache misses out of the worker cache', async () => {
get.mockResolvedValue(null);
const response = await fetch(
new Request('https://incremental-cache.internal/internal?key=missing')
);
expect(response.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(response.headers.get('x-gitbook-cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(await response.json()).toBeNull();
});
it('keeps revalidated and stale entries out of the worker cache', async () => {
get.mockResolvedValueOnce({ value: cacheValue, lastModified: Date.now() });
hasBeenRevalidated.mockResolvedValueOnce(true);
const revalidatedResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=revalidated')
);
get.mockResolvedValueOnce({
value: { ...cacheValue, revalidate: 0 },
lastModified: Date.now() - 1_000,
});
const staleResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=stale')
);
expect(revalidatedResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(revalidatedResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
expect(staleResponse.headers.get('cache-control')).toBe(NO_STORE_CACHE_CONTROL);
expect(staleResponse.headers.get('x-gitbook-cache-revalidated')).toBe('true');
});
it('restores the native Request after entering the OpenNext context', async () => {
const NativeRequest = globalThis.Request;
// The real `runWithCloudflareRequestContext` swaps the global for a subclass, which breaks
// the `instanceof Request` check in @cloudflare/containers on the container proxy path.
runWithCloudflareRequestContext.mockImplementationOnce(
async <T>(_: Request, __: unknown, ___: unknown, operation: () => Promise<T>) => {
globalThis.Request = class extends NativeRequest {} as typeof Request;
return operation();
}
);
get.mockResolvedValue(null);
await fetch(new Request('https://incremental-cache.internal/internal?key=entry'));
expect(globalThis.Request).toBe(NativeRequest);
});
it('rejects invalid internal requests and does not forward non-GET requests', async () => {
const invalidResponse = await fetch(
new Request('https://incremental-cache.internal/internal?key=entry&cacheType=invalid')
);
const methodResponse = await fetch(
new Request('https://incremental-cache.internal/?key=entry', { method: 'POST' })
);
expect(invalidResponse.status).toBe(400);
expect(methodResponse.status).toBe(405);
expect(selfFetch).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More