Skip to content

feat: precompress the client build for the start.nodeServer entry - #526

Merged
ryansolid merged 1 commit into
nextfrom
feat/node-precompress
Nov 3, 2026
Merged

ryansolid merged 1 commit into
nextfrom
feat/node-precompress

Conversation

@brenelz

@brenelz brenelz commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

The dist/server/node.js that start.nodeServer emits sends every client file uncompressed. In the e18e framework-tracker benchmark (e18e/framework-tracker#709), the Solid 2.0 app's /initial-load page transfers 110,431 B of JS and CSS, while the Nitro-based apps send the same kind of bundle compressed because they set compressPublicAssets.

sendFile in the Node entry streamed the file from disk and never looked at Accept-Encoding. Nothing in the build wrote compressed copies either, so there was nothing to serve.

What changed

  • When nodeServer is set, the client build's writeBundle writes a .br (quality 11) and a .gz (level 9) next to each text file of 1 KiB or more: JS, CSS, HTML, JSON, SVG, XML, wasm, ico and fonts other than woff/woff2. A variant is kept only when it is smaller than the file. Dot directories such as .vite/ are skipped. Files copied from public/ are included, because Vite copies them before writeBundle. In client start mode, the prerendered index.html is compressed after it is written. Source maps are skipped, because brotli at quality 11 runs at about 0.9 MB/s and only devtools fetch maps.
  • The entry reads Accept-Encoding with q-values, q=0 and *, and picks br, then gzip. A coding ranked below an explicit identity weight is not used. If the preferred variant is missing, it falls back to the next accepted one. The response gets Content-Encoding, the variant's Content-Length, and Vary: Accept-Encoding. Vary is set on every compressible type, including uncompressed responses, so a shared cache does not hand an uncompressed copy to a client that accepts br. HEAD returns the same headers with no body.
  • Rendered pages and every other handler response still stream uncompressed.
  • Precompression is on by default, because node.js is meant to be the server that serves these files. nodeServer: { precompress: false } turns it off for setups where a proxy or CDN compresses. With it off, the generated config sets precompressed: false and the entry ignores any variants left from an earlier build. The new NodeServerOptions type is exported.

This is the same approach as Nitro's compressPublicAssets: compress once at build time, then pick a file per request. It adds no per-request CPU cost and no dependency to the emitted entry, which still imports only node:* and ./server.js.

Verification

  • pnpm test:unit: 123 tests pass. The new src/node-entry-compression.test.ts builds the real entry with rolldown next to a stub server.js and sends raw HTTP requests to cover br, gzip, identity (no header, identity, and q=0 on both codings), HEAD, a missing br variant falling back to gzip, files under the size floor, PNG, handler responses, and precompressed: false. It also checks the Accept-Encoding parser and the build step: variants decompress to the original, small files and binary types are skipped, stale variants are removed, and .vite/ is skipped.
  • CHROME_BIN=… node examples/start-ssr/test/run.mjs node: 93/93 pass. New checks: variants are written but not inside .vite, an asset is served as br with Vary and the variant's length, the rendered page is not compressed, and precompress: false writes no variants and bakes in precompressed: false.
  • node examples/start-client/test/run.mjs node: 21/21 pass.
  • framework-tracker app-solid, built in a scratch copy against this branch (start.node renamed to start.nodeServer), with headless Chromium loading /initial-load:
Asset bytes (7 files) Total transfer, including HTML and headers
3.0.0-next.51 110,431 B 115,194 B
This branch, br 38,803 B 43,863 B
This branch, gzip (sum of the .gz files) 42,919 B

Build time for that app did not change (about 1.0 s).

Open questions

  • Requests for /assets/x.js.br itself are served as application/octet-stream, the same as any other unknown file. Nitro behaves the same way.
  • The build compresses everything in the client outDir, so with build.emptyOutDir: false, files left by earlier builds are compressed again on every build.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Nov 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00354b7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/vite-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@solidjs/vite-plugin@526

commit: 00354b7

@ryansolid
ryansolid merged commit 3cb10dd into next Nov 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants