@rsc-kit/core 0.30.29 → 0.30.30

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.
package/dist/vite.js CHANGED
@@ -23,7 +23,8 @@ import rsc, { getPluginApi } from "@vitejs/plugin-rsc";
23
23
  import { loadEnv, parseAst } from "vite";
24
24
  import { stampDisplayNames } from "./stableNames.js";
25
25
  import { REPORT_FILE, buildReport } from "./buildReport.js";
26
- import { edgeShells, pprMarker } from "./edgeShells.js";
26
+ import { contractPath, edgeGuarded, edgeMarker, edgeShells, edgeStatic } from "./edgeShells.js";
27
+ import { EDGE, EDGE_HEADERS } from "./headers.js";
27
28
  import { MANIFEST_PATH, manifestWarning, webManifest } from "./webManifest.js";
28
29
  import { ASSET_BASE, allAppAssets, assetFile, appAssets, headTags, shareImageUrls, typeOf, } from "./appAssets.js";
29
30
  import { reactCacheImports } from "./reactCache.js";
@@ -240,6 +241,8 @@ let siteHosts = [];
240
241
  let hostsOption = [];
241
242
  let barrelImports = true;
242
243
  let identify = true;
244
+ /** RscKitOptions.headers, checked against the edge contract's allow list. */
245
+ let pageHeaders = {};
243
246
  /** Resolved from RscKitOptions.openapi; null when off. */
244
247
  let openapi = null;
245
248
  let foundAssets = {
@@ -401,6 +404,36 @@ function aliasEntries() {
401
404
  });
402
405
  return entries;
403
406
  }
