@rsc-kit/core 0.20.3 → 0.20.5

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
@@ -20,6 +21,8 @@ export interface RscEngine {
20
21
  manifest?(): RouteManifest;
21
22
  /** A short id of this build's client, for the version the host answers with when given none. */
22
23
  buildId?(): Promise<string>;
24
+ /** The stylesheet and client entry every document links, for the Link header a CDN sends ahead. */
25
+ criticalAssets?(): CriticalAssets;
23
26
  installHostFn(fn: (name: string, ...args: unknown[]) => unknown): void;
24
27
  handleRscStream(component: string, props?: Record<string, unknown>, layouts?: {
25
28
  component: string;
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
@@ -356,6 +357,10 @@ export function createRscHandler(options) {
356
357
  const identify = Boolean(manifest.build?.identify);
357
358
  /** How a response was answered, for X-RSC-Kit: a file the build wrote, or a shell of one. */
358
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: [] };
359
364
  /**
360
365
  * The page a payload url belongs to, if this is one.
361
366
  *
@@ -457,6 +462,16 @@ export function createRscHandler(options) {
457
462
  // Network tab, the way X-Nextjs-Cache is. Always: it names no
458
463
  // product, and a CDN rule or a check can key on it.
459
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
+ }
460
475
  // What built it. The name only, never the version - a version in
461
476
  // every response is what a vulnerability scanner filters on - and
462
477
  // off for a team whose policy strips every framework identifier.
@@ -958,7 +973,7 @@ export function createRscHandler(options) {
958
973
  const whole = await read(`${key}.html`);
959
974
  // A whole page is finished. Nothing to resume, nothing to render.
960
975
  if (whole !== null) {
961
- return new Response(whole, {
976
+ const response = new Response(whole, {
962
977
  headers: withVersion({
963
978
  "Content-Type": HTML_TYPE,
964
979
  Vary: VARY_ON_RSC,
@@ -967,6 +982,14 @@ export function createRscHandler(options) {
967
982
  : REVALIDATE,
968
983
  }),
969
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;
970
993
  }
971
994
  // Then a shell, under this url or under the route's pattern. Which of the
972
995
  // two answered is tracked, because the state that resumes it sits beside
@@ -1032,6 +1055,13 @@ export function createRscHandler(options) {
1032
1055
  }),
1033
1056
  });
1034
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);
1035
1065
  return withHoles;
1036
1066
  }
1037
1067
  // Only the document is ever served frozen for a shell. The payload is what