Compare commits

...

36 Commits

Author SHA1 Message Date
Nicolas Dorseuil 3bd9976524 Revert "super hacky solution to PPR cache body"
This reverts commit 4c36cf9abd.
2026-09-07 11:43:52 +02:00
Nicolas Dorseuil 4c36cf9abd super hacky solution to PPR cache body 2026-09-07 11:41:10 +02:00
Nicolas Dorseuil c1530706d0 Refactor PPR layout functions to use 'header' context and enhance type definitions for site context 2026-09-07 11:40:54 +02:00
Nicolas Dorseuil d4fa433d92 Split the PPR layout context between site and revision scopes
`SitePPRLayout` resolved a full site context, which fetches the published
site (site-scoped token) and the space with its revision. Only the site
part is needed to render the shell, and the shell is re-rendered on every
request.

Split `GitBookSiteContext` into a `GitBookSiteScopeContext` holding
everything derived from the published site, and the space context it is
merged with. The PPR layout now resolves only the site scope, under the
header scope so it shares its site fetch, and delegates the announcement,
header, table of contents, footer, admin toolbar and the page/tag icons to
cached components that resolve the revision under their own scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:40:54 +02:00
Nicolas Dorseuil a2170a352a Add PPR token exchange functionality: implement endpoint for exchanging revalidation tokens for scoped content API tokens, enhancing caching and claims management across components. 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil e05abafddb Fix PPR test module mocks leaking into the rest of the suite
`mock.module` replaces a module for the whole bun test process, so mocking
`@/lib/context` and `jwt-decode` wholesale in the PPR route params test broke
every later test file that imported them. Spread the real context module and
sign a real JWT instead.

Also restore the `x-gitbook-route-site` debug header, commented out by mistake.
2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 7e0e76e58a Implement GitBook-secret signature for PPR headers: enforce signature requirement and update related handling in middleware, tests, and proxy scripts. 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 42ab12a387 Enhance PPR route parameters: add basePath handling for variant consistency in getPPRHeaderRouteParams 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 2251b36673 Refactor getPPRDefaults function: streamline error handling and validation for decoded PPR defaults 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 8ef76487c2 Refactor PPR route parameter handling: simplify getPPRTableOfContentsRouteParams by removing unnecessary cache path variation 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil e401c4d4c7 Apply oxfmt import ordering after rebase
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:39:51 +02:00
Nicolas Dorseuil fb971aefcc Refactor PPR cache handling: implement scoped cache tags for components to ensure independent revalidation and improve data fetching efficiency 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil b6770d959a Prefix cache tags with ppr: for PPR route rendering to ensure cache entries are partitioned from static ones 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 8d881d88f6 Remove redundant cache tagging in PPRTableOfContents, PPRPageBody, and cachedGenerateSitePageMetadata functions 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 351c4ffa28 Fix proxy host header handling in forward function to prevent 404 errors 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil bd608e6beb Add PPR development proxy and update documentation for local testing 2026-09-07 11:39:51 +02:00
Nicolas Dorseuil 825e245703 Refactor navigation components: implement client-side selection handling and improve context management 2026-09-07 11:39:50 +02:00
Nicolas Dorseuil 828ce66cd9 Refactor getPPRHeaderRouteParams and getPPRTableOfContentsRouteParams: enhance caching logic and add API token handling 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 8e1fdedf83 Remove NotFound component: delete unused page not found file 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 4a88202274 Refactor getPPRHeaderRouteParams: destructure revision from site URL data 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil d077e1a6b6 remove revalidationId from site data 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 7605d03d8c Refactor PPR components: remove unused files, add default site parameters, and enhance route handling 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 80026d2724 Refactor PPR components: remove deprecated files, enhance site context handling, and streamline API token management 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil b86ee531d4 Refactor PPR components: remove old files, add new layout and page components, and update utility functions for visitor tokens 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil d939d4c1fd Implement PPR route enhancements with revision and revalidation IDs, refactor headers handling, and add new layout and page components 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 0cc9a03add fix attempt 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 3805bac462 Fix invocation of cachedGenerateSitePageMetadata in generateMetadata function 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 23b438404c Refactor caching functions for site page metadata and viewport in PPR components 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 40d14b02d8 linting 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil ef0b6f020a Implement caching for site page metadata and viewport generation 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil d6c0d2dbcd comment 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 125872769f Refactor PPR tokens and enhance route parameter handling in utils 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil a04513f887 Implement PPR routing with token-based headers and restructure related components 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 7adbb5068f Enhance caching strategy in PPR components with remote cache and tagging 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil 2b146dbc6a Add dynamic export for force-static rendering in page component 2026-09-07 11:38:07 +02:00
Nicolas Dorseuil ea9420817f wip 2026-09-07 11:38:07 +02:00
51 changed files with 2818 additions and 300 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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.
@@ -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
@@ -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
+29
View File
@@ -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
```
+76 -1
View File
@@ -1,4 +1,4 @@
import { type Page, expect } from '@playwright/test';
import { type Page, expect, test } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
@@ -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,
@@ -346,6 +347,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)',
@@ -356,6 +400,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: '',
+10
View File
@@ -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
*/
@@ -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) => {
@@ -287,6 +296,7 @@ export function runTestCases(testCases: TestsCase[]) {
headers: {
...request.headers(),
'x-vercel-skip-toolbar': '1',
...headers,
},
});
} else {
+1
View File
@@ -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,
+1
View File
@@ -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",
+351
View File
@@ -0,0 +1,351 @@
/**
* Dev-only proxy that stands in for the upstream layer resolving PPR requests.
*
* It resolves the incoming URL against the published-URLs API and forwards the request to the local
* app with the complete `x-gbo-*` header set, so PPR routes can be exercised locally:
*
* bun dev # app on :3000
* bun run dev:ppr # this proxy on :3001
* open http://localhost:3001/url/gitbook.com/docs
*
* Never deploy this. It trusts its input and caches API tokens in memory.
*/
import type { PublishedSiteContent, PublishedSiteContentLookup, Space } from '@gitbook/api';
import { PPRRequestHeaders, signPPRRequestHeaders } from '../src/lib/ppr';
const PORT = Number(process.env.PPR_PROXY_PORT || 3001);
const UPSTREAM = process.env.PPR_UPSTREAM || 'http://localhost:3000';
const API_URL = process.env.GITBOOK_API_URL || 'https://api.gitbook.com/cache';
const API_TOKEN = process.env.GITBOOK_API_TOKEN;
const REVALIDATION_ID = process.env.PPR_REVALIDATION_ID;
const LOOKUP_TTL = Number(process.env.PPR_LOOKUP_TTL || 60) * 1000;
const URL_PREFIX = '/url/';
const PASSTHROUGH_PREFIXES = ['/_next/', '/~gitbook/static/'];
function log(message: string) {
// biome-ignore lint/suspicious/noConsole: this is a CLI script
console.log(`[ppr-proxy] ${message}`);
}
// The app rejects an unsigned header set, so `bun dev` must run with the same secret.
function requireSecret(): string {
const secret = process.env.GITBOOK_SECRET;
if (!secret) {
log(
'GITBOOK_SECRET is not set: the app rejects unsigned PPR headers. Add it to .env.local.'
);
process.exit(1);
}
return secret;
}
// This proxy mints API tokens, so it must never be reachable outside a developer machine.
if (process.env.NODE_ENV === 'production') {
log('This proxy mints API tokens and must never run in production.');
process.exit(1);
}
const SECRET = requireSecret();
// The app always exchanges the PPR token, and the exchange endpoint only accepts a revalidation
// token, which the published-URLs lookup never returns: we have to mint one ourselves.
function requireAPITokenSecret(): string {
const secret = process.env.PPR_DEV_API_TOKEN_SECRET;
if (!secret) {
log(
'PPR_DEV_API_TOKEN_SECRET is not set: the token exchange rejects the lookup token. Add it to .env.local.'
);
process.exit(1);
}
return secret;
}
const API_TOKEN_SECRET = requireAPITokenSecret();
const cache = new Map<string, { value: unknown; expiresAt: number }>();
const inflight = new Map<string, Promise<unknown>>();
/**
* Cache and dedupe an API call. A single page load fans out into many requests for the same site,
* and the site root lookup is shared by every page of a site.
*/
function cached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const entry = cache.get(key);
if (entry && entry.expiresAt > Date.now()) {
return Promise.resolve(entry.value as T);
}
const pending = inflight.get(key);
if (pending) {
return pending as Promise<T>;
}
const promise = fetcher()
.then((value) => {
cache.set(key, { value, expiresAt: Date.now() + LOOKUP_TTL });
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
async function api<T>(path: string, init: RequestInit, token = API_TOKEN): Promise<T> {
const response = await fetch(`${API_URL}/v1${path}`, {
...init,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response.json() as Promise<T>;
}
function lookupPublishedURL(url: string): Promise<PublishedSiteContentLookup> {
return cached(`url:${url}`, () =>
api<PublishedSiteContentLookup>('/urls/published', {
method: 'POST',
body: JSON.stringify({ url }),
})
);
}
/**
* The lookup only carries a revision for preview URLs, so for a regular published URL we read the
* space's active revision using the short-lived token the lookup returned.
*/
function getActiveRevision(content: PublishedSiteContent): Promise<string> {
return cached(`revision:${content.space}`, async () => {
const space = await api<Space>(`/spaces/${content.space}`, {}, content.apiToken);
return space.revision;
});
}
/**
* The `x-gbo-default-*` headers describe the site's default variant, which is the cache key of the
* shared header. Resolving the site root is the only way to get it from the published-URLs API.
*/
async function getDefaults(content: PublishedSiteContent) {
const fallback = {
siteSection: content.siteSection,
siteSpace: content.siteSpace,
space: content.space,
};
try {
const rootURL = `https://${new URL(content.canonicalUrl).host}${content.siteBasePath}`;
const root = await lookupPublishedURL(rootURL);
if ('redirect' in root) {
return fallback;
}
return { siteSection: root.siteSection, siteSpace: root.siteSpace, space: root.space };
} catch {
return fallback;
}
}
/**
* Turn the lookup's content token into the revalidation token the cache worker would send: the same
* payload, with the claims split into the per-scope buckets the exchange endpoint narrows down.
* Reusing the payload keeps `spaces`, `iat` and `exp` valid.
*/
async function mintRevalidationToken(apiToken: string): Promise<string> {
const { claims: _claims, ...payload } = decodeJWTPayload(apiToken);
const result = signJWT(
{
...payload,
target: 'content',
// Empty buckets: local dev has no revalidation run to compute adaptive claims from.
siteClaims: {},
revisionClaims: {},
pageClaims: {},
},
API_TOKEN_SECRET
);
console.log('minted revalidation token', await result);
return result;
}
function decodeJWTPayload(token: string): Record<string, unknown> {
const payload = token.split('.')[1];
if (!payload) {
throw new Error('API token is not a JWT');
}
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
}
async function signJWT(payload: Record<string, unknown>, secret: string): Promise<string> {
const signingInput = `${base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${base64url(
JSON.stringify(payload)
)}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput));
return `${signingInput}.${base64url(Buffer.from(signature))}`;
}
function base64url(value: string | Buffer): string {
return (typeof value === 'string' ? Buffer.from(value, 'utf8') : value).toString('base64url');
}
async function setPPRHeaders(
headers: Headers,
content: PublishedSiteContent & { revision: string },
defaults: { siteSection: string | undefined; siteSpace: string; space: string },
secret: string
) {
headers.set(PPRRequestHeaders.Site, content.site);
headers.set(PPRRequestHeaders.SiteSection, content.siteSection ?? '');
headers.set(PPRRequestHeaders.SiteSpace, content.siteSpace);
headers.set(PPRRequestHeaders.Space, content.space);
headers.set(PPRRequestHeaders.SiteBasePath, content.siteBasePath);
headers.set(PPRRequestHeaders.BasePath, content.basePath);
// An empty pathname is rejected; the root page is `/`.
headers.set(PPRRequestHeaders.Pathname, content.pathname || '/');
headers.set(PPRRequestHeaders.Organization, content.organization);
headers.set(PPRRequestHeaders.ShareKey, content.shareKey ?? '');
headers.set(PPRRequestHeaders.Complete, String(content.complete));
headers.set(PPRRequestHeaders.ContextID, content.contextId ?? '');
headers.set(PPRRequestHeaders.CanonicalURL, content.canonicalUrl);
// Anything other than '', 'true' or 'false' rejects the whole PPR request.
headers.set(
PPRRequestHeaders.Preview,
content.preview === undefined ? '' : String(content.preview)
);
headers.set(PPRRequestHeaders.Revision, content.revision);
headers.set(PPRRequestHeaders.ChangeRequest, content.changeRequest ?? '');
headers.set(PPRRequestHeaders.APIToken, await mintRevalidationToken(content.apiToken));
headers.set(PPRRequestHeaders.RevalidationID, REVALIDATION_ID || content.revision);
// Unlike the other optional headers this one is checked with `has()`, so it must be sent even
// when the site has no sections.
headers.set(PPRRequestHeaders.DefaultSiteSection, defaults.siteSection ?? '');
headers.set(PPRRequestHeaders.DefaultSiteSpace, defaults.siteSpace);
headers.set(PPRRequestHeaders.DefaultSpace, defaults.space);
// Signed last: the signature covers every other PPR header.
await signPPRRequestHeaders(headers, secret);
}
/**
* `fetch` decodes the response body, so the upstream framing headers no longer describe what we are
* about to send. Forwarding `content-encoding: gzip` with plain bytes renders as a blank page.
*/
const DECODED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding'];
async function forward(request: Request, url: URL, headers: Headers): Promise<Response> {
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
const upstreamURL = new URL(url.pathname + url.search, UPSTREAM);
// `/url/` mode is only enabled for requests on the app's own host (`GITBOOK_URL`), so the proxy
// host must not leak through: the app would treat it as a custom domain and 404.
headers.set('host', upstreamURL.host);
let response: Response;
try {
response = await fetch(upstreamURL, {
method: request.method,
headers,
body: hasBody ? request.body : undefined,
redirect: 'manual',
...(hasBody ? { duplex: 'half' } : {}),
} as RequestInit);
} catch (error) {
log(`${request.method} ${url.pathname} → upstream unreachable at ${UPSTREAM}`);
return new Response(`Upstream ${UPSTREAM} unreachable: ${error}`, { status: 502 });
}
const responseHeaders = new Headers(response.headers);
for (const name of DECODED_RESPONSE_HEADERS) {
responseHeaders.delete(name);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
async function handle(request: Request): Promise<Response> {
const url = new URL(request.url);
// `fetch` can't perform an upgrade, so hot reload only works when hitting the app directly.
if (request.headers.get('upgrade') === 'websocket') {
return new Response('Websocket upgrades are not proxied', { status: 501 });
}
const headers = new Headers(request.headers);
// Never let a client inject its own PPR headers.
for (const name of Object.values(PPRRequestHeaders)) {
headers.delete(name);
}
if (
!url.pathname.startsWith(URL_PREFIX) ||
PASSTHROUGH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))
) {
return forward(request, url, headers);
}
const publishedURL = `https://${url.pathname.slice(URL_PREFIX.length)}${url.search}`;
const skip = (reason: string) => {
log(`${request.method} ${url.pathname} → no PPR: ${reason}`);
return forward(request, url, headers);
};
let content: PublishedSiteContent;
try {
const result = await lookupPublishedURL(publishedURL);
if ('redirect' in result) {
return skip(`lookup redirected to ${result.redirect}`);
}
content = result;
} catch (error) {
return skip(`lookup failed: ${error instanceof Error ? error.message : String(error)}`);
}
let revision: string;
let defaults: Awaited<ReturnType<typeof getDefaults>>;
try {
[revision, defaults] = await Promise.all([
content.revision ?? getActiveRevision(content),
getDefaults(content),
]);
} catch (error) {
return skip(`no revision: ${error instanceof Error ? error.message : String(error)}`);
}
await setPPRHeaders(headers, { ...content, revision }, defaults, SECRET);
log(
`${request.method} ${url.pathname} → site=${content.site} space=${content.space} revision=${revision}`
);
return forward(request, url, headers);
}
Bun.serve({
port: PORT,
idleTimeout: 60,
fetch: handle,
});
log(`listening on http://localhost:${PORT} → ${UPSTREAM}`);
log(`try http://localhost:${PORT}/url/gitbook.com/docs`);
@@ -0,0 +1,5 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -0,0 +1,31 @@
import type { Metadata, Viewport } from 'next';
import { type PPRRouteParams, getPPRPageRouteParams, getPagePathFromParams } from '@/app/utils';
import {
PPRPageBody,
cachedGenerateSitePageMetadata,
cachedGenerateSitePageViewport,
} from '@/components/SitePage/PPRSitePage';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<PPRRouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const pathname = getPagePathFromParams(params);
return <PPRPageBody params={await getPPRPageRouteParams(params)} pathname={pathname} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const params = await props.params;
return cachedGenerateSitePageViewport(await getPPRPageRouteParams(params));
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
return cachedGenerateSitePageMetadata(await getPPRPageRouteParams(params));
}
@@ -0,0 +1,94 @@
import type React from 'react';
import {
type PPRRouteLayoutParams,
getPPRHeaderRouteParams,
getPPRPageRouteParams,
getPPRSiteRouteParams,
getPPRStaticSiteContext,
getPPRStaticSiteScopeContext,
getPPRTableOfContentsRouteParams,
getPPRVisitorAuthClaims,
} from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import {
PPRAdminToolbar,
PPRAnnouncement,
PPRFooter,
PPRHeader,
PPRRevisionIconsProvider,
PPRTableOfContents,
} from '@/components/SitePage/PPRSitePage';
import { shouldTrackEvents } from '@/lib/tracking';
interface SitePPRLayoutProps {
params: Promise<PPRRouteLayoutParams>;
}
export default async function SitePPRLayout({
params,
children,
}: React.PropsWithChildren<SitePPRLayoutProps>) {
const routeParams = await params;
const [siteParams, headerParams, tableOfContentsParams, visitorAuthClaims] = await Promise.all([
getPPRSiteRouteParams(routeParams),
getPPRHeaderRouteParams(routeParams),
getPPRTableOfContentsRouteParams(routeParams),
// Each component holds a token narrowed to one scope, so the client claims need their union.
getPPRVisitorAuthClaims(routeParams),
]);
// The layout is rendered on every request, so it only resolves site-level data — under the
// scope of the header, whose site fetch it then shares. Everything below the site level is
// delegated to the cached components in the slots.
const { context } = await getPPRStaticSiteScopeContext(siteParams, 'header');
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable"
bodyClassName="site-background"
context={context}
>
<PPRRevisionIconsProvider params={tableOfContentsParams}>
<SiteLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
slots={{
announcement: <PPRAnnouncement params={tableOfContentsParams} />,
header: <PPRHeader params={headerParams} />,
tableOfContents: <PPRTableOfContents params={tableOfContentsParams} />,
footer: <PPRFooter params={tableOfContentsParams} />,
adminToolbar: <PPRAdminToolbar params={tableOfContentsParams} />,
}}
// The header and table of contents are cached across pages, so the selection they
// were rendered with belongs to another page and has to be resolved on the client.
clientNavigationSelection
>
{children}
</SiteLayout>
</PPRRevisionIconsProvider>
</CustomizationRootLayout>
);
}
export async function generateViewport({ params }: SitePPRLayoutProps) {
const { context } = await getPPRStaticSiteContext(
await getPPRPageRouteParams(await params),
'header'
);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SitePPRLayoutProps) {
const { context } = await getPPRStaticSiteContext(
await getPPRPageRouteParams(await params),
'header'
);
return generateSiteLayoutMetadata(context);
}
+267
View File
@@ -0,0 +1,267 @@
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import * as realContext from '@/lib/context';
mock.module('server-only', () => ({}));
// Only the lookup is stubbed: mocking the whole module would leak into the other test files,
// as `mock.module` replaces it for the entire test process.
mock.module('@/lib/context', () => ({
...realContext,
getBaseContext: (input: unknown) => input,
fetchSiteContextByURLLookup: async (_baseContext: unknown, data: unknown) => data,
fetchSiteScopeContextByURLLookup: async (_baseContext: unknown, data: unknown) => data,
}));
// Stand in for the exchange endpoint, which is the only thing that can narrow the claims. It is
// stubbed at the network boundary rather than with `mock.module`, which would replace
// `@/lib/ppr-token` for the entire test process and break its own test file.
const realFetch = globalThis.fetch;
beforeAll(() => {
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
const { scope } = JSON.parse(String(init?.body));
// Deterministic per scope, so the assertions below can compare tokens across pages.
return Response.json({
token: jwt.sign(
{ exp: Math.floor(Date.now() / 1000) + 3600, claims: { scope } },
'secret'
),
});
}) as typeof fetch;
});
afterAll(() => {
globalThis.fetch = realFetch;
});
const {
getPPRHeaderRouteParams,
getPPRPageRouteParams,
getPPRRouteParams,
getPPRSiteRouteParams,
getPPRStaticSiteContext,
getPPRStaticSiteScopeContext,
getPPRTableOfContentsRouteParams,
getPPRVisitorAuthClaims,
getSiteURLDataFromParams,
} = await import('./utils');
type PPRRouteParams = import('./utils').PPRRouteParams;
const apiToken = jwt.sign(
{
exp: Math.floor(Date.now() / 1000) + 3600,
siteClaims: { audience: 'external' },
revisionClaims: { account: { tier: 'pro' } },
pageClaims: { unsigned: { locale: 'fr' } },
},
'secret'
);
const routeParams: PPRRouteParams = {
mode: 'url',
siteURL: 'docs.example.com',
siteData: encodeURIComponent(
rison.encode({
apiToken,
site: 'site-id',
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
revision: 'resolved-revision-id',
siteBasePath: '/docs/',
basePath: '/docs/v/page-variant/',
imagesContextId: 'images-context-id',
})
),
revisionId: encodeURIComponent('ppr-revision-id'),
revalidationId: encodeURIComponent('revalidation-id'),
pprDefaults: encodeURIComponent(
rison.encode({
siteSection: 'default-site-section-id',
siteSpace: 'default-site-space-id',
space: 'default-space-id',
})
),
pagePath: 'guide',
};
describe('getPPRRouteParams', () => {
it('removes the PPR cache key while preserving resolved content and API token', () => {
const params = getPPRRouteParams(routeParams);
expect(params).toMatchObject({
mode: routeParams.mode,
siteURL: routeParams.siteURL,
pagePath: routeParams.pagePath,
});
expect(params).not.toHaveProperty('revisionId');
expect(params).not.toHaveProperty('revalidationId');
expect(params).not.toHaveProperty('pprDefaults');
expect(getSiteURLDataFromParams(params)).toMatchObject({
apiToken,
site: 'site-id',
space: 'space-id',
revision: 'ppr-revision-id',
imagesContextId: 'images-context-id',
});
});
});
describe('PPR cache region params', () => {
const changedRouteParams: PPRRouteParams = {
...routeParams,
siteData: encodeURIComponent(
rison.encode({
apiToken: jwt.sign({ siteClaims: {} }, 'other-secret'),
site: 'site-id',
siteSection: 'new-page-site-section-id',
siteSpace: 'new-page-site-space-id',
space: 'new-space-id',
revision: 'resolved-revision-id',
siteBasePath: '/docs/',
basePath: '/docs/v/other-variant/',
imagesContextId: 'images-context-id',
})
),
};
it('keeps header params stable, API token included', async () => {
const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams));
const changedHeaderData = getSiteURLDataFromParams(
await getPPRHeaderRouteParams(changedRouteParams)
);
expect(headerData).toMatchObject({
siteSection: 'default-site-section-id',
siteSpace: 'default-site-space-id',
space: 'default-space-id',
// The default variant is served at the site root, so its links can't keep the base
// path of the variant the visitor is on.
basePath: '/docs/',
});
// The whole point of the exchange: the site-scoped token no longer varies per page, so the
// header can be cached once for the site instead of once per page.
expect(headerData).toEqual(changedHeaderData);
});
it('keeps the visited location in the site params, with the header token', async () => {
const siteData = getSiteURLDataFromParams(await getPPRSiteRouteParams(routeParams));
const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams));
// The shell renders the variant the visitor is on, it just never reads below the site level.
expect(siteData).toMatchObject({
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
basePath: '/docs/v/page-variant/',
revision: 'ppr-revision-id',
});
// Same scope as the header, so they share their site fetch.
expect(siteData.apiToken).toBe(headerData.apiToken);
});
it('narrows the header token to the site scope', async () => {
const { apiToken: headerToken } = getSiteURLDataFromParams(
await getPPRHeaderRouteParams(routeParams)
);
expect(headerToken).not.toBe(apiToken);
expect(jwt.decode(headerToken)).toMatchObject({ claims: { scope: 'site' } });
});
it('keeps the visited base path when the defaults point at the visited variant', async () => {
const headerData = getSiteURLDataFromParams(
await getPPRHeaderRouteParams({
...routeParams,
pprDefaults: encodeURIComponent(
rison.encode({
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
})
),
})
);
expect(headerData).toMatchObject({
siteSpace: 'page-site-space-id',
basePath: '/docs/v/page-variant/',
});
});
it('keeps TOC params stable apart from its current location', async () => {
const tocData = getSiteURLDataFromParams(
await getPPRTableOfContentsRouteParams(routeParams)
);
const changedTOCData = getSiteURLDataFromParams(
await getPPRTableOfContentsRouteParams(changedRouteParams)
);
expect(tocData).toMatchObject({
siteSection: 'page-site-section-id',
siteSpace: 'page-site-space-id',
space: 'space-id',
basePath: '/docs/v/page-variant/',
});
// The revision-scoped token drops the page claims, so it is shared by every page of the
// space; only the location data still varies.
expect(tocData.apiToken).toBe(changedTOCData.apiToken);
expect({
...tocData,
siteSection: undefined,
siteSpace: undefined,
space: undefined,
basePath: undefined,
}).toEqual({
...changedTOCData,
siteSection: undefined,
siteSpace: undefined,
space: undefined,
basePath: undefined,
});
});
it('narrows the TOC and page tokens to their own scopes', async () => {
const tocData = getSiteURLDataFromParams(
await getPPRTableOfContentsRouteParams(routeParams)
);
const pageData = getSiteURLDataFromParams(await getPPRPageRouteParams(routeParams));
expect(jwt.decode(tocData.apiToken)).toMatchObject({ claims: { scope: 'revision' } });
expect(jwt.decode(pageData.apiToken)).toMatchObject({ claims: { scope: 'page' } });
expect(tocData.apiToken).not.toBe(pageData.apiToken);
});
});
describe('getPPRVisitorAuthClaims', () => {
it('resolves every scope at once through a full exchange', async () => {
// The scoped tokens each carry one bucket, so the client claims can only come from `full`.
expect(await getPPRVisitorAuthClaims(routeParams)).toEqual({ scope: 'full' });
});
});
describe('getPPRStaticSiteScopeContext', () => {
it('resolves the site scope from the supplied params', async () => {
const { context } = await getPPRStaticSiteScopeContext(
getPPRRouteParams(routeParams),
'header'
);
expect(context).toMatchObject({
apiToken,
revision: 'ppr-revision-id',
});
});
});
describe('getPPRStaticSiteContext', () => {
it('uses the supplied API token without resolving published content again', async () => {
const { context } = await getPPRStaticSiteContext(getPPRRouteParams(routeParams), 'body');
expect(context).toMatchObject({
apiToken,
revision: 'ppr-revision-id',
});
});
});
+246 -11
View File
@@ -4,9 +4,20 @@ import rison from 'rison';
import type { SiteAPIToken } from '@gitbook/api';
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context';
import {
type VisitorAuthClaims,
getVisitorAuthClaims,
getVisitorAuthClaimsFromToken,
} from '@/lib/adaptive';
import type { PPRCacheScope } from '@/lib/cache-tags';
import {
type SiteURLData,
fetchSiteContextByURLLookup,
fetchSiteScopeContextByURLLookup,
getBaseContext,
} from '@/lib/context';
import { getDynamicCustomizationSettings } from '@/lib/customization';
import { PPR_TOKEN_SCOPE, type PPRTokenScope, exchangePPRToken } from '@/lib/ppr-token';
export type RouteParamMode = 'url-host' | 'url';
@@ -24,10 +35,50 @@ export type RouteParams = RouteLayoutParams & {
pagePath: string;
};
export type PPRRouteLayoutParams = RouteLayoutParams & {
revisionId: string;
revalidationId: string;
pprDefaults: string;
};
export type PPRRouteParams = PPRRouteLayoutParams & {
pagePath: string;
};
/**
* Get the static context when rendering statically a site.
*/
export async function getStaticSiteContext(params: RouteLayoutParams) {
export async function getStaticSiteContext(
params: RouteLayoutParams,
options?: { pprScope?: PPRCacheScope }
) {
const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options);
return {
context: await fetchSiteContextByURLLookup(baseContext, siteURLData),
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
* Get the site-level part of the static context, without resolving the space and its revision.
*/
export async function getStaticSiteScopeContext(
params: RouteLayoutParams,
options?: { pprScope?: PPRCacheScope }
) {
const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options);
return {
context: await fetchSiteScopeContextByURLLookup(baseContext, siteURLData),
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
* Decode the params of a static route and open a base context for them.
*/
function getStaticBaseContext(params: RouteLayoutParams, options?: { pprScope?: PPRCacheScope }) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
@@ -38,18 +89,15 @@ export async function getStaticSiteContext(params: RouteLayoutParams) {
forbidden();
}
const context = await fetchSiteContextByURLLookup(
getBaseContext({
return {
baseContext: getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
...(options?.pprScope ? { pprScope: options.pprScope } : {}),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
siteURLData,
decoded,
};
}
@@ -132,3 +180,190 @@ export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData
notFound();
}
}
export function getPPRRouteParams(params: PPRRouteParams): RouteParams;
export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams;
/**
* Project PPR route params for the current page, without PPR-only cache inputs.
*/
export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams {
const { revisionId, revalidationId, pprDefaults: _, ...routeParams } = params;
const siteURLData = getSiteURLDataFromParams(params);
return {
...routeParams,
siteData: encodeURIComponent(
rison.encode({
...siteURLData,
revision: getPPRRouteParam(revisionId, 'revision ID'),
})
),
};
}
/**
* Project PPR params for the current page, with a token scoped to the page claims.
*/
export function getPPRPageRouteParams(params: PPRRouteParams): Promise<RouteParams>;
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams>;
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.body);
}
/**
* Project PPR params for the site-level shell, with a token scoped to the site claims.
* Unlike the header params, they keep the location data of the visited page: the shell renders the
* current variant, it just never reads anything below the site level.
*/
export function getPPRSiteRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.header);
}
/**
* Project PPR params for the shared header by replacing page-varying location data.
*/
export async function getPPRHeaderRouteParams(
params: PPRRouteLayoutParams
): Promise<RouteLayoutParams> {
const routeParams = getPPRRouteParams(params);
const { revision, ...siteURLData } = getSiteURLDataFromParams(routeParams);
const defaults = getPPRDefaults(params);
return withExchangedPPRToken(
{
...routeParams,
siteData: encodeSiteData({
...siteURLData,
// For the header, we keep site section and space data from the PPR defaults, so that the header can be cached across all pages in a site.
siteSection: defaults.siteSection ?? undefined,
siteSpace: defaults.siteSpace,
space: defaults.space,
// The base path has to describe the same variant as the ids above, or the header
// prefixes one variant's page paths with another variant's base path. The site default
// variant is published at the site root; defaults pointing at the visited variant keep
// its own base path.
basePath:
defaults.siteSpace === siteURLData.siteSpace
? siteURLData.basePath
: siteURLData.siteBasePath,
}),
},
PPR_TOKEN_SCOPE.header
);
}
/** rison can't encode undefined values, so they are dropped like the middleware does. */
function encodeSiteData(siteURLData: Record<string, unknown>): string {
return encodeURIComponent(
rison.encode(
Object.fromEntries(
Object.entries(siteURLData).filter(([_, value]) => typeof value !== 'undefined')
)
)
);
}
/**
* Project PPR params for the table of contents, keeping its current location data.
* The table of contents depends only on the space you're in and the claims of that revision, not on the page,
* and the layout params carry no page path, so only the token has to be narrowed.
*/
export function getPPRTableOfContentsRouteParams(
params: PPRRouteLayoutParams
): Promise<RouteLayoutParams> {
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.toc);
}
/**
* Replace the revalidation token carried by the PPR params with a content API token narrowed to
* `scope`. Components sharing a scope then share a token, and with it a cache entry.
*/
async function withExchangedPPRToken<T extends RouteLayoutParams>(
params: T,
scope: PPRTokenScope
): Promise<T> {
const siteURLData = getSiteURLDataFromParams(params);
return {
...params,
siteData: encodeSiteData({
...siteURLData,
apiToken: await exchangePPRToken(siteURLData.apiToken, scope),
}),
};
}
/**
* Get the claims the client should resolve adaptive content with. Each component holds a token
* narrowed to a single scope, so the union has to come from a `full` exchange.
*/
export async function getPPRVisitorAuthClaims(
params: PPRRouteLayoutParams
): Promise<VisitorAuthClaims> {
const { apiToken } = getSiteURLDataFromParams(params);
const fullToken = await exchangePPRToken(apiToken, 'full');
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(fullToken));
}
/**
* Get the static context for a PPR component. The scope partitions the cache entries and scopes
* the tags they emit, so the component and its data are revalidated as one unit.
*/
export async function getPPRStaticSiteContext(params: RouteLayoutParams, pprScope: PPRCacheScope) {
return getStaticSiteContext(params, { pprScope });
}
/**
* Get the site-level context for a PPR component, without resolving the space and its revision.
*/
export async function getPPRStaticSiteScopeContext(
params: RouteLayoutParams,
pprScope: PPRCacheScope
) {
return getStaticSiteScopeContext(params, { pprScope });
}
function getPPRRouteParam(encodedParam: string, name: string): string {
try {
return decodeURIComponent(encodedParam);
} catch (error) {
console.error(`Returning 404 after failing to decode PPR ${name}: ${error}`);
notFound();
}
}
type PPRDefaults = {
siteSection: string | null;
siteSpace: string;
space: string;
};
function getPPRDefaults(params: PPRRouteLayoutParams): PPRDefaults {
let defaults: unknown;
try {
defaults = rison.decode(decodeURIComponent(params.pprDefaults));
} catch (error) {
console.error(`Returning 404 after failing to decode PPR defaults: ${error}`);
notFound();
}
if (
!defaults ||
typeof defaults !== 'object' ||
Array.isArray(defaults) ||
!('siteSection' in defaults) ||
!('siteSpace' in defaults) ||
!('space' in defaults) ||
(defaults.siteSection !== null && typeof defaults.siteSection !== 'string') ||
typeof defaults.siteSpace !== 'string' ||
!defaults.siteSpace ||
typeof defaults.space !== 'string' ||
!defaults.space
) {
console.error(`Returning 404 after decoding invalid PPR defaults: ${params.pprDefaults}`);
notFound();
}
return defaults as PPRDefaults;
}
+2
View File
@@ -7,6 +7,7 @@ import {
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_TRACKING,
GITBOOK_EXCHANGE_TOKEN_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
@@ -28,6 +29,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_EXCHANGE_TOKEN_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -34,7 +34,7 @@ export function SpacesDropdown(
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
path: siteSp.path,
spaceId: siteSp.space.id,
}));
@@ -46,6 +46,7 @@ export function SpacesDropdown(
className={className}
dropdownClassName={dropdownClassName}
slimSpaces={slimSpaces}
siteSpaceId={siteSpace.id}
curPath={siteSpace.path}
/>
);
@@ -2,9 +2,10 @@
import type { IconName } from '@gitbook/icons';
import { useSelectedSiteSpaceId } from '../hooks';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
import { SpacesDropdownMenuItems, type VariantSpace } from './SpacesDropdownMenuItem';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
@@ -17,16 +18,16 @@ export function SpacesDropdownClient(props: {
variant: ButtonProps['variant'];
className?: ClassValue;
dropdownClassName: string;
slimSpaces: {
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}[];
slimSpaces: VariantSpace[];
/** Site space the server rendered as selected, used as a fallback. */
siteSpaceId: string;
curPath: string;
}) {
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
const { title, icon, variant, className, dropdownClassName, slimSpaces, siteSpaceId, curPath } =
props;
const selectedId = useSelectedSiteSpaceId(siteSpaceId);
const selected = selectedId ? slimSpaces.find((space) => space.id === selectedId) : undefined;
return (
<DropdownMenu
@@ -40,11 +41,15 @@ export function SpacesDropdownClient(props: {
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">{title}</span>
<span className="button-content">{selected?.title ?? title}</span>
</Button>
}
>
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
<SpacesDropdownMenuItems
slimSpaces={slimSpaces}
selectedId={selectedId}
curPath={selected?.path ?? curPath}
/>
</DropdownMenu>
);
}
@@ -8,7 +8,7 @@ export interface VariantSpace {
id: string;
title: string;
url: string;
isActive: boolean;
path: string;
spaceId: string;
}
@@ -66,8 +66,12 @@ export function SpacesDropdownMenuItem(props: {
);
}
export function SpacesDropdownMenuItems(props: { slimSpaces: VariantSpace[]; curPath: string }) {
const { slimSpaces, curPath } = props;
export function SpacesDropdownMenuItems(props: {
slimSpaces: VariantSpace[];
selectedId: string | null;
curPath: string;
}) {
const { slimSpaces, selectedId, curPath } = props;
return (
<>
@@ -75,7 +79,7 @@ export function SpacesDropdownMenuItems(props: { slimSpaces: VariantSpace[]; cur
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
active={space.id === selectedId}
currentSpacePath={curPath}
/>
))}
@@ -123,9 +123,12 @@ export async function PageHeader(props: {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
url: getSiteSpaceURL(context, siteSpace),
isActive: siteSpace.id === currentSiteSpace.id,
path: siteSpace.path,
spaceId: siteSpace.space.id,
})),
// The breadcrumbs are rendered with the page's own context, so the selection is
// accurate here and doesn't need the client-side patching the shared shell does.
selectedId: currentSiteSpace.id,
curPath: currentSiteSpace.path,
},
});
@@ -329,7 +332,7 @@ type BreadcrumbContextCrumb = {
* Present only for the variant crumb: the header's variant switcher data, whose dropdown entries
* resolve to the current page in each variant. Rendered instead of `siblings`.
*/
variantSwitcher?: { slimSpaces: VariantSpace[]; curPath: string };
variantSwitcher?: { slimSpaces: VariantSpace[]; selectedId: string; curPath: string };
};
/** Render a context crumb (section group / section / variant) with its sibling dropdown. */
@@ -345,6 +348,7 @@ function ContextCrumb({ crumb }: { crumb: BreadcrumbContextCrumb }) {
{crumb.variantSwitcher ? (
<SpacesDropdownMenuItems
slimSpaces={crumb.variantSwitcher.slimSpaces}
selectedId={crumb.variantSwitcher.selectedId}
curPath={crumb.variantSwitcher.curPath}
/>
) : undefined}
@@ -36,7 +36,7 @@ import {
import './globals.css';
import { getContentLocale, getSpaceLanguage } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
import type { GitBookAnyContext } from '@/lib/context';
import type { GitBookAnyContext, GitBookSiteScopeContext } from '@/lib/context';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
import {
getContentInlineIconSourceRequests,
@@ -73,7 +73,7 @@ export async function CustomizationRootLayout(props: {
/** The class name to apply to the body element. */
bodyClassName?: string;
forcedTheme?: CustomizationDefaultThemeMode | null;
context: GitBookAnyContext;
context: GitBookAnyContext | GitBookSiteScopeContext;
children: React.ReactNode;
}) {
const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
@@ -111,12 +111,15 @@ export async function CustomizationRootLayout(props: {
preloadFont(headingFontData);
}
const iconStyle = getCustomizationIconStyle(customization);
// A site scope context has no revision: its page and tag icons are provided further down the
// tree, by a component that resolves the revision under its own cache scope.
const revision = 'revision' in context ? context.revision : null;
const iconSources = await getInlineIconSources([
...getDefaultInlineIconSourceRequests(iconStyle),
...getContentInlineIconSourceRequests({
iconStyle,
pages: context.revision.pages,
tags: context.revision.tags,
pages: revision?.pages,
tags: revision?.tags,
sections:
'sections' in context
? [...(context.sections?.list ?? []), ...(context.visibleSections?.list ?? [])]
@@ -6,15 +6,17 @@ import * as ReactDOM from 'react-dom';
import { CustomizationDefaultThemeMode } from '@gitbook/api';
import { AIContextProvider } from '../AI';
import { Announcement } from '../Announcement';
import { RocketLoaderDetector } from './RocketLoaderDetector';
import { SiteLayoutClientContexts } from './SiteLayoutClientContexts';
import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { Footer } from '@/components/Footer';
import { LoadIntegrations } from '@/components/Integrations';
import { SpaceLayout } from '@/components/SpaceLayout';
import { SpaceHeader, SpaceLayout, SpaceTableOfContents } from '@/components/SpaceLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { buildVersion } from '@/lib/build';
import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env';
import { getResizedImageURL } from '@/lib/images';
import { isSiteIndexable } from '@/lib/seo';
@@ -45,16 +47,67 @@ function isDeferrableScript(script: string): boolean {
}
/**
* Layout when rendering a site.
* Parts of the layout that can only be rendered from a full site context, as they read the
* revision. A site scope context has to provide them itself.
*/
export async function SiteLayout(props: {
context: GitBookSiteContext;
export type SiteLayoutSlots = {
announcement: React.ReactNode;
header: React.ReactNode;
tableOfContents: React.ReactNode;
footer: React.ReactNode;
adminToolbar: React.ReactNode;
};
/**
* Build the default slots of the layout from a full site context.
*/
export function getSiteLayoutSlots(context: GitBookSiteContext): SiteLayoutSlots {
return {
announcement: <Announcement context={context} />,
header: <SpaceHeader context={context} />,
tableOfContents: <SpaceTableOfContents context={context} />,
footer: <Footer context={context} />,
adminToolbar: <AdminToolbar context={context} />,
};
}
type SiteLayoutProps = {
forcedTheme?: CustomizationDefaultThemeMode | null;
withTracking: boolean;
visitorAuthClaims: VisitorAuthClaims;
children: React.ReactNode;
}) {
const { context, forcedTheme, withTracking, visitorAuthClaims, children } = props;
clientNavigationSelection?: boolean;
} & (
| {
context: GitBookSiteContext;
/** Overrides of the slots that would otherwise be rendered from the context. */
slots?: Partial<SiteLayoutSlots>;
}
| {
context: GitBookSiteScopeContext;
/** A site scope context can't render any of them, so they are all required. */
slots: SiteLayoutSlots;
}
);
/**
* Layout when rendering a site.
*/
export async function SiteLayout(props: SiteLayoutProps) {
const {
context,
forcedTheme,
withTracking,
visitorAuthClaims,
children,
clientNavigationSelection,
} = props;
// The prop type guarantees `slots` is complete whenever the context can't build them itself.
const slots = {
...('revision' in context ? getSiteLayoutSlots(context) : null),
...props.slots,
} as SiteLayoutSlots;
const { customization } = context;
const { ai } = customization;
@@ -111,6 +164,11 @@ export async function SiteLayout(props: {
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
announcementSlot={slots.announcement}
headerSlot={slots.header}
tableOfContentsSlot={slots.tableOfContents}
footerSlot={slots.footer}
clientNavigationSelection={clientNavigationSelection}
>
{children}
</SpaceLayout>
@@ -133,12 +191,14 @@ export async function SiteLayout(props: {
<RocketLoaderDetector />
<AdminToolbar context={context} />
{slots.adminToolbar}
</SiteLayoutClientContexts>
);
}
export async function generateSiteLayoutViewport(context: GitBookSiteContext): Promise<Viewport> {
export async function generateSiteLayoutViewport(
context: GitBookSiteContext | GitBookSiteScopeContext
): Promise<Viewport> {
const { customization } = context;
return {
colorScheme: customization.themes.toggeable
@@ -156,7 +216,9 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
};
}
export async function generateSiteLayoutMetadata(context: GitBookSiteContext): Promise<Metadata> {
export async function generateSiteLayoutMetadata(
context: GitBookSiteContext | GitBookSiteScopeContext
): Promise<Metadata> {
const { site, customization, linker, imageResizer } = context;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
@@ -0,0 +1,142 @@
import type { Metadata, Viewport } from 'next';
import { cacheLife } from 'next/cache';
import { IconsProvider } from '@gitbook/icons';
import { SitePage, generateSitePageMetadata, generateSitePageViewport } from './SitePage';
import {
type RouteLayoutParams,
type RouteParams,
getPPRStaticSiteContext,
getPagePathFromParams,
} from '@/app/utils';
import { AdminToolbar } from '@/components/AdminToolbar';
import { Announcement } from '@/components/Announcement';
import { Footer } from '@/components/Footer';
import { SpaceHeader, SpaceTableOfContents } from '@/components/SpaceLayout';
import {
getContentInlineIconSourceRequests,
getCustomizationIconStyle,
getInlineIconSources,
} from '@/lib/icons/inline';
// Each component below resolves its context under its own PPR cache scope. The scope is part of the
// cache key of every data fetcher, so the tags they emit are scoped too and propagate up to the
// entry here — making the component and the data it read a single revalidatable unit. No explicit
// `cacheTag` is needed, and adding one would only duplicate a propagated tag.
/**
* Render the header from cache without carrying a request-scoped data fetcher into the cache key.
*/
export async function PPRHeader(props: { params: RouteLayoutParams }) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'header');
return <SpaceHeader context={context} />;
}
/**
* Render the table of contents independently so navigation changes do not invalidate the header.
*/
export async function PPRTableOfContents(props: { params: RouteLayoutParams }) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
return <SpaceTableOfContents context={context} />;
}
/**
* Render the announcement banner, which resolves its link against the revision.
*/
export async function PPRAnnouncement(props: { params: RouteLayoutParams }) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
return <Announcement context={context} />;
}
/**
* Render the footer, whose links resolve against the revision.
*/
export async function PPRFooter(props: { params: RouteLayoutParams }) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
return <Footer context={context} />;
}
/**
* Render the admin toolbar, which reports on the revision and its change request.
*/
export async function PPRAdminToolbar(props: { params: RouteLayoutParams }) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
return <AdminToolbar context={context} />;
}
/**
* Provide the icons of the pages and tags of the revision to the tree below.
*
* The header and the body both render them, so they are resolved once here rather than duplicated
* in every per-page cache entry. The provider merges with the one of the root layout, which carries
* the site-level icons.
*/
export async function PPRRevisionIconsProvider(
props: React.PropsWithChildren<{ params: RouteLayoutParams }>
) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
const iconSources = await getInlineIconSources(
getContentInlineIconSourceRequests({
iconStyle: getCustomizationIconStyle(context.customization),
pages: context.revision.pages,
tags: context.revision.tags,
})
);
return <IconsProvider iconSources={iconSources}>{props.children}</IconsProvider>;
}
/**
* Render the page body independently from the shared navigation shell.
*/
export async function PPRPageBody(props: { params: RouteParams; pathname: string }) {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(props.params, 'body');
return <SitePage context={context} pageParams={{ pathname: props.pathname }} staticRoute />;
}
export async function cachedGenerateSitePageMetadata(routeParams: RouteParams): Promise<Metadata> {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(routeParams, 'body');
const pathname = getPagePathFromParams(routeParams);
return generateSitePageMetadata({ context, pageParams: { pathname } });
}
export async function cachedGenerateSitePageViewport(routeParams: RouteParams): Promise<Viewport> {
'use cache: remote';
cacheLife('days'); // Cache for 1 day
const { context } = await getPPRStaticSiteContext(routeParams, 'body');
return generateSitePageViewport(context);
}
@@ -1,4 +1,5 @@
export * from './SitePageNotFound';
export * from './SitePage';
export * from './PPRSitePage';
export * from './fetch';
export * from './SitePageSkeleton';
@@ -6,12 +6,11 @@ import React from 'react';
import type { IconName } from '@gitbook/icons';
import { useToggleAnimation } from '../hooks';
import { useSelectedSiteSectionId, useToggleAnimation } from '../hooks';
import { Link, ToggleChevron } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import type {
ClientSiteNavigationItem,
ClientSiteSection,
ClientSiteSectionGroup,
ClientSiteSections,
} from './encodeClientSiteSections';
@@ -30,6 +29,8 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
className,
} = props;
const currentSectionId = useSelectedSiteSectionId(currentSection.id);
return (
sectionsAndGroups.length > 0 && (
<nav
@@ -43,7 +44,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
orientation="vertical"
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="pb-4"
active={`#${currentSection.id}`}
active={currentSectionId ? `#${currentSectionId}` : undefined}
>
<div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => {
@@ -53,7 +54,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
<SiteSectionGroupItem
key={item.id}
group={item}
currentSection={currentSection}
currentSectionId={currentSectionId}
/>
);
case 'site-section':
@@ -63,7 +64,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
item={item}
isActive={
item.object === 'site-section' &&
item.id === currentSection.id
item.id === currentSectionId
}
key={item.id}
/>
@@ -139,13 +140,14 @@ export function SiteSectionListItem(props: {
export function SiteSectionGroupItem(props: {
group: ClientSiteSectionGroup;
currentSection: ClientSiteSection;
currentSectionId: string | null;
level?: number;
}) {
const { group, currentSection, level = 0 } = props;
const { group, currentSectionId, level = 0 } = props;
const hasDescendants = group.children.length > 0;
const isActiveGroup = Boolean(findSectionInGroup(group, currentSection.id));
const isActiveGroup =
currentSectionId !== null && Boolean(findSectionInGroup(group, currentSectionId));
const shouldOpen = hasDescendants && isActiveGroup;
const [isOpen, setIsOpen] = React.useState(shouldOpen);
@@ -236,7 +238,7 @@ export function SiteSectionGroupItem(props: {
item={child}
isActive={
child.object === 'site-section' &&
child.id === currentSection.id
child.id === currentSectionId
}
key={child.id}
/>
@@ -245,7 +247,7 @@ export function SiteSectionGroupItem(props: {
return (
<SiteSectionGroupItem
group={child}
currentSection={currentSection}
currentSectionId={currentSectionId}
key={child.id}
level={level + 1}
/>
@@ -5,13 +5,10 @@ import React from 'react';
import type { IconName } from '@gitbook/icons';
import { useSelectedSiteSectionId } from '../hooks';
import { CONTAINER_STYLE } from '../layout';
import { ScrollContainer } from '../primitives/ScrollContainer';
import type {
ClientSiteSection,
ClientSiteSections,
ClientSiteStructureNode,
} from './encodeClientSiteSections';
import type { ClientSiteSections, ClientSiteStructureNode } from './encodeClientSiteSections';
import { SectionIcon } from './SectionIcon';
import { Button, Link, ToggleChevron } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
@@ -47,6 +44,8 @@ export function SiteSectionTabs(props: {
children,
} = props;
const currentSectionId = useSelectedSiteSectionId(currentSection.id);
// Portalled into the tabs container rather than <body>, to keep inheriting the header's theming.
const containerRef = React.useRef<HTMLElement>(null);
@@ -79,7 +78,7 @@ export function SiteSectionTabs(props: {
? 'md:-mr-8 -mr-4 sm:-mr-6'
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
)}
active={`#${currentSection.id}`}
active={currentSectionId ? `#${currentSectionId}` : undefined}
trailing={{
fade: true,
button: true,
@@ -100,10 +99,11 @@ export function SiteSectionTabs(props: {
const isGroup = structureItem.object === 'site-section-group';
const isActiveGroup =
isGroup &&
Boolean(findSectionInGroup(structureItem, currentSection.id));
currentSectionId !== null &&
Boolean(findSectionInGroup(structureItem, currentSectionId));
const isActive =
isActiveGroup ||
(structureItem.object === 'site-section' && id === currentSection.id);
(structureItem.object === 'site-section' && id === currentSectionId);
return (
<NavigationMenu.Item key={id} value={id} id={id}>
{isGroup && structureItem.children.length > 0 ? (
@@ -129,7 +129,7 @@ export function SiteSectionTabs(props: {
>
<SectionGroupTileList
items={structureItem.children}
currentSection={currentSection}
currentSectionId={currentSectionId}
/>
</NavigationMenu.Content>
</>
@@ -227,9 +227,9 @@ const SectionTab = React.forwardRef(function SectionTab(
*/
function SectionGroupTileList(props: {
items: ClientSiteStructureNode[];
currentSection: ClientSiteSection;
currentSectionId: string | null;
}) {
const { items, currentSection } = props;
const { items, currentSectionId } = props;
// Separate navigable items (sections, external links) from grouped items.
const navigableItems = items.filter((item) => item.object !== 'site-section-group');
@@ -269,7 +269,7 @@ function SectionGroupTileList(props: {
<SectionGroupTile
key={item.id}
child={item}
currentSection={currentSection}
currentSectionId={currentSectionId}
invertIcon={navigableItemsRecessed}
/>
))}
@@ -309,7 +309,7 @@ function SectionGroupTileList(props: {
<SectionGroupTile
key={group.id}
child={group}
currentSection={currentSection}
currentSectionId={currentSectionId}
isMasonry={isMasonryLayout}
invertIcon={groupsRecessed}
/>
@@ -340,16 +340,16 @@ function SectionGroupTileList(props: {
*/
function SectionGroupTile(props: {
child: ClientSiteStructureNode;
currentSection: ClientSiteSection;
currentSectionId: string | null;
invertIcon?: boolean;
/** Whether the tile is a top-level group of the dropdown's masonry layout. */
isMasonry?: boolean;
}) {
const { child, currentSection, invertIcon, isMasonry } = props;
const { child, currentSectionId, invertIcon, isMasonry } = props;
if (child.object !== 'site-section-group') {
const { url, icon, title, description } = child;
const isActive = child.object === 'site-section' && child.id === currentSection.id;
const isActive = child.object === 'site-section' && child.id === currentSectionId;
return (
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-tile-max-width)]">
<Link
@@ -429,7 +429,7 @@ function SectionGroupTile(props: {
<SectionGroupTile
key={nestedChild.id}
child={nestedChild}
currentSection={currentSection}
currentSectionId={currentSectionId}
invertIcon={invertIcon}
/>
))}
@@ -6,9 +6,8 @@ import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat, AskAITextSelection } from '../AIChat';
import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { CurrentContentProvider } from '../hooks';
import { ClientNavigationSelectionProvider, CurrentContentProvider } from '../hooks';
import { InsightsProvider, VisitorProvider } from '../Insights';
import { CONTAINER_STYLE } from '../layout';
import { NavigationLoader } from '../primitives/NavigationLoader';
@@ -20,17 +19,16 @@ import {
} from '../SiteSections';
import { categorizeVariants } from './categorizeVariants';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
import { TableOfContents } from '@/components/TableOfContents';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
type SpaceLayoutProps = {
context: GitBookSiteContext;
context: GitBookSiteScopeContext;
/** Whether to enable tracking of events into site insights. */
withTracking: boolean;
@@ -43,14 +41,41 @@ type SpaceLayoutProps = {
/** The children of the layout. */
children: React.ReactNode;
// The slots below all read the revision, which a site scope context doesn't carry, so they are
// rendered by the caller rather than from `context`.
/** Announcement banner, rendered above the header. */
announcementSlot?: React.ReactNode;
/** Site header. */
headerSlot?: React.ReactNode;
/** Table of contents. */
tableOfContentsSlot?: React.ReactNode;
/** Site footer, rendered only when the customization asks for one. */
footerSlot?: React.ReactNode;
/**
* Resolve the selected section/space/page on the client instead of trusting the server render.
* Set when the navigation shell comes from a cache shared across pages (PPR).
*/
clientNavigationSelection?: boolean;
};
/**
* Provide all contexts for a space.
*/
export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
const { context, withTracking, visitorAuthClaims, aiChatRenderMessageOptions, children } =
props;
const {
context,
withTracking,
visitorAuthClaims,
aiChatRenderMessageOptions,
clientNavigationSelection = false,
children,
} = props;
const { customization } = context;
const siteAdaptiveAuthLoginHref =
@@ -84,7 +109,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
spaceId={context.space.id}
spaceId={context.siteSpace.space.id}
revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims}
>
@@ -97,7 +122,11 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
renderMessageOptions={aiChatRenderMessageOptions}
withPageFeedback={customization.feedback.enabled}
>
{children}
<ClientNavigationSelectionProvider
enabled={clientNavigationSelection}
>
{children}
</ClientNavigationSelectionProvider>
</AIChatProvider>
</InsightsProvider>
</VisitorProvider>
@@ -107,17 +136,118 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
);
}
/**
* Render the site header from a site context.
*/
export function SpaceHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const withTopHeader = context.customization.header.preset !== CustomizationHeaderPreset.None;
return (
<Header
withTopHeader={withTopHeader}
variants={categorizeVariants(context)}
context={context}
/>
);
}
/**
* Render the table of contents and its site-specific controls from a site context.
*/
export function SpaceTableOfContents(props: { context: GitBookSiteContext }) {
const { context } = props;
const { siteSpace, customization, visibleSections } = context;
const searchProps = getSearchBaseProps(context);
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = shouldRenderSiteSectionNavigation(visibleSections);
const variants = categorizeVariants(context);
return (
<TableOfContents
context={context}
header={
<div
className={tcls(
'pr-4',
'flex',
withTopHeader ? 'lg:hidden' : '',
'grow-0',
'dark:shadow-light/1',
'text-base/tight',
'items-center',
// On bold themes also color the TOC header so the logo looks correct.
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:m-[-1.5rem_-1px_-0.5rem_-2rem]',
'site-header:theme-bold:p-[1rem_1rem_1rem_2rem]'
)}
>
<HeaderLogo context={context} />
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={
variants.translations.find((space) => space.id === siteSpace.id) ??
siteSpace
}
siteSpaces={variants.translations}
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
variant="header"
/>
) : null}
</div>
}
// Displays the search button and/or the space dropdown in the ToC
// according to the header/variant settings.
// E.g if there is no header, the search button will be displayed in the ToC.
innerHeader={
!withTopHeader || variants.generic.length > 1 ? (
<div
className={tcls(
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
variants.generic.length > 1 ? '' : 'max-lg:hidden'
)}
>
{!withTopHeader && (
<div className="flex gap-2 max-lg:hidden">
<SearchContainer
{...searchProps}
style={CustomizationSearchStyle.Subtle}
viewport="desktop"
/>
</div>
)}
{!withTopHeader && withSections && visibleSections && (
<SiteSectionList
className="hidden lg:block"
sections={encodeClientSiteSections(context, visibleSections)}
/>
)}
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
/>
) : null}
</div>
) : null
}
/>
);
}
/**
* Render the entire layout of the space (header, table of contents, footer).
*/
export function SpaceLayout(props: SpaceLayoutProps) {
const { context, children } = props;
const { siteSpace, customization, visibleSections } = context;
const searchProps = getSearchBaseProps(context);
const { context, children, headerSlot, tableOfContentsSlot, announcementSlot, footerSlot } =
props;
const { customization } = context;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = shouldRenderSiteSectionNavigation(visibleSections);
const variants = categorizeVariants(context);
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
@@ -129,9 +259,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
customization.footer.groups?.length;
return (
<SpaceLayoutServerContext {...props}>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<SpaceLayoutServerContext
context={context}
withTracking={props.withTracking}
visitorAuthClaims={props.visitorAuthClaims}
aiChatRenderMessageOptions={props.aiChatRenderMessageOptions}
clientNavigationSelection={props.clientNavigationSelection}
>
{announcementSlot}
{headerSlot}
<NavigationLoader />
{isAIChatEnabled(customization.ai?.mode) ? (
<>
@@ -164,86 +300,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
: 'lg:min-h-screen'
)}
>
<TableOfContents
context={context}
header={
<div
className={tcls(
'pr-4',
'flex',
withTopHeader ? 'lg:hidden' : '',
'grow-0',
'dark:shadow-light/1',
'text-base/tight',
'items-center',
// On bold themes also color the TOC header so the logo looks correct.
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:m-[-1.5rem_-1px_-0.5rem_-2rem]',
'site-header:theme-bold:p-[1rem_1rem_1rem_2rem]'
)}
>
<HeaderLogo context={context} />
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={
variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
variant="header"
/>
) : null}
</div>
}
// Displays the search button and/or the space dropdown in the ToC
// according to the header/variant settings.
// E.g if there is no header, the search button will be displayed in the ToC.
innerHeader={
!withTopHeader || variants.generic.length > 1 ? (
<div
className={tcls(
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
variants.generic.length > 1 ? '' : 'max-lg:hidden'
)}
>
{!withTopHeader && (
<div className="flex gap-2 max-lg:hidden">
<SearchContainer
{...searchProps}
style={CustomizationSearchStyle.Subtle}
viewport="desktop"
/>
</div>
)}
{!withTopHeader && withSections && visibleSections && (
<SiteSectionList
className="hidden lg:block"
sections={encodeClientSiteSections(
context,
visibleSections
)}
/>
)}
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
/>
) : null}
</div>
) : null
}
/>
{tableOfContentsSlot}
{children}
</div>
</div>
{withFooter ? <Footer context={context} /> : null}
{withFooter ? footerSlot : null}
</SpaceLayoutServerContext>
);
}
@@ -1,11 +1,11 @@
import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteScopeContext } from '@/lib/context';
import { getSiteSpaceLanguages, normalizeLanguage } from '@/lib/sites';
/**
* Categorize the variants of the space into generic and translation variants.
*/
export function categorizeVariants(context: GitBookSiteContext) {
export function categorizeVariants(context: GitBookSiteScopeContext) {
const { siteSpace } = context;
// By default, variants only include visible spaces.
@@ -2,7 +2,7 @@
import { AnimatePresence, motion } from 'motion/react';
import React, { useRef } from 'react';
import { useCurrentPagePath } from '../hooks';
import { useSelectedPagePath } from '../hooks';
import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives';
/**
@@ -20,10 +20,13 @@ export function ToggleableLinkItem(
) {
const { href, children, descendants, pathnames, insights, icon, tag } = props;
const currentPagePath = useCurrentPagePath();
const isActive = pathnames.some((pathname) => pathname === currentPagePath);
const currentPagePath = useSelectedPagePath();
const isActive =
currentPagePath !== null && pathnames.some((pathname) => pathname === currentPagePath);
const defaultIsOpen =
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
isActive ||
(currentPagePath !== null &&
pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`)));
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
const hasBeenToggled = useRef(false);
@@ -10,3 +10,4 @@ export * from './useNow';
export * from './useListOverflow';
export * from './useCurrentPageMetadata';
export * from './useBackToSpace';
export * from './useSelectedNavigation';
@@ -20,11 +20,19 @@ export type CurrentContentContext = {
const ReactCurrentContentContext = React.createContext<CurrentContentContext | null>(null);
/**
* Hook to get the current content, or null outside of a `CurrentContentProvider`.
* Some surfaces (embeddable docs, PDF) render navigation components without the provider.
*/
export function useOptionalCurrentContent(): CurrentContentContext | null {
return React.useContext(ReactCurrentContentContext);
}
/**
* Hook to get the current content.
*/
export function useCurrentContent(): CurrentContentContext {
const context = React.useContext(ReactCurrentContentContext);
const context = useOptionalCurrentContent();
if (!context) {
throw new Error('useCurrentContent must be used within a CurrentContentProvider');
}
@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import { useOptionalCurrentContent } from './useCurrentContent';
import { useCurrentPagePath } from './useCurrentPagePath';
import { useIsMounted } from './useIsMounted';
const ClientNavigationSelectionContext = React.createContext(false);
/**
* Mark a subtree whose server-rendered selection cannot be trusted.
* Under PPR the header and the table of contents are cached fragments shared across pages, so the
* "where am I" state they were rendered with belongs to another page and has to be recomputed here.
*/
export function ClientNavigationSelectionProvider(
props: React.PropsWithChildren<{ enabled: boolean }>
) {
const { enabled, children } = props;
return (
<ClientNavigationSelectionContext.Provider value={enabled}>
{children}
</ClientNavigationSelectionContext.Provider>
);
}
/**
* Return the page path to mark as selected, or null while it hasn't been resolved on the client.
*/
export function useSelectedPagePath(): string | null {
// The page path always comes from the route, it just can't be trusted before hydration.
const pagePath = useCurrentPagePath();
return useSelected(pagePath, pagePath);
}
/**
* Return the site section to mark as selected, or null while it hasn't been resolved on the client.
*/
export function useSelectedSiteSectionId(serverValue: string | null): string | null {
return useSelected(serverValue, useOptionalCurrentContent()?.siteSectionId ?? null);
}
/**
* Return the site space to mark as selected, or null while it hasn't been resolved on the client.
*/
export function useSelectedSiteSpaceId(serverValue: string | null): string | null {
return useSelected(serverValue, useOptionalCurrentContent()?.siteSpaceId ?? null);
}
/**
* Pick between the value the server rendered with and the one resolved on the client.
* Nothing is selected until mount, so a stale highlight is never painted and the first client
* render still matches the cached markup (which was produced by this same branch).
*/
function useSelected<T>(serverValue: T, clientValue: T): T | null {
const enabled = React.useContext(ClientNavigationSelectionContext);
const isMounted = useIsMounted();
if (!enabled) {
return serverValue;
}
return isMounted ? clientValue : null;
}
+8 -4
View File
@@ -5,7 +5,7 @@ import {
isAvailableLanguage,
loadLanguage,
} from './translations';
import type { GitBookAnyContext } from '@/lib/context';
import type { GitBookAnyContext, GitBookSiteScopeContext } from '@/lib/context';
export * from './translate';
@@ -15,7 +15,7 @@ export const DEFAULT_LOCALE = 'en' satisfies TranslationLocale;
* Get the locale to use for the HTML lang attribute.
* This returns the actual content language even if we don't have UI translations for it.
*/
export function getContentLocale(context: GitBookAnyContext): string {
export function getContentLocale(context: GitBookAnyContext | GitBookSiteScopeContext): string {
if (context.locale) {
return context.locale;
}
@@ -31,7 +31,9 @@ export function getContentLocale(context: GitBookAnyContext): string {
/**
* Get the locale to use for a space.
*/
export function getSpaceLocale(context: GitBookAnyContext): TranslationLocale {
export function getSpaceLocale(
context: GitBookAnyContext | GitBookSiteScopeContext
): TranslationLocale {
const customization = 'site' in context ? context.customization : null;
// If the language is configured in the space, use it in priority
@@ -55,7 +57,9 @@ export function getSpaceLocale(context: GitBookAnyContext): TranslationLocale {
/**
* Create the translation context for a space to use in the server components.
*/
export async function getSpaceLanguage(context: GitBookAnyContext): Promise<TranslationLanguage> {
export async function getSpaceLanguage(
context: GitBookAnyContext | GitBookSiteScopeContext
): Promise<TranslationLanguage> {
const locale = getSpaceLocale(context);
const language = locale === DEFAULT_LOCALE ? defaultLanguage : await loadLanguage(locale);
+19
View File
@@ -0,0 +1,19 @@
/**
* Cache tags emitted while rendering the PPR route are prefixed, so that PPR cache entries
* live in their own namespace and are invalidated independently from the static ones.
*/
export const PPR_CACHE_TAG_PREFIX = 'ppr:';
/**
* Independently revalidatable units of the PPR route. The scope is part of the cache key of the
* data fetchers, so each component owns its own copy of the data it reads.
*/
export type PPRCacheScope = 'header' | 'toc' | 'body';
/**
* Scope every cache tag emitted while rendering a PPR component, so the component and the data it
* depends on are invalidated as one unit. Tags are left untouched outside the PPR route.
*/
export function scopeCacheTags(tags: string[], scope: PPRCacheScope | undefined): string[] {
return scope ? tags.map((tag) => `${PPR_CACHE_TAG_PREFIX}${scope}:${tag}`) : tags;
}
+115 -2
View File
@@ -1,8 +1,19 @@
import { describe, expect, it } from 'bun:test';
import type { SiteExternalLink, SiteSection, SiteSectionGroup } from '@gitbook/api';
import {
type SiteExternalLink,
type SiteSection,
type SiteSectionGroup,
TranslationLanguage,
} from '@gitbook/api';
import { filterSectionsAndGroupsWithHiddenSiteSpaces } from './context';
import {
type GitBookBaseContext,
fetchSiteContextByIds,
fetchSiteScopeContextByIds,
filterSectionsAndGroupsWithHiddenSiteSpaces,
} from './context';
import { createLinker } from './links';
function makeExternalLink(id: string): SiteExternalLink {
return {
@@ -37,3 +48,105 @@ describe('filterSectionsAndGroupsWithHiddenSiteSpaces', () => {
expect(filterSectionsAndGroupsWithHiddenSiteSpaces([hiddenSection, link])).toEqual([link]);
});
});
const siteSpace = {
object: 'site-space',
id: 'site-space-id',
title: 'Docs',
space: { id: 'space-id', language: TranslationLanguage.Fr, revision: 'space-revision-id' },
urls: {},
draft: false,
};
const space = {
id: 'space-id',
organization: 'org-id',
language: TranslationLanguage.En,
revision: 'space-revision-id',
};
const revision = { id: 'revision-id', pages: [], tags: [] };
function getBaseContext(): GitBookBaseContext {
const dataFetcher = {
getPublishedContentSite: async () => ({
data: {
site: { id: 'site-id', title: 'Site', urls: {} },
structure: { type: 'siteSpaces', structure: [siteSpace] },
customizations: { site: {}, siteSpaces: { 'site-space-id': {} } },
scripts: [],
},
}),
getSpace: async () => ({ data: space }),
getRevision: async () => ({ data: revision }),
};
return {
dataFetcher,
linker: createLinker({
host: 'docs.example.com',
siteBasePath: '/',
spaceBasePath: '/',
}),
} as unknown as GitBookBaseContext;
}
const ids = {
organization: 'org-id',
site: 'site-id',
siteSection: undefined,
siteSpace: 'site-space-id',
shareKey: undefined,
isFallback: false,
noIndexSearch: false,
isLoggedInVisitor: false,
};
describe('fetchSiteScopeContextByIds', () => {
it('resolves the site without reading the space or the revision', async () => {
const context = await fetchSiteScopeContextByIds(getBaseContext(), {
...ids,
revision: 'revision-id',
});
expect(context.site.id).toBe('site-id');
expect(context.siteSpace.id).toBe('site-space-id');
expect(context.revisionId).toBe('revision-id');
// The language comes from the site structure, as the space itself is never fetched.
expect(context.locale).toBe(TranslationLanguage.Fr);
expect(context).not.toHaveProperty('space');
expect(context).not.toHaveProperty('revision');
expect(context).not.toHaveProperty('changeRequest');
});
});
describe('fetchSiteContextByIds', () => {
it('carries the same site data as the site scope, plus the space and the revision', async () => {
const baseContext = getBaseContext();
const [context, siteScopeContext] = await Promise.all([
fetchSiteContextByIds(baseContext, {
...ids,
space: 'space-id',
changeRequest: undefined,
revision: 'revision-id',
}),
fetchSiteScopeContextByIds(baseContext, { ...ids, revision: 'revision-id' }),
]);
const { linker: _linker, ...siteScope } = siteScopeContext;
expect(context).toMatchObject(siteScope);
expect(context.space).toBe(space as unknown as typeof context.space);
expect(context.revision).toBe(revision as unknown as typeof context.revision);
expect(context.changeRequest).toBeNull();
});
it('falls back to the revision of the space when none is requested', async () => {
const context = await fetchSiteContextByIds(getBaseContext(), {
...ids,
space: 'space-id',
changeRequest: undefined,
revision: undefined,
});
expect(context.revisionId).toBe('space-revision-id');
});
});
+146 -38
View File
@@ -23,6 +23,7 @@ import type {
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
import type { PPRCacheScope } from '@/lib/cache-tags';
import {
type GitBookDataFetcher,
createDataFetcher,
@@ -101,6 +102,9 @@ export type SiteURLData = Pick<
* the static cache of the other routes.
*/
isAiAgent?: boolean;
/** Opaque identifier that partitions PPR renders across revalidations. */
revalidationId?: string;
};
/**
@@ -160,9 +164,23 @@ export type SiteSections = {
};
/**
* Context when rendering a site.
* Site-level context: everything derived from the published site and the URL data, without the
* space, revision or change request. It is what the PPR shell renders with, so its data only
* depends on the site-scoped token.
*
* Its `linker` does not know about a custom home page (that needs the revision pages), so page
* links must be built from the full site context. Its `dataFetcher` may hold a site-scoped token,
* so revision data must never be fetched through it.
*/
export type GitBookSiteContext = GitBookSpaceContext & {
export type GitBookSiteScopeContext = GitBookBaseContext & {
organizationId: string;
/** Identifier of the revision, as resolved by the URL lookup. */
revisionId: string;
/** Share key of the space. */
shareKey: string | undefined;
site: Site;
/** Current site space. */
@@ -206,8 +224,16 @@ export type GitBookSiteContext = GitBookSpaceContext & {
/** Whether the request comes from a detected AI agent. Only set for markdown routes. */
isAiAgent?: boolean;
/** Opaque identifier that partitions PPR renders across revalidations. */
revalidationId?: string;
};
/**
* Context when rendering a site.
*/
export type GitBookSiteContext = GitBookSpaceContext & GitBookSiteScopeContext;
/**
* Context when rendering a page.
*/
@@ -222,12 +248,15 @@ export function getBaseContext(input: {
siteURL: URL | string;
siteURLData: SiteURLData;
urlMode: 'url' | 'url-host';
/** Set when rendering a PPR component, to scope the cache tags emitted by the fetcher. */
pprScope?: PPRCacheScope;
}) {
const { urlMode, siteURLData } = input;
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const dataFetcher = createDataFetcher({
apiToken: siteURLData.apiToken ?? null,
...(input.pprScope ? { pprScope: input.pprScope } : {}),
});
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
@@ -289,44 +318,93 @@ export async function fetchSiteContextByURLLookup(
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
displayAgentInstructions: data.displayAgentInstructions,
isAiAgent: data.isAiAgent,
revalidationId: data.revalidationId,
});
}
/**
* Fetch a site context by IDs.
* Identifiers needed to resolve the site-level part of a context. They all come from the URL
* lookup, so no space or revision is involved.
*/
export async function fetchSiteContextByIds(
type SiteScopeIds = {
organization: string;
site: string;
siteSection: string | undefined;
siteSpace: string | undefined;
shareKey: string | undefined;
contextId?: string;
isFallback: boolean;
noIndexSearch: boolean;
isLoggedInVisitor: boolean;
displayAgentInstructions?: boolean;
isAiAgent?: boolean;
revalidationId?: string;
};
/**
* Fetch a site scope context by IDs.
*/
export async function fetchSiteScopeContextByIds(
baseContext: GitBookBaseContext,
ids: {
organization: string;
site: string;
siteSection: string | undefined;
siteSpace: string | undefined;
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
contextId?: string;
isFallback: boolean;
noIndexSearch: boolean;
isLoggedInVisitor: boolean;
displayAgentInstructions?: boolean;
isAiAgent?: boolean;
}
): Promise<GitBookSiteContext> {
ids: SiteScopeIds & { revision: string }
): Promise<GitBookSiteScopeContext> {
return {
...(await resolveSiteScope(baseContext, ids)),
revisionId: ids.revision,
};
}
/**
* Fetch the site scope context of a site using the resolution of a URL.
*/
export async function fetchSiteScopeContextByURLLookup(
baseContext: GitBookBaseContext,
data: SiteURLData
): Promise<GitBookSiteScopeContext> {
// The revision is only resolved upstream for PPR requests, the only ones rendering a site scope.
assert(data.revision, 'cannot resolve a site scope context without a resolved revision');
return fetchSiteScopeContextByIds(baseContext, {
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
shareKey: data.shareKey,
revision: data.revision,
contextId: data.contextId,
isFallback: data.isFallback ?? false,
noIndexSearch: data.noIndexSearch ?? false,
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
displayAgentInstructions: data.displayAgentInstructions,
isAiAgent: data.isAiAgent,
revalidationId: data.revalidationId,
});
}
/**
* Resolve everything a site context holds that doesn't depend on the space or the revision.
*
* It never sets `revisionId`: the caller knows whether it comes from the URL lookup (site scope) or
* from the resolved space context, and a key here would override the latter.
*/
async function resolveSiteScope(
baseContext: GitBookBaseContext,
ids: SiteScopeIds
): Promise<Omit<GitBookSiteScopeContext, 'revisionId'>> {
const { dataFetcher } = baseContext;
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
await Promise.all([
throwIfDataError(
dataFetcher.getPublishedContentSite({
organizationId: ids.organization,
siteId: ids.site,
siteShareKey: ids.shareKey,
})
),
fetchSpaceContextByIds(baseContext, ids),
]);
const {
site: orgSite,
structure: siteStructure,
customizations,
scripts,
} = await throwIfDataError(
dataFetcher.getPublishedContentSite({
organizationId: ids.organization,
siteId: ids.site,
siteShareKey: ids.shareKey,
})
);
const sections = ids.siteSection
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
@@ -390,7 +468,7 @@ export async function fetchSiteContextByIds(
return siteSpaceSettings;
}
const logger = getLogger().subLogger('fetchSiteContextByIds', {});
const logger = getLogger().subLogger('resolveSiteScope', {});
// We got the pointer from an API and customizations from another.
// It's possible that the two are unsynced leading to not found customizations for the space.
// It's better to fallback on customization of the site that displaying an error.
@@ -418,14 +496,15 @@ export async function fetchSiteContextByIds(
};
const siteLinker = site.urls.published
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
: spaceContext.linker;
? linkerForPublishedURL(baseContext.linker, site.urls.published)
: baseContext.linker;
return {
...spaceContext,
locale: siteSpace.space.language ?? spaceContext.locale,
linker: getLinkerForSiteSpace(siteLinker, siteSpace, spaceContext.revision.pages),
...baseContext,
locale: siteSpace.space.language,
linker: siteLinker,
organizationId: ids.organization,
shareKey: ids.shareKey,
site,
siteSpaces,
visibleSiteSpaces,
@@ -441,6 +520,35 @@ export async function fetchSiteContextByIds(
isLoggedInVisitor: ids.isLoggedInVisitor,
displayAgentInstructions: ids.displayAgentInstructions,
isAiAgent: ids.isAiAgent,
revalidationId: ids.revalidationId,
};
}
/**
* Fetch a site context by IDs.
*/
export async function fetchSiteContextByIds(
baseContext: GitBookBaseContext,
ids: SiteScopeIds & {
space: string;
changeRequest: string | undefined;
revision: string | undefined;
}
): Promise<GitBookSiteContext> {
const [siteScope, spaceContext] = await Promise.all([
resolveSiteScope(baseContext, ids),
fetchSpaceContextByIds(baseContext, ids),
]);
return {
...spaceContext,
...siteScope,
locale: siteScope.siteSpace.space.language ?? spaceContext.locale,
linker: getLinkerForSiteSpace(
siteScope.linker,
siteScope.siteSpace,
spaceContext.revision.pages
),
};
}
+74 -50
View File
@@ -13,6 +13,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
import { cache } from '../cache';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import type { GitBookDataFetcher } from './types';
import { type PPRCacheScope, scopeCacheTags } from '@/lib/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
import { trace } from '@/lib/tracing';
@@ -21,6 +22,12 @@ interface DataFetcherInput {
* API token.
*/
apiToken: string | null;
/**
* Set when rendering a PPR component. It is part of the cache key, so each scope owns its own
* copy of the data and can carry its own `ppr:<scope>:` cache tags.
*/
pprScope?: PPRCacheScope;
}
/**
@@ -37,15 +44,24 @@ export const noCacheFetchOptions: Partial<RequestInit> = {
* The data are being cached by Next.js built-in cache.
*/
export function createDataFetcher(
input: DataFetcherInput = { apiToken: null }
rawInput: DataFetcherInput = { apiToken: null }
): GitBookDataFetcher {
// The input is part of the cache key of every cached fetcher below, so we normalize its shape
// here: `pprScope` is omitted entirely when unset, keeping non-PPR cache keys unchanged.
const input: DataFetcherInput = rawInput.pprScope
? { apiToken: rawInput.apiToken, pprScope: rawInput.pprScope }
: { apiToken: rawInput.apiToken };
return {
pprScope: input.pprScope,
async api() {
return apiClient(input);
},
withToken({ apiToken }) {
return createDataFetcher({
...input,
apiToken,
});
},
@@ -246,7 +262,7 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
const res = await api.users.getUserById(params.userId, {
...noCacheFetchOptions,
});
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
@@ -256,12 +272,12 @@ const getUserById = cache(async (input: DataFetcherInput, params: { userId: stri
const getSpace = cache(
async (input: DataFetcherInput, params: { spaceId: string; shareKey: string | undefined }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
@@ -275,7 +291,7 @@ const getSpace = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
@@ -286,13 +302,13 @@ const getSpace = cache(
const getChangeRequest = cache(
async (input: DataFetcherInput, params: { spaceId: string; changeRequestId: string }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: params.changeRequestId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -306,7 +322,7 @@ const getChangeRequest = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data;
}
@@ -332,7 +348,7 @@ const getRevision = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
});
@@ -346,13 +362,13 @@ const getChangeRequestChanges = cache(
params: { spaceId: string; changeRequestId: string; limit?: number }
) => {
'use cache: remote';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: params.changeRequestId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -369,7 +385,7 @@ const getChangeRequestChanges = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data;
}
@@ -401,7 +417,7 @@ const getRevisionSemanticChanges = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
@@ -438,7 +454,7 @@ const getRevisionPageMarkdown = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
if (!('markdown' in res.data)) {
@@ -480,7 +496,7 @@ const getRevisionPageDocument = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
@@ -513,7 +529,7 @@ const getRevisionReusableContentDocument = cache(
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
@@ -546,7 +562,7 @@ const getRevisionPageByPath = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
@@ -569,7 +585,7 @@ const getDocument = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
});
@@ -588,15 +604,15 @@ const getComputedDocument = cache(
}
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -614,7 +630,7 @@ const getComputedDocument = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
}
@@ -627,13 +643,13 @@ const getComputedDocument = cache(
const getLatestOpenAPISpecVersionContent = cache(
async (input: DataFetcherInput, params: { organizationId: string; slug: string }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -647,7 +663,7 @@ const getLatestOpenAPISpecVersionContent = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
@@ -663,12 +679,12 @@ const getPublishedContentSite = cache(
_apiVersion: string
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -685,7 +701,7 @@ const getPublishedContentSite = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
@@ -705,12 +721,12 @@ const getSiteRedirectBySource = cache(
}
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -728,7 +744,7 @@ const getSiteRedirectBySource = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
@@ -740,12 +756,12 @@ const getSiteRedirectBySource = cache(
const getEmbedByUrl = cache(
async (input: DataFetcherInput, params: { spaceId: string; url: string }) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => {
@@ -759,7 +775,7 @@ const getEmbedByUrl = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('weeks');
return res.data;
});
@@ -774,12 +790,12 @@ const searchSiteContent = cache(
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(
@@ -809,7 +825,7 @@ const searchSiteContent = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('hours');
return res.data.items;
}
@@ -824,12 +840,12 @@ const renderIntegrationUi = cache(
params: { integrationName: string; request: RenderIntegrationUI }
) => {
'use cache';
cacheTag(
cacheTagsFor(input, [
getCacheTag({
tag: 'integration',
integration: params.integrationName,
})
);
}),
]);
return wrapDataFetcherError(async () => {
return trace(`renderIntegrationUi(${params.integrationName})`, async () => {
@@ -841,7 +857,7 @@ const renderIntegrationUi = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
@@ -871,7 +887,7 @@ const listRevisionPageMetaLinks = cache(
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheTagsFor(input, getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
}
@@ -903,3 +919,11 @@ function getCacheTagsFromResponse(response: HttpResponse<unknown, unknown>) {
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
return tags;
}
/**
* Tag the current cache entry. Always tag through this helper, never `cacheTag` directly,
* so that a PPR render never emits an unscoped tag.
*/
function cacheTagsFor(input: DataFetcherInput, tags: string[]) {
cacheTag(...scopeCacheTags(tags, input.pprScope));
}
@@ -0,0 +1,20 @@
import { getURLLookupAlternatives, stripURLSearch } from './urls';
/**
* Build the lookup candidates, using a trusted PPR lookup URL as the only candidate.
*/
export function getPublishedContentLookupPlan(input: { url: string; urlLookup?: string }) {
if (input.urlLookup) {
return {
urls: [{ url: input.urlLookup, primary: true, extraPath: '' }],
basePath: undefined,
changeRequest: undefined,
revision: undefined,
direct: true,
};
}
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
return { ...getURLLookupAlternatives(url), direct: false };
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'bun:test';
import { getPublishedContentLookupPlan } from './lookup-plan';
describe('getPublishedContentLookupPlan', () => {
it('uses a supplied lookup URL directly without generating alternatives', () => {
expect(
getPublishedContentLookupPlan({
url: 'https://docs.example.com/a/b/c',
urlLookup: 'https://docs.example.com/a',
})
).toEqual({
urls: [
{
url: 'https://docs.example.com/a',
primary: true,
extraPath: '',
},
],
basePath: undefined,
changeRequest: undefined,
revision: undefined,
direct: true,
});
});
it('uses URL alternatives when no direct lookup URL is supplied', () => {
const plan = getPublishedContentLookupPlan({
url: 'https://docs.example.com/a/b/c',
});
expect(plan.direct).toBeFalse();
expect(plan.urls.length).toBeGreaterThan(1);
});
});
+20 -7
View File
@@ -2,8 +2,8 @@ import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from
import { apiClient } from './api';
import { getExposableError } from './errors';
import { getPublishedContentLookupPlan } from './lookup-plan';
import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls';
import { isAPITokenExpired } from '@/lib/api-token';
import { race, tryCatch } from '@/lib/async';
import { getLogger } from '@/lib/logger';
@@ -17,6 +17,8 @@ interface LookupPublishedContentByUrlInput {
redirectOnError: boolean;
apiToken: string | null;
visitorPayload: SiteVisitorPayload;
/** A known lookup URL that can be resolved directly without racing alternatives. */
urlLookup?: string;
}
/**
@@ -26,11 +28,12 @@ interface LookupPublishedContentByUrlInput {
export async function lookupPublishedContentByUrl(
input: LookupPublishedContentByUrlInput
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const lookup = getPublishedContentLookupPlan(input);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const resolveAlternative = async (
alternative: (typeof lookup.urls)[number],
signal?: AbortSignal
) => {
const api = apiClient({ apiToken: input.apiToken });
const resolveURL = (cacheBust?: string) =>
tryCatch(
@@ -43,7 +46,7 @@ export async function lookupPublishedContentByUrl(
// field is enough to miss the cache and get a freshly minted token.
...(cacheBust ? { cacheBust } : {}),
} as ResolveBody, //TODO: remove cast when we are sure that everything is good
{ signal }
signal ? { signal } : undefined
)
);
@@ -142,7 +145,17 @@ export async function lookupPublishedContentByUrl(
}
return null;
});
};
const result = lookup.direct
? await resolveAlternative({
url: input.urlLookup ?? input.url,
primary: true,
extraPath: '',
})
: await race(lookup.urls, (alternative, { signal }) =>
resolveAlternative(alternative, signal)
);
if (!result) {
return {
+8
View File
@@ -1,5 +1,7 @@
import type * as api from '@gitbook/api';
import type { PPRCacheScope } from '@/lib/cache-tags';
export type DataFetcherErrorData = {
code: number;
message: string;
@@ -24,6 +26,12 @@ export type DataFetcherResponse<T> =
* It is used between v1 and v2.
*/
export interface GitBookDataFetcher {
/**
* Set when rendering a PPR component: the cache entries are partitioned per scope and their
* cache tags are scoped, so a component and its data are invalidated as one unit.
*/
readonly pprScope?: PPRCacheScope;
/**
* Get an API client for the current context.
*/
+7
View File
@@ -132,6 +132,13 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
*/
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
/**
* Endpoint exchanging a PPR revalidation token for a content API token scoped to a single claims
* bucket. Signing one requires the API token secret, which GitBook Open does not have.
*/
export const GITBOOK_EXCHANGE_TOKEN_URL =
process.env.GITBOOK_EXCHANGE_TOKEN_URL || 'https://sites.gitbook.com/token';
/**
* Shared secret used to sign server-to-server requests to the sites OAuth server consent endpoints.
* This must match the sites OAuth provider signing secret (`functionsConfig.sitesOAuth.signingSecret`
+6 -3
View File
@@ -17,6 +17,7 @@ import type {
ResolveOpenAPIBlockArgs,
} from './types';
import type { FetchOpenAPIFilesystemResult } from './types';
import { type PPRCacheScope, scopeCacheTags } from '@/lib/cache-tags';
import { DataFetcherError, noCacheFetchOptions } from '@/lib/data';
import { resolveContentRef } from '@/lib/references';
@@ -44,7 +45,7 @@ export async function fetchOpenAPIFilesystem(
return resolved.openapi.filesystem;
}
// For legacy blocks ("swagger"), we need to fetch the file system.
return fetchFilesystem(resolved.href, context.space.id);
return fetchFilesystem(resolved.href, context.space.id, context.dataFetcher.pprScope);
})();
if ('error' in result) {
@@ -74,7 +75,9 @@ export async function fetchOpenAPIFilesystem(
*/
async function fetchFilesystem(
url: string,
spaceId: string
spaceId: string,
// Part of the cache key, so each PPR scope owns its own entry, separate from the static one.
pprScope: PPRCacheScope | undefined
): Promise<
| Filesystem
| {
@@ -86,7 +89,7 @@ async function fetchFilesystem(
> {
'use cache';
try {
cacheTag(getCacheTag({ tag: 'space', space: spaceId }));
cacheTag(...scopeCacheTags([getCacheTag({ tag: 'space', space: spaceId })], pprScope));
return await fetchFilesystemNoCache(url);
} catch (error) {
// To avoid hammering the file with requests, we cache the error for around a minute.
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
import { PPR_TOKEN_SCOPE, exchangePPRToken } from './ppr-token';
import { DataFetcherError } from '@/lib/data/errors';
import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env';
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
function mockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response) {
const calls: { url: string; body: unknown }[] = [];
globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) });
return handler(input, init);
}) as unknown as typeof fetch;
return calls;
}
describe('PPR_TOKEN_SCOPE', () => {
it('maps every PPR cache scope to the claims bucket it resolves', () => {
expect(PPR_TOKEN_SCOPE).toEqual({ header: 'site', toc: 'revision', body: 'page' });
});
});
describe('exchangePPRToken', () => {
it('posts the token and scope, and returns the exchanged token', async () => {
for (const scope of ['site', 'revision', 'page', 'full'] as const) {
const calls = mockFetch(() => Response.json({ token: `exchanged-${scope}` }));
expect(await exchangePPRToken(`revalidation-token-${scope}`, scope)).toBe(
`exchanged-${scope}`
);
expect(calls).toEqual([
{
url: GITBOOK_EXCHANGE_TOKEN_URL,
body: { token: `revalidation-token-${scope}`, scope },
},
]);
}
});
it('fails with a 502 when the endpoint rejects the token', async () => {
mockFetch(() => Response.json({ error: 'Invalid or expired token' }, { status: 401 }));
const error = await exchangePPRToken('rejected-token', 'site').catch((e) => e);
expect(error).toBeInstanceOf(DataFetcherError);
expect((error as DataFetcherError).code).toBe(502);
});
it('fails with a 502 when the endpoint returns no token', async () => {
mockFetch(() => Response.json({}));
const error = await exchangePPRToken('tokenless-response', 'revision').catch((e) => e);
expect(error).toBeInstanceOf(DataFetcherError);
expect((error as DataFetcherError).code).toBe(502);
});
it('fails with a 502 when the endpoint is unreachable', async () => {
globalThis.fetch = mock(async () => {
throw new TypeError('fetch failed');
}) as unknown as typeof fetch;
const error = await exchangePPRToken('unreachable', 'page').catch((e) => e);
expect(error).toBeInstanceOf(DataFetcherError);
expect((error as DataFetcherError).code).toBe(502);
});
// Request-level memoization is `React.cache`, which is inert outside a render scope and so
// cannot be exercised here. What is asserted instead: each scope is a distinct exchange.
it('exchanges each scope separately', async () => {
const calls = mockFetch(() => Response.json({ token: 'exchanged' }));
await Promise.all([
exchangePPRToken('shared-token', 'site'),
exchangePPRToken('shared-token', 'revision'),
]);
expect(calls.map((call) => call.body)).toEqual([
{ token: 'shared-token', scope: 'site' },
{ token: 'shared-token', scope: 'revision' },
]);
});
});
+68
View File
@@ -0,0 +1,68 @@
import 'server-only';
import { cache } from '@/lib/cache';
import type { PPRCacheScope } from '@/lib/cache-tags';
import { DataFetcherError } from '@/lib/data/errors';
import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env';
import { trace } from '@/lib/tracing';
/**
* Scope of the claims to keep in the exchanged token. `full` merges every scope into one object,
* for callers that resolve them all at once and cannot present a different token per scope.
*/
export type PPRTokenScope = 'site' | 'revision' | 'page' | 'full';
/** Each PPR cache scope resolves exactly one claims bucket, so it maps to one exchange scope. */
export const PPR_TOKEN_SCOPE: Record<PPRCacheScope, PPRTokenScope> = {
header: 'site',
toc: 'revision',
body: 'page',
};
/**
* Exchange the revalidation token carried by a PPR request for a content API token whose claims are
* narrowed to `scope`. The API only understands the latter, and narrowing is what lets components
* sharing a scope share a cache entry: the token is part of their cache key.
*
* Memoized per request, but never persisted — an exchanged token is a credential.
*/
export const exchangePPRToken = cache(
async (token: string, scope: PPRTokenScope): Promise<string> => {
return trace(`exchangePPRToken(${scope})`, async () => {
const response = await fetchExchangedToken(token, scope);
if (!response.ok) {
throw new DataFetcherError(
`Token exchange for scope "${scope}" responded with ${response.status}`,
502
);
}
const { token: exchanged } = (await response.json()) as { token?: unknown };
if (typeof exchanged !== 'string' || !exchanged) {
throw new DataFetcherError(
`Token exchange for scope "${scope}" returned no token`,
502
);
}
return exchanged;
});
}
);
async function fetchExchangedToken(token: string, scope: PPRTokenScope): Promise<Response> {
try {
return await fetch(GITBOOK_EXCHANGE_TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, scope }),
cache: 'no-store',
});
} catch (error) {
// Surface a transport failure the same way as a rejection, so callers only handle one type.
throw new DataFetcherError(
`Token exchange for scope "${scope}" failed: ${error instanceof Error ? error.message : String(error)}`,
502
);
}
}
+254
View File
@@ -0,0 +1,254 @@
import { describe, expect, it } from 'bun:test';
import { getPPRRequest, getPPRRouteType, signPPRRequestHeaders } from './ppr';
const pprHeaders = new Headers({
'x-gbo-site': 'site-id',
'x-gbo-site-section': 'site-section-id',
'x-gbo-site-space': 'site-space-id',
'x-gbo-space': 'space-id',
'x-gbo-site-base-path': '/docs',
'x-gbo-base-path': '/docs/guide',
'x-gbo-pathname': '/getting-started',
'x-gbo-organization': 'organization-id',
'x-gbo-share-key': 'share-key',
'x-gbo-complete': 'true',
'x-gbo-context-id': 'context-id',
'x-gbo-canonical-url': 'https://docs.example.com/guide',
'x-gbo-preview': 'false',
'x-gbo-revision': 'revision-id',
'x-gbo-change-request': 'change-request-id',
'x-gbo-api-token': 'api-token',
'x-gbo-revalidation-id': 'revalidation-id',
'x-gbo-default-site-section': 'default-site-section-id',
'x-gbo-default-site-space': 'default-site-space-id',
'x-gbo-default-space': 'default-space-id',
});
const SECRET = 'ppr-signing-secret';
/**
* Sign a header set the way GBO does, so the parsing assertions can't pass or fail on the signature.
*/
async function signHeaders(headers: Headers, secret = SECRET) {
const signed = new Headers(headers);
await signPPRRequestHeaders(signed, secret);
return signed;
}
function getSignedPPRRequest(headers: Headers) {
return signHeaders(headers).then((signed) => getPPRRequest(signed, SECRET));
}
describe('getPPRRouteType', () => {
it('routes static document pages through PPR when resolved content headers are present', async () => {
expect(getPPRRouteType('static', true, await getSignedPPRRequest(pprHeaders))).toBe('ppr');
});
it('keeps special static routes on their existing route', async () => {
expect(getPPRRouteType('static', false, await getSignedPPRRequest(pprHeaders))).toBe(
'static'
);
});
it('keeps dynamic pages dynamic even when PPR is requested', async () => {
expect(getPPRRouteType('dynamic', true, await getSignedPPRRequest(pprHeaders))).toBe(
'dynamic'
);
});
it('does not opt in with a partial resolved-content header set', async () => {
for (const header of [
'x-gbo-site',
'x-gbo-site-space',
'x-gbo-space',
'x-gbo-site-base-path',
'x-gbo-base-path',
'x-gbo-pathname',
'x-gbo-organization',
'x-gbo-complete',
'x-gbo-canonical-url',
'x-gbo-api-token',
'x-gbo-revision',
'x-gbo-revalidation-id',
'x-gbo-default-site-section',
'x-gbo-default-site-space',
'x-gbo-default-space',
]) {
const headers = new Headers(pprHeaders);
headers.delete(header);
expect(await getSignedPPRRequest(headers)).toBeUndefined();
expect(getPPRRouteType('static', true, await getSignedPPRRequest(headers))).toBe(
'static'
);
}
});
it('does not opt in when required headers are empty', async () => {
for (const header of [
'x-gbo-site',
'x-gbo-site-space',
'x-gbo-space',
'x-gbo-site-base-path',
'x-gbo-base-path',
'x-gbo-pathname',
'x-gbo-organization',
'x-gbo-complete',
'x-gbo-canonical-url',
'x-gbo-api-token',
'x-gbo-revision',
'x-gbo-revalidation-id',
'x-gbo-default-site-space',
'x-gbo-default-space',
]) {
const headers = new Headers(pprHeaders);
headers.set(header, '');
expect(await getSignedPPRRequest(headers)).toBeUndefined();
}
});
it('does not opt in when boolean headers are invalid', async () => {
for (const header of ['x-gbo-complete', 'x-gbo-preview']) {
const headers = new Headers(pprHeaders);
headers.set(header, 'yes');
expect(await getSignedPPRRequest(headers)).toBeUndefined();
}
});
it('reads resolved content from the complete header set', async () => {
expect(await getSignedPPRRequest(pprHeaders)).toEqual({
content: {
site: 'site-id',
siteSection: 'site-section-id',
siteSpace: 'site-space-id',
space: 'space-id',
siteBasePath: '/docs',
basePath: '/docs/guide',
pathname: '/getting-started',
organization: 'organization-id',
shareKey: 'share-key',
complete: true,
contextId: 'context-id',
canonicalUrl: 'https://docs.example.com/guide',
preview: false,
revision: 'revision-id',
changeRequest: 'change-request-id',
apiToken: 'api-token',
},
defaults: {
siteSection: 'default-site-section-id',
siteSpace: 'default-site-space-id',
space: 'default-space-id',
},
revalidationId: 'revalidation-id',
});
});
it('treats empty optional headers as absent', async () => {
const headers = new Headers(pprHeaders);
for (const header of [
'x-gbo-site-section',
'x-gbo-share-key',
'x-gbo-context-id',
'x-gbo-preview',
'x-gbo-change-request',
]) {
headers.set(header, '');
}
expect((await getSignedPPRRequest(headers))?.content).toMatchObject({
site: 'site-id',
siteSection: undefined,
shareKey: undefined,
contextId: undefined,
preview: undefined,
revision: 'revision-id',
changeRequest: undefined,
});
});
it('accepts an explicitly empty default site section', async () => {
const headers = new Headers(pprHeaders);
headers.set('x-gbo-default-site-section', '');
expect((await getSignedPPRRequest(headers))?.defaults).toEqual({
siteSection: undefined,
siteSpace: 'default-site-space-id',
space: 'default-space-id',
});
});
it('requires all default location headers before opting into PPR', async () => {
for (const header of [
'x-gbo-default-site-section',
'x-gbo-default-site-space',
'x-gbo-default-space',
]) {
const headers = new Headers(pprHeaders);
headers.delete(header);
expect(await getSignedPPRRequest(headers)).toBeUndefined();
expect(getPPRRouteType('static', true, await getSignedPPRRequest(headers))).toBe(
'static'
);
}
});
it('parses false and true boolean header values', async () => {
const headers = new Headers(pprHeaders);
headers.set('x-gbo-complete', 'false');
headers.set('x-gbo-preview', 'true');
expect((await getSignedPPRRequest(headers))?.content).toMatchObject({
complete: false,
preview: true,
});
});
});
describe('PPR request signature', () => {
it('accepts a header set signed with the configured secret', async () => {
expect(await getPPRRequest(await signHeaders(pprHeaders), SECRET)).toBeDefined();
});
it('ignores an unsigned header set', async () => {
expect(await getPPRRequest(pprHeaders, SECRET)).toBeUndefined();
});
it('ignores a header set signed with another secret', async () => {
const signed = await signHeaders(pprHeaders, 'another-secret');
expect(await getPPRRequest(signed, SECRET)).toBeUndefined();
});
it('ignores a malformed signature', async () => {
for (const signature of ['', 'not-hex', 'abc', 'ab'.repeat(31)]) {
const headers = new Headers(pprHeaders);
headers.set('x-gbo-signature', signature);
expect(await getPPRRequest(headers, SECRET)).toBeUndefined();
}
});
it('ignores a header set modified after signing', async () => {
for (const header of [
'x-gbo-revalidation-id',
'x-gbo-api-token',
'x-gbo-pathname',
'x-gbo-site',
]) {
const signed = await signHeaders(pprHeaders);
signed.set(header, 'tampered');
expect(await getPPRRequest(signed, SECRET)).toBeUndefined();
}
});
it('ignores a header set with an optional header dropped after signing', async () => {
for (const header of ['x-gbo-share-key', 'x-gbo-context-id', 'x-gbo-change-request']) {
const signed = await signHeaders(pprHeaders);
signed.delete(header);
expect(await getPPRRequest(signed, SECRET)).toBeUndefined();
}
});
it('does not opt into PPR when no secret is configured', async () => {
expect(await getPPRRequest(await signHeaders(pprHeaders), null)).toBeUndefined();
});
});
+234
View File
@@ -0,0 +1,234 @@
import type { PublishedSiteContent } from '@gitbook/api';
export type SiteRouteType = 'dynamic' | 'static' | 'ppr';
export type PPRRequest = {
content: PublishedSiteContent & { revision: string };
defaults: {
siteSection: string | undefined;
siteSpace: string;
space: string;
};
revalidationId: string;
};
export const PPRRequestHeaders = {
Site: 'x-gbo-site',
SiteSection: 'x-gbo-site-section',
SiteSpace: 'x-gbo-site-space',
Space: 'x-gbo-space',
SiteBasePath: 'x-gbo-site-base-path',
BasePath: 'x-gbo-base-path',
Pathname: 'x-gbo-pathname',
Organization: 'x-gbo-organization',
ShareKey: 'x-gbo-share-key',
Complete: 'x-gbo-complete',
ContextID: 'x-gbo-context-id',
CanonicalURL: 'x-gbo-canonical-url',
Preview: 'x-gbo-preview',
Revision: 'x-gbo-revision',
ChangeRequest: 'x-gbo-change-request',
APIToken: 'x-gbo-api-token',
RevalidationID: 'x-gbo-revalidation-id',
DefaultSiteSection: 'x-gbo-default-site-section',
DefaultSiteSpace: 'x-gbo-default-site-space',
DefaultSpace: 'x-gbo-default-space',
Signature: 'x-gbo-signature',
} as const;
/**
* Header names covered by the signature, in an order both signer and verifier must agree on.
*/
const SignedPPRRequestHeaders = Object.values(PPRRequestHeaders)
.filter((name) => name !== PPRRequestHeaders.Signature)
.sort();
/**
* GBO has already resolved PPR requests, so a complete header set can skip URL resolution.
*
* That skips visitor-auth validation and lets the headers pick the cache key, so the set is only
* trusted once its GBO signature checks out. Without a secret to check against, PPR stays off.
*/
export async function getPPRRequest(
headers: Headers,
secret: string | null
): Promise<PPRRequest | undefined> {
const pprRequest = parsePPRRequest(headers);
if (!pprRequest || !secret || !(await verifyPPRRequestHeaders(headers, secret))) {
return undefined;
}
return pprRequest;
}
/**
* Sign a PPR header set the way GBO does. Only used by the dev proxy and tests.
*/
export async function signPPRRequestHeaders(headers: Headers, secret: string): Promise<void> {
const signature = await crypto.subtle.sign(
'HMAC',
await importSigningKey(secret),
encodeUTF8(getSignedPayload(headers))
);
headers.set(PPRRequestHeaders.Signature, toHex(signature));
}
async function verifyPPRRequestHeaders(headers: Headers, secret: string): Promise<boolean> {
const signature = headers.get(PPRRequestHeaders.Signature);
const decoded = signature ? fromHex(signature) : null;
if (!decoded) {
return false;
}
// `crypto.subtle.verify` compares in constant time.
return crypto.subtle.verify(
'HMAC',
await importSigningKey(secret),
decoded,
encodeUTF8(getSignedPayload(headers))
);
}
/**
* Canonical string that gets signed. An absent header must not hash like an empty one, as dropping
* one changes how the set is read.
*/
function getSignedPayload(headers: Headers): string {
return SignedPPRRequestHeaders.map((name) => {
const value = headers.get(name);
return value === null ? name : `${name}=${encodeURIComponent(value)}`;
}).join('\n');
}
function importSigningKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
encodeUTF8(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
}
function encodeUTF8(value: string): Uint8Array<ArrayBuffer> {
return new Uint8Array(new TextEncoder().encode(value));
}
function toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, '0')).join(
''
);
}
function fromHex(value: string): Uint8Array<ArrayBuffer> | null {
if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) {
return null;
}
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index++) {
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
function parsePPRRequest(headers: Headers): PPRRequest | undefined {
const site = getRequiredHeader(headers, PPRRequestHeaders.Site);
const siteSpace = getRequiredHeader(headers, PPRRequestHeaders.SiteSpace);
const space = getRequiredHeader(headers, PPRRequestHeaders.Space);
const siteBasePath = getRequiredHeader(headers, PPRRequestHeaders.SiteBasePath);
const basePath = getRequiredHeader(headers, PPRRequestHeaders.BasePath);
const pathname = getRequiredHeader(headers, PPRRequestHeaders.Pathname);
const organization = getRequiredHeader(headers, PPRRequestHeaders.Organization);
const complete = getBooleanHeader(headers, PPRRequestHeaders.Complete);
const canonicalUrl = getRequiredHeader(headers, PPRRequestHeaders.CanonicalURL);
const apiToken = getRequiredHeader(headers, PPRRequestHeaders.APIToken);
const preview = getBooleanHeader(headers, PPRRequestHeaders.Preview);
const revision = getRequiredHeader(headers, PPRRequestHeaders.Revision);
const revalidationId = headers.get(PPRRequestHeaders.RevalidationID);
const defaultSiteSection = getOptionalHeader(headers, PPRRequestHeaders.DefaultSiteSection);
const defaultSiteSpace = getRequiredHeader(headers, PPRRequestHeaders.DefaultSiteSpace);
const defaultSpace = getRequiredHeader(headers, PPRRequestHeaders.DefaultSpace);
if (
!site ||
!siteSpace ||
!space ||
!siteBasePath ||
!basePath ||
!pathname ||
!organization ||
complete === undefined ||
!canonicalUrl ||
!apiToken ||
(headers.get(PPRRequestHeaders.Preview) && preview === undefined) ||
!revision ||
!revalidationId ||
!headers.has(PPRRequestHeaders.DefaultSiteSection) ||
!defaultSiteSpace ||
!defaultSpace
) {
return undefined;
}
return {
content: {
site,
siteSection: getOptionalHeader(headers, PPRRequestHeaders.SiteSection),
siteSpace,
space,
siteBasePath,
basePath,
pathname,
organization,
shareKey: getOptionalHeader(headers, PPRRequestHeaders.ShareKey),
complete,
contextId: getOptionalHeader(headers, PPRRequestHeaders.ContextID),
canonicalUrl,
preview,
revision,
changeRequest: getOptionalHeader(headers, PPRRequestHeaders.ChangeRequest),
apiToken,
},
defaults: {
siteSection: defaultSiteSection,
siteSpace: defaultSiteSpace,
space: defaultSpace,
},
revalidationId,
};
}
function getRequiredHeader(headers: Headers, name: string): string | undefined {
return getOptionalHeader(headers, name);
}
function getOptionalHeader(headers: Headers, name: string): string | undefined {
return headers.get(name) || undefined;
}
function getBooleanHeader(headers: Headers, name: string): boolean | undefined {
const value = getOptionalHeader(headers, name);
if (!value) {
return undefined;
}
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
return undefined;
}
/**
* Keep the PPR rollout limited to ordinary static document pages.
*/
export function getPPRRouteType(
routeType: SiteRouteType,
isPPRPage: boolean | undefined,
pprRequest: PPRRequest | undefined
): SiteRouteType {
return routeType === 'static' && isPPRPage && pprRequest ? 'ppr' : routeType;
}
+7 -3
View File
@@ -1,6 +1,6 @@
import { type RevisionPageDocument, type RevisionPageGroup, SiteVisibility } from '@gitbook/api';
import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
/**
* Return true if a page is indexable in search.
@@ -24,13 +24,17 @@ export function isPageIndexable(
/**
* Return true if a space should be indexed by search engines.
*/
export function isSiteIndexable(context: GitBookSiteContext) {
export function isSiteIndexable(context: GitBookSiteContext | GitBookSiteScopeContext) {
if (context.noIndexSearch) {
return false;
}
// Prevent indexation of preview of revisions / change-requests
if (context.changeRequest || context.revisionId !== context.space.revision) {
// It cannot happen for Scoped kind of route
if (
('changeRequest' in context && context.changeRequest) ||
('space' in context && context.revisionId !== context.space.revision)
) {
return false;
}
+59 -22
View File
@@ -33,7 +33,7 @@ import {
normalizeRequestURL,
throwIfDataError,
} from '@/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
import { GITBOOK_SECRET, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
import { getImageResizingContextId } from '@/lib/images';
import { isAITrainingOrIndexingRequest } from '@/lib/indexing-crawlers';
import { MiddlewareHeaders } from '@/lib/middleware';
@@ -44,6 +44,7 @@ import {
isOAuthProtectedResourceRequest,
} from '@/lib/oauth-protected';
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
import { PPRRequestHeaders, type SiteRouteType, getPPRRequest, getPPRRouteType } from '@/lib/ppr';
import {
getPreviewCookieResponse,
getPreviewRequestIdentifier,
@@ -213,25 +214,40 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
//
request.headers.delete('x-gitbook-disable-tracking');
const withAPIToken = async (apiToken: string | null) => {
const siteURLData = await throwIfDataError(
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
type: getVisitorType(request),
},
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling resolvePublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
const pprRequest = await getPPRRequest(request.headers, GITBOOK_SECRET);
// Use the API token passed in the request, if any
// as it could be used for .preview hostnames
apiToken,
})
);
//
// Strip the PPR headers once consumed: nothing downstream reads them, and a client can send
// them itself.
//
for (const name of Object.values(PPRRequestHeaders)) {
request.headers.delete(name);
}
const withAPIToken = async (
apiToken: string | null,
resolvedPPRContent?: PublishedSiteContent
) => {
const siteURLData =
resolvedPPRContent ??
(await throwIfDataError(
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
type: getVisitorType(request),
},
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling resolvePublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
// Use the API token passed in the request, if any
// as it could be used for .preview hostnames
apiToken,
})
));
const cookies: ResponseCookies = visitorParamsCookie
? [
@@ -360,7 +376,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// The route is static, except when using dynamic parameters from query params
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
let routeType: SiteRouteType = 'static';
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
@@ -393,7 +409,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
};
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(
MiddlewareHeaders.SiteURL,
@@ -457,6 +472,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const {
pathname,
routeType: routeTypeFromPathname,
isPPRPage,
events,
isAiAgent,
} = encodePathInSiteContent(siteURLData, request);
@@ -493,6 +509,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
}
}
routeType = getPPRRouteType(routeType, isPPRPage, pprRequest);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
if (events && events.length > 0) {
waitUntil(
trackServerInsightsEvents({
@@ -522,6 +541,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
)
)
),
...(routeType === 'ppr' && pprRequest
? [
encodeURIComponent(pprRequest.content.revision),
encodeURIComponent(pprRequest.revalidationId),
encodeURIComponent(
rison.encode({
siteSection: pprRequest.defaults.siteSection ?? null,
siteSpace: pprRequest.defaults.siteSpace,
space: pprRequest.defaults.space,
})
),
]
: []),
pathname,
].join('/');
@@ -597,6 +629,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return writeResponseCookies(response, cookies);
};
if (pprRequest) {
return withAPIToken(null, pprRequest.content);
}
// For preview requests like:
// - https://<GITBOOK_PREVIEW_BASE_URL>/<siteID> requests (ex: https://sites.gitbook.com/preview/site_id/path)
if (isPreviewRequest(siteRequestURL)) {
@@ -776,6 +812,7 @@ function encodePathInSiteContent(
): {
pathname: string;
routeType?: 'static' | 'dynamic';
isPPRPage?: boolean;
events?: ServerInsightsEventInput[] | undefined;
/** Only set for markdown routes, where the output depends on the visitor being an agent. */
isAiAgent?: boolean;
@@ -938,7 +975,7 @@ function encodePathInSiteContent(
],
};
}
return { pathname: encodePagePath(pathname) };
return { pathname: encodePagePath(pathname), isPPRPage: true };
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"main": ".open-next/worker.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",