Add PPR development proxy and update documentation for local testing

This commit is contained in:
Nicolas Dorseuil
2026-08-12 10:16:22 +02:00
parent 825e245703
commit bd608e6beb
4 changed files with 290 additions and 11 deletions
+13
View File
@@ -24,6 +24,19 @@ 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 and injects those headers:
```
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
```
+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",
+256
View File
@@ -0,0 +1,256 @@
/**
* 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 } 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}`);
}
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;
}
}
function setPPRHeaders(
headers: Headers,
content: PublishedSiteContent & { revision: string },
defaults: { siteSection: string | undefined; siteSpace: string; space: 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, 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);
}
/**
* `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';
let response: Response;
try {
response = await fetch(new URL(url.pathname + url.search, UPSTREAM), {
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)}`);
}
setPPRHeaders(headers, { ...content, revision }, defaults);
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`);
+20 -11
View File
@@ -181,20 +181,29 @@ export function getPPRHeaderRouteParams(params: PPRRouteLayoutParams): RouteLayo
return {
...routeParams,
siteData: encodeURIComponent(
rison.encode({
...siteURLData,
// For the header, we don't want to vary the cache by page path, so we set it to the root.
pathname: '/',
// 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,
})
),
siteData: encodeSiteData({
...siteURLData,
// For the header, we don't want to vary the cache by page path, so we set it to the root.
pathname: '/',
// 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,
}),
};
}
/** 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.
* For the table of contents, we don't want to vary the cache by page path, so we set it to the root.