mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 16:15:22 +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": {
|
"scripts": {
|
||||||
"generate": "./scripts/generate.sh",
|
"generate": "./scripts/generate.sh",
|
||||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
"generate:assets": "bun ./scripts/generate-mermaid-runtime.ts && bun ./scripts/generate-scalar-runtime.ts",
|
||||||
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
|
"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",
|
||||||
"build": "next build --webpack",
|
"dev": "bun run generate:assets && env-cmd --silent -f ../../.env.local next --webpack",
|
||||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build --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",
|
"check:css-browser-compatibility": "bun scripts/check-css-browser-compatibility.ts",
|
||||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
"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: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: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",
|
"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": "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",
|
"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/",
|
"e2e-browserless": "bun test ./tests/",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ rm -rf ./.next
|
|||||||
rm -rf ./public/~gitbook/static/icons
|
rm -rf ./public/~gitbook/static/icons
|
||||||
rm -rf ./public/~gitbook/static/math
|
rm -rf ./public/~gitbook/static/math
|
||||||
rm -rf ./public/~gitbook/static/embed
|
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 errexit
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
|
# Generate assets that the server loads by URL instead of bundling.
|
||||||
|
bun run generate:assets
|
||||||
|
|
||||||
# Copy the assets
|
# Copy the assets
|
||||||
gitbook-icons ./public/~gitbook/static/icons custom-icons
|
gitbook-icons ./public/~gitbook/static/icons custom-icons
|
||||||
gitbook-math ./public/~gitbook/static/math
|
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,
|
SiteCustomizationSettings,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
|
|
||||||
|
import { getAssetURL } from '@/lib/assets';
|
||||||
import { getNodeFragmentByType } from '@/lib/document';
|
import { getNodeFragmentByType } from '@/lib/document';
|
||||||
|
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
@@ -15,6 +16,7 @@ import { CodeBlockRenderer } from './CodeBlockRenderer';
|
|||||||
import { MermaidCodeBlockLazy } from './MermaidCodeBlockLazy';
|
import { MermaidCodeBlockLazy } from './MermaidCodeBlockLazy';
|
||||||
import { highlight } from './highlight';
|
import { highlight } from './highlight';
|
||||||
import { type RenderedInline, getInlines } from './highlight-tokens';
|
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.
|
* Render a code block, can be client-side or server-side.
|
||||||
@@ -118,7 +120,10 @@ export async function CodeBlock(
|
|||||||
return (
|
return (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
{isMermaid ? (
|
{isMermaid ? (
|
||||||
<MermaidCodeBlockLazy {...clientProps} />
|
<MermaidCodeBlockLazy
|
||||||
|
{...clientProps}
|
||||||
|
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ClientCodeBlock {...clientProps} />
|
<ClientCodeBlock {...clientProps} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ const DIALOG_ANIMATION_MS = 200;
|
|||||||
/**
|
/**
|
||||||
* Used to render a Mermaid diagram from a CodeBlock.
|
* Used to render a Mermaid diagram from a CodeBlock.
|
||||||
*/
|
*/
|
||||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
export function MermaidCodeBlock(
|
||||||
const { block, mode, style } = props;
|
props: ClientBlockProps & {
|
||||||
|
mermaidRuntimeURL: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { block, mode, style, mermaidRuntimeURL } = props;
|
||||||
const source = getPlainCodeBlock(block);
|
const source = getPlainCodeBlock(block);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -80,6 +84,7 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
|||||||
source,
|
source,
|
||||||
id,
|
id,
|
||||||
darkMode,
|
darkMode,
|
||||||
|
mermaidRuntimeURL,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -116,7 +121,7 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
|||||||
cleanupPanZoom?.();
|
cleanupPanZoom?.();
|
||||||
setPanZoom(null);
|
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).
|
// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
|
||||||
usePreventScroll({ isDisabled: !isPresent });
|
usePreventScroll({ isDisabled: !isPresent });
|
||||||
@@ -286,9 +291,10 @@ async function renderMermaidDiagram(args: {
|
|||||||
source: string;
|
source: string;
|
||||||
id: string;
|
id: string;
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
|
mermaidRuntimeURL: string;
|
||||||
}): Promise<RenderResult> {
|
}): Promise<RenderResult> {
|
||||||
const { source, id, darkMode } = args;
|
const { source, id, darkMode, mermaidRuntimeURL } = args;
|
||||||
const { mermaid } = await loadMermaid();
|
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
|
||||||
|
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
@@ -338,13 +344,16 @@ let mermaidLoadPromise: Promise<{
|
|||||||
mermaid: typeof import('mermaid')['default'];
|
mermaid: typeof import('mermaid')['default'];
|
||||||
}> | null = null;
|
}> | null = null;
|
||||||
|
|
||||||
async function loadMermaid() {
|
async function loadMermaid(runtimeURL: string) {
|
||||||
if (!mermaidLoadPromise) {
|
if (!mermaidLoadPromise) {
|
||||||
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
|
mermaidLoadPromise = import(/* webpackIgnore: true */ runtimeURL)
|
||||||
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
|
.then(
|
||||||
await mermaid.registerExternalDiagrams([zenuml]);
|
async (runtime: {
|
||||||
return { mermaid };
|
loadMermaid: () => Promise<typeof import('mermaid')['default']>;
|
||||||
})
|
}) => {
|
||||||
|
return { mermaid: await runtime.loadMermaid() };
|
||||||
|
}
|
||||||
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
mermaidLoadPromise = null;
|
mermaidLoadPromise = null;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ const MermaidCodeBlock = dynamic(
|
|||||||
{ ssr: true }
|
{ ssr: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
export function MermaidCodeBlockLazy(props: ClientBlockProps) {
|
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
|
||||||
return <MermaidCodeBlock {...props} />;
|
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 './style.css';
|
||||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||||
|
import { getAssetURL } from '@/lib/assets';
|
||||||
import type { GitBookAnyContext } from '@/lib/context';
|
import type { GitBookAnyContext } from '@/lib/context';
|
||||||
import { GITBOOK_URL } from '@/lib/env';
|
import { GITBOOK_URL } from '@/lib/env';
|
||||||
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
||||||
@@ -17,6 +18,7 @@ import type {
|
|||||||
OpenAPISchemasBlock,
|
OpenAPISchemasBlock,
|
||||||
OpenAPIWebhookBlock,
|
OpenAPIWebhookBlock,
|
||||||
} from '@/lib/openapi/types';
|
} 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
|
// 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.
|
// 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',
|
expandAllResponses: expandAllResponses || props.context.mode === 'print',
|
||||||
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
|
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
|
||||||
headless,
|
headless,
|
||||||
|
scalarRuntimeURL: getAssetURL(SCALAR_RUNTIME_PATH),
|
||||||
id: block.meta?.id,
|
id: block.meta?.id,
|
||||||
blockKey: block.key,
|
blockKey: block.key,
|
||||||
locale,
|
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;
|
@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 {
|
.scalar-app-loading {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ApiClientModalProvider, useApiClientModal } from '@scalar/api-client-react';
|
import * as React from 'react';
|
||||||
import { Suspense, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
import { type ComponentType, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||||
|
|
||||||
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
||||||
import { useOpenAPIOperationContext } from './OpenAPIOperationContext';
|
import type {
|
||||||
import { useOpenAPIPrefillContext } from './OpenAPIPrefillContextProvider';
|
ScalarApiModalProps,
|
||||||
|
ScalarModalControllerRef,
|
||||||
|
ScalarRuntime,
|
||||||
|
} from './ScalarApiModal';
|
||||||
import type { OpenAPIClientContext } from './context';
|
import type { OpenAPIClientContext } from './context';
|
||||||
import { t } from './translate';
|
import { t } from './translate';
|
||||||
import type { OpenAPIOperationData } from './types';
|
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
|
* 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 { method, path, securities, servers, specUrl, withProxy, context } = props;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [client, setClient] = useState<ScalarClient | null>(null);
|
||||||
const controllerRef = useRef<ScalarModalControllerRef>(null);
|
const controllerRef = useRef<ScalarModalControllerRef>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scalar scalar-activate">
|
<div className="scalar scalar-activate">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="scalar-activate-button button"
|
className="scalar-activate-button button"
|
||||||
|
aria-busy={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
controllerRef.current?.openClient?.();
|
controllerRef.current?.openClient?.();
|
||||||
setIsOpen(true);
|
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')}
|
{t(context.translation, 'test_it')}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 12" fill="currentColor">
|
{isLoading ? (
|
||||||
<path
|
<svg
|
||||||
stroke="currentColor"
|
className="scalar-activate-spinner"
|
||||||
strokeWidth="1.5"
|
aria-hidden="true"
|
||||||
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"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
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>
|
</button>
|
||||||
|
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
|
client &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<Suspense fallback={null}>
|
<client.Modal
|
||||||
<ScalarModal
|
controllerRef={controllerRef}
|
||||||
controllerRef={controllerRef}
|
method={method}
|
||||||
withProxy={withProxy}
|
path={path}
|
||||||
proxyUrl={context.proxyUrl}
|
securities={securities}
|
||||||
method={method}
|
servers={servers}
|
||||||
path={path}
|
specUrl={specUrl}
|
||||||
securities={securities}
|
withProxy={withProxy}
|
||||||
servers={servers}
|
context={context}
|
||||||
specUrl={specUrl}
|
runtime={client.runtime}
|
||||||
/>
|
onReady={() => setIsLoading(false)}
|
||||||
</Suspense>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScalarModal(props: {
|
/** Fetch the modal chunk and the Scalar runtime in parallel. */
|
||||||
method: OpenAPIV3_1.HttpMethods;
|
async function loadScalarClient(runtimeURL: string): Promise<ScalarClient> {
|
||||||
path: string;
|
const [runtime, mod] = await Promise.all([
|
||||||
securities: OpenAPIOperationData['securities'];
|
loadScalarRuntime(runtimeURL),
|
||||||
servers: OpenAPIOperationData['servers'];
|
import('./ScalarApiModal'),
|
||||||
specUrl: string;
|
]);
|
||||||
withProxy: boolean;
|
|
||||||
proxyUrl?: string;
|
|
||||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
|
||||||
}) {
|
|
||||||
const { method, path, securities, servers, specUrl, withProxy, proxyUrl, controllerRef } =
|
|
||||||
props;
|
|
||||||
|
|
||||||
const getPrefillInputContextData = useOpenAPIPrefillContext();
|
return { runtime, Modal: mod.ScalarApiModal };
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toScalarHttpMethod<T extends OpenAPIV3_1.HttpMethods>(method: T): Uppercase<T> {
|
async function loadScalarRuntime(runtimeURL: string): Promise<ScalarRuntime> {
|
||||||
return method.toUpperCase() as Uppercase<T>;
|
if (!scalarRuntimePromise) {
|
||||||
}
|
Object.assign(globalThis, {
|
||||||
|
__gitbookScalarReact: React,
|
||||||
type ScalarModalControllerRef = {
|
__gitbookScalarJSXRuntime: ReactJSXRuntime,
|
||||||
openClient: (() => void) | undefined;
|
});
|
||||||
};
|
scalarRuntimePromise = import(/* webpackIgnore: true */ runtimeURL).catch((error) => {
|
||||||
|
scalarRuntimePromise = null;
|
||||||
function ScalarModalController(props: {
|
throw error;
|
||||||
method: OpenAPIV3_1.HttpMethods;
|
});
|
||||||
path: string;
|
}
|
||||||
controllerRef: React.Ref<ScalarModalControllerRef>;
|
|
||||||
}) {
|
return scalarRuntimePromise;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
proxyUrl?: string;
|
||||||
|
|
||||||
|
/** URL of the lazily loaded Scalar browser runtime. */
|
||||||
|
scalarRuntimeURL: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the context as a client context.
|
* Mark the context as a client context.
|
||||||
*/
|
*/
|
||||||
@@ -54,7 +57,7 @@ export interface OpenAPIClientContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAPIContext
|
export interface OpenAPIContext
|
||||||
extends Omit<OpenAPIClientContext, '$$isClientContext$$' | 'proxyUrl'> {
|
extends Omit<OpenAPIClientContext, '$$isClientContext$$' | 'proxyUrl' | 'scalarRuntimeURL'> {
|
||||||
/**
|
/**
|
||||||
* Render a code block.
|
* Render a code block.
|
||||||
*/
|
*/
|
||||||
@@ -90,6 +93,9 @@ export interface OpenAPIContext
|
|||||||
* Called at render time (server-side) with the server origins for an operation.
|
* Called at render time (server-side) with the server origins for an operation.
|
||||||
*/
|
*/
|
||||||
resolveProxyUrl?: (allowedOrigins: string[]) => string | null;
|
resolveProxyUrl?: (allowedOrigins: string[]) => string | null;
|
||||||
|
|
||||||
|
/** URL of the lazily loaded Scalar browser runtime. */
|
||||||
|
scalarRuntimeURL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenAPIUniversalContext = OpenAPIClientContext | OpenAPIContext;
|
export type OpenAPIUniversalContext = OpenAPIClientContext | OpenAPIContext;
|
||||||
@@ -125,6 +131,7 @@ export function getOpenAPIClientContext(context: OpenAPIUniversalContext): OpenA
|
|||||||
blockKey: context.blockKey,
|
blockKey: context.blockKey,
|
||||||
id: context.id,
|
id: context.id,
|
||||||
proxyUrl: '$$isClientContext$$' in context ? context.proxyUrl : undefined,
|
proxyUrl: '$$isClientContext$$' in context ? context.proxyUrl : undefined,
|
||||||
|
scalarRuntimeURL: context.scalarRuntimeURL,
|
||||||
$$isClientContext$$: true,
|
$$isClientContext$$: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user