-
Notifications
You must be signed in to change notification settings - Fork 292
perf: build-time precompression + startup metadata cache for static serving #641
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
Changes from all commits
0c7f808
91dc43c
0b83607
53f19d2
078746d
bd0ea02
320466e
6b4b1e1
a623e58
5b02a94
c614d1b
f8fa3ab
626ed6f
d5d4774
4d741ae
e75b62b
9c84455
43c7703
3ade536
0e33529
f212d6a
6e74b39
e47501a
3523c75
c309af2
52f4828
54f7e51
43d0d4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,160 @@ | ||||||||||
| /** | ||||||||||
| * Build-time precompression for hashed static assets. | ||||||||||
| * | ||||||||||
| * Generates .br (brotli q5), .gz (gzip l8), and .zst (zstd l8) files | ||||||||||
| * alongside compressible assets in dist/client/assets/. Served directly by | ||||||||||
| * the production server — no per-request compression needed for immutable | ||||||||||
| * build output. | ||||||||||
| * | ||||||||||
| * Only targets assets/ (hashed, immutable) — public directory files use | ||||||||||
| * on-the-fly compression since they may change between deploys. | ||||||||||
| */ | ||||||||||
| import fsp from "node:fs/promises"; | ||||||||||
| import os from "node:os"; | ||||||||||
| import path from "node:path"; | ||||||||||
| import zlib from "node:zlib"; | ||||||||||
| import { promisify } from "node:util"; | ||||||||||
|
|
||||||||||
| const brotliCompress = promisify(zlib.brotliCompress); | ||||||||||
| const gzip = promisify(zlib.gzip); | ||||||||||
| const zstdCompress = typeof zlib.zstdCompress === "function" ? promisify(zlib.zstdCompress) : null; | ||||||||||
|
|
||||||||||
| /** File extensions worth compressing (text-based, not already compressed). */ | ||||||||||
| const COMPRESSIBLE_EXTENSIONS = new Set([ | ||||||||||
| ".js", | ||||||||||
| ".mjs", | ||||||||||
| ".css", | ||||||||||
| ".html", | ||||||||||
| ".json", | ||||||||||
| ".xml", | ||||||||||
| ".svg", | ||||||||||
| ".txt", | ||||||||||
| ".map", | ||||||||||
| ".wasm", | ||||||||||
| ]); | ||||||||||
|
|
||||||||||
| /** Below this size, compression overhead exceeds savings. */ | ||||||||||
| const MIN_SIZE = 1024; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Past ~8 parallel files, mixed-size asset sets spend more time queueing zlib | ||||||||||
| * work than making forward progress. Keep the batch size bounded even on | ||||||||||
| * higher-core machines. | ||||||||||
| */ | ||||||||||
| const CONCURRENCY = Math.min(os.availableParallelism(), 8); | ||||||||||
|
|
||||||||||
| export type PrecompressResult = { | ||||||||||
| filesCompressed: number; | ||||||||||
| totalOriginalBytes: number; | ||||||||||
| /** Sum of brotli-compressed sizes (used for compression ratio reporting). */ | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: JSDoc on |
||||||||||
| totalBrotliBytes: number; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Walk a directory recursively, yielding relative paths for regular files. | ||||||||||
| */ | ||||||||||
| async function* walkFiles(dir: string, base: string = dir): AsyncGenerator<string> { | ||||||||||
| let entries; | ||||||||||
| try { | ||||||||||
| entries = await fsp.readdir(dir, { withFileTypes: true }); | ||||||||||
| } catch { | ||||||||||
| return; // directory doesn't exist | ||||||||||
| } | ||||||||||
| for (const entry of entries) { | ||||||||||
| const fullPath = path.join(dir, entry.name); | ||||||||||
| if (entry.isDirectory()) { | ||||||||||
| yield* walkFiles(fullPath, base); | ||||||||||
| } else if (entry.isFile()) { | ||||||||||
| yield path.relative(base, fullPath); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Precompress all compressible hashed assets under `clientDir/assets/`. | ||||||||||
| * | ||||||||||
| * Writes `.br`, `.gz`, and `.zst` files alongside each original. | ||||||||||
| * Safe to re-run — overwrites existing compressed variants with identical | ||||||||||
| * output, and never compresses `.br`, `.gz`, or `.zst` files themselves. | ||||||||||
| */ | ||||||||||
| export async function precompressAssets( | ||||||||||
| clientDir: string, | ||||||||||
| onProgress?: (completed: number, total: number, file: string) => void, | ||||||||||
| ): Promise<PrecompressResult> { | ||||||||||
| const assetsDir = path.join(clientDir, "assets"); | ||||||||||
| const result: PrecompressResult = { | ||||||||||
| filesCompressed: 0, | ||||||||||
| totalOriginalBytes: 0, | ||||||||||
| totalBrotliBytes: 0, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| // Collect compressible file paths, then read + compress in bounded chunks | ||||||||||
| // to keep peak memory at O(CONCURRENCY * max_file_size) instead of | ||||||||||
| // O(total_assets). | ||||||||||
| const filePaths: string[] = []; | ||||||||||
|
|
||||||||||
| for await (const relativePath of walkFiles(assetsDir)) { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Robustness: The JSDoc on line 78 says "never compresses The current approach works, just noting it's worth a brief inline comment on the filter to make the invariant explicit: |
||||||||||
| const ext = path.extname(relativePath).toLowerCase(); | ||||||||||
|
|
||||||||||
| if (!COMPRESSIBLE_EXTENSIONS.has(ext)) continue; | ||||||||||
| // .br/.gz/.zst are intentionally absent from COMPRESSIBLE_EXTENSIONS, so | ||||||||||
| // precompressed variants generated by a previous run are never re-compressed. | ||||||||||
|
|
||||||||||
| filePaths.push(path.join(assetsDir, relativePath)); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| let processed = 0; | ||||||||||
| for (let i = 0; i < filePaths.length; i += CONCURRENCY) { | ||||||||||
| const chunk = filePaths.slice(i, i + CONCURRENCY); | ||||||||||
| await Promise.all( | ||||||||||
| chunk.map(async (fullPath) => { | ||||||||||
| const content = await fsp.readFile(fullPath); | ||||||||||
| // readFile already done before this check — stat()-first would save | ||||||||||
| // the read for tiny files but costs an extra syscall per file; | ||||||||||
| // sub-1KB hashed assets are rare enough that read-first is cheaper. | ||||||||||
| if (content.length < MIN_SIZE) return; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: files below For a micro-optimization, you could check file size via
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Robustness: Every file gets fully read into memory before the size check. For a project with many tiny assets (e.g., icon SVGs under 1KB), this does unnecessary I/O. A micro-optimization would be to No change needed — just documenting the tradeoff for future readers.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should fix: Every file in the chunk gets fully read into memory before the This is fine for the current use case (most hashed assets are well above 1KB), but if you want to tighten it up later, you could
Suggested change
|
||||||||||
|
|
||||||||||
| // Compress all variants concurrently within each file | ||||||||||
| const compressions: Promise<Buffer>[] = [ | ||||||||||
| brotliCompress(content, { | ||||||||||
| params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 }, | ||||||||||
| }), | ||||||||||
| gzip(content, { level: 8 }), | ||||||||||
| ]; | ||||||||||
| if (zstdCompress) { | ||||||||||
| compressions.push( | ||||||||||
| zstdCompress(content, { | ||||||||||
| params: { [zlib.constants.ZSTD_c_compressionLevel]: 8 }, | ||||||||||
| }), | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const results = await Promise.all(compressions); | ||||||||||
| const [brContent, gzContent, zstdContent] = results; | ||||||||||
|
|
||||||||||
| const writes = [ | ||||||||||
| fsp.writeFile(fullPath + ".br", brContent), | ||||||||||
| fsp.writeFile(fullPath + ".gz", gzContent), | ||||||||||
| ]; | ||||||||||
| if (zstdContent) { | ||||||||||
| writes.push(fsp.writeFile(fullPath + ".zst", zstdContent)); | ||||||||||
| } | ||||||||||
| await Promise.all(writes); | ||||||||||
|
|
||||||||||
| // Increment counters only after all writes succeed, so partial | ||||||||||
| // failures (e.g. ENOSPC mid-write) don't inflate the reported totals. | ||||||||||
| result.filesCompressed++; | ||||||||||
| result.totalOriginalBytes += content.length; | ||||||||||
| result.totalBrotliBytes += brContent.length; | ||||||||||
| }), | ||||||||||
| ); | ||||||||||
| // Report progress once per chunk to avoid non-deterministic ordering | ||||||||||
| // within Promise.all (smaller files complete before larger ones). | ||||||||||
| // Progress tracks all files (including skipped ones below MIN_SIZE), | ||||||||||
| // which differs from filesCompressed (only files actually compressed). | ||||||||||
| processed += chunk.length; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Progress reports after-the-fact for the last chunk. The Not blocking — the current approach is the right tradeoff over per-file reporting within a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Robustness: progress reporting is chunk-granular, not per-file. This is the right tradeoff — it avoids the non-deterministic ordering issue within The comment at lines 146-149 clearly explains both the decision and the semantic difference between
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should fix (minor): If a project has 100 compressible files but 20 are below The actual minor bug: if all files in a chunk are below
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Progress is chunk-granular rather than per-file, which avoids the non-deterministic ordering issue within One cosmetic note: |
||||||||||
| onProgress?.(processed, filePaths.length, path.basename(chunk[chunk.length - 1])); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Progress bar reports inflated count for skipped files.
This is fine for the progress bar (it's tracking processing not compressing), but worth noting the semantic difference between
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||||||||||
| } | ||||||||||
|
|
||||||||||
| return result; | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -101,6 +101,7 @@ type ParsedArgs = { | |
| turbopack?: boolean; // accepted for compat, always ignored | ||
| experimental?: boolean; // accepted for compat, always ignored | ||
| prerenderAll?: boolean; | ||
| precompress?: boolean; | ||
| }; | ||
|
|
||
| function parseArgs(args: string[]): ParsedArgs { | ||
|
|
@@ -117,6 +118,9 @@ function parseArgs(args: string[]): ParsedArgs { | |
| result.experimental = true; // no-op | ||
| } else if (arg === "--prerender-all") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting |
||
| result.prerenderAll = true; | ||
| } else if (arg === "--precompress") { | ||
| result.precompress = true; | ||
| process.env.VINEXT_PRECOMPRESS = "1"; | ||
| } else if (arg === "--port" || arg === "-p") { | ||
| result.port = parseInt(args[++i], 10); | ||
| } else if (arg.startsWith("--port=")) { | ||
|
|
@@ -509,6 +513,9 @@ async function buildApp() { | |
| prerenderResult = await runPrerender({ root: process.cwd() }); | ||
| } | ||
|
|
||
| // Precompression runs as a Vite plugin writeBundle hook (vinext:precompress). | ||
| // Opt-in via --precompress CLI flag or `precompress: true` in plugin options. | ||
|
|
||
| process.stdout.write("\x1b[0m"); | ||
| await printBuildReport({ | ||
| root: process.cwd(), | ||
|
|
@@ -678,6 +685,7 @@ function printHelp(cmd?: string) { | |
| --verbose Show full Vite/Rollup build output (suppressed by default) | ||
| --prerender-all Pre-render discovered routes after building (future releases | ||
| will serve these files in vinext start) | ||
| --precompress Precompress static assets at build time (.br, .gz, .zst) | ||
| -h, --help Show this help | ||
| `); | ||
| return; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -40,6 +40,7 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runInstrumentation, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "./server/instrumentation.js"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { PHASE_PRODUCTION_BUILD, PHASE_DEVELOPMENT_SERVER } from "./shims/constants.js"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { precompressAssets } from "./build/precompress.js"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { validateDevRequest } from "./server/dev-origin-check.js"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isExternalUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -817,6 +818,22 @@ export type VinextOptions = { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @default true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| react?: VitePluginReactOptions | boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Enable build-time precompression of static assets (.br, .gz, .zst). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * When enabled, hashed assets in the client build are precompressed at | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * build time so the production server can serve them without on-the-fly | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * compression overhead. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Disabled by default. Not useful when deploying to edge platforms | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * (Cloudflare Workers, Nitro) that handle compression at the CDN layer. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Can also be enabled via the `--precompress` CLI flag or by setting the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * `VINEXT_PRECOMPRESS=1` environment variable (useful for CI pipelines | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * that need to enable precompression without modifying vite.config.ts). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * @default false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| precompress?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Experimental vinext-only feature flags. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -3325,6 +3342,95 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Build-time precompression: generate .br, .gz, .zst for hashed assets. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Runs after the client bundle is written so compressed variants are | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // available for the production server's static file cache. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Opt-in via `precompress: true` in plugin options or `--precompress` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CLI flag. Not useful for edge platforms (Cloudflare Workers, Nitro) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // that handle compression at the CDN layer. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let pendingPrecompress: Promise<void> | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let pendingPrecompressError: unknown = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: "vinext:precompress", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| apply: "build" as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enforce: "post" as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| writeBundle: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sequential: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| order: "post" as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handler(outputOptions: { dir?: string }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: One subtle edge: if the client
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: fire-and-forget pattern has a subtle edge case on The Since |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this.environment?.name !== "client") return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!options.precompress && process.env.VINEXT_PRECOMPRESS !== "1") return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Opt-in gate is correct. This checks both
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: the opt-in gate correctly checks both This addresses the maintainer's request. One thing to note: the env var approach means
Suggested change
(No code change — just noting the env var should be mentioned in the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The env var |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const outDir = outputOptions.dir; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!outDir) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only precompress hashed assets — public directory files use | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // on-the-fly compression since they may change between deploys. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const assetsDir = path.join(outDir, "assets"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!fs.existsSync(assetsDir)) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isTTY = process.stderr.isTTY; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let lastLineLen = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Start precompression as soon as the client bundle is written, but | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // defer awaiting it until the SSR environment finishes. This overlaps | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the extra asset work with the final build phase instead of putting | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the full precompression cost on the critical path of step 4/5. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingPrecompressError = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingPrecompress = (async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await precompressAssets(outDir, (completed, total, file) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isTTY) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pct = total > 0 ? Math.floor((completed / total) * 100) : 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const bar = `[${"█".repeat(Math.floor(pct / 5))}${" ".repeat(20 - Math.floor(pct / 5))}]`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const maxFile = 30; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fileLabel = file.length > maxFile ? "…" + file.slice(-(maxFile - 1)) : file; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const line = `Compressing assets... ${bar} ${String(completed).padStart(String(total).length)}/${total} ${fileLabel}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const padded = line.padEnd(lastLineLen); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lastLineLen = line.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.stderr.write(`\r${padded}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isTTY) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| process.stderr.write(`\r${" ".repeat(lastLineLen)}\r`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (result.filesCompressed > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ratio = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (1 - result.totalBrotliBytes / result.totalOriginalBytes) * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the log line says "smaller with brotli" but |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 100 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).toFixed(1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ` Precompressed ${result.filesCompressed} assets (${ratio}% smaller with brotli)`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })().catch((error) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: error handling is now complete. The |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingPrecompressError = error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Log immediately so the error isn't invisible if closeBundle | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // never fires (e.g. a crash in a later SSR build plugin). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error("[vinext] Precompression failed:", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3378
to
+3412
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential issue: The pattern here starts precompression in In practice this is unlikely since the SSR environment always runs after client in the build pipeline, but consider adding a
Suggested change
This way operators see the error even if |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| closeBundle: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sequential: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| order: "post" as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async handler() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this.environment?.name !== "ssr") return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!pendingPrecompress) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const task = pendingPrecompress; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingPrecompress = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await task; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (pendingPrecompressError) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = pendingPrecompressError; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingPrecompressError = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cloudflare Workers production build integration: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // After all environments are built, compute lazy chunks from the client | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // build manifest and inject globals into the worker entry. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
Minor:
os.availableParallelism()was added in Node 19.4 / backported to 18.14. Since the repo requiresengines.node >= 22, this is fine — just noting for awareness. If vinext ever widens engine support, this would need a fallback toos.cpus().length.