Skip to content

Commit 9ce2a5d

Browse files
lxsmnsycclaudebrenelz
authored
feat(server): add an earlyHints option for 103 Early Hints (#2350)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brenley Dueck <brenleydueck@gmail.com>
1 parent 6a9fb75 commit 9ce2a5d

7 files changed

Lines changed: 145 additions & 2 deletions

File tree

‎.changeset/early-hints.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Add an `earlyHints` option that sends the page's assets as `103 Early Hints`, so the browser can start downloading them while the server is still rendering. It covers the app's entry assets and the assets of the routes being rendered. On runtimes other than Node, the same hints are added as `Link` headers on the response. Off by default, and only applies to production builds.

‎packages/start/src/config/index.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ export interface SolidStartOptions {
6767
*/
6868
devOverlay?: boolean;
6969

70+
/**
71+
* Send the page's assets as `103 Early Hints` before the page is rendered, so
72+
* the browser can start downloading them while the server is still working.
73+
* This covers the app's entry assets and the assets of the routes being
74+
* rendered. On runtimes other than Node, the hints are added as `Link`
75+
* headers on the response instead. Only applies to production builds.
76+
*
77+
* Off by default, because some proxies and clients mishandle `1xx` responses.
78+
*
79+
* @default false
80+
*/
81+
earlyHints?: boolean;
82+
7083
/**
7184
* Reload the page once when a route's code fails to load during client-side
7285
* navigation, such as when a deploy replaced it while the page was open.
@@ -241,6 +254,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
241254
routeDir: "./routes",
242255
ssr: true,
243256
devOverlay: true,
257+
earlyHints: false,
244258
reloadOnRouteChunkError: true,
245259
experimental: {
246260
islands: false,
@@ -384,6 +398,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
384398
"import.meta.env.START_CLIENT_ENTRY": JSON.stringify(handlers.client),
385399
"import.meta.env.START_CLIENT_ENTRY_URL": JSON.stringify(clientEntryUrl),
386400
"import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay),
401+
"import.meta.env.START_EARLY_HINTS": JSON.stringify(start.earlyHints),
387402
"import.meta.env.START_RELOAD_ON_ROUTE_CHUNK_ERROR": JSON.stringify(
388403
start.reloadOnRouteChunkError,
389404
),

‎packages/start/src/env.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface SolidStartMetaEnv {
1313
START_CLIENT_ENTRY: string;
1414
START_CLIENT_ENTRY_URL: string;
1515
START_ISLANDS: boolean;
16+
START_EARLY_HINTS: boolean;
1617
START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean;
1718
// START_DEV_OVERLAY: boolean;
1819
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { Asset } from "./assets/render.tsx";
3+
import type { FetchEvent } from "./types.ts";
4+
5+
const writeEarlyHints = vi.fn();
6+
vi.mock("h3", () => ({ writeEarlyHints }));
7+
8+
const { sendEarlyHints, toLinkValues } = await import("./early-hints.ts");
9+
10+
const assets: Asset[] = [
11+
{ tag: "link", attrs: { href: "/_build/assets/entry-client.css", rel: "stylesheet" } },
12+
{ tag: "link", attrs: { href: "/_build/assets/entry-client.js", rel: "modulepreload" } },
13+
{ tag: "link", attrs: { href: "/_build/assets/漢字-abc.js", rel: "modulepreload" } },
14+
{ tag: "style", attrs: {}, children: "body {}" },
15+
{ tag: "link", attrs: { href: "/favicon.ico", rel: "icon" } },
16+
];
17+
18+
function createEvent(headersSent = false) {
19+
return { nativeEvent: { runtime: { node: { res: { headersSent } } } } } as unknown as FetchEvent;
20+
}
21+
22+
describe("toLinkValues", () => {
23+
it("preloads stylesheets and modulepreloads scripts, like the page does", () => {
24+
expect(toLinkValues(assets)).toEqual([
25+
"</_build/assets/entry-client.css>; rel=preload; as=style",
26+
"</_build/assets/entry-client.js>; rel=modulepreload",
27+
"</_build/assets/%E6%BC%A2%E5%AD%97-abc.js>; rel=modulepreload",
28+
]);
29+
});
30+
});
31+
32+
describe("sendEarlyHints", () => {
33+
beforeEach(() => {
34+
vi.stubEnv("PROD", true);
35+
// A build inlines a boolean here. Vitest stores env values as strings, so a
36+
// test can only set it (truthy) or leave it unset (falsy).
37+
import.meta.env.START_EARLY_HINTS = true;
38+
});
39+
40+
afterEach(() => {
41+
vi.unstubAllEnvs();
42+
delete (import.meta.env as Partial<ImportMetaEnv>).START_EARLY_HINTS;
43+
writeEarlyHints.mockReset();
44+
});
45+
46+
it("sends the assets as early hints", () => {
47+
const event = createEvent();
48+
sendEarlyHints(event, assets);
49+
50+
expect(writeEarlyHints).toHaveBeenCalledWith(event.nativeEvent, {
51+
link: toLinkValues(assets),
52+
});
53+
});
54+
55+
it("does nothing when the earlyHints option is off", () => {
56+
delete (import.meta.env as Partial<ImportMetaEnv>).START_EARLY_HINTS;
57+
sendEarlyHints(createEvent(), assets);
58+
expect(writeEarlyHints).not.toHaveBeenCalled();
59+
});
60+
61+
it("does nothing outside production", () => {
62+
vi.stubEnv("PROD", false);
63+
sendEarlyHints(createEvent(), assets);
64+
expect(writeEarlyHints).not.toHaveBeenCalled();
65+
});
66+
67+
it("does nothing once the response headers are sent", () => {
68+
sendEarlyHints(createEvent(true), assets);
69+
expect(writeEarlyHints).not.toHaveBeenCalled();
70+
});
71+
72+
it("does nothing when there is nothing to hint", () => {
73+
sendEarlyHints(createEvent(), [{ tag: "style", attrs: {}, children: "body {}" }]);
74+
expect(writeEarlyHints).not.toHaveBeenCalled();
75+
});
76+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { writeEarlyHints } from "h3";
2+
import type { Asset } from "./assets/render.tsx";
3+
import type { FetchEvent } from "./types.ts";
4+
5+
/**
6+
* Turns the page's `<link>` assets into `Link` header values: stylesheets as
7+
* `preload` and scripts as `modulepreload`, the same way the page links them.
8+
*/
9+
export function toLinkValues(assets: Asset[]): string[] {
10+
const links: string[] = [];
11+
for (const asset of assets) {
12+
if (asset.tag !== "link") continue;
13+
const { href, rel } = asset.attrs;
14+
if (typeof href !== "string") continue;
15+
// Header values must be ASCII, and a chunk name can contain any character
16+
// a route file name can.
17+
const target = `<${encodeURI(href)}>`;
18+
if (rel === "stylesheet") links.push(`${target}; rel=preload; as=style`);
19+
else if (rel === "modulepreload") links.push(`${target}; rel=modulepreload`);
20+
}
21+
return links;
22+
}
23+
24+
/**
25+
* Sends the assets as `103 Early Hints` when the server runs on Node, or adds
26+
* them as `Link` headers on the response otherwise. Only in production, and
27+
* only when the `earlyHints` option is on.
28+
*
29+
* Hints only help before the response headers are sent, so this does nothing
30+
* once they are.
31+
*
32+
* @see https://github.com/solidjs/solid-start/issues/1935
33+
*/
34+
export function sendEarlyHints(event: FetchEvent, assets: Asset[]): void {
35+
if (!import.meta.env.PROD || !import.meta.env.START_EARLY_HINTS) return;
36+
if (event.nativeEvent.runtime?.node?.res?.headersSent) return;
37+
const links = toLinkValues(assets);
38+
if (links.length === 0) return;
39+
void writeEarlyHints(event.nativeEvent, { link: links });
40+
}

‎packages/start/src/server/handler.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getRequestEvent, renderToStream, renderToString } from "solid-js/web";
66

77
import { createRoutes } from "../router.tsx";
88
import { decorateHandler, decorateMiddleware } from "./fetchEvent.ts";
9+
import { sendEarlyHints } from "./early-hints.ts";
910
import { getSsrManifest } from "./manifest/ssr-manifest.ts";
1011
import { matchAPIRoute } from "./routes.ts";
1112
import { handleServerFunction } from "../fns/handler.ts";
@@ -150,6 +151,7 @@ export async function createPageEvent(ctx: FetchEvent) {
150151
// )
151152
// : [])
152153
];
154+
sendEarlyHints(ctx, assets);
153155
const pageEvent: PageEvent = Object.assign(ctx, {
154156
assets,
155157
router: {

‎packages/start/src/shared/lazy.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Component, lazy as solidLazy } from "solid-js";
22
import { getRequestEvent, isServer } from "solid-js/web";
33
import { getManifest } from "solid-start:get-manifest";
44
import { useAssets } from "../server/assets/index.ts";
5+
import { sendEarlyHints } from "../server/early-hints.ts";
56
import { Asset, PageEvent } from "../server/types.ts";
67

78
const assetsById: Record<string, Asset[]> = {};
@@ -34,8 +35,11 @@ const withAssets = function <T extends () => Promise<{ default: Component<any> }
3435

3536
return {
3637
default: (props: any) => {
37-
const { nonce } = getRequestEvent() as PageEvent;
38-
useAssets(assets, nonce);
38+
const event = getRequestEvent() as PageEvent;
39+
// Runs on every render, unlike the code above, which runs once per
40+
// module because lazy() caches it.
41+
sendEarlyHints(event, assets);
42+
useAssets(assets, event.nonce);
3943

4044
return mod.default(props);
4145
},

0 commit comments

Comments
 (0)