Skip to content
Open
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/start-early-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': patch
---

New `start.earlyHints` option, off by default. A production SSR build sends the client entry's scripts and stylesheets as `103 Early Hints` before a page renders, so the browser can start downloading them sooner. The `start.node` server and `vite preview` send a real `103`, other hosts can pass an `earlyHints` callback to `handleRequest`, and otherwise the links are added as `Link` headers on the page response.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ The object form carries the options (`start: true` is pure sugar for
`start: {}` — both mean the identical start mode with defaults, and
`false`/absent means off): `app`, `document`, `entryServer`, `entryClient`,
`middleware`, `setup`, `instrument`, `clientSetup`, `renderMode`, `env`,
`devtools`, `errorBoundary`,
`devtools`, `errorBoundary`, `earlyHints`,
`css`, `external`, `node`, all documented below.

Install `@solidjs/start-devtools` as a development dependency to add the
Expand Down Expand Up @@ -751,6 +751,34 @@ generated-entry codegen. Disable it with `start: { errorBoundary: false }`
when application middleware owns error handling (an error middleware only
sees the throw when no boundary catches it first). Default: `true`.

**`earlyHints`** sends the client entry's scripts and stylesheets as
`103 Early Hints` before a page renders. The browser starts downloading
them while the server is still running middleware, `setup` and the render.

```ts
solid({ start: { earlyHints: true }, ssr: true });
```

- The hints go out once the middleware chain reaches the page render.
Requests that middleware answers, and server-function calls, get none.
- The built-in Node servers (`node` and `vite preview`) send a real `103`.
- Another host can do the same by passing a callback to `handleRequest`.
Each link is a `Link` header value:

```js
handleRequest(request, {
earlyHints: (links) => res.writeEarlyHints({ link: links }),
});
```

- Without a callback, the links are added as `Link` headers on the page
response. CDNs such as Cloudflare turn those into `103` responses.
- Stylesheets are hinted as `rel=preload; as=style` and scripts as
`rel=modulepreload`.

It applies to production builds in SSR mode. It is off by default, because
some proxies and clients mishandle `1xx` responses. Default: `false`.

**`css.filter`** — include/exclude patterns
([picomatch](https://github.com/micromatch/picomatch) globs or regexes;
relative globs resolve against the Vite root) for the module graphs the dev
Expand Down
200 changes: 199 additions & 1 deletion examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@
// the app graph and finishes its top-level await before hydrate(); an
// edit reloads the page; an authored entry-client with the option is a
// config error (see runClientSetupMode).
// - `start.earlyHints` (early-hints mode, START_EARLY_HINTS=1): the Node
// entry and `vite preview` send a 103 with the client entry's scripts
// and stylesheets, a host without the callback gets Link headers, and
// nothing is hinted without the option (see runEarlyHintsMode).
// - `performanceTracks` (on by default): under dev serve the generated and
// authored client entries import the plugin's virtual module ahead of
// everything else, the served module calls enablePerformanceTracks(),
Expand All @@ -173,7 +177,7 @@
//
// Requires the plugin built (pnpm build at the repo root) and Google Chrome.
// Usage: node test/run.mjs
// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|nonce|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node|client-setup]
// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|nonce|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node|client-setup|early-hints]
// (default: all)

import { spawn, execSync, execFileSync } from 'node:child_process';
Expand Down Expand Up @@ -7338,6 +7342,198 @@ async function runClientSetupMode() {
}
}

// `start.earlyHints`: a production SSR build hints the client entry's
// scripts and stylesheets before the page renders.
// - The `start.node` entry and `vite preview` send a real 103 response.
// - server.js calls handleRequest without an `earlyHints` callback, so the
// links land as Link headers on the page response instead.
// - Hinted URLs are the entry script the page loads and its stylesheets,
// and each one exists.
// - Requests that never reach the page render get no hints.
// - Without the option nothing is hinted.
async function runEarlyHintsMode() {
const mode = 'early-hints';
console.log(`\n=== ${mode.toUpperCase()} ===`);
const nodePort = 3190;
const serverPort = 3191;
const previewPort = 3192;

const splitLinks = (value) =>
value ? (Array.isArray(value) ? value : [value]).flatMap((v) => v.split(/,\s*(?=<)/)) : [];
const targetOf = (link) => /^<([^>]+)>/.exec(link)?.[1];
// Requests a URL with node:http to see 1xx responses, which fetch() hides.
const requestWithHints = (url, { method = 'GET', accept = 'text/html' } = {}) =>
new Promise((resolve, reject) => {
const hints = [];
const req = http.request(url, { method, headers: { accept } }, (res) => {
let body = '';
res.setEncoding('utf-8');
res.on('data', (d) => (body += d));
res.on('end', () =>
resolve({ status: res.statusCode, hints, link: res.headers.link ?? null, body }),
);
});
req.on('information', (info) => {
if (info.statusCode === 103) hints.push(...splitLinks(info.headers.link));
});
req.on('error', reject);
req.end();
});

// What the page itself loads: its module entry script and stylesheets.
const pageAssets = (html) => ({
entry: /<script type="module"[^>]*src="([^"]+)"/.exec(html)?.[1],
styles: [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map((m) => m[1]),
});

const checkHints = async (phase, origin, links, html) => {
const { entry, styles } = pageAssets(html);
const scripts = links.filter((l) => l.endsWith('; rel=modulepreload')).map(targetOf);
const hintedStyles = links.filter((l) => l.endsWith('; rel=preload; as=style')).map(targetOf);
record(
mode,
phase,
'the entry script the page loads is hinted as modulepreload',
!!entry && scripts.includes(entry),
`entry: ${entry}; hints: ${links.join(' | ')}`,
);
record(
mode,
phase,
'the entry stylesheets are hinted as style preloads',
hintedStyles.length > 0 && hintedStyles.every((href) => styles.includes(href)),
`styles: ${styles.join(', ')}; hinted: ${hintedStyles.join(', ')}`,
);
const missing = [];
for (const href of [...scripts, ...hintedStyles]) {
const res = await fetch(origin + href);
if (res.status !== 200) missing.push(`${href} (${res.status})`);
}
record(mode, phase, 'every hinted URL exists', missing.length === 0, missing.join(', '));
};

let server;
let serverLog = '';
const start = (cmd, args, env, origin) => {
server = startProcess(cmd, args, { cwd: exampleDir, env });
server.stdout.on('data', (d) => (serverLog += d));
server.stderr.on('data', (d) => (serverLog += d));
return waitForHttp(origin + '/', 30000, { headers: { accept: 'text/html' } });
};
const stop = () => {
try {
process.kill(-server.pid, 'SIGTERM');
} catch {}
server = undefined;
};

try {
const env = { ...process.env, START_EARLY_HINTS: '1', START_NODE: '1', SSR_DEVTOOLS: '0' };
console.log(' building…');
execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env });

// ---- start.node: a real 103 -----------------------------------------
let origin = `http://localhost:${nodePort}`;
await start('node', ['dist/server/node.js'], { ...env, PORT: String(nodePort) }, origin);
const page = await requestWithHints(origin + '/');
record(
mode,
'node',
'a page request gets a 103 with Link values',
page.status === 200 && page.hints.length > 0,
`status: ${page.status}; hints: ${page.hints.length}`,
);
await checkHints('node', origin, page.hints, page.body);
record(
mode,
'node',
'the page response carries no Link header (the 103 replaced it)',
page.link === null,
String(page.link),
);
const endpoint = await requestWithHints(origin + '/_server/missing', {
method: 'POST',
accept: '*/*',
});
record(
mode,
'node',
'a server-function request gets no hints',
endpoint.hints.length === 0 && endpoint.link === null,
`status: ${endpoint.status}; hints: ${endpoint.hints.join(' | ')}; link: ${endpoint.link}`,
);
stop();

// ---- server.js: no callback, Link headers ----------------------------
origin = `http://localhost:${serverPort}`;
await start(
'node',
['server.js'],
{ ...env, PORT: String(serverPort), NODE_ENV: 'production' },
origin,
);
const fallback = await requestWithHints(origin + '/');
const headerLinks = splitLinks(fallback.link);
record(
mode,
'link-header',
'without a callback the links are Link headers on the page, with no 103',
fallback.hints.length === 0 && headerLinks.length > 0,
`hints: ${fallback.hints.length}; link: ${fallback.link}`,
);
await checkHints('link-header', origin, headerLinks, fallback.body);
stop();

// ---- vite preview: a real 103 ----------------------------------------
origin = `http://localhost:${previewPort}`;
await start(
'pnpm',
['exec', 'vite', 'preview', '--port', String(previewPort), '--strictPort'],
env,
origin,
);
const preview = await requestWithHints(origin + '/');
record(
mode,
'preview',
'vite preview sends a 103 for a page request',
preview.status === 200 && preview.hints.length > 0 && preview.link === null,
`status: ${preview.status}; hints: ${preview.hints.length}; link: ${preview.link}`,
);
stop();

