Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
879bbd0
wip
conico974 Jul 21, 2026
5b3d8e5
Add dynamic export for force-static rendering in page component
conico974 Jul 29, 2026
2659a8d
Enhance caching strategy in PPR components with remote cache and tagging
conico974 Jul 29, 2026
5494fb2
Implement PPR routing with token-based headers and restructure relate…
conico974 Jul 29, 2026
89f3df8
Refactor PPR tokens and enhance route parameter handling in utils
conico974 Jul 29, 2026
c040182
comment
conico974 Jul 29, 2026
7dd0fa4
Implement caching for site page metadata and viewport generation
conico974 Jul 30, 2026
71dd38a
linting
conico974 Jul 30, 2026
08e8d69
Refactor caching functions for site page metadata and viewport in PPR…
conico974 Jul 30, 2026
2b609ab
Fix invocation of cachedGenerateSitePageMetadata in generateMetadata …
conico974 Jul 30, 2026
5218076
fix attempt
conico974 Jul 30, 2026
c29cf3f
Implement PPR route enhancements with revision and revalidation IDs, …
conico974 Jul 30, 2026
9b2a4b8
Refactor PPR components: remove old files, add new layout and page co…
conico974 Jul 30, 2026
4dcf0ae
Refactor PPR components: remove deprecated files, enhance site contex…
conico974 Jul 31, 2026
a737b01
Refactor PPR components: remove unused files, add default site parame…
conico974 Aug 3, 2026
60359c1
remove revalidationId from site data
conico974 Aug 3, 2026
4f456d0
Refactor getPPRHeaderRouteParams: destructure revision from site URL …
conico974 Aug 4, 2026
ca5201d
Remove NotFound component: delete unused page not found file
conico974 Aug 4, 2026
5142f17
Refactor getPPRHeaderRouteParams and getPPRTableOfContentsRouteParams…
conico974 Aug 4, 2026
bcfe5a6
Refactor navigation components: implement client-side selection handl…
conico974 Aug 11, 2026
066e5bb
Add PPR development proxy and update documentation for local testing
conico974 Aug 12, 2026
901a49b
Fix proxy host header handling in forward function to prevent 404 errors
conico974 Aug 12, 2026
50dfe7c
Remove redundant cache tagging in PPRTableOfContents, PPRPageBody, an…
conico974 Aug 12, 2026
6bd3adb
Prefix cache tags with `ppr:` for PPR route rendering to ensure cache…
conico974 Aug 12, 2026
f78c845
Refactor PPR cache handling: implement scoped cache tags for componen…
conico974 Aug 14, 2026
aed6e98
Apply oxfmt import ordering after rebase
conico974 Aug 21, 2026
e935b67
Refactor PPR route parameter handling: simplify getPPRTableOfContents…
conico974 Aug 24, 2026
deae34d
Refactor getPPRDefaults function: streamline error handling and valid…
conico974 Aug 24, 2026
41046b6
Enhance PPR route parameters: add basePath handling for variant consi…
conico974 Aug 24, 2026
5f2e3aa
Implement GitBook-secret signature for PPR headers: enforce signature…
conico974 Aug 25, 2026
683ed85
Fix PPR test module mocks leaking into the rest of the suite
conico974 Aug 26, 2026
12b2a8d
Add PPR token exchange functionality: implement endpoint for exchangi…
conico974 Sep 1, 2026
ca63079
Split the PPR layout context between site and revision scopes
conico974 Sep 2, 2026
d30135a
Refactor PPR layout functions to use 'header' context and enhance typ…
conico974 Sep 2, 2026
313e1b9
super hacky solution to PPR cache body
conico974 Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ppr-cache-tag-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Prefix all cache tags with `ppr:` when rendering under the PPR route, so PPR cache entries are partitioned from the static ones.
5 changes: 5 additions & 0 deletions .changeset/ppr-request-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Require a GitBook-secret signature on the `x-gbo-*` PPR headers, so a client can't opt itself into the PPR route.
5 changes: 5 additions & 0 deletions .changeset/ppr-token-exchange.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Exchange the PPR revalidation token for a content API token scoped to each PPR component, so the API receives claims it understands and the header and table of contents can be cached across pages.
1 change: 1 addition & 0 deletions .github/composite/deploy-cloudflare/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ runs:
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_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL
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
Expand Down
1 change: 1 addition & 0 deletions .github/composite/deploy-vercel/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ runs:
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL
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
Expand Down
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ Examples:
- `http://localhost:3000/url/gitbook.com/docs`
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`

### PPR routes

PPR requests are normally resolved upstream and arrive with a large set of `x-gbo-*` headers, so they
can't be reproduced by hitting the dev server directly. `bun run dev:ppr` (from `packages/gitbook`)
starts a dev-only proxy on port 3001 that resolves the URL, injects those headers and signs them.
The app rejects an unsigned set, so `GITBOOK_SECRET` must be set in `.env.local` (any value works
locally, as long as both processes read the same one).

The app also exchanges the PPR token for one scoped to each component, against
`GITBOOK_EXCHANGE_TOKEN_URL`. That endpoint only accepts a revalidation token, which the
published-URLs lookup never returns, so the proxy mints one with `PPR_DEV_API_TOKEN_SECRET` — a
local-only stand-in for the API token secret the cache worker holds in production. It must match the
secret the target `/token` endpoint verifies with, so local PPR needs a local gitbook-x sites stack.
The secret is read only by the proxy script: never add it to `src/lib/env`, `next.config.mjs` or a
deploy workflow.

```
GITBOOK_SECRET=<any value>
GITBOOK_EXCHANGE_TOKEN_URL=http://localhost:8788/token
PPR_DEV_API_TOKEN_SECRET=<local gitbook-x functionsConfig.api.tokenSecret>
```

```
http://localhost:3001/url/<published-gitbook-url>
```

Responses carry `x-gitbook-route-type: ppr` when the PPR route was used. Hot reload doesn't work
through the proxy (its websocket can't be forwarded), so keep using port 3000 while iterating.

## Architecture

```
Expand Down
77 changes: 76 additions & 1 deletion packages/gitbook/e2e/internal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Page, expect } from '@playwright/test';
import { type Page, expect, test } from '@playwright/test';
import jwt from 'jsonwebtoken';

