mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
Reduce bundle size on the server (#4464)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@gitbook/react-openapi": major
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Lazy load the Scalar API client modal and stop preloading the Scalar runtime. The modal is now code-split into its own chunk, fetched in parallel with the runtime only when a reader clicks "Test it", and a spinner is shown on the button until the client opens.
|
||||
|
||||
Breaking: the package no longer ships the modal in its main entry — consumers must serve the emitted `ScalarApiModal` chunk and use a bundler that supports dynamic `import()`, and the Scalar runtime is no longer preloaded on page load. The internal `preloadScalarRuntime` helper is removed.
|
||||
@@ -125,19 +125,21 @@
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "./scripts/generate.sh",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"generate:assets": "bun ./scripts/generate-mermaid-runtime.ts && bun ./scripts/generate-scalar-runtime.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",
|
||||
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"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",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
||||
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
|
||||
"build:cloudflare": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"unit": "bun run generate:assets && bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -7,3 +7,5 @@ rm -rf ./.next
|
||||
rm -rf ./public/~gitbook/static/icons
|
||||
rm -rf ./public/~gitbook/static/math
|
||||
rm -rf ./public/~gitbook/static/embed
|
||||
rm -rf ./public/~gitbook/static/mermaid
|
||||
rm -rf ./public/~gitbook/static/scalar
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'bun';
|
||||
|
||||
import { MERMAID_RUNTIME_PATH } from '../src/components/DocumentView/CodeBlock/mermaid-runtime-path';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const outputDir = join(scriptDir, '../public/~gitbook/static/mermaid');
|
||||
const temporaryDir = join(outputDir, '.build');
|
||||
const require = createRequire(import.meta.url);
|
||||
const mermaidPackage = require('mermaid/package.json') as { version: string };
|
||||
const zenumlPackage = require('@mermaid-js/mermaid-zenuml/package.json') as { version: string };
|
||||
|
||||
// The runtime URL is served as immutable, it has to change whenever the bundled versions change.
|
||||
if (
|
||||
MERMAID_RUNTIME_PATH !==
|
||||
`mermaid/mermaid@${mermaidPackage.version}-zenuml@${zenumlPackage.version}.mjs`
|
||||
) {
|
||||
throw new Error(
|
||||
'Update MERMAID_RUNTIME_PATH for the installed mermaid and @mermaid-js/mermaid-zenuml versions'
|
||||
);
|
||||
}
|
||||
|
||||
await rm(outputDir, { force: true, recursive: true });
|
||||
await mkdir(temporaryDir, { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entrypoints: [join(scriptDir, 'mermaid-runtime.ts')],
|
||||
format: 'esm',
|
||||
minify: true,
|
||||
outdir: temporaryDir,
|
||||
target: 'browser',
|
||||
});
|
||||
|
||||
const [output] = result.outputs;
|
||||
if (!result.success || !output || result.outputs.length !== 1) {
|
||||
throw new Error(`Unable to build Mermaid runtime: ${result.logs.join('\n')}`);
|
||||
}
|
||||
|
||||
const outputPath = join(outputDir, MERMAID_RUNTIME_PATH.replace('mermaid/', ''));
|
||||
await rename(output.path, outputPath);
|
||||
await rm(temporaryDir, { force: true, recursive: true });
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'bun';
|
||||
|
||||
import { SCALAR_RUNTIME_PATH } from '../src/components/DocumentView/OpenAPI/scalar-runtime-path';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const outputDir = join(scriptDir, '../public/~gitbook/static/scalar');
|
||||
const temporaryDir = join(outputDir, '.build');
|
||||
const scalarPackage = createRequire(import.meta.url)('@scalar/api-client-react/package.json') as {
|
||||
version: string;
|
||||
};
|
||||
|
||||
if (SCALAR_RUNTIME_PATH !== `scalar/scalar-api-client@${scalarPackage.version}.mjs`) {
|
||||
throw new Error(
|
||||
'Update SCALAR_RUNTIME_PATH for the installed @scalar/api-client-react version'
|
||||
);
|
||||
}
|
||||
|
||||
const reactShim = `
|
||||
const react = () => globalThis.__gitbookScalarReact;
|
||||
export const createContext = (...args) => react().createContext(...args);
|
||||
export const useContext = (...args) => react().useContext(...args);
|
||||
export const useEffect = (...args) => react().useEffect(...args);
|
||||
export const useRef = (...args) => react().useRef(...args);
|
||||
export const useSyncExternalStore = (...args) => react().useSyncExternalStore(...args);
|
||||
`;
|
||||
|
||||
const jsxRuntimeShim = `
|
||||
const runtime = () => globalThis.__gitbookScalarJSXRuntime;
|
||||
export const jsx = (...args) => runtime().jsx(...args);
|
||||
export const jsxs = (...args) => runtime().jsxs(...args);
|
||||
`;
|
||||
|
||||
await rm(outputDir, { force: true, recursive: true });
|
||||
await mkdir(temporaryDir, { recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entrypoints: [join(scriptDir, 'scalar-runtime.ts')],
|
||||
format: 'esm',
|
||||
minify: true,
|
||||
outdir: temporaryDir,
|
||||
plugins: [
|
||||
{
|
||||
name: 'scalar-react-shims',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react$/ }, () => ({
|
||||
namespace: 'scalar-runtime',
|
||||
path: 'react',
|
||||
}));
|
||||
build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({
|
||||
namespace: 'scalar-runtime',
|
||||
path: 'react-jsx-runtime',
|
||||
}));
|
||||
build.onLoad({ filter: /^react$/, namespace: 'scalar-runtime' }, () => ({
|
||||
contents: reactShim,
|
||||
loader: 'js',
|
||||
}));
|
||||
build.onLoad(
|
||||
{ filter: /^react-jsx-runtime$/, namespace: 'scalar-runtime' },
|
||||
() => ({
|
||||
contents: jsxRuntimeShim,
|
||||
loader: 'js',
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
target: 'browser',
|
||||
});
|
||||
|
||||
const [output] = result.outputs;
|
||||
if (!result.success || !output || result.outputs.length !== 1) {
|
||||
throw new Error(`Unable to build Scalar runtime: ${result.logs.join('\n')}`);
|
||||
}
|
||||
|
||||
const outputPath = join(outputDir, SCALAR_RUNTIME_PATH.replace('scalar/', ''));
|
||||
await rename(output.path, outputPath);
|
||||
await rm(temporaryDir, { force: true, recursive: true });
|
||||
@@ -3,6 +3,9 @@
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Generate assets that the server loads by URL instead of bundling.
|
||||
bun run generate:assets
|
||||
|
||||
# Copy the assets
|
||||
gitbook-icons ./public/~gitbook/static/icons custom-icons
|
||||
gitbook-math ./public/~gitbook/static/math
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import zenuml from '@mermaid-js/mermaid-zenuml';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
let registration: Promise<void> | null = null;
|
||||
|
||||
export async function loadMermaid() {
|
||||
if (!registration) {
|
||||
registration = mermaid.registerExternalDiagrams([zenuml]).catch((error) => {
|
||||
registration = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
await registration;
|
||||
return mermaid;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { createConnection } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { file, sleep, spawn } from 'bun';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
type HeapUsage = {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
embedderHeapUsedSize: number;
|
||||
backingStorageSize: number;
|
||||
};
|
||||
|
||||
type DevWorker = {
|
||||
command: string[];
|
||||
process: WorkerProcess;
|
||||
};
|
||||
|
||||
type WorkerProcess = {
|
||||
exitCode: number | null;
|
||||
exited: Promise<number>;
|
||||
stdin: { write(data: string): unknown };
|
||||
kill(): void;
|
||||
};
|
||||
|
||||
const appPath = `${import.meta.dir}/..`;
|
||||
const requestURL = process.env.PROFILE_URL ?? 'http://127.0.0.1:8771/url/gitbook.com/docs';
|
||||
const requestCount = Number.parseInt(process.env.PROFILE_REQUESTS ?? '20', 10);
|
||||
const forceGarbageCollection = process.env.PROFILE_FORCE_GC === 'true';
|
||||
const settleMs = Number.parseInt(process.env.PROFILE_SETTLE_MS ?? '5000', 10);
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(requestCount) ||
|
||||
requestCount < 1 ||
|
||||
!Number.isSafeInteger(settleMs) ||
|
||||
settleMs < 0
|
||||
) {
|
||||
throw new Error('PROFILE_REQUESTS and PROFILE_SETTLE_MS must be positive integers');
|
||||
}
|
||||
|
||||
const workers: DevWorker[] = [];
|
||||
|
||||
try {
|
||||
const server = await startWorker(['bun', 'run', 'dev:cf:server'], 8772);
|
||||
workers.push(server);
|
||||
const middleware = await startWorker(['bun', 'run', 'dev:cf:middleware'], 8771);
|
||||
workers.push(middleware);
|
||||
|
||||
const coldResponse = await requestUntilReady(requestURL);
|
||||
await maybeCollectGarbage();
|
||||
|
||||
const cold = await getMeasurements();
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: requestCount }, () => request(requestURL))
|
||||
);
|
||||
|
||||
await sleep(settleMs);
|
||||
await maybeCollectGarbage();
|
||||
|
||||
const afterLoad = await getMeasurements();
|
||||
const bundle = await getBundleMetrics();
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: JSON on stdout is this script's public interface.
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
requestURL,
|
||||
requestCount,
|
||||
forceGarbageCollection,
|
||||
settleMs,
|
||||
coldResponse,
|
||||
responses: summarizeResponses(responses),
|
||||
heap: { cold, afterLoad },
|
||||
bundle,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
for (const worker of workers.reverse()) {
|
||||
await stopWorker(worker.process);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWorker(command: string[], port: number): Promise<DevWorker> {
|
||||
const process = spawn(command, {
|
||||
cwd: appPath,
|
||||
stdin: 'pipe',
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(port, process);
|
||||
return { command, process };
|
||||
} catch (error) {
|
||||
process.kill();
|
||||
await process.exited;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWorker(process: WorkerProcess) {
|
||||
process.stdin.write('x\n');
|
||||
await Promise.race([process.exited, sleep(5_000)]);
|
||||
|
||||
if (process.exitCode === null) {
|
||||
process.kill();
|
||||
await process.exited;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPort(port: number, process: WorkerProcess) {
|
||||
const timeout = Date.now() + 60_000;
|
||||
|
||||
while (Date.now() < timeout) {
|
||||
if (process.exitCode !== null) {
|
||||
throw new Error(`Worker exited before becoming ready: ${process.exitCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await connectToPort(port);
|
||||
return;
|
||||
} catch {
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Worker did not become ready within 60 seconds on port ${port}`);
|
||||
}
|
||||
|
||||
async function connectToPort(port: number) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = createConnection({ host: '127.0.0.1', port });
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', (error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function request(url: string) {
|
||||
const startedAt = performance.now();
|
||||
const response = await fetch(url);
|
||||
const body = await response.arrayBuffer();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
bytes: body.byteLength,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestUntilReady(url: string) {
|
||||
let response: Awaited<ReturnType<typeof request>> | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
response = await request(url);
|
||||
if (response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error(`Worker did not return a successful response: ${response?.status}`);
|
||||
}
|
||||
|
||||
function summarizeResponses(responses: Awaited<ReturnType<typeof request>>[]) {
|
||||
return {
|
||||
statuses: Object.fromEntries(
|
||||
Object.entries(Object.groupBy(responses, ({ status }) => status)).map(
|
||||
([status, groupedResponses]) => [status, groupedResponses?.length ?? 0]
|
||||
)
|
||||
),
|
||||
bytes: responses.reduce((total, { bytes }) => total + bytes, 0),
|
||||
maxDurationMs: Math.max(...responses.map(({ durationMs }) => durationMs)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMeasurements() {
|
||||
return {
|
||||
server: await sendDevtoolsCommand<HeapUsage>(
|
||||
'ws://127.0.0.1:9229/ws',
|
||||
'Runtime.getHeapUsage'
|
||||
),
|
||||
middleware: await sendDevtoolsCommand<HeapUsage>(
|
||||
'ws://127.0.0.1:9230/ws',
|
||||
'Runtime.getHeapUsage'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeCollectGarbage() {
|
||||
if (!forceGarbageCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
collectGarbage('ws://127.0.0.1:9229/ws'),
|
||||
collectGarbage('ws://127.0.0.1:9230/ws'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function collectGarbage(url: string) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const websocket = new WebSocket(url);
|
||||
const timeout = setTimeout(() => {
|
||||
websocket.terminate();
|
||||
reject(new Error('Timed out waiting for HeapProfiler.takeHeapSnapshot'));
|
||||
}, 60_000);
|
||||
|
||||
websocket.on('open', () => {
|
||||
websocket.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: 'HeapProfiler.takeHeapSnapshot',
|
||||
params: { reportProgress: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
websocket.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.id !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
websocket.terminate();
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
websocket.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Unable to connect to DevTools: ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sendDevtoolsCommand<Result>(url: string, method: string): Promise<Result> {
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
const websocket = new WebSocket(url);
|
||||
const timeout = setTimeout(() => {
|
||||
websocket.terminate();
|
||||
reject(new Error(`Timed out waiting for ${method}`));
|
||||
}, 10_000);
|
||||
|
||||
websocket.on('open', () => {
|
||||
websocket.send(JSON.stringify({ id: 1, method }));
|
||||
});
|
||||
websocket.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.id !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
websocket.terminate();
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message));
|
||||
return;
|
||||
}
|
||||
resolve(message.result as Result);
|
||||
});
|
||||
websocket.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Unable to connect to DevTools: ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getBundleMetrics() {
|
||||
const handlerPath = join(
|
||||
appPath,
|
||||
'.open-next/server-functions/default/packages/gitbook/handler.mjs'
|
||||
);
|
||||
const metafilePath = `${handlerPath}.meta.json`;
|
||||
const metafile = (await file(metafilePath).json()) as {
|
||||
outputs: Record<string, { inputs: Record<string, { bytesInOutput: number }> }>;
|
||||
};
|
||||
const [outputPath] = Object.keys(metafile.outputs);
|
||||
if (!outputPath) {
|
||||
throw new Error(`No output found in metafile: ${metafilePath}`);
|
||||
}
|
||||
const output = metafile.outputs[outputPath];
|
||||
if (!output) {
|
||||
throw new Error(`Missing output in metafile: ${outputPath}`);
|
||||
}
|
||||
const shikiBytes = Object.entries(output.inputs).reduce(
|
||||
(total, [path, input]) =>
|
||||
path.includes('@shikijs/langs') ? total + input.bytesInOutput : total,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
handlerBytes: (await stat(handlerPath)).size,
|
||||
shikiLanguageBytes: shikiBytes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ApiClientModalProvider, useApiClientModal } from '@scalar/api-client-react';
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { getNodeFragmentByType } from '@/lib/document';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
@@ -15,6 +16,7 @@ import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { MermaidCodeBlockLazy } from './MermaidCodeBlockLazy';
|
||||
import { highlight } from './highlight';
|
||||
import { type RenderedInline, getInlines } from './highlight-tokens';
|
||||
import { MERMAID_RUNTIME_PATH } from './mermaid-runtime-path';
|
||||
|
||||
/**
|
||||
* Render a code block, can be client-side or server-side.
|
||||
@@ -118,7 +120,10 @@ export async function CodeBlock(
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
{isMermaid ? (
|
||||
<MermaidCodeBlockLazy {...clientProps} />
|
||||
<MermaidCodeBlockLazy
|
||||
{...clientProps}
|
||||
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
|
||||
/>
|
||||
) : (
|
||||
<ClientCodeBlock {...clientProps} />
|
||||
)}
|
||||
|
||||
@@ -20,8 +20,12 @@ const DIALOG_ANIMATION_MS = 200;
|
||||
/**
|
||||
* Used to render a Mermaid diagram from a CodeBlock.
|
||||
*/
|
||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { block, mode, style } = props;
|
||||
export function MermaidCodeBlock(
|
||||
props: ClientBlockProps & {
|
||||
mermaidRuntimeURL: string;
|
||||
}
|
||||
) {
|
||||
const { block, mode, style, mermaidRuntimeURL } = props;
|
||||
const source = getPlainCodeBlock(block);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -80,6 +84,7 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
mermaidRuntimeURL,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
@@ -116,7 +121,7 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
cleanupPanZoom?.();
|
||||
setPanZoom(null);
|
||||
};
|
||||
}, [source, id, darkMode, shouldRender]);
|
||||
}, [source, id, darkMode, mermaidRuntimeURL, shouldRender]);
|
||||
|
||||
// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
|
||||
usePreventScroll({ isDisabled: !isPresent });
|
||||
@@ -286,9 +291,10 @@ async function renderMermaidDiagram(args: {
|
||||
source: string;
|
||||
id: string;
|
||||
darkMode: boolean;
|
||||
mermaidRuntimeURL: string;
|
||||
}): Promise<RenderResult> {
|
||||
const { source, id, darkMode } = args;
|
||||
const { mermaid } = await loadMermaid();
|
||||
const { source, id, darkMode, mermaidRuntimeURL } = args;
|
||||
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -338,13 +344,16 @@ let mermaidLoadPromise: Promise<{
|
||||
mermaid: typeof import('mermaid')['default'];
|
||||
}> | null = null;
|
||||
|
||||
async function loadMermaid() {
|
||||
async function loadMermaid(runtimeURL: string) {
|
||||
if (!mermaidLoadPromise) {
|
||||
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
|
||||
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
return { mermaid };
|
||||
})
|
||||
mermaidLoadPromise = import(/* webpackIgnore: true */ runtimeURL)
|
||||
.then(
|
||||
async (runtime: {
|
||||
loadMermaid: () => Promise<typeof import('mermaid')['default']>;
|
||||
}) => {
|
||||
return { mermaid: await runtime.loadMermaid() };
|
||||
}
|
||||
)
|
||||
.catch((error) => {
|
||||
mermaidLoadPromise = null;
|
||||
throw error;
|
||||
|
||||
@@ -10,6 +10,6 @@ const MermaidCodeBlock = dynamic(
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function MermaidCodeBlockLazy(props: ClientBlockProps) {
|
||||
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
|
||||
return <MermaidCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MERMAID_RUNTIME_PATH = 'mermaid/mermaid@11.14.0-zenuml@0.2.2.mjs';
|
||||
@@ -9,6 +9,7 @@ import { Heading } from '../Heading';
|
||||
|
||||
import './style.css';
|
||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import { GITBOOK_URL } from '@/lib/env';
|
||||
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
OpenAPISchemasBlock,
|
||||
OpenAPIWebhookBlock,
|
||||
} from '@/lib/openapi/types';
|
||||
import { SCALAR_RUNTIME_PATH } from './scalar-runtime-path';
|
||||
|
||||
// Serve the proxy from GitBook's own origin rather than the customer domain, so a proxied
|
||||
// response can never execute as HTML under a customer's trusted origin.
|
||||
@@ -113,6 +115,7 @@ export function getOpenAPIContext(args: {
|
||||
expandAllResponses: expandAllResponses || props.context.mode === 'print',
|
||||
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
|
||||
headless,
|
||||
scalarRuntimeURL: getAssetURL(SCALAR_RUNTIME_PATH),
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
locale,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SCALAR_RUNTIME_PATH = 'scalar/scalar-api-client@1.3.46.mjs';
|
||||
@@ -284,6 +284,11 @@ body {
|
||||
@apply size-2.5;
|
||||
}
|
||||
|
||||
/* Same size as the play icon it replaces, so the button doesn't shift while loading. */
|
||||
.scalar-activate-button .scalar-activate-spinner {
|
||||
@apply animate-spin motion-reduce:animate-none;
|
||||
}
|
||||
|
||||
.scalar-app-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { ApiClientModalProvider, useApiClientModal } from '@scalar/api-client-react';
|
||||
import { Suspense, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { type ComponentType, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
|
||||
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
||||
import { useOpenAPIOperationContext } from './OpenAPIOperationContext';
|
||||
import { useOpenAPIPrefillContext } from './OpenAPIPrefillContextProvider';
|
||||
import type {
|
||||
ScalarApiModalProps,
|
||||
ScalarModalControllerRef,
|
||||
ScalarRuntime,
|
||||
} from './ScalarApiModal';
|
||||
import type { OpenAPIClientContext } from './context';
|
||||
import { t } from './translate';
|
||||
import type { OpenAPIOperationData } from './types';
|
||||
import { resolveTryItPrefillForOperation } from './util/tryit-prefill';
|
||||
|
||||
let scalarRuntimePromise: Promise<ScalarRuntime> | null = null;
|
||||
|
||||
/** Everything needed to render the client, none of it in the initial bundle. */
|
||||
type ScalarClient = {
|
||||
runtime: ScalarRuntime;
|
||||
Modal: ComponentType<ScalarApiModalProps>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Button which launches the Scalar API Client
|
||||
@@ -26,121 +37,113 @@ export function ScalarApiButton(props: {
|
||||
}) {
|
||||
const { method, path, securities, servers, specUrl, withProxy, context } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [client, setClient] = useState<ScalarClient | null>(null);
|
||||
const controllerRef = useRef<ScalarModalControllerRef>(null);
|
||||
|
||||
return (
|
||||
<div className="scalar scalar-activate">
|
||||
<button
|
||||
type="button"
|
||||
className="scalar-activate-button button"
|
||||
aria-busy={isLoading}
|
||||
onClick={() => {
|
||||
controllerRef.current?.openClient?.();
|
||||
setIsOpen(true);
|
||||
if (!client) {
|
||||
setIsLoading(true);
|
||||
loadScalarClient(context.scalarRuntimeURL)
|
||||
.then(setClient)
|
||||
.catch((error) => {
|
||||
console.error('Unable to load the Scalar API client', error);
|
||||
setIsOpen(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(context.translation, 'test_it')}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 12" fill="currentColor">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
d="M1 10.05V1.43c0-.2.2-.31.37-.22l7.26 4.08c.17.1.17.33.01.43l-7.26 4.54a.25.25 0 0 1-.38-.21Z"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="scalar-activate-spinner"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.3"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 10 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
d="M1 10.05V1.43c0-.2.2-.31.37-.22l7.26 4.08c.17.1.17.33.01.43l-7.26 4.54a.25.25 0 0 1-.38-.21Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
client &&
|
||||
createPortal(
|
||||
<Suspense fallback={null}>
|
||||
<ScalarModal
|
||||
controllerRef={controllerRef}
|
||||
withProxy={withProxy}
|
||||
proxyUrl={context.proxyUrl}
|
||||
method={method}
|
||||
path={path}
|
||||
securities={securities}
|
||||
servers={servers}
|
||||
specUrl={specUrl}
|
||||
/>
|
||||
</Suspense>,
|
||||
<client.Modal
|
||||
controllerRef={controllerRef}
|
||||
method={method}
|
||||
path={path}
|
||||
securities={securities}
|
||||
servers={servers}
|
||||
specUrl={specUrl}
|
||||
withProxy={withProxy}
|
||||
context={context}
|
||||
runtime={client.runtime}
|
||||
onReady={() => setIsLoading(false)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScalarModal(props: {
|
||||
method: OpenAPIV3_1.HttpMethods;
|
||||
path: string;
|
||||
securities: OpenAPIOperationData['securities'];
|
||||
servers: OpenAPIOperationData['servers'];
|
||||
specUrl: string;
|
||||
withProxy: boolean;
|
||||
proxyUrl?: string;
|
||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
||||
}) {
|
||||
const { method, path, securities, servers, specUrl, withProxy, proxyUrl, controllerRef } =
|
||||
props;
|
||||
/** Fetch the modal chunk and the Scalar runtime in parallel. */
|
||||
async function loadScalarClient(runtimeURL: string): Promise<ScalarClient> {
|
||||
const [runtime, mod] = await Promise.all([
|
||||
loadScalarRuntime(runtimeURL),
|
||||
import('./ScalarApiModal'),
|
||||
]);
|
||||
|
||||
const getPrefillInputContextData = useOpenAPIPrefillContext();
|
||||
const prefillInputContext = getPrefillInputContextData();
|
||||
|
||||
const prefillConfig = resolveTryItPrefillForOperation({
|
||||
operation: { securities, servers },
|
||||
prefillInputContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<ApiClientModalProvider
|
||||
configuration={{
|
||||
url: specUrl,
|
||||
...prefillConfig,
|
||||
proxyUrl: withProxy ? proxyUrl : undefined,
|
||||
}}
|
||||
initialRequest={{ method: toScalarHttpMethod(method), path }}
|
||||
>
|
||||
<ScalarModalController method={method} path={path} controllerRef={controllerRef} />
|
||||
</ApiClientModalProvider>
|
||||
);
|
||||
return { runtime, Modal: mod.ScalarApiModal };
|
||||
}
|
||||
|
||||
function toScalarHttpMethod<T extends OpenAPIV3_1.HttpMethods>(method: T): Uppercase<T> {
|
||||
return method.toUpperCase() as Uppercase<T>;
|
||||
}
|
||||
|
||||
type ScalarModalControllerRef = {
|
||||
openClient: (() => void) | undefined;
|
||||
};
|
||||
|
||||
function ScalarModalController(props: {
|
||||
method: OpenAPIV3_1.HttpMethods;
|
||||
path: string;
|
||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
||||
}) {
|
||||
const { method, path, controllerRef } = props;
|
||||
const client = useApiClientModal();
|
||||
const openScalarClient = client?.open;
|
||||
const { onOpenClient: trackClientOpening } = useOpenAPIOperationContext();
|
||||
const openClient = useMemo(() => {
|
||||
if (openScalarClient) {
|
||||
return () => {
|
||||
openScalarClient({
|
||||
method: toScalarHttpMethod(method),
|
||||
path,
|
||||
_source: 'gitbook',
|
||||
});
|
||||
trackClientOpening({ method, path });
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [openScalarClient, method, path, trackClientOpening]);
|
||||
useImperativeHandle(
|
||||
controllerRef,
|
||||
() => ({ openClient: openClient ? () => openClient() : undefined }),
|
||||
[openClient]
|
||||
);
|
||||
|
||||
// Open at mount
|
||||
useEffect(() => {
|
||||
openClient?.();
|
||||
}, [openClient]);
|
||||
return null;
|
||||
async function loadScalarRuntime(runtimeURL: string): Promise<ScalarRuntime> {
|
||||
if (!scalarRuntimePromise) {
|
||||
Object.assign(globalThis, {
|
||||
__gitbookScalarReact: React,
|
||||
__gitbookScalarJSXRuntime: ReactJSXRuntime,
|
||||
});
|
||||
scalarRuntimePromise = import(/* webpackIgnore: true */ runtimeURL).catch((error) => {
|
||||
scalarRuntimePromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return scalarRuntimePromise;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
||||
import { useOpenAPIOperationContext } from './OpenAPIOperationContext';
|
||||
import { useOpenAPIPrefillContext } from './OpenAPIPrefillContextProvider';
|
||||
import type { OpenAPIClientContext } from './context';
|
||||
import type { OpenAPIOperationData } from './types';
|
||||
import { resolveTryItPrefillForOperation } from './util/tryit-prefill';
|
||||
|
||||
export type ScalarModalControllerRef = {
|
||||
openClient: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export type ScalarRuntime = {
|
||||
ApiClientModalProvider: ComponentType<{
|
||||
configuration: object;
|
||||
initialRequest: { method: string; path: string };
|
||||
children: ReactNode;
|
||||
}>;
|
||||
useApiClientModal: () => {
|
||||
open?: (request: { method: string; path: string; _source?: string }) => void;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ScalarApiModalProps = {
|
||||
method: OpenAPIV3_1.HttpMethods;
|
||||
path: string;
|
||||
securities: OpenAPIOperationData['securities'];
|
||||
servers: OpenAPIOperationData['servers'];
|
||||
specUrl: string;
|
||||
withProxy: boolean;
|
||||
context: OpenAPIClientContext;
|
||||
runtime: ScalarRuntime;
|
||||
controllerRef: Ref<ScalarModalControllerRef>;
|
||||
/** Called once the client is initialized and the modal is opening. */
|
||||
onReady: () => void;
|
||||
};
|
||||
|
||||
/** Loaded only after a reader opens the Try it client. */
|
||||
export function ScalarApiModal(props: ScalarApiModalProps) {
|
||||
const {
|
||||
method,
|
||||
path,
|
||||
securities,
|
||||
servers,
|
||||
specUrl,
|
||||
withProxy,
|
||||
context,
|
||||
controllerRef,
|
||||
runtime,
|
||||
onReady,
|
||||
} = props;
|
||||
const getPrefillInputContextData = useOpenAPIPrefillContext();
|
||||
const prefillInputContext = getPrefillInputContextData();
|
||||
|
||||
const prefillConfig = resolveTryItPrefillForOperation({
|
||||
operation: { securities, servers },
|
||||
prefillInputContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<runtime.ApiClientModalProvider
|
||||
configuration={{
|
||||
url: specUrl,
|
||||
...prefillConfig,
|
||||
proxyUrl: withProxy ? context.proxyUrl : undefined,
|
||||
}}
|
||||
initialRequest={{ method: toScalarHttpMethod(method), path }}
|
||||
>
|
||||
<ScalarModalController
|
||||
method={method}
|
||||
path={path}
|
||||
controllerRef={controllerRef}
|
||||
runtime={runtime}
|
||||
onReady={onReady}
|
||||
/>
|
||||
</runtime.ApiClientModalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScalarModalController(props: {
|
||||
method: OpenAPIV3_1.HttpMethods;
|
||||
path: string;
|
||||
controllerRef: Ref<ScalarModalControllerRef>;
|
||||
runtime: ScalarRuntime;
|
||||
onReady: () => void;
|
||||
}) {
|
||||
const { method, path, controllerRef, runtime, onReady } = props;
|
||||
const client = runtime.useApiClientModal();
|
||||
const openScalarClient = client?.open;
|
||||
const { onOpenClient: trackClientOpening } = useOpenAPIOperationContext();
|
||||
const openClient = useMemo(() => {
|
||||
if (openScalarClient) {
|
||||
return () => {
|
||||
openScalarClient({
|
||||
method: toScalarHttpMethod(method),
|
||||
path,
|
||||
_source: 'gitbook',
|
||||
});
|
||||
trackClientOpening({ method, path });
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [openScalarClient, method, path, trackClientOpening]);
|
||||
useImperativeHandle(
|
||||
controllerRef,
|
||||
() => ({ openClient: openClient ? () => openClient() : undefined }),
|
||||
[openClient]
|
||||
);
|
||||
|
||||
// Through a ref, so an unstable callback doesn't re-open the client on every render.
|
||||
const onReadyRef = useRef(onReady);
|
||||
onReadyRef.current = onReady;
|
||||
|
||||
useEffect(() => {
|
||||
if (openClient) {
|
||||
openClient();
|
||||
onReadyRef.current();
|
||||
}
|
||||
}, [openClient]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toScalarHttpMethod<T extends OpenAPIV3_1.HttpMethods>(method: T): Uppercase<T> {
|
||||
return method.toUpperCase() as Uppercase<T>;
|
||||
}
|
||||
@@ -47,6 +47,9 @@ export interface OpenAPIClientContext {
|
||||
*/
|
||||
proxyUrl?: string;
|
||||
|
||||
/** URL of the lazily loaded Scalar browser runtime. */
|
||||
scalarRuntimeURL: string;
|
||||
|
||||
/**
|
||||
* Mark the context as a client context.
|
||||
*/
|
||||
@@ -54,7 +57,7 @@ export interface OpenAPIClientContext {
|
||||
}
|
||||
|
||||
export interface OpenAPIContext
|
||||
extends Omit<OpenAPIClientContext, '$$isClientContext$$' | 'proxyUrl'> {
|
||||
extends Omit<OpenAPIClientContext, '$$isClientContext$$' | 'proxyUrl' | 'scalarRuntimeURL'> {
|
||||
/**
|
||||
* Render a code block.
|
||||
*/
|
||||
@@ -90,6 +93,9 @@ export interface OpenAPIContext
|
||||
* Called at render time (server-side) with the server origins for an operation.
|
||||
*/
|
||||
resolveProxyUrl?: (allowedOrigins: string[]) => string | null;
|
||||
|
||||
/** URL of the lazily loaded Scalar browser runtime. */
|
||||
scalarRuntimeURL: string;
|
||||
}
|
||||
|
||||
export type OpenAPIUniversalContext = OpenAPIClientContext | OpenAPIContext;
|
||||
@@ -125,6 +131,7 @@ export function getOpenAPIClientContext(context: OpenAPIUniversalContext): OpenA
|
||||
blockKey: context.blockKey,
|
||||
id: context.id,
|
||||
proxyUrl: '$$isClientContext$$' in context ? context.proxyUrl : undefined,
|
||||
scalarRuntimeURL: context.scalarRuntimeURL,
|
||||
$$isClientContext$$: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user