407
+ /**
408
+ * rscKit({ headers }), in the case the contract spells each name. Refused at
409
+ * build, never trimmed at runtime: a header that was asked for and quietly not
410
+ * sent is a security header missing from every page.
411
+ */
412
+ export function normalisePageHeaders(raw) {
413
+ if (raw === undefined)
414
+ return {};
415
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
416
+ throw new Error("[rsc-kit] headers: an object of header names to values, as in { 'X-Frame-Options': 'DENY' }.");
417
+ const known = new Map(EDGE_HEADERS.map((name) => [name.toLowerCase(), name]));
418
+ // The renderer's own: an asset is immutable, a guarded page no-store, and
419
+ // the Link header is the document's early hints. One value for every
420
+ // response would be wrong for most of them.
421
+ const owned = new Set(["cache-control", "link"]);
422
+ const headers = {};
423
+ for (const [name, value] of Object.entries(raw)) {
424
+ if (owned.has(name.toLowerCase()))
425
+ throw new Error(`[rsc-kit] headers: ${name} is the renderer's to set per response. Set it in a middleware if a page needs its own.`);
426
+ const canonical = known.get(name.toLowerCase());
427
+ if (!canonical)
428
+ throw new Error(`[rsc-kit] headers: ${JSON.stringify(name)} is not one an edge may send with a cached page (${EDGE_HEADERS.join(", ")}). Set it in a middleware instead, which runs per request.`);
429
+ if (typeof value !== "string" || value.trim() === "" || /[\r\n]/.test(value))
430
+ throw new Error(`[rsc-kit] headers: ${canonical} needs a value on one line, not ${JSON.stringify(value)}.`);
431
+ if (canonical in headers)
432
+ throw new Error(`[rsc-kit] headers: ${canonical} is named twice.`);
433
+ headers[canonical] = value.trim();
434
+ }
435
+ return headers;
436
+ }
404
437
  /**
405
438
  * The prefixes a backend owns, as the matcher reads them: `/auth`, no trailing
406
439
  * slash. Refused rather than corrected where a mistake would silently send the
@@ -515,6 +548,7 @@ function resolvePaths(options) {
515
548
  hostsOption = options.hosts ?? [];
516
549
  barrelImports = options.barrelImports !== false;
517
550
  identify = options.identify !== false;
551
+ pageHeaders = normalisePageHeaders(options.headers);
518
552
  openapi = options.openapi
519
553
  ? (() => {
520
554
  const { path, ...document } = typeof options.openapi === "object" ? options.openapi : {};
@@ -836,6 +870,7 @@ function routeManifest() {
836
870
  hosts: siteHosts,
837
871
  identify,
838
872
  refreshOn: usesRefreshOn() || undefined,
873
+ headers: Object.keys(pageHeaders).length ? pageHeaders : undefined,
839
874
  },
840
875
  routes,
841
876
  intercepts,
@@ -1609,40 +1644,84 @@ export function workerVersion(precache, storedDir) {
1609
1644
  return version.digest("hex").slice(0, 12);
1610
1645
  }
1611
1646
  /**
1612
- * The PPR contract's build: what an edge checks a cached shell against.
1647
+ * The edge contract's build: what an edge checks a cached page against.
1613
1648
  *
1614
1649
  * Not the client's build id, which is what a browser tab is told and covers
1615
1650
  * only what the browser downloads - a release that changed only what the
1616
- * server renders kept it, and an edge holding the old shell would have joined
1617
- * it to the new server's holes. So it is that id and the bytes of every shell
1618
- * and its postponed state. A server change that reaches a shell is a new
1619
- * build to the edge; one that leaves them all byte for byte the same is not,
1620
- * because the shell the edge holds is then this build's. A tab is not made to
1621
- * reload for either.
1622
- */
1623
- export function pprBuildId(clientBuild, storedDir) {
1651
+ * server renders kept it, and an edge holding the old page would have served
1652
+ * it, or joined it to the new server's holes. So it is that id and the bytes
1653
+ * of every stored document and postponed state. A server change that reaches
1654
+ * one is a new build to the edge; one that leaves them all byte for byte the
1655
+ * same is not, because what the edge holds is then this build's. A tab is not
1656
+ * made to reload for either.
1657
+ */
1658
+ export function edgeBuildId(clientBuild, storedDir) {
1624
1659
  const id = createHash("sha256");
1625
1660
  id.update(clientBuild);
1626
- const shells = [];
1661
+ const pages = [];
1627
1662
  const walk = (dir) => {
1628
1663
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
1629
1664
  const path = join(dir, entry.name);
1630
1665
  if (entry.isDirectory())
1631
1666
  walk(path);
1632
- else if (/\.(?:ppr\.html|postponed\.json)$/.test(entry.name))
1633
- shells.push(path);
1667
+ else if (/\.(?:html|postponed\.json)$/.test(entry.name))
1668
+ pages.push(path);
1634
1669
  }
1635
1670
  };
1636
1671
  if (existsSync(storedDir))
1637
1672
  walk(storedDir);
1638
- for (const path of shells.sort()) {
1673
+ for (const path of pages.sort()) {
1639
1674
  id.update("\n" + relative(storedDir, path) + "\n");
1640
1675
  id.update(readFileSync(path));
1641
1676
  }
1642
1677
  return id.digest("hex").slice(0, 16);
1643
1678
  }
1644
- /** Where the host reads the PPR build back, beside the shells it describes. */
1645
- export const PPR_BUILD_FILE = "_ppr.build";
1679
+ /**
1680
+ * Give a static page a payload it can boot from without the app.
1681
+ *
1682
+ * A page the build wrote whole hydrates from its stored payload, which it
1683
+ * fetched from its own url with X-RSC - a request an edge passes to the app,
1684
+ * so a page the edge answered whole still woke a sleeping app on every visit
1685
+ * from a browser. So its payload is copied to a hashed asset, served as
1686
+ * immutable like every other, and the document names it: a preload, so the
1687
+ * fetch starts as the head parses, and a meta the client boots from. Its
1688
+ * layouts ride the meta, since an asset carries no X-RSC-Layouts.
1689
+ *
1690
+ * Only for the pages the marker lists: a guarded page's payload at a public
1691
+ * url would be read past its guard. Returns the paths it could do it for.
1692
+ */
1693
+ export function storedPayloadAssets(staticDir, clientDir, pages) {
1694
+ const done = [];
1695
+ const attribute = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1696
+ for (const { path, key } of pages) {
1697
+ const htmlFile = join(staticDir, `${key}.html`);
1698
+ const flightFile = join(staticDir, `${key}.flight`);
1699
+ if (!existsSync(htmlFile) || !existsSync(flightFile))
1700
+ continue;
1701
+ const html = readFileSync(htmlFile, "utf8");
1702
+ const at = html.indexOf("</head>");
1703
+ if (at === -1)
1704
+ continue;
1705
+ const flight = readFileSync(flightFile);
1706
+ const name = `${createHash("sha256").update(flight).digest("hex").slice(0, 16)}.rsc`;
1707
+ const url = `/assets/_rsc/${name}`;
1708
+ mkdirSync(join(clientDir, "assets", "_rsc"), { recursive: true });
1709
+ writeFileSync(join(clientDir, "assets", "_rsc", name), flight);
1710
+ let layouts = [];
1711
+ try {
1712
+ layouts = JSON.parse(readFileSync(join(staticDir, `${key}.meta.json`), "utf8")).layouts ?? [];
1713
+ }
1714
+ catch {
1715
+ continue;
1716
+ }
1717
+ writeFileSync(htmlFile, html.slice(0, at) +
1718
+ `<link rel="preload" as="fetch" crossorigin="" href="${attribute(url)}"/>` +
1719
+ `<meta name="rsc-kit:payload" content="${attribute(url)}" data-layouts="${attribute(layouts.join(","))}"/>` +
1720
+ html.slice(at));
1721
+ done.push(path);
1722
+ }
1723
+ return done;
1724
+ }
1646
1725
  function writeServiceWorker(clientDir, frozen = [], offlineUrl = null,
1647
1726
  // The pages the build stored, which the worker serves cache-first. Their
1648
1727
  // bytes are part of what a version is - see below.
@@ -2010,6 +2089,8 @@ ${legend(counted)}
2010
2089
  .map((r) => r.url),
2011
2090
  results,
2012
2091
  shells: edgeShells(results, manifest.routes),
2092
+ routes: manifest.routes,
2093
+ pathKey: pathKeyOf,
2013
2094
  build,
2014
2095
  };
2015
2096
  }
