diff --git a/.changeset/node-server-precompress.md b/.changeset/node-server-precompress.md
new file mode 100644
index 0000000..291aeb2
--- /dev/null
+++ b/.changeset/node-server-precompress.md
@@ -0,0 +1,5 @@
+---
+'@solidjs/vite-plugin': patch
+---
+
+`start.nodeServer` now precompresses the client build: the build writes a `.br` and a `.gz` beside each text file of 1 KiB or more, and `dist/server/node.js` serves the best one the request's `Accept-Encoding` allows, with `Content-Encoding`, the variant's `Content-Length` and `Vary: Accept-Encoding`. Rendered pages still stream uncompressed. `nodeServer: { precompress: false }` turns it off.
diff --git a/README.md b/README.md
index 79082bb..06fe520 100644
--- a/README.md
+++ b/README.md
@@ -347,7 +347,14 @@ The entry serves `dist/client` statically — files under
`build.assetsDir` as `Cache-Control: public, max-age=31536000, immutable`
(their names are content hashes), everything else `public, max-age=0,
must-revalidate` with `Last-Modified`; `HEAD` supported, dot-segment
-paths and `..` traversal refused — and hands every other request to
+paths and `..` traversal refused. The build also writes a `.br` and a
+`.gz` beside every client text file of 1 KiB or more (JS, CSS, HTML,
+JSON, SVG and the like), and the entry serves the best one the request's
+`Accept-Encoding` allows with `Content-Encoding`, the variant's
+`Content-Length` and `Vary: Accept-Encoding`; set
+`nodeServer: { precompress: false }` when a proxy or CDN compresses
+instead. Rendered pages and every other handler response stream
+uncompressed. The entry hands every other request to
`handleRequest` with the raw Node request as `nativeEvent`, so
`getRequestEvent().nativeEvent` is the `IncomingMessage` (client IP:
`event.nativeEvent.socket.remoteAddress`; behind a proxy read the
diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs
index 6587cc1..fdf8540 100644
--- a/examples/start-ssr/test/run.mjs
+++ b/examples/start-ssr/test/run.mjs
@@ -187,6 +187,7 @@ import {
existsSync,
readdirSync,
readFileSync,
+ statSync,
writeFileSync,
} from 'node:fs';
import http from 'node:http';
@@ -5776,6 +5777,26 @@ async function runNodeMode() {
record(mode, 'gate', 'no start.nodeServer: no dist/server/node.js', !existsSync(nodeJs));
const serverWithout = readFileSync(serverJs, 'utf-8');
+ rmSync(distDir, { recursive: true, force: true });
+ console.log(' building (with start.nodeServer: { precompress: false })…');
+ execSync('pnpm run build', {
+ cwd: exampleDir,
+ stdio: 'pipe',
+ env: { ...env, START_NODE_SERVER: 'no-precompress' },
+ });
+ const plainVariants = readdirSync(path.join(distDir, 'client'), { recursive: true }).filter(
+ (file) => /\.(br|gz)$/.test(file),
+ );
+ record(
+ mode,
+ 'build',
+ 'precompress: false emits node.js without .br/.gz variants',
+ existsSync(nodeJs) &&
+ plainVariants.length === 0 &&
+ readFileSync(nodeJs, 'utf-8').includes('"precompressed":false'),
+ plainVariants.join(', '),
+ );
+
rmSync(distDir, { recursive: true, force: true });
console.log(' building (with start.nodeServer)…');
execSync('pnpm run build', {
@@ -5949,6 +5970,39 @@ async function runNodeMode() {
Number(headAsset.headers['content-length']) === Buffer.byteLength(assetBody),
`status ${headAsset.status}, content-length ${headAsset.headers['content-length']}`,
);
+ const entryFile = entryAsset ? path.join(distDir, 'client', entryAsset) : '';
+ record(
+ mode,
+ 'static',
+ 'build writes .br and .gz beside client assets, not inside .vite',
+ !!entryAsset &&
+ existsSync(entryFile + '.br') &&
+ existsSync(entryFile + '.gz') &&
+ !existsSync(path.join(distDir, 'client/.vite/manifest.json.br')),
+ );
+ const brAsset = await rawRequest(`${origin}/${entryAsset}`, {
+ headers: { 'accept-encoding': 'gzip, deflate, br' },
+ });
+ record(
+ mode,
+ 'static',
+ 'asset negotiates br with Vary and the variant length',
+ brAsset.status === 200 &&
+ brAsset.headers['content-encoding'] === 'br' &&
+ brAsset.headers['vary'] === 'Accept-Encoding' &&
+ Number(brAsset.headers['content-length']) === statSync(entryFile + '.br').size,
+ `encoding ${brAsset.headers['content-encoding']}, length ${brAsset.headers['content-length']}`,
+ );
+ const page = await rawRequest(`${origin}/`, {
+ headers: { accept: 'text/html', 'accept-encoding': 'gzip, br' },
+ });
+ record(
+ mode,
+ 'static',
+ 'rendered pages stream uncompressed',
+ page.status === 200 && !page.headers['content-encoding'],
+ `encoding ${page.headers['content-encoding']}`,
+ );
const headRobots = await rawPath(origin, '/robots.txt', 'HEAD');
record(
mode,
diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts
index 051f572..3062be7 100644
--- a/examples/start-ssr/vite.config.ts
+++ b/examples/start-ssr/vite.config.ts
@@ -82,7 +82,8 @@ import solidPlugin from '@solidjs/vite-plugin';
// which the solid plugin's post-order hook must still wrap; `off` is
// `false`, the opt-out the plugin leaves alone (#391).
// - START_NODE_SERVER=1 (node mode) sets `start.nodeServer`: the build emits the
-// ready-to-run Node server entry dist/server/node.js beside server.js.
+// ready-to-run Node server entry dist/server/node.js beside server.js;
+// `no-precompress` sets `nodeServer: { precompress: false }` instead.
// - SOLID_PERF_TRACKS (perf-tracks mode) sets `performanceTracks`: `0` opts
// out, a JSON object is the options form (`{"minMs":2}`); unset leaves
// the default (on under dev serve), which the dev mode asserts.
@@ -212,7 +213,14 @@ export default defineConfig({
// runs double as coverage for the default-on wiring.
...(process.env.SSR_DEVTOOLS === '0' ? { devtools: false } : {}),
// START_NODE_SERVER=1 (node mode): emit the Node server entry.
- ...(process.env.START_NODE_SERVER ? { nodeServer: true } : {}),
+ ...(process.env.START_NODE_SERVER
+ ? {
+ nodeServer:
+ process.env.START_NODE_SERVER === 'no-precompress'
+ ? { precompress: false }
+ : true,
+ }
+ : {}),
// SSR_MIDDLEWARE=1 (middleware/preview modes): a fetch-style
// chain fronting every dispatch path — page SSR, /_server,
// preview — with getRequestEvent() live inside it.
diff --git a/src/index.ts b/src/index.ts
index 69944be..40d82ea 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -27,7 +27,12 @@ import {
serverFunctions,
type ServerFunctionsOptions,
} from './server-functions/index.js';
-import { SSR_HANDLER_ID, startServe, type StartOptions } from './ssr/index.js';
+import {
+ type NodeServerOptions,
+ SSR_HANDLER_ID,
+ startServe,
+ type StartOptions,
+} from './ssr/index.js';
import { envPlugin } from './env.js';
import {
detectLocationSupport,
@@ -60,7 +65,7 @@ export type {
PersistedServerFunctionManifest,
ServerFunctionsFilter,
} from './server-functions/index.js';
-export type { StartOptions };
+export type { NodeServerOptions, StartOptions };
export type {
StartMiddleware,
StartRequestEvent,
diff --git a/src/node-entry-compression.test.ts b/src/node-entry-compression.test.ts
new file mode 100644
index 0000000..49380d5
--- /dev/null
+++ b/src/node-entry-compression.test.ts
@@ -0,0 +1,227 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
+import { createServer, request, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { brotliDecompressSync, gunzipSync } from 'node:zlib';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { rolldown } from 'rolldown';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { acceptedEncodings } from './node-entry/encoding.js';
+import { precompressDir, precompressFile } from './precompress.js';
+
+const root = mkdtempSync(path.join(tmpdir(), 'solid-node-compress-'));
+afterAll(() => rmSync(root, { recursive: true, force: true }));
+
+// Repetitive enough to compress well, large enough to clear the 1 KiB floor.
+const bigJs = Array.from({ length: 200 }, (_, i) => `export const value${i} = ${i};`).join('\n');
+
+describe('acceptedEncodings', () => {
+ it('prefers br, then gzip', () => {
+ expect(acceptedEncodings('gzip, deflate, br').map((e) => e.name)).toEqual(['br', 'gzip']);
+ });
+ it('accepts nothing without a header or with identity only', () => {
+ expect(acceptedEncodings(undefined)).toEqual([]);
+ expect(acceptedEncodings('identity')).toEqual([]);
+ });
+ it('honors q-values and q=0 refusals', () => {
+ expect(acceptedEncodings('br;q=0, gzip').map((e) => e.name)).toEqual(['gzip']);
+ expect(acceptedEncodings('br;q=0.5, gzip;q=0.8').map((e) => e.name)).toEqual(['gzip', 'br']);
+ expect(acceptedEncodings('GZIP ; Q=1').map((e) => e.name)).toEqual(['gzip']);
+ });
+ it('keeps identity when the header ranks it above a coding', () => {
+ expect(acceptedEncodings('identity;q=1, br;q=0.1')).toEqual([]);
+ expect(acceptedEncodings('identity;q=0.5, br, gzip;q=0.1').map((e) => e.name)).toEqual(['br']);
+ expect(acceptedEncodings('identity, br').map((e) => e.name)).toEqual(['br']);
+ });
+ it('treats * as every coding the header does not name', () => {
+ expect(acceptedEncodings('*').map((e) => e.name)).toEqual(['br', 'gzip']);
+ expect(acceptedEncodings('br;q=0, *').map((e) => e.name)).toEqual(['gzip']);
+ });
+});
+
+describe('precompressDir', () => {
+ const dir = path.join(root, 'build');
+ beforeAll(async () => {
+ mkdirSync(path.join(dir, 'assets'), { recursive: true });
+ mkdirSync(path.join(dir, '.vite'), { recursive: true });
+ writeFileSync(path.join(dir, 'assets/app.js'), bigJs);
+ writeFileSync(path.join(dir, 'assets/tiny.css'), 'body{margin:0}');
+ writeFileSync(path.join(dir, 'assets/tiny.css.br'), 'stale');
+ writeFileSync(path.join(dir, 'assets/image.png'), Buffer.alloc(4096));
+ writeFileSync(path.join(dir, '.vite/manifest.json'), JSON.stringify({ bigJs }));
+ await precompressDir(dir);
+ });
+
+ it('writes br and gzip variants that decode to the file', () => {
+ const file = path.join(dir, 'assets/app.js');
+ expect(brotliDecompressSync(readFileSync(file + '.br')).toString()).toBe(bigJs);
+ expect(gunzipSync(readFileSync(file + '.gz')).toString()).toBe(bigJs);
+ });
+ it('skips files under the floor and removes their stale variants', () => {
+ expect(existsSync(path.join(dir, 'assets/tiny.css.br'))).toBe(false);
+ expect(existsSync(path.join(dir, 'assets/tiny.css.gz'))).toBe(false);
+ });
+ it('skips binary formats and dot directories', () => {
+ expect(existsSync(path.join(dir, 'assets/image.png.br'))).toBe(false);
+ expect(existsSync(path.join(dir, '.vite/manifest.json.br'))).toBe(false);
+ });
+ it('precompressFile handles a single late file (the client-mode shell)', async () => {
+ const shell = path.join(dir, 'index.html');
+ writeFileSync(shell, ``);
+ await precompressFile(shell);
+ expect(existsSync(shell + '.br')).toBe(true);
+ expect(existsSync(shell + '.gz')).toBe(true);
+ });
+});
+
+type Reply = { status: number; headers: Record; body: Buffer };
+
+// The entry as it ships: the runtime bundle behind the generated config
+// header, beside a stub server.js (see nodeEntryCode in src/ssr/index.ts).
+async function emitNodeEntry(serverDir: string, clientDir: string, precompressed: boolean) {
+ const bundle = await rolldown({
+ input: fileURLToPath(new URL('./node-entry/index.ts', import.meta.url)),
+ platform: 'node',
+ external: (id) => id === './server.js' || id.startsWith('node:'),
+ makeAbsoluteExternalsRelative: false,
+ });
+ const { output } = await bundle.generate({ format: 'esm' });
+ await bundle.close();
+ const config = {
+ clientDir: path.relative(serverDir, clientDir),
+ assetsDir: 'assets',
+ base: '/',
+ spa: false,
+ entry: 'node.js',
+ precompressed,
+ };
+ mkdirSync(serverDir, { recursive: true });
+ writeFileSync(
+ path.join(serverDir, 'server.js'),
+ `export const handleRequest = async () => new Response('from handler', { status: 404 });\n`,
+ );
+ const file = path.join(serverDir, 'node.js');
+ writeFileSync(file, `const SOLID_NODE_CONFIG = ${JSON.stringify(config)};\n${output[0].code}`);
+ return file;
+}
+
+async function startListener(clientDir: string, precompressed: boolean) {
+ const serverDir = path.join(root, precompressed ? 'server' : 'server-plain');
+ const entry = await emitNodeEntry(serverDir, clientDir, precompressed);
+ const { createListener } = await import(/* @vite-ignore */ pathToFileURL(entry).href);
+ const server = createServer(createListener());
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const { port } = server.address() as AddressInfo;
+ const get = (pathname: string, headers: Record = {}, method = 'GET') =>
+ new Promise((resolve, reject) => {
+ // http.request never decodes, so the bytes on the wire are what we see.
+ const req = request({ host: '127.0.0.1', port, path: pathname, method, headers }, (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () =>
+ resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks) }),
+ );
+ });
+ req.on('error', reject);
+ req.end();
+ });
+ return { server, get };
+}
+
+describe('node entry static negotiation', () => {
+ const clientDir = path.join(root, 'client');
+ let server: Server;
+ let get: Awaited>['get'];
+ beforeAll(async () => {
+ mkdirSync(path.join(clientDir, 'assets'), { recursive: true });
+ writeFileSync(path.join(clientDir, 'assets/app.js'), bigJs);
+ writeFileSync(path.join(clientDir, 'assets/gzip-only.js'), bigJs);
+ writeFileSync(path.join(clientDir, 'assets/small.js'), 'export {};');
+ writeFileSync(path.join(clientDir, 'assets/image.png'), Buffer.alloc(4096));
+ await precompressDir(clientDir);
+ rmSync(path.join(clientDir, 'assets/gzip-only.js.br'));
+ // A variant beside a binary file is never consulted.
+ writeFileSync(path.join(clientDir, 'assets/image.png.br'), 'not served');
+ ({ server, get } = await startListener(clientDir, true));
+ });
+ afterAll(() => server?.close());
+
+ const brSize = () => readFileSync(path.join(clientDir, 'assets/app.js.br')).length;
+
+ it('serves br when accepted', async () => {
+ const res = await get('/assets/app.js', { 'accept-encoding': 'gzip, deflate, br' });
+ expect(res.status).toBe(200);
+ expect(res.headers['content-encoding']).toBe('br');
+ expect(res.headers['vary']).toBe('Accept-Encoding');
+ expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8');
+ expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
+ expect(Number(res.headers['content-length'])).toBe(brSize());
+ expect(brotliDecompressSync(res.body).toString()).toBe(bigJs);
+ });
+
+ it('serves gzip when br is not accepted', async () => {
+ const res = await get('/assets/app.js', { 'accept-encoding': 'gzip' });
+ expect(res.headers['content-encoding']).toBe('gzip');
+ expect(res.headers['vary']).toBe('Accept-Encoding');
+ expect(Number(res.headers['content-length'])).toBe(res.body.length);
+ expect(gunzipSync(res.body).toString()).toBe(bigJs);
+ });
+
+ it('serves the file as is without Accept-Encoding or with identity', async () => {
+ for (const headers of [
+ {},
+ { 'accept-encoding': 'identity' },
+ { 'accept-encoding': 'br;q=0, gzip;q=0' },
+ ]) {
+ const res = await get('/assets/app.js', headers);
+ expect(res.headers['content-encoding']).toBeUndefined();
+ expect(res.headers['vary']).toBe('Accept-Encoding');
+ expect(Number(res.headers['content-length'])).toBe(Buffer.byteLength(bigJs));
+ expect(res.body.toString()).toBe(bigJs);
+ }
+ });
+
+ it('answers HEAD with the variant headers and no body', async () => {
+ const res = await get('/assets/app.js', { 'accept-encoding': 'br' }, 'HEAD');
+ expect(res.status).toBe(200);
+ expect(res.headers['content-encoding']).toBe('br');
+ expect(Number(res.headers['content-length'])).toBe(brSize());
+ expect(res.body.length).toBe(0);
+ });
+
+ it('falls back to the next accepted coding when a variant is missing', async () => {
+ const res = await get('/assets/gzip-only.js', { 'accept-encoding': 'br, gzip' });
+ expect(res.headers['content-encoding']).toBe('gzip');
+ expect(gunzipSync(res.body).toString()).toBe(bigJs);
+ });
+
+ it('serves files without variants as is', async () => {
+ const small = await get('/assets/small.js', { 'accept-encoding': 'br, gzip' });
+ expect(small.headers['content-encoding']).toBeUndefined();
+ expect(small.body.toString()).toBe('export {};');
+ const png = await get('/assets/image.png', { 'accept-encoding': 'br' });
+ expect(png.headers['content-encoding']).toBeUndefined();
+ expect(png.headers['vary']).toBeUndefined();
+ expect(png.body.length).toBe(4096);
+ });
+
+ it('leaves handler responses alone', async () => {
+ const res = await get('/missing', { 'accept-encoding': 'br' });
+ expect(res.status).toBe(404);
+ expect(res.headers['content-encoding']).toBeUndefined();
+ expect(res.body.toString()).toBe('from handler');
+ });
+
+ it('ignores variants when the build did not precompress', async () => {
+ const plain = await startListener(clientDir, false);
+ try {
+ const res = await plain.get('/assets/app.js', { 'accept-encoding': 'br' });
+ expect(res.headers['content-encoding']).toBeUndefined();
+ expect(res.headers['vary']).toBeUndefined();
+ expect(res.body.toString()).toBe(bigJs);
+ } finally {
+ plain.server.close();
+ }
+ });
+});
diff --git a/src/node-entry/encoding.ts b/src/node-entry/encoding.ts
new file mode 100644
index 0000000..00cad38
--- /dev/null
+++ b/src/node-entry/encoding.ts
@@ -0,0 +1,52 @@
+// Shared by the build-time precompression and the Node entry's static handler.
+
+/** Text-like extensions; images and woff2 are compressed already, maps only reach devtools. */
+export const PRECOMPRESSIBLE_EXTENSIONS = new Set([
+ '.js',
+ '.mjs',
+ '.css',
+ '.html',
+ '.json',
+ '.svg',
+ '.txt',
+ '.xml',
+ '.webmanifest',
+ '.wasm',
+ '.ico',
+ '.ttf',
+ '.otf',
+]);
+
+/** Files smaller than this are served as they are. */
+export const PRECOMPRESS_MIN_BYTES = 1024;
+
+/** Content-Encoding token and file suffix, in server preference order. */
+export const ENCODINGS = [
+ { name: 'br', suffix: '.br' },
+ { name: 'gzip', suffix: '.gz' },
+] as const;
+
+export type Encoding = (typeof ENCODINGS)[number];
+
+/** Encodings an `Accept-Encoding` header prefers over identity, by q-value, br first on ties. */
+export function acceptedEncodings(header: string | string[] | undefined): Encoding[] {
+ if (!header) return [];
+ const weights = new Map();
+ for (const part of String(header).split(',')) {
+ const [token, ...params] = part.split(';');
+ const name = token.trim().toLowerCase();
+ if (!name) continue;
+ let q = 1;
+ for (const param of params) {
+ const [key, value] = param.split('=');
+ if (key.trim().toLowerCase() === 'q') q = Number(value);
+ }
+ weights.set(name, Number.isFinite(q) ? q : 0);
+ }
+ const wildcard = weights.get('*') ?? 0;
+ const identity = weights.get('identity') ?? 0;
+ const weightOf = (encoding: Encoding) => weights.get(encoding.name) ?? wildcard;
+ return ENCODINGS.filter(
+ (encoding) => weightOf(encoding) > 0 && weightOf(encoding) >= identity,
+ ).sort((a, b) => weightOf(b) - weightOf(a));
+}
diff --git a/src/node-entry/index.ts b/src/node-entry/index.ts
index d4ca0c4..b571113 100644
--- a/src/node-entry/index.ts
+++ b/src/node-entry/index.ts
@@ -12,6 +12,7 @@ import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { handleRequest } from './server.js';
import { parseRequestTarget, sendWebResponse, webRequestFromNode } from '../http.js';
+import { acceptedEncodings, PRECOMPRESSIBLE_EXTENSIONS } from './encoding.js';
// Baked in at build time by the plugin (the generated header above this
// module's code).
@@ -26,9 +27,11 @@ declare const SOLID_NODE_CONFIG: {
spa: boolean;
/** The program file whose run listens: node.js, beside this file. */
entry: string;
+ /** The build wrote `.br` / `.gz` variants beside the client files. */
+ precompressed: boolean;
};
-const { clientDir, assetsDir, base, spa, entry } = SOLID_NODE_CONFIG;
+const { clientDir, assetsDir, base, spa, entry, precompressed } = SOLID_NODE_CONFIG;
const here = path.dirname(fileURLToPath(import.meta.url));
const clientRoot = path.resolve(here, clientDir);
const assetsRoot = assetsDir ? path.join(clientRoot, assetsDir) + path.sep : null;
@@ -64,21 +67,38 @@ function isReadRequest(req: IncomingMessage): boolean {
return req.method === 'GET' || req.method === 'HEAD';
}
-/** Sends a regular file from the client build; false when it does not exist. */
-async function sendFile(req: IncomingMessage, res: ServerResponse, file: string): Promise {
- let stats;
+async function statFile(file: string) {
try {
- stats = await stat(file);
+ const stats = await stat(file);
+ return stats.isFile() ? stats : null;
} catch {
- return false;
+ return null;
+ }
+}
+
+/** The best precompressed sibling of `file` the request accepts, if one was built. */
+async function findVariant(req: IncomingMessage, file: string) {
+ for (const encoding of acceptedEncodings(req.headers['accept-encoding'])) {
+ const variant = file + encoding.suffix;
+ const stats = await statFile(variant);
+ if (stats) return { encoding, file: variant, size: stats.size };
}
- if (!stats.isFile()) return false;
+ return null;
+}
+
+/** Sends a regular file from the client build; false when it does not exist. */
+async function sendFile(req: IncomingMessage, res: ServerResponse, file: string): Promise {
+ const stats = await statFile(file);
+ if (!stats) return false;
+ const extension = path.extname(file).toLowerCase();
+ const compressible = precompressed && PRECOMPRESSIBLE_EXTENSIONS.has(extension);
+ const variant = compressible ? await findVariant(req, file) : null;
res.statusCode = 200;
- res.setHeader(
- 'Content-Type',
- MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
- );
- res.setHeader('Content-Length', stats.size);
+ res.setHeader('Content-Type', MIME[extension] ?? 'application/octet-stream');
+ // Set even on identity responses: a shared cache must not reuse them for br/gzip clients.
+ if (compressible) res.setHeader('Vary', 'Accept-Encoding');
+ if (variant) res.setHeader('Content-Encoding', variant.encoding.name);
+ res.setHeader('Content-Length', variant ? variant.size : stats.size);
if (assetsRoot && file.startsWith(assetsRoot)) {
// Hashed file names: cache forever.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
@@ -91,7 +111,7 @@ async function sendFile(req: IncomingMessage, res: ServerResponse, file: string)
return true;
}
// pipeline destroys the read stream if the client goes away mid-file.
- await pipeline(createReadStream(file), res).catch(() => res.destroy());
+ await pipeline(createReadStream(variant ? variant.file : file), res).catch(() => res.destroy());
return true;
}
diff --git a/src/precompress.ts b/src/precompress.ts
new file mode 100644
index 0000000..a01d86e
--- /dev/null
+++ b/src/precompress.ts
@@ -0,0 +1,67 @@
+// Client build variants for `start.nodeServer`, like Nitro's `compressPublicAssets`.
+import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { availableParallelism } from 'node:os';
+import path from 'node:path';
+import { promisify } from 'node:util';
+import { brotliCompress, constants, gzip } from 'node:zlib';
+import {
+ ENCODINGS,
+ PRECOMPRESS_MIN_BYTES,
+ PRECOMPRESSIBLE_EXTENSIONS,
+ type Encoding,
+} from './node-entry/encoding.js';
+
+const brotliAsync = promisify(brotliCompress);
+const gzipAsync = promisify(gzip);
+
+function compress(encoding: Encoding, source: Buffer): Promise {
+ if (encoding.name === 'br') {
+ return brotliAsync(source, {
+ params: {
+ [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
+ [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
+ [constants.BROTLI_PARAM_SIZE_HINT]: source.length,
+ },
+ });
+ }
+ return gzipAsync(source, { level: constants.Z_BEST_COMPRESSION });
+}
+
+/** Writes the variants worth having and removes stale ones, which the server would prefer. */
+export async function precompressFile(file: string): Promise {
+ const source = await readFile(file);
+ await Promise.all(
+ ENCODINGS.map(async (encoding) => {
+ const variant = file + encoding.suffix;
+ const compressed =
+ source.length >= PRECOMPRESS_MIN_BYTES ? await compress(encoding, source) : null;
+ if (compressed && compressed.length < source.length) {
+ await writeFile(variant, compressed);
+ } else {
+ await rm(variant, { force: true });
+ }
+ }),
+ );
+}
+
+/** Precompresses every eligible file under `dir`, skipping dot segments (.vite/). */
+export async function precompressDir(dir: string): Promise {
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
+ const files = entries
+ .filter(
+ (entry) =>
+ entry.isFile() && PRECOMPRESSIBLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()),
+ )
+ .map((entry) => path.join(entry.parentPath, entry.name))
+ .filter((file) =>
+ path
+ .relative(dir, file)
+ .split(path.sep)
+ .every((segment) => !segment.startsWith('.')),
+ );
+ let next = 0;
+ const worker = async () => {
+ while (next < files.length) await precompressFile(files[next++]);
+ };
+ await Promise.all(Array.from({ length: Math.min(files.length, availableParallelism()) }, worker));
+}
diff --git a/src/ssr/index.ts b/src/ssr/index.ts
index b9e76c3..a4f3518 100644
--- a/src/ssr/index.ts
+++ b/src/ssr/index.ts
@@ -81,6 +81,7 @@ import {
renderDevStyleTag,
} from '../dev-manifest.js';
import { joinBase, parseRequestTarget, sendWebResponse, webRequestFromNode } from '../http.js';
+import { precompressDir, precompressFile } from '../precompress.js';
/**
* Options for the main plugin's `start` option (`start: true` is
@@ -339,6 +340,13 @@ export interface StartOptions {
* to the framework; `event` adds request-event fields next to
* `nativeEvent`) and `serve({ port?, host?, static?, event? })`.
*
+ * The build also precompresses the client build for it: a `.br` and a
+ * `.gz` beside each text file (JS, CSS, HTML, JSON, SVG and the like) of
+ * 1 KiB or more, served by `Accept-Encoding` with `Vary: Accept-Encoding`.
+ * Rendered pages and other handler responses stream uncompressed.
+ * `nodeServer: { precompress: false }` skips the extra files, for when a
+ * proxy or CDN compresses instead.
+ *
* Applies to both start modes. In SSR mode the entry renders pages; in
* client mode (with `serverFunctions`, which keeps `dist/server`) it
* serves `dist/client` with an `index.html` history fallback for HTML
@@ -351,7 +359,18 @@ export interface StartOptions {
*
* @default false
*/
- nodeServer?: boolean;
+ nodeServer?: boolean | NodeServerOptions;
+}
+
+/** The object form of {@link StartOptions.nodeServer}. */
+export interface NodeServerOptions {
+ /**
+ * Write `.br` and `.gz` variants of the client build's text files for the
+ * Node entry to serve.
+ *
+ * @default true
+ */
+ precompress?: boolean;
}
// Server-only start-mode request handler; also the server bundle's entry so a
@@ -422,6 +441,8 @@ interface NodeEntryConfig {
spa: boolean;
/** The file whose run listens (node.js even when the runtime sits beside it). */
entry: string;
+ /** The client build has `.br` / `.gz` variants to serve. */
+ precompressed: boolean;
}
/**
@@ -723,6 +744,9 @@ export function startServe(
const nodeEntry = !!options.nodeServer;
const nodeEntryApplies =
nodeEntry && !externalServer && (!clientMode || !!internal.serverFunctions);
+ const precompress =
+ nodeEntryApplies &&
+ (typeof options.nodeServer !== 'object' || options.nodeServer.precompress !== false);
// Resolved build outDirs (absolute) of the client and ssr environments,
// for the entry's client-directory constant. The client's generateBundle
// overrides with the directory it actually wrote to (a host orchestrator
@@ -1999,6 +2023,7 @@ export function startServe(
base,
spa: clientMode,
entry: NODE_ENTRY_FILE,
+ precompressed: precompress,
},
serverDirName,
);
@@ -2014,6 +2039,13 @@ export function startServe(
}
},
},
+ async writeBundle(outputOptions) {
+ if (!isBuild || !precompress) return;
+ if (getEnvironmentConsumer(this.environment) !== 'client') return;
+ const dir = outputOptions.dir ? path.resolve(root, outputOptions.dir) : clientOutDir;
+ // The written dir holds the bundle and the copied public dir alike.
+ if (dir) await precompressDir(dir);
+ },
async resolveId(source, importer, opts) {
if (source === HANDLER_ID) {
return { id: HANDLER_ID, moduleSideEffects: true };
@@ -2361,7 +2393,9 @@ export function startServe(
`[@solidjs/vite-plugin] prerendering the client-mode shell failed: the handler answered ${response.status}`,
);
}
- writeFileSync(path.join(clientOutDir!, 'index.html'), await response.text());
+ const shellFile = path.join(clientOutDir!, 'index.html');
+ writeFileSync(shellFile, await response.text());
+ if (precompress) await precompressFile(shellFile);
// A host may still need this service for its final server
// build, even when the application has no server functions.
// Pre-order buildApp hooks prepare (they clean output) and