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