@@ -8112,12 +8193,23 @@ export function rscKit(options = {}) {
8112
8193
  const staticDir = clientOut
8113
8194
  ? join(dirname(clientOut), "server", NITRO_STATIC_DIR)
8114
8195
  : join(outDir, NITRO_STATIC_DIR);
8115
- const { frozen, results, shells, build: clientBuild } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir, builder ? knownActionsOf(builder.config, projectRoot) : []);
8116
- // Before the pages are packed into a module or the assets, so the host
8117
- // finds it wherever it reads the shells from.
8118
- const build = clientBuild ? pprBuildId(clientBuild, staticDir) : undefined;
8119
- if (build && existsSync(staticDir))
8120
- writeFileSync(join(staticDir, PPR_BUILD_FILE), build);
8196
+ const { frozen, results, shells, routes, pathKey: pathKeyOf, build: clientBuild } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir, builder ? knownActionsOf(builder.config, projectRoot) : []);
8197
+ // The edge contract's static pages, each given a payload it boots from
8198
+ // without the app - before the build id, which hashes the documents
8199
+ // this changes, and before the pages are packed into a module or the
8200
+ // assets. A server build only: an export has no edge in front of an app.
8201
+ const listed = output !== "export" && clientOut ? edgeStatic(results, routes, shells.shells) : { static: [], left: [] };
8202
+ const keyOf = new Map(results.map((r) => [contractPath(r.url), pathKeyOf(r.url)]));
8203
+ const statics = clientOut
8204
+ ? storedPayloadAssets(staticDir, clientOut, listed.static.map((path) => ({ path, key: keyOf.get(path) })).filter((page) => page.key))
8205
+ : [];
8206
+ const build = clientBuild ? edgeBuildId(clientBuild, staticDir) : undefined;
8207
+ const marker = build ? edgeMarker(build, shells.shells, statics) : null;
8208
+ // Beside the pages too, so the host finds the build and the static
8209
+ // list wherever it reads the pages from.
8210
+ if (marker && output !== "export" && existsSync(staticDir)) {
8211
+ writeFileSync(join(staticDir, EDGE.storedMarker), JSON.stringify(marker));
8212
+ }
8121
8213
  // The same pages once more, as a module. A Worker has no filesystem, and
