@rsc-kit/core 0.20.2 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ export interface CriticalAssets {
2
+ /** Stylesheets the document links: `<link rel="stylesheet">`. */
3
+ styles: string[];
4
+ /** The client entry: `<script type="module">`'s import. */
5
+ modules: string[];
6
+ /** Fonts the document preloads: `<link rel="preload" as="font">`. */
7
+ fonts: string[];
8
+ }
9
+ /** One `Link` header value for the set. Empty string for an empty set. */
10
+ export declare function linkHeader(assets: CriticalAssets): string;
11
+ /**
12
+ * What a stored document's head names: its stylesheets and preloaded fonts.
13
+ * The first 16 KB, which is the head of any document this build writes.
14
+ */
15
+ export declare function criticalAssetsOf(html: string): CriticalAssets;
16
+ /** One set over another: the document's own names first, the build's where the document has none. */
17
+ export declare function mergeAssets(primary: CriticalAssets, fallback: CriticalAssets | null): CriticalAssets;
@@ -0,0 +1,65 @@
1
+ // The Link header a document answers with, for a CDN to send ahead as 103
2
+ // Early Hints - so the stylesheet, the client entry and the fonts are on
3
+ // their way while the HTML is still being written. Cloudflare turns the
4
+ // header into hints and caches them at the edge for the next visitor;
5
+ // anything else passes it through, where a browser still reads it as the
6
+ // document arrives.
7
+ //
8
+ // The critical set and nothing more: a hint promotes whatever it names, and
9
+ // naming every chunk demotes the document behind them. The stylesheet and
10
+ // the client entry come from the build's own manifest, the same names the
11
+ // document carries. The fonts are the app's - preloaded in its layout - and
12
+ // are read from a stored document's own head; a rendered document's head is
13
+ // not known until it is rendered, after its headers have gone.
14
+ /** One `Link` header value for the set. Empty string for an empty set. */
15
+ export function linkHeader(assets) {
16
+ const parts = [];
17
+ for (const href of assets.styles)
18
+ parts.push(`<${href}>; rel=preload; as=style`);
19
+ for (const href of assets.modules)
20
+ parts.push(`<${href}>; rel=modulepreload`);
21
+ for (const href of assets.fonts)
22
+ parts.push(`<${href}>; rel=preload; as=font; crossorigin`);
23
+ return parts.join(', ');
24
+ }
25
+ const HEAD_LIMIT = 16_384;
26
+ const LINK = /<link\s[^>]*>/g;
27
+ const ATTR = (name, tag) => {
28
+ const match = new RegExp(`\\b${name}=["']([^"']*)["']`, 'i').exec(tag);
29
+ return match ? match[1] : null;
30
+ };
31
+ /**
32
+ * What a stored document's head names: its stylesheets and preloaded fonts.
33
+ * The first 16 KB, which is the head of any document this build writes.
34
+ */
35
+ export function criticalAssetsOf(html) {
36
+ const head = html.slice(0, HEAD_LIMIT);
37
+ const found = { styles: [], modules: [], fonts: [] };
38
+ for (const tag of head.match(LINK) ?? []) {
39
+ const rel = ATTR('rel', tag)?.toLowerCase();
40
+ const href = ATTR('href', tag);
41
+ if (!href || !href.startsWith('/'))
42
+ continue;
43
+ if (rel === 'stylesheet')
44
+ found.styles.push(href);
45
+ else if (rel === 'preload' && ATTR('as', tag)?.toLowerCase() === 'font')
46
+ found.fonts.push(href);
47
+ }
48
+ // The bootstrap script is the last thing in the body, after the shell; its
49
+ // import names the entry. The tail, then, and the head for a short page.
50
+ const entry = /import\(["'](\/[^"']+\.js)["']\)/.exec(html.slice(-4096)) ?? /import\(["'](\/[^"']+\.js)["']\)/.exec(head);
51
+ if (entry)
52
+ found.modules.push(entry[1]);
53
+ return found;
54
+ }
55
+ /** One set over another: the document's own names first, the build's where the document has none. */
56
+ export function mergeAssets(primary, fallback) {
57
+ if (!fallback)
58
+ return primary;
59
+ return {
60
+ styles: primary.styles.length ? primary.styles : fallback.styles,
61
+ modules: primary.modules.length ? primary.modules : fallback.modules,
62
+ fonts: primary.fonts.length ? primary.fonts : fallback.fonts,
63
+ };
64
+ }
65
+ //# sourceMappingURL=earlyHints.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"earlyHints.js","sourceRoot":"","sources":["../src/earlyHints.ts"],"names":[],"mappings":"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","sourcesContent":["// The Link header a document answers with, for a CDN to send ahead as 103\n// Early Hints - so the stylesheet, the client entry and the fonts are on\n// their way while the HTML is still being written. Cloudflare turns the\n// header into hints and caches them at the edge for the next visitor;\n// anything else passes it through, where a browser still reads it as the\n// document arrives.\n//\n// The critical set and nothing more: a hint promotes whatever it names, and\n// naming every chunk demotes the document behind them. The stylesheet and\n// the client entry come from the build's own manifest, the same names the\n// document carries. The fonts are the app's - preloaded in its layout - and\n// are read from a stored document's own head; a rendered document's head is\n// not known until it is rendered, after its headers have gone.\n\nexport interface CriticalAssets {\n /** Stylesheets the document links: `<link rel=\"stylesheet\">`. */\n styles: string[];\n /** The client entry: `<script type=\"module\">`'s import. */\n modules: string[];\n /** Fonts the document preloads: `<link rel=\"preload\" as=\"font\">`. */\n fonts: string[];\n}\n\n/** One `Link` header value for the set. Empty string for an empty set. */\nexport function linkHeader(assets: CriticalAssets): string {\n const parts: string[] = []\n\n for (const href of assets.styles) parts.push(`<${href}>; rel=preload; as=style`)\n for (const href of assets.modules) parts.push(`<${href}>; rel=modulepreload`)\n for (const href of assets.fonts) parts.push(`<${href}>; rel=preload; as=font; crossorigin`)\n\n return parts.join(', ')\n}\n\nconst HEAD_LIMIT = 16_384\nconst LINK = /<link\\s[^>]*>/g\nconst ATTR = (name: string, tag: string): string | null => {\n const match = new RegExp(`\\\\b${name}=[\"']([^\"']*)[\"']`, 'i').exec(tag)\n\n return match ? match[1] : null\n}\n\n/**\n * What a stored document's head names: its stylesheets and preloaded fonts.\n * The first 16 KB, which is the head of any document this build writes.\n */\nexport function criticalAssetsOf(html: string): CriticalAssets {\n const head = html.slice(0, HEAD_LIMIT)\n const found: CriticalAssets = { styles: [], modules: [], fonts: [] }\n\n for (const tag of head.match(LINK) ?? []) {\n const rel = ATTR('rel', tag)?.toLowerCase()\n const href = ATTR('href', tag)\n\n if (!href || !href.startsWith('/')) continue\n\n if (rel === 'stylesheet') found.styles.push(href)\n else if (rel === 'preload' && ATTR('as', tag)?.toLowerCase() === 'font') found.fonts.push(href)\n }\n\n // The bootstrap script is the last thing in the body, after the shell; its\n // import names the entry. The tail, then, and the head for a short page.\n const entry = /import\\([\"'](\\/[^\"']+\\.js)[\"']\\)/.exec(html.slice(-4096)) ?? /import\\([\"'](\\/[^\"']+\\.js)[\"']\\)/.exec(head)\n\n if (entry) found.modules.push(entry[1])\n\n return found\n}\n\n/** One set over another: the document's own names first, the build's where the document has none. */\nexport function mergeAssets(primary: CriticalAssets, fallback: CriticalAssets | null): CriticalAssets {\n if (!fallback) return primary\n\n return {\n styles: primary.styles.length ? primary.styles : fallback.styles,\n modules: primary.modules.length ? primary.modules : fallback.modules,\n fonts: primary.fonts.length ? primary.fonts : fallback.fonts,\n }\n}\n"]}
package/dist/host.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * from `@rsc-kit/core/revalidate`.
4
4
  */
