From a08788b2db0a92b426bf56a2716b850cbafb058a Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 10:18:00 +0800 Subject: [PATCH] feat(start): send 103 Early Hints for the client entry assets - New start.earlyHints option, off by default, as SolidStart's earlyHints. - Before a page renders, the generated handler builds Link values for the client entry, its static imports, and their stylesheets from the client manifest. - handleRequest accepts an earlyHints(links) callback. The start.node entry and vite preview pass one that sends a real 103. - Without a callback, the links are added as Link headers on the page. - New early-hints mode in the start-ssr e2e suite. Co-Authored-By: Claude Opus 5.5 --- .changeset/start-early-hints.md | 5 + README.md | 30 ++++- examples/start-ssr/test/run.mjs | 200 +++++++++++++++++++++++++++++- examples/start-ssr/vite.config.ts | 3 + src/http.ts | 10 ++ src/node-entry/index.ts | 3 +- src/node-entry/server.d.ts | 2 +- src/ssr/index.ts | 101 ++++++++++++--- 8 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 .changeset/start-early-hints.md diff --git a/.changeset/start-early-hints.md b/.changeset/start-early-hints.md new file mode 100644 index 00000000..aec84832 --- /dev/null +++ b/.changeset/start-early-hints.md @@ -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. diff --git a/README.md b/README.md index 66f402e0..d52ebdd0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 9fef49a3..c28f1c25 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -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(), @@ -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'; @@ -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: /