8122
8214
  // until this existed every Cloudflare deploy rendered its "static" pages
8123
8215
  // live, silently - the reader found no directory and fell through. The
@@ -8153,8 +8245,13 @@ export function rscKit(options = {}) {
8153
8245
  // after the prerender, and an export taken any earlier left them out.
8154
8246
  if (output === "export")
8155
8247
  await exportAfterPrerender(results, staticDir, clientOut ?? publicAssetsDir);
8156
- if (shells.left.length) {
8157
- log(`ppr: not listed for an edge in ppr.json, so served by the origin: ${shells.left.join("; ")}`);
8248
+ if (shells.left.length || listed.left.length) {
8249
+ log(`edge: not listed in edge.json, so served by the origin: ${[...shells.left, ...listed.left].join("; ")}`);
8250
+ }
8251
+ const guarded = output !== "export" && clientOut ? edgeGuarded(results, routes) : [];
8252
+ if (guarded.length) {
8253
+ const none = marker && !marker.shells.length && !marker.static.length;
8254
+ log(`edge: ${none ? "edge.json lists no page: every one is under middleware" : "left out of edge.json, under middleware"}, which runs per request: ${guarded.join(", ")}`);
8158
8255
  }
8159
8256
  // What this build produced, where a deploy can read it without parsing
8160
8257
  // vite.config.ts - which can turn export on through RSC_OUTPUT, or
@@ -8169,19 +8266,22 @@ export function rscKit(options = {}) {
8169
8266
  ...compileStep(projectRoot),
8170
8267
  ...(buildCommands().length ? { commands: buildCommands().map((c) => c.name) } : {}),
8171
8268
  }, null, 2) + "\n");
8172
- // The PPR contract's marker: the pages an edge may answer from a
8173
- // cached shell, and the build both endpoints will say they belong to.
8174
- // Written by every server build, with an empty list when there is no
8175
- // PPR page, so a platform can tell "none" from "an rsc-kit too old to
8176
- // say". Not by an export, which has no origin to resume against.
8177
- const marker = join(dirname(clientOut), "ppr.json");
8178
- if (output !== "export" && build) {
8179
- writeFileSync(marker, JSON.stringify(pprMarker(build, shells.shells), null, 2) + "\n");
8269
+ // The edge contract's marker: the pages an edge may answer from a
8270
+ // cached shell or whole, and the build both endpoints will say they
8271
+ // belong to. Written by every server build, with empty lists when
8272
+ // there are none, so a platform can tell "none" from "an rsc-kit too
8273
+ // old to say". Not by an export, which has no origin to answer for it.
8274
+ const markerFile = join(dirname(clientOut), "edge.json");
8275
+ // v1's marker, from a build before this one: an edge reading it
8276
+ // would ask for endpoints that are no longer here.
8277
+ rmSync(join(dirname(clientOut), "ppr.json"), { force: true });
8278
+ if (output !== "export" && marker) {
8279
+ writeFileSync(markerFile, JSON.stringify(marker, null, 2) + "\n");
8180
8280
  }
8181
8281
  else {
8182
8282
  // One left by an earlier server build would promise an origin that
8183
8283
  // is not there.
8184
- rmSync(marker, { force: true });
8284
+ rmSync(markerFile, { force: true });
8185
8285
  }
8186
8286
  }
8187
8287
  },