-
Notifications
You must be signed in to change notification settings - Fork 3
fix(build): cut Cloudflare build time #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
os.cpus().lengthmay be zero or excessive in containersMedium Severity
os.cpus()can return an empty array in certain containerized environments (documented Node.js behavior when/procCPU info is unavailable), makingos.cpus().lengthequal to0. A concurrency of0would 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 hardcoded3was guarding against. Usingos.availableParallelism()(available in both Node.js 19+ and Bun) is safer — it always returns ≥ 1 and better respects cgroup limits.Reviewed by Cursor Bugbot for commit 01ac397. Configure here.