5
5
  export { revalidate } from "./revalidate.js";
6
+ import { type CriticalAssets } from "./earlyHints.js";
6
7
  /**
7
8
  * @internal For a host adapter that embeds the engine. An app imports this
8
9
  * from `@rsc-kit/core/redirect` - the one the guides teach, and the one an
@@ -18,6 +19,10 @@ import type { RouteManifest } from "./manifest.js";
18
19
  export interface RscEngine {
19
20
  /** The route table this bundle was built from. */
20
21
  manifest?(): RouteManifest;
22
+ /** A short id of this build's client, for the version the host answers with when given none. */
23
+ buildId?(): Promise<string>;
24
+ /** The stylesheet and client entry every document links, for the Link header a CDN sends ahead. */
25
+ criticalAssets?(): CriticalAssets;
21
26
  installHostFn(fn: (name: string, ...args: unknown[]) => unknown): void;
22
27
  handleRscStream(component: string, props?: Record<string, unknown>, layouts?: {
23
28
  component: string;
@@ -143,10 +148,11 @@ export interface RscHostOptions {
143
148
  /** Serve a built browser asset. Return null for anything not found. */
144
149
  assets?: (pathname: string, request: Request) => Promise<Response | null> | Response | null;
145
150
  /**
146
- * Identifies this build to the client, which compares it on every
147
- * navigation and falls back to a full load when it changes. Without one a
148
- * client keeps talking to a deployment that no longer exists — worst behind
149
- * a CDN, where the shell it holds may already be from an older build.
151
+ * Identifies this build to the client, which says it back on every
152
+ * navigation and is sent to load the document when it differs. For an
153
+ * engine with no `buildId` of its own; the generated one has, and every
154
+ * document it renders says that id, so a version named here would
155
+ * disagree with what the client was told and refuse every navigation.
150
156
  */
151
157
  version?: string;
152
158
  /**
package/dist/host.js CHANGED
@@ -27,6 +27,7 @@ import { currentNotFound, withRedirect } from "./redirect.js";
27
27
  import { compressed } from "./compress.js";
28
28
  import { withCache } from "./cache.js";
29
29
  import { takeAfterWork, withRequest, withResponseDraft } from "./request.js";
30
+ import { criticalAssetsOf, linkHeader, mergeAssets } from "./earlyHints.js";
30
31
  /**
31
32
  * @internal For a host adapter that embeds the engine. An app imports this
32
33
  * from `@rsc-kit/core/redirect` - the one the guides teach, and the one an
@@ -286,7 +287,13 @@ function matchPage(routes, url) {
286
287
  return matchRoute(routes, url.pathname, hostOf.get(url) ?? null);
287
288
  }
288
289
  export function createRscHandler(options) {
289
- const { engine, assets, version } = options;
290
+ const { engine, assets } = options;
291
+ // The build's own id, which is also what every document says it is - the
292
+ // two must agree, or a client's honest claim is a 409 on every request.
293
+ // A version named by the app applies only to an engine with no id of its
294
+ // own. Resolved on the first request: the engine reads it from a build
295
+ // product it only has at runtime.
296
+ let version = engine.buildId ? undefined : options.version;
290
297
  const maxActionBody = options.maxActionBody ?? DEFAULT_MAX_ACTION_BODY;
291
298
  const compress = options.compress ?? true;
292
299
  // Annotated rather than inferred: the narrowing below is lost inside the
@@ -350,6 +357,10 @@ export function createRscHandler(options) {
350
357
  const identify = Boolean(manifest.build?.identify);
351
358
  /** How a response was answered, for X-RSC-Kit: a file the build wrote, or a shell of one. */
352
359
  const servedFrom = new WeakMap();
360
+ /** What a stored document's head names, read once per file, for the Link header. */
361
+ const hinted = new WeakMap();
362
+ const hintsByKey = new Map();
363
+ const EMPTY_ASSETS = { styles: [], modules: [], fonts: [] };
353
364
  /**
354
365
  * The page a payload url belongs to, if this is one.
355
366
  *
@@ -421,6 +432,8 @@ export function createRscHandler(options) {
421
432
  return [version ?? "", url.pathname + url.search, ...varies].join("\n");
422
433
  }
423
434
  return async function handle(request) {
435
+ if (version === undefined && engine.buildId)
436
+ version = await engine.buildId();
424
437
  return await withRequest(request, () => withCache(() =>
425
438
  // Open for the whole request and sealed the moment an answer exists,
426
439
  // so middleware — which runs before any rendering — can put headers on
@@ -449,6 +462,16 @@ export function createRscHandler(options) {
449
462
  // Network tab, the way X-Nextjs-Cache is. Always: it names no
450
463
  // product, and a CDN rule or a check can key on it.
451
464
  response.headers.set("X-RSC-Kit", servedFrom.get(response) ?? "rendered");
465
+ // What the document needs first, as a Link header: a CDN sends it
466
+ // ahead as 103 Early Hints, and the stylesheet, the entry and the
467
+ // fonts download while the HTML is still being written. Every
468
+ // document, stored ones included - a stored document's own head
469
+ // names its fonts; a rendered one's is not known until too late.
470
+ if (response.status === 200 && (response.headers.get("Content-Type") ?? "").startsWith("text/html")) {
471
+ const link = linkHeader(mergeAssets(hinted.get(response) ?? EMPTY_ASSETS, engine.criticalAssets?.() ?? null));
472
+ if (link)
473
+ response.headers.set("Link", link);
474
+ }
452
475
  // What built it. The name only, never the version - a version in
453
476
  // every response is what a vulnerability scanner filters on - and
454
477
  // off for a team whose policy strips every framework identifier.
@@ -583,6 +606,20 @@ export function createRscHandler(options) {
583
606
  const formPost = await formPostOf(request, url);
584
607
  if (!formPost && request.method !== "GET" && request.method !== "HEAD")
585
608
  return null;
609
+ // A client saying which build it runs, and it is not this one: its
610
+ // manifest cannot load what this build's payload names - a client
611
+ // component added since is "client reference not found" and the route's
612
+ // error boundary, on a page that worked a click ago. Under a service
613
+ // worker that serves the last build's document first, that is every
614
+ // returning visitor's first navigation after a deploy, not an open tab.
615
+ // A 409 sends the client to load the document instead, from this build.
616
+ const claimed = request.headers.get(HEADER.version);
617
+ if (claimed !== null && claimed !== "" && version && claimed !== version && request.headers.get(HEADER.rsc) !== null) {
618
+ return new Response(null, {
619
+ status: 409,
620
+ headers: withVersion({ "X-RSC-Location": url.pathname + url.search, "Cache-Control": "no-store" }),
621
+ });
622
+ }
586
623
  // One named region of this page, asked for without mutating anything to
587
624
  // earn it. What an action invalidated does not come through here — that
588
625
  // travels back inside the action's own answer, which is the whole point of
@@ -936,7 +973,7 @@ export function createRscHandler(options) {
936
973
  const whole = await read(`${key}.html`);
937
974
  // A whole page is finished. Nothing to resume, nothing to render.
938
975
  if (whole !== null) {
939
- return new Response(whole, {
976
+ const response = new Response(whole, {
940
977
  headers: withVersion({
941
978
  "Content-Type": HTML_TYPE,
942
979
  Vary: VARY_ON_RSC,
@@ -945,6 +982,14 @@ export function createRscHandler(options) {
945
982
  : REVALIDATE,
946
983
  }),
947
984
  });
985
+ // Its head is in hand, so its fonts are too. Read once per file.
986
+ let found = hintsByKey.get(key);
987
+ if (!found) {
988
+ found = criticalAssetsOf(whole);
989
+ hintsByKey.set(key, found);
990
+ }
991
+ hinted.set(response, found);
992
+ return response;
948
993
  }
949
994
  // Then a shell, under this url or under the route's pattern. Which of the
950
995
  // two answered is tracked, because the state that resumes it sits beside
@@ -1010,6 +1055,13 @@ export function createRscHandler(options) {
1010
1055
  }),
1011
1056
  });
1012
1057
  servedFrom.set(withHoles, "shell");
1058
+ // The shell's head is the document's head; its fonts are known too.
1059
+ let found = hintsByKey.get(shellKey);
1060
+ if (!found) {
1061
+ found = criticalAssetsOf(shell);
1062
+ hintsByKey.set(shellKey, found);
1063
+ }
1064
+ hinted.set(withHoles, found);
1013
1065
  return withHoles;
1014
1066
  }
1015
1067
  // Only the document is ever served frozen for a shell. The payload is what