From 03ac05e5be5b5c8bc09a823a67e8aa981c862dc7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 10 Sep 2026 18:59:48 -0400 Subject: [PATCH] perf(cli): prune build-only files from the portable runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next build` copies its tracing root into `.next/standalone`, so the staged runtime shipped app sources, repository documentation, build trace metadata and assets that `server.js` never reads. Pruned from `dist/runtime`: - `public/audios/radios` (39.3 MB) — the radio catalogue is played by the hosted community app, which serves its own copy; nothing in this repository requests `/audios/radios`. - `next/dist/server/capsize-font-metrics.json` + `font-utils.js` (4.1 MB) — `font-utils.js` is the only reader of the metrics and is itself unreachable from the standalone server. - A stray 3.15 MB authoring screenshot and a duplicate `.glb` under `public/items` — item assets are addressed by convention, and anything else is now dropped and named on stdout. - `apps/editor/{app,components,lib}` plus dev-only configuration and docs (0.5 MB) — TypeScript sources and tests that Node never executes. - `.nft.json` build trace metadata and source maps under `.next` (0.7 MB). Before: 107.5 MB tarball, 149.2 MB unpacked, 2956 files. After: 64.6 MB tarball, 101.7 MB unpacked, 2870 files. The release budget in the smoke test drops to 75 MB / 115 MB / 3200 files so the regression cannot come back unnoticed. `stage-runtime` + `smoke-runtime` pass, and the packed CLI still serves the editor, `/scenes`, a scene page with all 84 of its static chunks, and every sampled public asset. Co-Authored-By: Claude Fable 5.1 --- packages/cli/scripts/smoke-packed-runtime.ts | 6 +- packages/cli/scripts/stage-runtime.ts | 103 ++++++++++++++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 45bd607ae..5b783fa62 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -169,9 +169,9 @@ function enforceArtifactBudget(artifact: { unpackedSize: number entryCount: number }): void { - const maximumSize = 110 * 1024 * 1024 - const maximumUnpackedSize = 160 * 1024 * 1024 - const maximumEntryCount = 4_000 + const maximumSize = 75 * 1024 * 1024 + const maximumUnpackedSize = 115 * 1024 * 1024 + const maximumEntryCount = 3_200 if ( artifact.size > maximumSize || artifact.unpackedSize > maximumUnpackedSize || diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts index b26914947..7eadb47f4 100644 --- a/packages/cli/scripts/stage-runtime.ts +++ b/packages/cli/scripts/stage-runtime.ts @@ -1,5 +1,15 @@ import { spawn } from 'node:child_process' -import { chmod, cp, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { + chmod, + cp, + mkdir, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,6 +20,32 @@ const standaloneDirectory = path.join(appDirectory, '.next/standalone') const standaloneAppDirectory = path.join(standaloneDirectory, 'apps/editor') const outputDirectory = path.join(packageDirectory, 'dist/runtime') +/** + * `next build` copies its tracing root into `.next/standalone`, so the portable runtime + * inherits app sources, repository documentation and build-time-only assets that + * `server.js` never reads. Every entry below was checked against the staged tree: nothing + * in `.next`, `node_modules` or the bundled MCP server resolves it. + */ +const buildOnlyRuntimePaths = [ + 'apps/editor/app', + 'apps/editor/components', + 'apps/editor/lib', + 'apps/editor/AGENTS.md', + 'apps/editor/CLAUDE.md', + 'apps/editor/README.md', + 'apps/editor/bunfig.toml', + 'apps/editor/next.config.ts', + 'apps/editor/postcss.config.mjs', + 'apps/editor/tsconfig.json', + 'apps/editor/vercel.json', + // The radio catalogue is played by the hosted community app, which serves its own copy. + 'apps/editor/public/audios/radios', + // `next/dist/server/font-utils.js` is the sole reader of these font metrics and is + // itself unreachable from the standalone server. + 'node_modules/next/dist/server/capsize-font-metrics.json', + 'node_modules/next/dist/server/font-utils.js', +] + const packageJson = JSON.parse( await readFile(path.join(packageDirectory, 'package.json'), 'utf8'), ) as { @@ -38,6 +74,7 @@ await removeUnusedSharp(outputDirectory) await flattenBunNodeModules(outputDirectory) await materializeSymlinks(outputDirectory) await rm(path.join(outputDirectory, 'node_modules/.bun'), { recursive: true, force: true }) +await pruneBuildOnlyFiles(outputDirectory) const nativeFiles = await findNativeModules(outputDirectory) if (nativeFiles.length > 0) { throw new Error(`portable runtime contains native modules:\n${nativeFiles.join('\n')}`) @@ -101,6 +138,70 @@ async function assertFile(filePath: string): Promise { } } +async function pruneBuildOnlyFiles(root: string): Promise { + await Promise.all( + buildOnlyRuntimePaths.map((relative) => + rm(path.join(root, relative), { recursive: true, force: true }), + ), + ) + await removeStrayItemAssets(path.join(root, 'apps/editor/public/items')) + await removeTraceArtifacts(path.join(root, 'apps/editor/.next')) +} + +/** + * Item directories are addressed by convention (`model.glb`, `thumbnail.*`, `floor-plan.*`). + * Anything else is an authoring leftover, so it is dropped and named on stdout: a future + * asset that does not follow the convention has to be reported rather than silently lost. + */ +async function removeStrayItemAssets(itemsDirectory: string): Promise { + let entries + try { + entries = await readdir(itemsDirectory, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return + } + const isConventional = (name: string): boolean => + name === 'model.glb' || name.startsWith('thumbnail.') || name.startsWith('floor-plan.') + for (const entry of entries) { + if (!entry.isDirectory()) continue + const itemDirectory = path.join(itemsDirectory, entry.name) + for (const asset of await readdir(itemDirectory, { withFileTypes: true })) { + if (!asset.isFile() || isConventional(asset.name)) continue + const assetPath = path.join(itemDirectory, asset.name) + const { size } = await stat(assetPath) + await rm(assetPath, { force: true }) + console.log( + `Dropped unreferenced item asset ${entry.name}/${asset.name} (${formatMegabytes(size)} MB)`, + ) + } + } +} + +function formatMegabytes(bytes: number): string { + return (bytes / 1024 / 1024).toFixed(2) +} + +async function removeTraceArtifacts(nextDirectory: string): Promise { + const walk = async (directory: string): Promise => { + let entries + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return + } + for (const entry of entries) { + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(absolute) + else if (entry.name.endsWith('.nft.json') || entry.name.endsWith('.map')) { + await rm(absolute, { force: true }) + } + } + } + await walk(nextDirectory) +} + async function removeUnusedSharp(root: string): Promise { const nodeModules = path.join(root, 'node_modules') await rm(path.join(nodeModules, 'sharp'), { recursive: true, force: true })