// ---- off --------------------------------------------------------------
const offEnv = { ...process.env, START_NODE: '1', SSR_DEVTOOLS: '0' };
console.log(' building (earlyHints off)…');
execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env: offEnv });
origin = `http://localhost:${nodePort}`;
await start('node', ['dist/server/node.js'], { ...offEnv, PORT: String(nodePort) }, origin);
const off = await requestWithHints(origin + '/');
record(
mode,
'off',
'without the option there is no 103 and no Link header',
off.status === 200 && off.hints.length === 0 && off.link === null,
`status: ${off.status}; hints: ${off.hints.length}; link: ${off.link}`,
);
stop();
} catch (e) {
record(
mode,
'run',
'mode completed',
false,
String(e?.stack ?? e) + (serverLog ? `\nserver: ${serverLog.slice(-2000)}` : ''),
);
} finally {
if (server) stop();
// Leave dist in the standard state for anyone poking at it.
try {
execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe' });
} catch {}
}
}

const ALL_MODES = [
'dev',
'prod',
Expand Down Expand Up @@ -7365,6 +7561,7 @@ const ALL_MODES = [
'vitest',
'node',
'client-setup',
'early-hints',
];
const arg = process.argv[2];
const modes = ALL_MODES.includes(arg) ? [arg] : ALL_MODES;
Expand Down Expand Up @@ -7394,6 +7591,7 @@ for (const mode of modes) {
else if (mode === 'vitest') await runVitestMode();
else if (mode === 'node') await runNodeMode();
else if (mode === 'client-setup') await runClientSetupMode();
else if (mode === 'early-hints') await runEarlyHintsMode();
else await runDetectMode();
}

Expand Down
3 changes: 3 additions & 0 deletions examples/start-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import solidPlugin from '@solidjs/vite-plugin';
// same function from a later, normal-order plugin's `outputOptions` hook,
// which the solid plugin's post-order hook must still wrap; `off` is
// `false`, the opt-out the plugin leaves alone (#391).
// - START_EARLY_HINTS=1 (early-hints mode) sets `start.earlyHints`.
// - START_NODE=1 (node mode) sets `start.node`: the build emits the
// ready-to-run Node server entry dist/server/node.js beside server.js.
// - SOLID_PERF_TRACKS (perf-tracks mode) sets `performanceTracks`: `0` opts
Expand Down Expand Up @@ -219,6 +220,8 @@ export default defineConfig({
...(process.env.SSR_DEVTOOLS === '0' ? { devtools: false } : {}),
// START_NODE=1 (node mode): emit the Node server entry.
...(process.env.START_NODE ? { node: true } : {}),
// START_EARLY_HINTS=1 (early-hints mode): send 103 Early Hints.
...(process.env.START_EARLY_HINTS ? { earlyHints: 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
10 changes: 10 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ export async function sendWebResponse(res: ServerResponse, response: Response):
}
}

// The `earlyHints` callback the Node bridges pass to `handleRequest`: sends
// the links as a `103 Early Hints` response. Hints only help before the
// response head is sent, so it does nothing after that.
export function nodeEarlyHints(res: ServerResponse): (links: string[]) => void {
return (links) => {
if (res.headersSent || typeof res.writeEarlyHints !== 'function') return;
res.writeEarlyHints({ link: links });
};
}

export function joinBase(base: string, pathname: string): string {
// Absolute-URL or relative bases (CDN deploys, './') don't prefix
// same-origin server paths.
Expand Down
3 changes: 2 additions & 1 deletion src/node-entry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { handleRequest } from './server.js';
import { sendWebResponse, webRequestFromNode } from '../http.js';
import { nodeEarlyHints, sendWebResponse, webRequestFromNode } from '../http.js';

// Baked in at build time by the plugin (the generated header above this
// module's code).
Expand Down Expand Up @@ -152,6 +152,7 @@ export function createListener({
// `nativeEvent` is the raw Node request, readable via getRequestEvent().
const response = await handleRequest(webRequestFromNode(req, undefined, res), {
event: { nativeEvent: req, ...event?.(req) },
earlyHints: nodeEarlyHints(res),
});
await sendWebResponse(res, response);
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/node-entry/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
// The import stays external in the runtime artifact — see rollup.config.js.
export function handleRequest(
request: Request,
options?: { event?: Record<string, unknown> },
options?: { event?: Record<string, unknown>; earlyHints?: (links: string[]) => void },
): Promise<Response>;
Loading
Loading