diff --git a/package.json b/package.json index a719127..279d7bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-static-assets.ts b/scripts/generate-static-assets.ts new file mode 100644 index 0000000..c8cc816 --- /dev/null +++ b/scripts/generate-static-assets.ts @@ -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>) { + 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>) { + 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); +}); diff --git a/scripts/measure-build.ts b/scripts/measure-build.ts new file mode 100644 index 0000000..c5bcd2f --- /dev/null +++ b/scripts/measure-build.ts @@ -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/.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 { + 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 { + 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); +}); diff --git a/source.config.ts b/source.config.ts index 0710dda..4bf3f8d 100644 --- a/source.config.ts +++ b/source.config.ts @@ -14,7 +14,12 @@ 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, }, @@ -22,6 +27,12 @@ export const docs = defineDocs({ }); 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 diff --git a/vite.config.ts b/vite.config.ts index 50450ac..12d16d2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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. @@ -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, filter: (page) => { return !( page.path === "/sdk" ||