Reduce bundle size on the server (#4464)

This commit is contained in:
conico974
2026-08-12 13:22:51 +02:00
committed by GitHub
parent 7594a334d8
commit 65f99eafe5
19 changed files with 751 additions and 117 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@gitbook/react-openapi": major
"gitbook": patch
---
Lazy load the Scalar API client modal and stop preloading the Scalar runtime. The modal is now code-split into its own chunk, fetched in parallel with the runtime only when a reader clicks "Test it", and a spinner is shown on the button until the client opens.
Breaking: the package no longer ships the modal in its main entry — consumers must serve the emitted `ScalarApiModal` chunk and use a bundler that supports dynamic `import()`, and the Scalar runtime is no longer preloaded on page load. The internal `preloadScalarRuntime` helper is removed.
+8 -6
View File
@@ -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"
},
+2
View File
@@ -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
View File
@@ -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;
+100 -97
View File
@@ -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>;
}
+8 -1
View File
@@ -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,
};
}