Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-server-precompress.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import http from 'node:http';
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions examples/start-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -60,7 +65,7 @@ export type {
PersistedServerFunctionManifest,
ServerFunctionsFilter,
} from './server-functions/index.js';
export type { StartOptions };
export type { NodeServerOptions, StartOptions };
export type {
StartMiddleware,
StartRequestEvent,
Expand Down
227 changes: 227 additions & 0 deletions src/node-entry-compression.test.ts
Original file line number Diff line number Diff line change
@@ -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, `<!doctype html><script>${bigJs}</script>`);
await precompressFile(shell);
expect(existsSync(shell + '.br')).toBe(true);
expect(existsSync(shell + '.gz')).toBe(true);
});
});

type Reply = { status: number; headers: Record<string, unknown>; 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<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const get = (pathname: string, headers: Record<string, string> = {}, method = 'GET') =>
new Promise<Reply>((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<ReturnType<typeof startListener>>['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();
}
});
});
Loading
Loading