Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
"dev": "vite dev",
"dev:port": "vite dev --port",
"prebuild": "bun run generate:changelog && bun run scripts/copy-docs-images.cjs",
"build": "NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-cache.ts && bun run scripts/generate-search-index.ts",
"build": "NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-assets.ts",
"build:cf": "bun run build",
"measure:build": "bun run scripts/measure-build.ts",
"build:cf:staging": "NODE_OPTIONS=--max-old-space-size=8192 CLOUDFLARE_ENV=staging bun run build",
"sync:mixedbread": "mxbai vs sync $MIXEDBREAD_STORE_ID './content/docs' --ci",
"preview": "vite preview",
Expand Down
133 changes: 133 additions & 0 deletions scripts/generate-static-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Post-build: generates static cache JSON files + search index.
*
* Replaces the previous two-step pipeline (`generate-static-cache.ts` +
* `generate-search-index.ts`), which each spun up a fresh Vite SSR server
* and re-evaluated the same fumadocs MDX collection. We do that work once
* here and run the cache-file emission in parallel with the search-index
* build, saving the cost of one full Vite cold-start (~5-7s locally,
* ~15-20s on Cloudflare's slower builders).
*
* Usage: bun run scripts/generate-static-assets.ts
* Called automatically as part of `bun run build`.
*/

import { createServer } from "vite";
import fs from "node:fs/promises";
import path from "node:path";
import react from "@vitejs/plugin-react";
import tsConfigPaths from "vite-tsconfig-paths";
import mdx from "fumadocs-mdx/vite";

const ROOT = process.cwd();
const DIST_CLIENT = path.join(ROOT, "dist/client");
const CACHE_DIR = path.join(DIST_CLIENT, "docs/__sfn_cache");
const SEARCH_DIR = path.join(DIST_CLIENT, "docs");

async function generateStaticCache(server: Awaited<ReturnType<typeof createServer>>) {
const start = performance.now();
const { source } = await server.ssrLoadModule("./src/lib/source");
const { gitConfig } = await server.ssrLoadModule("./src/lib/layout.shared.tsx");
const { buildCanonicalUrl, SITE_NAME, DEFAULT_DESCRIPTION } = await server.ssrLoadModule(
"./src/lib/metadata",
);

const pageTree = await source.serializePageTree(source.getPageTree());
const pages = source.getPages() as Array<{
slugs: string[];
url: string;
path: string;
data: { title?: string; description?: string };
}>;

await fs.mkdir(CACHE_DIR, { recursive: true });

let count = 0;
await Promise.all(
pages.map(async (page) => {
const cacheKey = page.slugs.length === 0 ? "_index" : page.slugs.join("/");
const pageTitle = page.data.title || SITE_NAME;
const pageDescription = page.data.description || DEFAULT_DESCRIPTION;
const canonicalUrl = buildCanonicalUrl(page.url);
const githubUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`;

const payload = {
url: page.url,
path: page.path,
githubUrl,
pageTitle,
pageDescription,
canonicalUrl,
pageTree,
};

const filePath = path.join(CACHE_DIR, `${cacheKey}.json`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(payload));
count++;
}),
);

const ms = performance.now() - start;
console.log(
` ✓ ${count} cache files written to ${path.relative(ROOT, CACHE_DIR)}/ (${ms.toFixed(0)}ms)`,
);
}

async function generateSearchIndex(server: Awaited<ReturnType<typeof createServer>>) {
const start = performance.now();
const { buildDocsSearchDocuments } = await server.ssrLoadModule("./src/lib/search");
const { SEARCH_INDEX_FILENAME } = await server.ssrLoadModule("./src/lib/search.shared");
const { buildStaticSearchIndex, exportStaticSearchIndex } = await server.ssrLoadModule(
"./src/lib/search-index",
);

const outputPath = path.join(SEARCH_DIR, SEARCH_INDEX_FILENAME);
const documents = await buildDocsSearchDocuments();

await fs.rm(path.join(SEARCH_DIR, "assets", "search"), { force: true, recursive: true });
await fs.mkdir(SEARCH_DIR, { recursive: true });
const index = buildStaticSearchIndex(documents);
const payload = JSON.stringify(await exportStaticSearchIndex(index));

await fs.writeFile(outputPath, payload);

const ms = performance.now() - start;
const bytes = Buffer.byteLength(payload);
console.log(
` ✓ search index written to ${path.relative(ROOT, outputPath)} ` +
`(${bytes.toLocaleString()} bytes, ${ms.toFixed(0)}ms)`,
);
}

async function main() {
const overallStart = performance.now();
console.log("Generating static assets (cache + search index)…");

const server = await createServer({
configFile: false,
logLevel: "error",
server: { port: 0, host: "127.0.0.1" },
resolve: {
alias: { "@": path.resolve(ROOT, "./src") },
},
plugins: [
mdx(await import("../source.config")),
tsConfigPaths({ projects: ["./tsconfig.json"] }),
react(),
],
});

try {
await Promise.all([generateStaticCache(server), generateSearchIndex(server)]);
const total = performance.now() - overallStart;
console.log(`Static assets generated in ${(total / 1000).toFixed(2)}s`);
} finally {
await server.close();
}
}

main().catch((err) => {
console.error("Static asset generation failed:", err);
process.exit(1);
});
179 changes: 179 additions & 0 deletions scripts/measure-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Measures Cloudflare build time, broken down by phase.
*
* Usage:
* bun run scripts/measure-build.ts # clean run, all phases
* bun run scripts/measure-build.ts --no-clean # keep prior dist/.source
* bun run scripts/measure-build.ts --skip-changelog
* bun run scripts/measure-build.ts --label=baseline
*
* Phases match `bun run build:cf` (= `bun run build`):
* 1. prebuild: generate-changelog (skippable)
* 2. prebuild: copy-docs-images
* 3. vite build (the big one — TanStack Start prerender)
* 4. generate-static-cache
* 5. generate-search-index
*
* Each phase runs as its own child process so timings are isolated.
* Results are written to `.context/build-timings/<ISO>.json`.
*/

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";

const args = new Set(process.argv.slice(2));
let label = "run";
for (const arg of args) {
if (arg.startsWith("--label=")) {
label = arg.slice("--label=".length);
}
}

const skipClean = args.has("--no-clean");
const skipChangelog = args.has("--skip-changelog");
const onlyClean = args.has("--clean-only");

const ROOT = process.cwd();
const TIMINGS_DIR = path.join(ROOT, ".context", "build-timings");

interface PhaseResult {
name: string;
ms: number;
exitCode: number | null;
skipped?: boolean;
}

function runPhase(name: string, cmd: string, cmdArgs: string[]): Promise<PhaseResult> {
return new Promise((resolve) => {
const start = performance.now();
process.stdout.write(`\n▶ [${name}] ${cmd} ${cmdArgs.join(" ")}\n`);
const child = spawn(cmd, cmdArgs, {
cwd: ROOT,
stdio: "inherit",
env: {
...process.env,
NODE_OPTIONS: process.env.NODE_OPTIONS ?? "--max-old-space-size=8192",
},
});
child.on("exit", (code) => {
const ms = performance.now() - start;
resolve({ name, ms, exitCode: code });
});
});
}

async function clean(): Promise<PhaseResult> {
const start = performance.now();
process.stdout.write("\n▶ [clean] rm dist + .source + node_modules/.vite\n");
const targets = [
path.join(ROOT, "dist"),
path.join(ROOT, ".source"),
path.join(ROOT, "node_modules", ".vite"),
];
for (const target of targets) {
await fs.rm(target, { recursive: true, force: true });
}
const ms = performance.now() - start;
return { name: "clean", ms, exitCode: 0 };
}

function fmt(ms: number) {
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}

function printTable(rows: PhaseResult[], totalMs: number) {
const headers = ["Phase", "Duration", "% Total", "Status"];
const body = rows.map((row) => [
row.name,
row.skipped ? "—" : fmt(row.ms),
row.skipped ? "—" : `${((row.ms / totalMs) * 100).toFixed(1)}%`,
row.skipped ? "skipped" : row.exitCode === 0 ? "ok" : `exit ${row.exitCode}`,
]);
body.push(["TOTAL", fmt(totalMs), "100.0%", ""]);

const widths = headers.map((header, i) =>
Math.max(header.length, ...body.map((row) => row[i].length)),
);
const formatRow = (row: string[]) =>
row.map((cell, i) => cell.padEnd(widths[i])).join(" ");

console.log("");
console.log(formatRow(headers));
console.log(widths.map((w) => "-".repeat(w)).join(" "));
for (const row of body) console.log(formatRow(row));
}

async function main() {
const totalStart = performance.now();
const results: PhaseResult[] = [];

if (!skipClean) {
results.push(await clean());
}
if (onlyClean) {
const totalMs = performance.now() - totalStart;
printTable(results, totalMs);
return;
}

if (skipChangelog) {
results.push({ name: "generate-changelog", ms: 0, exitCode: 0, skipped: true });
} else {
results.push(await runPhase("generate-changelog", "bun", [
"run",
"scripts/generate-changelog.ts",
]));
}

results.push(await runPhase("copy-docs-images", "bun", [
"run",
"scripts/copy-docs-images.cjs",
]));

results.push(await runPhase("vite build", "bunx", ["vite", "build"]));

results.push(await runPhase("generate-static-assets", "bun", [
"run",
"scripts/generate-static-assets.ts",
]));

const totalMs = performance.now() - totalStart;
const failed = results.find((r) => !r.skipped && r.exitCode !== 0);

printTable(results, totalMs);

await fs.mkdir(TIMINGS_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const outFile = path.join(TIMINGS_DIR, `${timestamp}-${label}.json`);
await fs.writeFile(
outFile,
JSON.stringify(
{
label,
timestamp,
totalMs,
results,
env: {
skipClean,
skipChangelog,
node: process.version,
},
},
null,
2,
),
);
console.log(`\nSaved → ${path.relative(ROOT, outFile)}`);

if (failed) {
console.error(`\nPhase "${failed.name}" failed (exit ${failed.exitCode}).`);
process.exit(1);
}
}

main().catch((err) => {
console.error("Build measurement failed:", err);
process.exit(1);
});
13 changes: 12 additions & 1 deletion source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,25 @@ const isDevelopment = process.env.NODE_ENV === "development";
export const docs = defineDocs({
dir: "content/docs",
docs: {
async: isDevelopment,
// Async MDX collection: each page's compiled MDX is a dynamic import,
// not bundled into the SSR/server entry. Without this the server bundle
// inlined all ~530 docs (~30 MB) and Vite's SSR transform took ~33s.
// Pages are still fully prerendered to static HTML at build time, so
// there's no end-user impact.
async: true,
postprocess: {
includeProcessedMarkdown: true,
},
},
});

export default defineConfig({
// Persist the MDX-compilation cache (shiki highlighting + remark pipeline)
// between builds. Lives inside node_modules/, which Cloudflare Pages
// caches between deploys when build caching is enabled. Each MDX file is
// hashed and only re-processed when its content changes; on a deploy
// touching a handful of pages the rest skip transform entirely.
experimentalBuildCache: "node_modules/.cache/fumadocs-mdx",
mdxOptions: {
// Shiki highlighting is one of the most expensive parts of the MDX pipeline.
// Keep it in builds, but skip it in local dev so first-page SSR doesn't have
Expand Down
8 changes: 6 additions & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import tsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import mdx from "fumadocs-mdx/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import path from "path";
import os from "node:os";
import path from "node:path";

/**
* Only bundle the Shiki language packs actually used in docs.
Expand Down Expand Up @@ -69,7 +70,10 @@ export default defineConfig({
},
prerender: {
enabled: true,
concurrency: 3,
// Was hardcoded to 3 (suspected OOM defense). With
// --max-old-space-size=8192 set in `bun run build` we have plenty of
// headroom — let it use whatever the builder actually has.
concurrency: os.cpus().length,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.cpus().length may be zero or excessive in containers

Medium Severity

os.cpus() can return an empty array in certain containerized environments (documented Node.js behavior when /proc CPU info is unavailable), making os.cpus().length equal to 0. A concurrency of 0 would likely cause the prerender step to hang or error. Even when it doesn't return empty, in containers like Cloudflare's builders it often reports the host machine's CPU count rather than the container's allocation, which could trigger the OOM the previous hardcoded 3 was guarding against. Using os.availableParallelism() (available in both Node.js 19+ and Bun) is safer — it always returns ≥ 1 and better respects cgroup limits.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 01ac397. Configure here.

filter: (page) => {
return !(
page.path === "/sdk" ||
Expand Down
Loading