import {
Expand All @@ -15,6 +15,7 @@ import {
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';

import { signPPRRequestHeaders } from '../src/lib/ppr';
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
import {
type Test,
Expand Down Expand Up @@ -315,6 +316,49 @@ const searchTestCases: Test[] = [
},
];

const PPR_TEST_SITE_URL = 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/';

/**
* The `x-gbo-*` set GBO resolves upstream. `sign` mirrors what GBO does with `GITBOOK_SECRET`;
* without it the app must fall back to resolving the URL itself.
*/
async function getPPRHeaders(options: { sign: string | undefined }) {
const data = await getSiteAPIToken(PPR_TEST_SITE_URL);

if (!data.revision) {
throw new Error('PPR test site did not resolve to content with a revision');
}

const headers = new Headers({
'x-gbo-site': data.site,
'x-gbo-site-section': data.siteSection ?? '',
'x-gbo-site-space': data.siteSpace,
'x-gbo-space': data.space,
'x-gbo-site-base-path': data.siteBasePath,
'x-gbo-base-path': data.basePath,
'x-gbo-pathname': data.pathname || '/',
'x-gbo-organization': data.organization,
'x-gbo-share-key': data.shareKey ?? '',
'x-gbo-complete': String(data.complete),
'x-gbo-context-id': data.contextId ?? '',
'x-gbo-canonical-url': data.canonicalUrl,
'x-gbo-preview': data.preview === undefined ? '' : String(data.preview),
'x-gbo-revision': data.revision ?? '',
'x-gbo-change-request': data.changeRequest ?? '',
'x-gbo-api-token': data.apiToken,
'x-gbo-revalidation-id': 'ppr-e2e-revalidation',
'x-gbo-default-site-section': data.siteSection ?? '',
'x-gbo-default-site-space': data.siteSpace,
'x-gbo-default-space': data.space,
});

if (options.sign) {
await signPPRRequestHeaders(headers, options.sign);
}

return Object.fromEntries(headers.entries());
}

const testCases: TestsCase[] = [
{
name: 'GitBook Site (Single Variant)',
Expand All @@ -325,6 +369,37 @@ const testCases: TestsCase[] = [
url: '',
run: waitForCookiesDialog,
},
{
name: 'PPR route renders the site shell',
url: '',
headers: () => getPPRHeaders({ sign: process.env.GITBOOK_SECRET }),
screenshot: false,
run: async (page, response) => {
// The deployment signs with its own `GITBOOK_SECRET`; without it here the
// headers can only be tested for rejection (see the test below).
test.skip(
!process.env.GITBOOK_SECRET,
'GITBOOK_SECRET is required to sign PPR headers'
);
expect(response?.headers()['x-gitbook-route-type']).toBe('ppr');
await expect(page.locator('header[data-gb-site-header]')).toBeVisible();
await expect(page.getByTestId('table-of-contents')).toBeVisible();
await expect(page.locator('main')).toBeVisible();
},
},
{
name: 'PPR route ignores an unsigned header set',
url: '',
headers: () => getPPRHeaders({ sign: undefined }),
screenshot: false,
run: async (page, response) => {
// Anyone can send these headers, so an unsigned set must never take the PPR
// path: it skips URL resolution and visitor-auth, and picks the cache key.
expect(response?.headers()['x-gitbook-route-type']).not.toBe('ppr');
await expect(page.locator('header[data-gb-site-header]')).toBeVisible();
await expect(page.locator('main')).toBeVisible();
},
},
{
name: 'No variants dropdown',
url: '',
Expand Down
10 changes: 10 additions & 0 deletions packages/gitbook/e2e/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface Test {
*/
url: string | (() => string | Promise<string>);
cookies?: Parameters<BrowserContext['addCookies']>[0];
/** Headers to send with the main document request. */
headers?:
| Record<string, string>
| (() => Record<string, string> | Promise<Record<string, string>>);
/**
* Test to run
*/
Expand Down Expand Up @@ -279,6 +283,11 @@ export function runTestCases(testCases: TestsCase[]) {
} catch {}
});

const headers =
typeof testEntry.headers === 'function'
? await testEntry.headers()
: testEntry.headers;

// Set the header to disable the Vercel toolbar
// But only on the main document as it'd cause CORS issues on other resources
await page.route('**/*', async (route, request) => {
Expand All @@ -287,6 +296,7 @@ export function runTestCases(testCases: TestsCase[]) {
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
...headers,
},
});
} else {
Expand Down
1 change: 1 addition & 0 deletions packages/gitbook/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const nextConfig = {
GITBOOK_API_TOKEN: process.env.GITBOOK_API_TOKEN,
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_EXCHANGE_TOKEN_URL: process.env.GITBOOK_EXCHANGE_TOKEN_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
Expand Down
1 change: 1 addition & 0 deletions packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"generate:fonts": "bun ./scripts/generate-font-faces.ts",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math && rm -rf ./public/~gitbook/static/mermaid && rm -rf ./public/~gitbook/static/scalar && rm -rf ./public/~gitbook/static/fonts",
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
"dev:ppr": "env-cmd --silent -f ../../.env.local bun scripts/ppr-dev-proxy.ts",
"build": "bun run generate:assets && next build --webpack",
"build:local": "bun run generate:assets && GITBOOK_URL=http://localhost:3000 next build --webpack",
"check:css-browser-compatibility": "bun scripts/check-css-browser-compatibility.ts",
Expand Down
Loading
Loading