@rsc-kit/core 0.16.3 → 0.18.0

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.
Files changed (107) hide show
  1. package/dist/barrelImports.d.ts +6 -0
  2. package/dist/barrelImports.js +93 -0
  3. package/dist/barrelImports.js.map +1 -0
  4. package/dist/buildReport.d.ts +6 -1
  5. package/dist/buildReport.js +16 -18
  6. package/dist/buildReport.js.map +1 -1
  7. package/dist/cache.js +8 -4
  8. package/dist/cache.js.map +1 -1
  9. package/dist/clientEntries.d.ts +32 -0
  10. package/dist/clientEntries.js +107 -0
  11. package/dist/clientEntries.js.map +1 -0
  12. package/dist/clientImports.d.ts +36 -0
  13. package/dist/clientImports.js +36 -0
  14. package/dist/clientImports.js.map +1 -0
  15. package/dist/devReload.d.ts +19 -0
  16. package/dist/devReload.js +32 -0
  17. package/dist/devReload.js.map +1 -0
  18. package/dist/events.d.ts +57 -0
  19. package/dist/events.js +113 -0
  20. package/dist/events.js.map +1 -0
  21. package/dist/host.d.ts +16 -6
  22. package/dist/host.js +219 -124
  23. package/dist/host.js.map +1 -1
  24. package/dist/hostCalls.d.ts +27 -0
  25. package/dist/hostCalls.js +127 -12
  26. package/dist/hostCalls.js.map +1 -1
  27. package/dist/hostRouting.d.ts +53 -0
  28. package/dist/hostRouting.js +100 -0
  29. package/dist/hostRouting.js.map +1 -0
  30. package/dist/js/DefaultRouteError.d.ts +3 -0
  31. package/dist/js/DefaultRouteError.js +61 -0
  32. package/dist/js/DefaultRouteError.js.map +1 -0
  33. package/dist/js/Form.js +54 -6
  34. package/dist/js/Form.js.map +1 -1
  35. package/dist/js/Link.d.ts +0 -3
  36. package/dist/js/Link.js +3 -6
  37. package/dist/js/Link.js.map +1 -1
  38. package/dist/js/SegmentBoundary.d.ts +38 -2
  39. package/dist/js/SegmentBoundary.js +51 -13
  40. package/dist/js/SegmentBoundary.js.map +1 -1
  41. package/dist/js/createViteRscApp.js +32 -17
  42. package/dist/js/createViteRscApp.js.map +1 -1
  43. package/dist/js/devNotice.d.ts +2 -2
  44. package/dist/js/devNotice.js +17 -7
  45. package/dist/js/devNotice.js.map +1 -1
  46. package/dist/js/fallbackReport.d.ts +2 -0
  47. package/dist/js/fallbackReport.js +51 -0
  48. package/dist/js/fallbackReport.js.map +1 -0
  49. package/dist/js/navigate.d.ts +1 -1
  50. package/dist/js/navigate.js +55 -23
  51. package/dist/js/navigate.js.map +1 -1
  52. package/dist/js/queryClient.d.ts +2 -1
  53. package/dist/js/queryClient.js +0 -12
  54. package/dist/js/queryClient.js.map +1 -1
  55. package/dist/js/router.d.ts +2 -2
  56. package/dist/js/router.js.map +1 -1
  57. package/dist/js/segmentStore.d.ts +2 -0
  58. package/dist/js/segmentStore.js +21 -1
  59. package/dist/js/segmentStore.js.map +1 -1
  60. package/dist/js/staleAssets.js +10 -1
  61. package/dist/js/staleAssets.js.map +1 -1
  62. package/dist/js/standardSchema.d.ts +6 -0
  63. package/dist/js/standardSchema.js +19 -1
  64. package/dist/js/standardSchema.js.map +1 -1
  65. package/dist/js/useEvents.d.ts +25 -0
  66. package/dist/js/useEvents.js +78 -0
  67. package/dist/js/useEvents.js.map +1 -0
  68. package/dist/js/useLinkStatus.d.ts +14 -1
  69. package/dist/js/useLinkStatus.js +15 -1
  70. package/dist/js/useLinkStatus.js.map +1 -1
  71. package/dist/js/usePolling.d.ts +29 -0
  72. package/dist/js/usePolling.js +141 -0
  73. package/dist/js/usePolling.js.map +1 -0
  74. package/dist/manifest.d.ts +18 -1
  75. package/dist/manifest.js.map +1 -1
  76. package/dist/metadata.d.ts +94 -15
  77. package/dist/metadata.js.map +1 -1
  78. package/dist/metadataRoutes.d.ts +61 -0
  79. package/dist/metadataRoutes.js +173 -0
  80. package/dist/metadataRoutes.js.map +1 -0
  81. package/dist/prerender.js +44 -10
  82. package/dist/prerender.js.map +1 -1
  83. package/dist/reactCache.d.ts +1 -0
  84. package/dist/reactCache.js +52 -0
  85. package/dist/reactCache.js.map +1 -0
  86. package/dist/redirect.d.ts +16 -2
  87. package/dist/redirect.js +10 -19
  88. package/dist/redirect.js.map +1 -1
  89. package/dist/request.d.ts +18 -1
  90. package/dist/request.js +91 -5
  91. package/dist/request.js.map +1 -1
  92. package/dist/revalidate.d.ts +6 -4
  93. package/dist/revalidate.js +6 -5
  94. package/dist/revalidate.js.map +1 -1
  95. package/dist/routes.d.ts +16 -3
  96. package/dist/routes.js +5 -5
  97. package/dist/routes.js.map +1 -1
  98. package/dist/routing.d.ts +2 -2
  99. package/dist/routing.js +47 -25
  100. package/dist/routing.js.map +1 -1
  101. package/dist/useSsr.d.ts +19 -0
  102. package/dist/useSsr.js +104 -0
  103. package/dist/useSsr.js.map +1 -0
  104. package/dist/vite.d.ts +86 -9
  105. package/dist/vite.js +827 -24
  106. package/dist/vite.js.map +1 -1
  107. package/package.json +13 -1
package/dist/vite.js CHANGED
@@ -16,13 +16,21 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync,
16
16
  import { gzipSync } from "node:zlib";
17
17
  import { createHash } from "node:crypto";
18
18
  import { createRequire } from "node:module";
19
- import { dirname, join, relative, resolve } from "node:path";
19
+ import { spawnSync } from "node:child_process";
20
+ import { basename, dirname, join, relative, resolve } from "node:path";
20
21
  import { fileURLToPath, pathToFileURL } from "node:url";
21
22
  import rsc, { getPluginApi } from "@vitejs/plugin-rsc";
22
23
  import { loadEnv } from "vite";
23
24
  import { REPORT_FILE, buildReport } from "./buildReport.js";
24
25
  import { MANIFEST_PATH, manifestWarning, webManifest } from "./webManifest.js";
25
26
  import { ASSET_BASE, allAppAssets, appAssets, headTags, typeOf, } from "./appAssets.js";
27
+ import { reactCacheImports } from "./reactCache.js";
28
+ import { clientEntries, clientScanPlugin, engineClientEntries, } from "./clientEntries.js";
29
+ import { serverImportsOfClientPackages } from "./clientImports.js";
30
+ import { automaticSitemap, METADATA_ROUTES, ROOT_FILES, rootFileType, } from "./metadataRoutes.js";
31
+ import { ownHosts } from "./hostRouting.js";
32
+ import { unrollBarrelImports } from "./barrelImports.js";
33
+ import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, } from "./useSsr.js";
26
34
  import { httpHostCalls } from "./hostCalls.js";
27
35
  // Resolved once per rscKit() call. One build runs in one process, so these are
28
36
  // module state rather than threaded through every helper.
@@ -30,8 +38,13 @@ let projectRoot;
30
38
  let sourceDir;
31
39
  let inlineStylesheets = "auto";
32
40
  let resolvedConfig = null;
41
+ /** Server files importing a client library, read off the rsc graph when it is built. */
42
+ let clientLibraryImports = [];
33
43
  /** Modules a runtime provides and no bundle should try to carry. */
34
44
  const RUNTIME_BUILTINS = ["bun", /^bun:/];
45
+ function arrayOf(value) {
46
+ return value == null ? [] : Array.isArray(value) ? value : [value];
47
+ }
35
48
  /**
36
49
  * What a client chunk is called on disk.
37
50
  *
@@ -93,11 +106,15 @@ let routeConfig;
93
106
  let prerenderAfterBuild;
94
107
  /** True during `vite build --watch`, where re-rendering every route is noise. */
95
108
  let isWatch = false;
96
- /** Whether navigations are wrapped in React's ViewTransition — see options. */
97
- let viewTransitions = false;
98
109
  /** Whether a service worker is generated and registered — see options. */
99
110
  let offline = false;
111
+ let typecheck = true;
100
112
  let webManifestOptions = null;
113
+ /** The site's own hosts, from the root layout's metadataBase and rscKit({ hosts }). */
114
+ let siteHosts = [];
115
+ let hostsOption = [];
116
+ let barrelImports = true;
117
+ let identify = true;
101
118
  let foundAssets = {
102
119
  favicon: null,
103
120
  icons: [],
@@ -258,8 +275,11 @@ function resolvePaths(options) {
258
275
  // sets it, and so does a host that drives the build out of process and
259
276
  // prerenders itself afterwards with paths only it knows.
260
277
  prerenderAfterBuild = process.env.RSC_PRERENDER !== "0";
261
- viewTransitions = options.viewTransitions === true;
262
278
  offline = options.offline === true;
279
+ typecheck = options.typecheck !== false;
280
+ hostsOption = options.hosts ?? [];
281
+ barrelImports = options.barrelImports !== false;
282
+ identify = options.identify !== false;
263
283
  inlineStylesheets = options.inlineStylesheets ?? "auto";
264
284
  maxActionBody = options.maxActionBody;
265
285
  // One place, and it is the file. A plugin option as well would be the same
@@ -313,7 +333,12 @@ function urlSegments(componentName) {
313
333
  continue;
314
334
  }
315
335
  if (part.startsWith("[") && part.endsWith("]")) {
316
- segments.push({ type: "param", value: part.slice(1, -1) });
336
+ // At the top of app/ - the first segment the url has - a parameter is
337
+ // the host's: bound from acme.example.com, never from example.com/acme.
338
+ segments.push({
339
+ type: segments.length === 0 ? "host" : "param",
340
+ value: part.slice(1, -1),
341
+ });
317
342
  continue;
318
343
  }
319
344
  // An interception marker says which url this replaces, not what it is
@@ -385,7 +410,11 @@ function routeManifest() {
385
410
  * to whoever knows what they mean.
386
411
  */
387
412
  const middlewareIn = (absDir) => {
388
- for (const file of ["route.ts", "route.tsx"]) {
413
+ // middleware.ts first: it is the file named for what this is. A guard
414
+ // the engine runs is its default export; the names a host runs are its
415
+ // `middleware` export, and a file may carry either or both. route.ts is
416
+ // read too, for the apps written before middleware.ts could.
417
+ for (const file of ["middleware.ts", "middleware.tsx", "route.ts", "route.tsx"]) {
389
418
  const path = join(absDir, file);
390
419
  if (!existsSync(path))
391
420
  continue;
@@ -471,14 +500,22 @@ function routeManifest() {
471
500
  // writing the site out, and knowing which filename the client will ask for.
472
501
  return {
473
502
  version: 1,
474
- build: { output, exportPath, payloadName: staticPayloads },
503
+ build: {
504
+ output,
505
+ exportPath,
506
+ payloadName: staticPayloads,
507
+ hosts: siteHosts,
508
+ identify,
509
+ },
475
510
  routes,
476
511
  intercepts,
477
- apis: [...apiRoutes.values()].map(({ name, methods }) => ({
512
+ apis: [...apiRoutes.values()].map(({ name, methods, generated }) => ({
478
513
  name,
479
514
  segments: urlSegments(name),
480
515
  methods,
481
- middleware: ancestors(name, "middleware"),
516
+ // A synthesised robots.txt or sitemap.xml runs no guard: it exists to be
517
+ // read by anyone, and a guard on the root would 401 the crawler.
518
+ middleware: generated ? [] : ancestors(name, "middleware"),
482
519
  })),
483
520
  };
484
521
  }
@@ -956,6 +993,9 @@ export function declaredManifest(appDir) {
956
993
  function copyAppAssets(clientDir) {
957
994
  if (!existsSync(clientDir))
958
995
  return;
996
+ for (const file of rootFiles) {
997
+ copyFileSync(join(sourceDir, "app", file), join(clientDir, file));
998
+ }
959
999
  const all = allAppAssets(foundAssets);
960
1000
  if (all.length === 0)
961
1001
  return;
@@ -1221,6 +1261,41 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
1221
1261
  // places to look for one fact.
1222
1262
  const manifest = engine.manifest();
1223
1263
  const apis = await prerenderApiRoutes(engine, manifest, writeTo(staticDir));
1264
+ // A sitemap the build writes itself, when the app wrote none: every url
1265
+ // it stored or was told about, minus the guarded ones. It needs the site's
1266
+ // host, which the root layout's metadataBase is for; without one there is
1267
+ // nothing to write and the report says so once.
1268
+ const writesSitemap = manifest.apis?.some((api) => api.name === "app/sitemap.xml/route");
1269
+ const hasSitemapFile = rootFiles.includes("sitemap.xml");
1270
+ if (!writesSitemap && !hasSitemapFile) {
1271
+ const base = rootMetadataBase(join(sourceDir, "app"));
1272
+ if (base) {
1273
+ const xml = automaticSitemap(results, manifest.routes, base);
1274
+ const stored = JSON.stringify({
1275
+ status: 200,
1276
+ headers: [["content-type", "application/xml; charset=utf-8"]],
1277
+ body: xml,
1278
+ varies: false,
1279
+ });
1280
+ await writeTo(staticDir)("sitemap.xml.api.json", stored);
1281
+ pending.push({
1282
+ line: " ○ /sitemap.xml",
1283
+ bytes: null,
1284
+ extra: [
1285
+ ` written by the build: ${xml.split("<url>").length - 1} urls; a sitemap.ts beside the root layout replaces it`,
1286
+ ],
1287
+ });
1288
+ }
1289
+ else {
1290
+ pending.push({
1291
+ line: " - /sitemap.xml",
1292
+ bytes: null,
1293
+ extra: [
1294
+ " not written: the root layout has no metadataBase to make the urls absolute",
1295
+ ],
1296
+ });
1297
+ }
1298
+ }
1224
1299
  for (const api of apis) {
1225
1300
  pending.push({
1226
1301
  line: ` ${api.type === "frozen" ? "○" : "ƒ"} ${api.url}`,
@@ -1258,6 +1333,30 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
1258
1333
  console.log(" Nothing checks who calls them. Fine for a public one; otherwise build it\n" +
1259
1334
  " from an action client, so the check cannot be forgotten.");
1260
1335
  }
1336
+ // Server files importing cache from React. Its cache() memoises on the
1337
+ // dispatcher a render installs, so in a guard, an action, an api route or
1338
+ // the SSR pass it calls straight through - no dedupe, no error, the helper
1339
+ // runs twice. The difference is silent, which is why this line exists.
1340
+ const reactCache = reactCacheImports(sourceDir);
1341
+ if (reactCache.length > 0) {
1342
+ console.log(`\n \u26a0 ${reactCache.length} server ${reactCache.length === 1 ? "file imports" : "files import"} cache from 'react': ` +
1343
+ reactCache.join(", "));
1344
+ console.log(" React's cache() dedupes only inside a component render; in a guard, an action or\n" +
1345
+ " an api route it calls straight through. Import it from @rsc-kit/core/cache instead.");
1346
+ }
1347
+ // Server files importing a client library. Legal - a server component may
1348
+ // render a client component from a package - but the shape that costs an
1349
+ // afternoon is a file with no "use client" that only wraps them, so the
1350
+ // library's internals run on the server. Said, with the packages and the
1351
+ // importer, so the person reading can tell which it is.
1352
+ if (clientLibraryImports.length > 0) {
1353
+ console.log(`\n \u2139 ${clientLibraryImports.length} server ${clientLibraryImports.length === 1 ? "file imports" : "files import"} a client library: ` +
1354
+ clientLibraryImports
1355
+ .map((c) => `${c.file} (${c.packages.join(", ")}${c.from ? `; imported by ${c.from}` : ""})`)
1356
+ .join("; "));
1357
+ console.log(' Legal for a server component. A file that only wraps client components wants "use client" -\n' +
1358
+ " as shadcn ships it - so the server stops at the boundary.");
1359
+ }
1261
1360
  // Written from the rows that were just printed rather than recomputed: the
1262
1361
  // report and the terminal must not be able to disagree about what happened.
1263
1362
  writeFileSync(join(outDir, REPORT_FILE), buildReport(results.map((r) => ({
@@ -1274,7 +1373,7 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
1274
1373
  type: a.type,
1275
1374
  reason: a.reason,
1276
1375
  warning: a.warning ?? null,
1277
- })), audited));
1376
+ })), audited, reactCache, clientLibraryImports));
1278
1377
  const note = notes(results);
1279
1378
  const counted = [...results, ...apis];
1280
1379
  console.log(`
@@ -1441,6 +1540,23 @@ function renderRouteTypes(manifest) {
1441
1540
  const target = relative(typesDir, join(sourceDir, route.component)).replace(/\\/g, "/");
1442
1541
  search.set(pattern, target.startsWith(".") ? target : "./" + target);
1443
1542
  }
1543
+ // Section names are read from the source - section('orders', …) - the way
1544
+ // generateStaticParams is detected, because this runs before any bundle
1545
+ // exists. A name computed at runtime is not seen and revalidate() falls
1546
+ // back to refusing it at the renderer, as before.
1547
+ const regions = [
1548
+ ...new Set([
1549
+ ...[...components.values()]
1550
+ .filter((c) => SECTION_FILE.test(c.absPath))
1551
+ .flatMap((c) => [
1552
+ ...readFileSync(c.absPath, "utf-8").matchAll(/\bsection\(\s*['"`]([^'"`]+)['"`]/g),
1553
+ ].map((m) => m[1])),
1554
+ ...[...components.keys()].flatMap((name) => {
1555
+ const slot = name.split("/").find((part) => part.startsWith("@"));
1556
+ return slot ? [slot.slice(1)] : [];
1557
+ }),
1558
+ ]),
1559
+ ].sort();
1444
1560
  return [
1445
1561
  "// @generated — do not edit. Written by the RSC build from the route tree.",
1446
1562
  "//",
@@ -1474,6 +1590,14 @@ function renderRouteTypes(manifest) {
1474
1590
  // Api routes are a separate union, so Link refuses an api url and apiUrl()
1475
1591
  // refuses a page. Linking to an api route navigates the browser away to a
1476
1592
  // json document, which is the mistake worth catching.
1593
+ // Regions: every section('name', …) the build read, and every @slot
1594
+ // directory. This is what types revalidate().
1595
+ " interface RegisterRegions {",
1596
+ regions.length > 0
1597
+ ? " regions:\n" +
1598
+ regions.map((r) => " | " + JSON.stringify(r)).join("\n")
1599
+ : " // No sections or slots found under the source directory.\n regions: never",
1600
+ " }",
1477
1601
  " interface RegisterApi {",
1478
1602
  apis.length > 0
1479
1603
  ? " apis:\n" +
@@ -1571,6 +1695,76 @@ const components = new Map();
1571
1695
  * a default component.
1572
1696
  */
1573
1697
  const apiRoutes = new Map();
1698
+ /** Files beside the root layout served at the root as they are: robots.txt, a hand-written sitemap.xml, humans.txt. */
1699
+ let rootFiles = [];
1700
+ const METADATA_ROUTE_ID = "virtual:rsc-kit/metadata-route/";
1701
+ /**
1702
+ * robots.ts, sitemap.ts, llms.ts beside the root layout, each registered as
1703
+ * an api route at the file it stands for. The module the entry imports is
1704
+ * synthesised (metadataRoutesPlugin): the app's default export, formatted by
1705
+ * the engine, with the root layout's metadataBase for relative urls.
1706
+ *
1707
+ * Api routes, so nothing else is new: the build stores the ones that read
1708
+ * nothing per request and names the ones that do. Without middleware, on
1709
+ * purpose - these exist to be read by anyone, and a guard on the root would
1710
+ * otherwise 401 the crawler asking for robots.txt.
1711
+ */
1712
+ function registerMetadataRoutes(appDir) {
1713
+ rootFiles = existsSync(appDir)
1714
+ ? readdirSync(appDir)
1715
+ .filter((name) => ROOT_FILES.test(name))
1716
+ .sort()
1717
+ : [];
1718
+ for (const kind of Object.keys(METADATA_ROUTES)) {
1719
+ const source = ["ts", "tsx", "js", "mjs"]
1720
+ .map((ext) => join(appDir, `${kind}.${ext}`))
1721
+ .find((file) => existsSync(file));
1722
+ if (!source)
1723
+ continue;
1724
+ const { file } = METADATA_ROUTES[kind];
1725
+ if (rootFiles.includes(file)) {
1726
+ throw new Error(`[rsc-kit] app/${basename(source)} and app/${file} both answer /${file}. Keep one: the function, or the file as written.`);
1727
+ }
1728
+ const name = `app/${file}/route`;
1729
+ if (apiRoutes.has(name)) {
1730
+ throw new Error(`[rsc-kit] app/${basename(source)} and app/${file}/route.ts both answer /${file}. Keep one.`);
1731
+ }
1732
+ apiRoutes.set(name, {
1733
+ name,
1734
+ absPath: METADATA_ROUTE_ID + kind,
1735
+ methods: ["GET"],
1736
+ generated: { kind, file: source },
1737
+ });
1738
+ }
1739
+ }
1740
+ /** The module behind a synthesised metadata route. */
1741
+ function metadataRoutesPlugin() {
1742
+ return {
1743
+ name: "rsc-kit:metadata-routes",
1744
+ resolveId(id) {
1745
+ if (id.startsWith(METADATA_ROUTE_ID))
1746
+ return "\0" + id;
1747
+ },
1748
+ load(id) {
1749
+ if (!id.startsWith("\0" + METADATA_ROUTE_ID))
1750
+ return;
1751
+ const kind = id.slice(("\0" + METADATA_ROUTE_ID).length);
1752
+ const route = [...apiRoutes.values()].find((r) => r.generated?.kind === kind);
1753
+ if (!route?.generated)
1754
+ return;
1755
+ const rootLayout = components.get("app/layout")?.absPath ?? null;
1756
+ return [
1757
+ `import produce from ${JSON.stringify(route.generated.file)}`,
1758
+ rootLayout
1759
+ ? `import * as __root from ${JSON.stringify(rootLayout)}`
1760
+ : "const __root = {}",
1761
+ `import { metadataResponse } from ${JSON.stringify(join(packageDir, "metadataRoutes"))}`,
1762
+ `export const GET = () => metadataResponse(${JSON.stringify(kind)}, produce, __root.metadata?.metadataBase ?? null)`,
1763
+ "",
1764
+ ].join("\n");
1765
+ },
1766
+ };
1767
+ }
1574
1768
  function register(absPath) {
1575
1769
  const name = componentName(absPath);
1576
1770
  const existing = components.get(name);
@@ -1605,10 +1799,20 @@ function registerApiRoute(absPath) {
1605
1799
  }
1606
1800
  apiRoutes.set(name, { name, absPath, methods });
1607
1801
  }
1802
+ /**
1803
+ * Whether a middleware.ts is a guard the engine runs, or only names guards
1804
+ * the host runs. The engine imports a guard's default export; a file with
1805
+ * none - `export const middleware = ['auth']` and nothing else - has nothing
1806
+ * to import, and registering it would make the build fail on an export that
1807
+ * was never meant to exist.
1808
+ */
1809
+ function isEngineGuard(absPath) {
1810
+ return /export\s+default\b/.test(readFileSync(absPath, "utf-8"));
1811
+ }
1608
1812
  function discover(dir) {
1609
1813
  for (const base of ROUTE_FILES) {
1610
1814
  const p = findRouteFile(dir, base);
1611
- if (p)
1815
+ if (p && (base !== "middleware" || isEngineGuard(p)))
1612
1816
  register(p);
1613
1817
  }
1614
1818
  // route.ts — an api endpoint, colocated with the pages it sits among. Read
@@ -1639,6 +1843,21 @@ function discover(dir) {
1639
1843
  * pages export only the static object, so referencing both meant that warning
1640
1844
  * for almost every route in an app.
1641
1845
  */
1846
+ /**
1847
+ * The root layout's metadataBase, read from the source. Wanted while the
1848
+ * manifest is built, before there is a bundle to execute - the same reason
1849
+ * generateStaticParams is detected by reading. A `new URL('…')` or a string
1850
+ * literal; anything computed is not seen, and rscKit({ hosts }) says it.
1851
+ */
1852
+ function rootMetadataBase(appDir) {
1853
+ const layout = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]
1854
+ .map((name) => join(appDir, name))
1855
+ .find((file) => existsSync(file));
1856
+ if (!layout)
1857
+ return null;
1858
+ const match = /metadataBase\s*:\s*(?:new\s+URL\(\s*)?["'`](https?:\/\/[^"'`]+)["'`]/.exec(readFileSync(layout, "utf-8"));
1859
+ return match ? match[1] : null;
1860
+ }
1642
1861
  function metadataExports(absPath) {
1643
1862
  const src = readFileSync(absPath, "utf-8");
1644
1863
  return {
@@ -1833,7 +2052,29 @@ function installHostCallsOnce(): void {
1833
2052
  }
1834
2053
 
1835
2054
  `;
2055
+ /**
2056
+ * The app's process bootstrap, if it has one: `instrumentation.ts` beside
2057
+ * `app/`, the name Next uses so a reader arriving from there knows what it
2058
+ * is.
2059
+ *
2060
+ * Two things make it a framework concern rather than an import the app
2061
+ * adds. Module evaluation order: a page that configures a shared package at
2062
+ * import time runs before any module the app could put first, and only the
2063
+ * generated entry can import something before the pages. And "before the
2064
+ * first request": an async `register()` — connecting, validating env — has
2065
+ * to be awaited by every entry point a host or a prerender can call, which
2066
+ * is a list the app cannot see.
2067
+ */
2068
+ function instrumentationFile() {
2069
+ for (const ext of ["ts", "tsx", "mts", "js", "mjs"]) {
2070
+ const file = join(sourceDir, `instrumentation.${ext}`);
2071
+ if (existsSync(file))
2072
+ return file;
2073
+ }
2074
+ return null;
2075
+ }
1836
2076
  function generateEntryRsc(fallbackOrigin = "") {
2077
+ const instrumentation = instrumentationFile();
1837
2078
  // The 404 page, if the app has one, and the layouts it renders inside.
1838
2079
  // Computed here rather than looked up at runtime: not-found is not a route,
1839
2080
  // so the manifest has no entry to read its chain from.
@@ -1890,12 +2131,20 @@ function generateEntryRsc(fallbackOrigin = "") {
1890
2131
  // from src/ in its own repo and from dist/ once published, and Vite resolves
1891
2132
  // either. Naming .tsx here builds fine from source and fails after publish.
1892
2133
  return `// GENERATED by rscKit() — do not edit.
2134
+ ${
2135
+ // First, before any page: an import's side effects run in import order,
2136
+ // and a package configured here has to be configured before a page module
2137
+ // that reads it at evaluation time.
2138
+ instrumentation
2139
+ ? `import * as __instrumentation from ${JSON.stringify(instrumentation)}`
2140
+ : "const __instrumentation: { register?: () => unknown } = {}"}
1893
2141
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
1894
2142
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
1895
2143
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
1896
2144
  import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
1897
2145
  import { sectionComponent } from ${JSON.stringify(join(packageDir, "js/section"))}
1898
2146
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
2147
+ import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultRouteError"))}
1899
2148
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
1900
2149
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
1901
2150
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
@@ -1907,6 +2156,9 @@ import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
1907
2156
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
1908
2157
  import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, "query"))}
1909
2158
  import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, "action"))}
2159
+ import { noteFallback as noteCaughtRead } from ${JSON.stringify(join(packageDir, "request"))}
2160
+ import { isOutdatedOptimizedDep, outdatedDepResponse } from ${JSON.stringify(join(packageDir, "devReload"))}
2161
+ import { sharedDepth } from ${JSON.stringify(join(packageDir, "routing"))}
1910
2162
  import { Suspense, createElement, Fragment } from 'react'
1911
2163
  import { AsyncLocalStorage } from 'node:async_hooks'
1912
2164
  ${imports.join("\n")}
@@ -2032,6 +2284,7 @@ export async function handleApiRoute(
2032
2284
  params: Record<string, string>,
2033
2285
  allow: string,
2034
2286
  ): Promise<Response> {
2287
+ await instrumented()
2035
2288
  applyHost()
2036
2289
 
2037
2290
  const mod = apiRoutes[name]
@@ -2139,6 +2392,8 @@ export async function auditActions(ids: string[]): Promise<{ id: string; client:
2139
2392
  * that asked for everything, or the reverse.
2140
2393
  */
2141
2394
  export async function getStaticParams(component: string): Promise<Record<string, string>[] | null> {
2395
+ await instrumented()
2396
+
2142
2397
  const generate = staticParamsMap[component]
2143
2398
 
2144
2399
  if (!generate) return null
@@ -2162,6 +2417,57 @@ const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
2162
2417
  */
2163
2418
  const HOST_MIDDLEWARE_FN = '__rsc.middleware'
2164
2419
 
2420
+ /**
2421
+ * instrumentation.ts's register(), once.
2422
+ *
2423
+ * On a long-lived server it runs at startup: the entry is evaluated when the
2424
+ * process starts, and this begins then, so env that fails validation fails
2425
+ * the boot rather than the first visitor, and the first request does not
2426
+ * pay for it. On a Worker there is no startup — an isolate is created for a
2427
+ * request, and a binding is only readable once one has arrived — so it runs
2428
+ * at the first request of each isolate instead. Every entry point awaits it
2429
+ * either way, which is what makes "before the first render" hold whether the
2430
+ * caller is the built server, the dev server or the prerender.
2431
+ *
2432
+ * A rejection is not memoised. Env that fails validation fails the same way
2433
+ * on every request, which is right; a database that was not up yet gets
2434
+ * asked again rather than leaving the process permanently refusing. In
2435
+ * production a startup failure is reported and the process exits, because a
2436
+ * server that could not bootstrap has nothing correct to serve; the dev
2437
+ * server stays up and reports it on the page instead.
2438
+ */
2439
+ let __instrumented: Promise<void> | null = null
2440
+
2441
+ function instrumented(): Promise<void> {
2442
+ if (!__instrumented) {
2443
+ __instrumented = Promise.resolve()
2444
+ .then(() => __instrumentation.register?.())
2445
+ .then(
2446
+ () => undefined,
2447
+ (error) => {
2448
+ __instrumented = null
2449
+ throw error
2450
+ },
2451
+ )
2452
+ }
2453
+
2454
+ return __instrumented
2455
+ }
2456
+
2457
+ // Workers name themselves; nothing else does. Where there is a process to
2458
+ // start, start now.
2459
+ const isolateRuntime = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
2460
+
2461
+ if (__instrumentation.register && !isolateRuntime) {
2462
+ instrumented().catch((error) => {
2463
+ console.error('[rsc-kit] instrumentation.ts register() failed:', error)
2464
+
2465
+ if (!import.meta.env.DEV && typeof process !== 'undefined' && typeof process.exit === 'function') {
2466
+ process.exit(1)
2467
+ }
2468
+ })
2469
+ }
2470
+
2165
2471
  let currentHost: HostFn | null = null
2166
2472
 
2167
2473
  export function installHostFn(fn: HostFn) {
@@ -2395,6 +2701,19 @@ function buildElement(
2395
2701
  )
2396
2702
  }
2397
2703
 
2704
+ // Outermost, for a throw no error.tsx covers - including one in a layout,
2705
+ // which the boundary inside that layout cannot see. Without it React
2706
+ // unmounted the document on hydration: a black page with the cause nowhere
2707
+ // near it. Only with the runtime: it is a client component, and a route
2708
+ // shipping none has nothing to catch with.
2709
+ if (bootstrap) {
2710
+ element = createElement(
2711
+ RouteErrorBoundary,
2712
+ { fallback: DefaultRouteError as never, resetKey: pageKey || component },
2713
+ element,
2714
+ )
2715
+ }
2716
+
2398
2717
  // <title>/<meta> go OUTSIDE the Suspense boundaries so they reach the shell
2399
2718
  // immediately — inside, they would be withheld until the page's data
2400
2719
  // resolves, delaying the whole document on a slow page.
@@ -2548,6 +2867,40 @@ async function renderTree(
2548
2867
  if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
2549
2868
  }
2550
2869
  if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
2870
+ // The name only, never the version - see the identify option.
2871
+ if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
2872
+
2873
+ // robots is a string, or the object Next takes: index and follow as
2874
+ // their no- forms, the flags by name, the limits as name:value. googleBot
2875
+ // is the same shape for the googlebot tag. The object used to fall
2876
+ // through to the catch-all below as "[object Object]" - which no crawler
2877
+ // reads, on the one page that asked not to be indexed.
2878
+ const robotsContent = (value: unknown): string => {
2879
+ if (typeof value !== 'object' || value === null) return String(value)
2880
+
2881
+ const r = value as Record<string, unknown>
2882
+ const parts: string[] = []
2883
+
2884
+ if (r.index != null) parts.push(r.index ? 'index' : 'noindex')
2885
+ if (r.follow != null) parts.push(r.follow ? 'follow' : 'nofollow')
2886
+ for (const flag of ['noarchive', 'nosnippet', 'noimageindex', 'nocache', 'notranslate', 'indexifembedded', 'nositelinkssearchbox']) {
2887
+ if (r[flag]) parts.push(flag)
2888
+ }
2889
+ if (r.unavailable_after != null) parts.push('unavailable_after: ' + String(r.unavailable_after))
2890
+ for (const limit of ['max-video-preview', 'max-image-preview', 'max-snippet']) {
2891
+ if (r[limit] != null) parts.push(limit + ':' + String(r[limit]))
2892
+ }
2893
+
2894
+ return parts.join(', ')
2895
+ }
2896
+
2897
+ if (md.robots != null) {
2898
+ head.push(createElement('meta', { key: '__r', name: 'robots', content: robotsContent(md.robots) }))
2899
+
2900
+ const bot = typeof md.robots === 'object' ? (md.robots as { googleBot?: unknown }).googleBot : null
2901
+
2902
+ if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
2903
+ }
2551
2904
 
2552
2905
  // og: and its relatives are PROPERTY, not name. Facebook's scraper - and
2553
2906
  // Slack's, and LinkedIn's - reads only property=, so every og tag this
@@ -2653,7 +3006,7 @@ async function renderTree(
2653
3006
  // meta tags rather than a meta tag by that name. A key at the top level
2654
3007
  // still renders - the type no longer invites one, but an app written
2655
3008
  // against the old shape must not silently lose its tags.
2656
- const structured = new Set(['title', 'description', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
3009
+ const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
2657
3010
  const named = Object.entries(md).filter(([k]) => !structured.has(k))
2658
3011
  const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
2659
3012
 
@@ -2817,6 +3170,7 @@ async function runMiddleware(component: string, props: Record<string, unknown> =
2817
3170
  * throws, exactly as it does mid-render.
2818
3171
  */
2819
3172
  export async function runRouteMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
3173
+ await instrumented()
2820
3174
  applyHost()
2821
3175
 
2822
3176
  return runMiddleware(component, props)
@@ -2832,6 +3186,7 @@ export async function handleRscStream(
2832
3186
  from = 0,
2833
3187
  pageKey = '',
2834
3188
  ): Promise<{ stream: ReadableStream; clientChunks: unknown; segmentDepth: number }> {
3189
+ await instrumented()
2835
3190
  applyHost()
2836
3191
 
2837
3192
  // The host proposes how much the client already has; the engine decides what
@@ -2887,6 +3242,7 @@ export async function handleRscHtmlStream(
2887
3242
  pageKey = '',
2888
3243
  bootstrap = true,
2889
3244
  ): Promise<{ htmlStream: ReadableStream; rscPayloadPromise: Promise<string>; clientChunks: unknown }> {
3245
+ await instrumented()
2890
3246
  applyHost()
2891
3247
  await runMiddleware(component, props)
2892
3248
  const flight = renderToReadableStream(
@@ -2922,6 +3278,7 @@ export async function handleRscResume(
2922
3278
  nonce?: string,
2923
3279
  pageKey = '',
2924
3280
  ): Promise<{ htmlStream: ReadableStream }> {
3281
+ await instrumented()
2925
3282
  applyHost()
2926
3283
  await runMiddleware(component, props)
2927
3284
 
@@ -3072,6 +3429,7 @@ export async function handleQuery(
3072
3429
  | { status: number; message: string; errors?: Record<string, string[]> }
3073
3430
  | null
3074
3431
  > {
3432
+ await instrumented()
3075
3433
  applyHost()
3076
3434
 
3077
3435
  let fn: unknown
@@ -3120,6 +3478,7 @@ export async function handleAction(
3120
3478
  page?: PageContext,
3121
3479
  takeRevalidated?: () => string[],
3122
3480
  ): Promise<{ stream: ReadableStream }> {
3481
+ await instrumented()
3123
3482
  applyHost()
3124
3483
 
3125
3484
  // Every body arrives as bytes on its own socket frame — an upload because it
@@ -3212,6 +3571,8 @@ export async function resolveMetadata(
3212
3571
  props: Record<string, unknown> = {},
3213
3572
  layouts: LayoutEntry[] = [],
3214
3573
  ): Promise<Record<string, unknown> | null> {
3574
+ await instrumented()
3575
+
3215
3576
  const pageEntry = metadataMap[component]
3216
3577
  const page: Record<string, unknown> = pageEntry
3217
3578
  ? (pageEntry.generate
@@ -3281,6 +3642,7 @@ export async function handleRsc(
3281
3642
  bootstrap = true,
3282
3643
  canReachHost = true,
3283
3644
  ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; dynamicBecause: string[]; clientComponents: string[]; serverReferences: boolean }> {
3645
+ await instrumented()
3284
3646
  applyHost()
3285
3647
 
3286
3648
  // A build renders this with no host installed, so every rpc() has to suspend
@@ -3394,6 +3756,7 @@ export async function handleRscRevalidate(
3394
3756
  target: string,
3395
3757
  page: PageContext,
3396
3758
  ): Promise<{ rscPayload: string }> {
3759
+ await instrumented()
3397
3760
  applyHost()
3398
3761
 
3399
3762
  const flight = renderToReadableStream(await renderRevalidated(target, page))
@@ -3410,6 +3773,7 @@ export async function handleRscPayload(
3410
3773
  from = 0,
3411
3774
  pageKey = '',
3412
3775
  ): Promise<{ rscPayload: string }> {
3776
+ await instrumented()
3413
3777
  applyHost()
3414
3778
 
3415
3779
  const flight = renderToReadableStream(
@@ -3477,6 +3841,7 @@ export async function handleRscPprShell(
3477
3841
  // and make every guarded route dynamic for the wrong reason.
3478
3842
  let usedDynamicApis = false
3479
3843
 
3844
+ await instrumented()
3480
3845
  applyHost()
3481
3846
 
3482
3847
  const probe = (..._args: unknown[]) => {
@@ -3506,12 +3871,22 @@ export async function handleRscPprShell(
3506
3871
  // page, stored, with the build reporting success.
3507
3872
  let renderFailure: string | undefined
3508
3873
 
3509
- const noteFailure = (e: unknown): string | undefined => {
3874
+ const noteFailure = (e: unknown, info?: { componentStack?: string }): string | undefined => {
3510
3875
  const digest = redirectDigest(e)
3511
3876
 
3512
3877
  // A redirect is a classification here, not a failure.
3513
3878
  if (digest) return digest
3514
3879
 
3880
+ // A read the server could not answer, caught at a boundary: the fallback
3881
+ // there is what gets stored. Noted with its component so the build can
3882
+ // say so on the route's line; the digest goes into the document for the
3883
+ // browser to recognise.
3884
+ if ((e as { digest?: string } | null)?.digest === 'rsc-kit:search-params-fallback') {
3885
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
3886
+ noteCaughtRead('useSearchParams()' + (where ? ' in ' + where : ''))
3887
+ return 'rsc-kit:search-params-fallback'
3888
+ }
3889
+
3515
3890
  // Every probe ends by aborting, so React reports that abort. Asked of the
3516
3891
  // signal rather than matched against the message: a string test would be a
3517
3892
  // guess about wording, and would quietly stop working when React changed it.
@@ -3624,6 +3999,29 @@ ${fallbackOrigin ? FALLBACK_CONSTS.replace("__ORIGIN__", JSON.stringify(fallback
3624
3999
  let devHandler: ((request: Request) => Promise<Response | null>) | null = null
3625
4000
 
3626
4001
  export default async function handler(request: Request): Promise<Response> {
4002
+ try {
4003
+ return await serve(request)
4004
+ } catch (error) {
4005
+ // Vite re-optimised a server pre-bundle under this render. The condition
4006
+ // is over already; the page is asked to load again.
4007
+ if (import.meta.env.DEV && isOutdatedOptimizedDep(error)) return outdatedDepResponse(request)
4008
+
4009
+ throw error
4010
+ }
4011
+ }
4012
+
4013
+ async function serve(request: Request): Promise<Response> {
4014
+ // The startup plugin's probe. Nitro loads this module at the first request
4015
+ // rather than when the server starts, so a plugin that runs at startup
4016
+ // sends one request to load it; the module's evaluation began register(),
4017
+ // and this waits for it so a failure is reported as the startup failure it
4018
+ // is. No page is rendered and nothing is disclosed.
4019
+ if (request.headers.get('x-rsc-kit-startup') !== null) {
4020
+ await instrumented()
4021
+
4022
+ return new Response(null, { status: 204 })
4023
+ }
4024
+
3627
4025
  installHostCallsOnce()
3628
4026
 
3629
4027
  devHandler ??= createRscHandler({
@@ -3647,7 +4045,7 @@ export default async function handler(request: Request): Promise<Response> {
3647
4045
  ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} maxActionBody: ${maxActionBody === undefined ? "undefined" : String(maxActionBody)},
3648
4046
  })
3649
4047
 
3650
- ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (await notFound())\n"}}
4048
+ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (await notFound(request))\n"}}
3651
4049
 
3652
4050
  /**
3653
4051
  * The page for a url nothing answers.
@@ -3658,9 +4056,42 @@ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (awa
3658
4056
  *
3659
4057
  * Without a not-found.tsx this is the string it always was.
3660
4058
  */
3661
- async function notFound(): Promise<Response> {
4059
+ async function notFound(request: Request): Promise<Response> {
3662
4060
  ${notFoundComponent
3663
4061
  ? `
4062
+ // A navigation, not a document: answer with the not-found tree as a
4063
+ // payload, at the depth the client already holds, so the router renders
4064
+ // it in place - the layout stays, the url changes, nothing reloads. The
4065
+ // status is still 404; a payload is a payload whatever it says.
4066
+ if (request.headers.get('X-RSC')) {
4067
+ try {
4068
+ const chain = ${JSON.stringify(notFoundLayouts)}
4069
+ const from = sharedDepth(request.headers.get('X-RSC-Segments'), chain)
4070
+ const { rscPayload } = await handleRscPayload(
4071
+ ${JSON.stringify(notFoundComponent)},
4072
+ {},
4073
+ ${JSON.stringify(notFoundLayouts.map((component) => ({ component, props: {} })))},
4074
+ [],
4075
+ {},
4076
+ from,
4077
+ '/404',
4078
+ )
4079
+
4080
+ return new Response(rscPayload, {
4081
+ status: 404,
4082
+ headers: {
4083
+ 'Content-Type': 'text/x-component; charset=utf-8',
4084
+ 'X-RSC-Segment-Depth': String(from),
4085
+ 'X-RSC-Layouts': chain.join(','),
4086
+ 'Cache-Control': 'no-store',
4087
+ Vary: 'X-RSC',
4088
+ },
4089
+ })
4090
+ } catch {
4091
+ // Fall through to the document answer below.
4092
+ }
4093
+ }
4094
+
3664
4095
  try {
3665
4096
  const { htmlStream } = await handleRscHtmlStream(
3666
4097
  ${JSON.stringify(notFoundComponent)},
@@ -3689,11 +4120,15 @@ async function notFound(): Promise<Response> {
3689
4120
  }
3690
4121
  function generateEntrySsr() {
3691
4122
  const devUrls = join(packageDir, "devUrls");
4123
+ const request = join(packageDir, "request");
4124
+ const fallbackReport = join(packageDir, "js/fallbackReport");
3692
4125
  return `// GENERATED by rscKit() — do not edit.
3693
4126
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
3694
4127
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
3695
4128
  import { prerender } from 'react-dom/static.edge'
3696
4129
  import { rewriteViteDevUrlStream } from ${JSON.stringify(devUrls)}
4130
+ import { noteFallback } from ${JSON.stringify(request)}
4131
+ import { cancelledByConsumer, caughtByLoading } from ${JSON.stringify(fallbackReport)}
3697
4132
 
3698
4133
  // Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
3699
4134
  // links root-relative in dev, which would send the browser to the host for
@@ -3724,12 +4159,28 @@ export async function handleSsr(
3724
4159
  // The query-string fallback is the designed path for a stored page, and
3725
4160
  // its digest is what lets the client tell it from a fault on hydration;
3726
4161
  // returned here so React writes it into the document.
3727
- onError: onError ?? ((error: unknown) => {
4162
+ onError: onError ?? ((error: unknown, info?: { componentStack?: string }) => {
4163
+ // The consumer cancelled - a browser that left mid-stream, a prefetch
4164
+ // abandoned. React reports it as an error; the page had none.
4165
+ if (cancelledByConsumer(error)) return
3728
4166
  const digest = (error as { digest?: string } | null)?.digest
3729
4167
  if (digest === 'rsc-kit:search-params-fallback') {
3730
- // One line, not a stack: the line is the dev server saying which
3731
- // boundary the build will want.
3732
- console.error('[rsc-kit] ' + String((error as Error).message))
4168
+ // The component is the first frame of React's stack. Noted on the
4169
+ // request so the build attaches it to the route; printed as one line,
4170
+ // not a stack, so the dev server says which boundary the build wants.
4171
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4172
+ noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4173
+ // Under a boundary the developer wrote, nothing to say. With nothing
4174
+ // closer than a loading.tsx, one line: the whole segment is the
4175
+ // fallback until the query arrives.
4176
+ if (caughtByLoading(info?.componentStack)) {
4177
+ console.error(
4178
+ '[rsc-kit] ' + (where ? where + ': ' : '') +
4179
+ 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4180
+ 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4181
+ 'keeps the rest of the page painted.',
4182
+ )
4183
+ }
3733
4184
  return digest
3734
4185
  }
3735
4186
  console.error('[rsc-kit:ssr]', error)
@@ -3769,8 +4220,14 @@ export async function handleSsrPrerender(
3769
4220
  bootstrapScriptContent,
3770
4221
  nonce: options.nonce,
3771
4222
  signal: options.signal,
3772
- // Aborting is how this ends, so React's report of it is not news.
3773
- onError: () => {},
4223
+ // Aborting is how this ends, so React's report of it is not news. A read
4224
+ // caught at a boundary is: the build attaches it to the route.
4225
+ onError: (error: unknown, info?: { componentStack?: string }) => {
4226
+ if ((error as { digest?: string } | null)?.digest !== 'rsc-kit:search-params-fallback') return
4227
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4228
+ noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4229
+ return 'rsc-kit:search-params-fallback'
4230
+ },
3774
4231
  })
3775
4232
 
3776
4233
  return {
@@ -4062,16 +4519,307 @@ function extendableClientReferences() {
4062
4519
  },
4063
4520
  };
4064
4521
  }
4522
+ /**
4523
+ * The worker the dev server serves at /sw.js: it removes the one a production
4524
+ * run left on this origin, and the caches with it, then reloads each page it
4525
+ * was controlling so they load uncontrolled. Nothing else runs in development.
4526
+ */
4527
+ export const DEV_WORKER = `self.addEventListener('install', () => self.skipWaiting())
4528
+ self.addEventListener('activate', (event) => {
4529
+ event.waitUntil((async () => {
4530
+ const keys = await caches.keys()
4531
+ await Promise.all(keys.filter((k) => k.startsWith('rsc-kit-')).map((k) => caches.delete(k)))
4532
+ await self.clients.claim()
4533
+ const pages = await self.clients.matchAll({ type: 'window' })
4534
+ await self.registration.unregister()
4535
+ for (const page of pages) page.navigate(page.url).catch(() => {})
4536
+ })())
4537
+ })
4538
+ `;
4539
+ /**
4540
+ * Named imports from a barrel package, unrolled on the server environments.
4541
+ *
4542
+ * The browser gets lucide pre-bundled; the server environments do not, so
4543
+ * `import { ArrowRight } from 'lucide-react'` loaded and transformed every
4544
+ * icon module - 3,700 transforms, twenty-five seconds before the first
4545
+ * document, for thirteen icons. See barrelImports. Development only: a
4546
+ * build tree-shakes the barrel itself.
4547
+ */
4548
+ function barrelImportsPlugin() {
4549
+ const entries = new Map();
4550
+ return {
4551
+ name: "rsc-kit:barrel-imports",
4552
+ apply: () => barrelImports && process.env.NODE_ENV !== "production",
4553
+ enforce: "pre",
4554
+ applyToEnvironment: (environment) => environment.name === "rsc" || environment.name === "ssr",
4555
+ async transform(code, id) {
4556
+ if (id.includes("/node_modules/") ||
4557
+ !/\.[cm]?[jt]sx?$/.test(id.split("?")[0]))
4558
+ return;
4559
+ if (!code.includes("from"))
4560
+ return;
4561
+ const pending = [];
4562
+ const wanted = new Set();
4563
+ for (const m of code.matchAll(/import\s*(?:type\s+)?\{[^}]*\}\s*from\s*['"]([^'"./][^'"]*)['"]/g)) {
4564
+ wanted.add(m[1]);
4565
+ }
4566
+ for (const specifier of wanted) {
4567
+ if (entries.has(specifier))
4568
+ continue;
4569
+ pending.push(this.resolve(specifier, id).then((resolved) => {
4570
+ entries.set(specifier, resolved && !resolved.external ? resolved.id.split("?")[0] : null);
4571
+ }));
4572
+ }
4573
+ await Promise.all(pending);
4574
+ const out = unrollBarrelImports(code, (specifier) => entries.get(specifier) ?? null);
4575
+ return out === null ? undefined : { code: out, map: null };
4576
+ },
4577
+ };
4578
+ }
4579
+ /**
4580
+ * "use ssr" modules, rewritten for the server-components environment into
4581
+ * proxies that call the real module in the ssr environment. See useSsr.ts.
4582
+ *
4583
+ * Before every other transform, so the cross-environment import this emits
4584
+ * is still ahead of plugin-rsc's handling of it - and so the module's own
4585
+ * imports, react-dom/server among them, are never resolved here at all.
4586
+ */
4587
+ function useSsrModules() {
4588
+ return {
4589
+ name: "rsc-kit:use-ssr",
4590
+ enforce: "pre",
4591
+ applyToEnvironment: (environment) => environment.name === "rsc",
4592
+ transform(code, id) {
4593
+ if (!code.includes("use ssr"))
4594
+ return;
4595
+ try {
4596
+ const proxy = ssrProxyModule(code, id);
4597
+ return proxy === null ? undefined : { code: proxy, map: null };
4598
+ }
4599
+ catch (error) {
4600
+ if (error instanceof UseSsrError)
4601
+ this.error(error.message);
4602
+ throw error;
4603
+ }
4604
+ },
4605
+ };
4606
+ }
4607
+ /**
4608
+ * react-dom/server, imported where server components render, becomes a
4609
+ * module that throws the fix instead of React's refusal - naming the app
4610
+ * file that imported it (through @react-email/render, say) and the
4611
+ * directive that moves it. The build says the same once, as a warning.
4612
+ */
4613
+ const RENDERER_STUB = "\0rsc-kit:react-dom-server?from=";
4614
+ function serverRendererInRsc() {
4615
+ const warned = new Set();
4616
+ const appImporter = (ctx, importer) => {
4617
+ let current = importer;
4618
+ for (let hop = 0; current && hop < 12; hop++) {
4619
+ if (!current.includes("/node_modules/"))
4620
+ return relative(process.cwd(), current.split("?")[0]);
4621
+ const next = ctx.environment.mode === "build"
4622
+ ? ctx.getModuleInfo(current)?.importers[0]
4623
+ : (ctx.environment.moduleGraph
4624
+ ?.getModuleById(current)
4625
+ ?.importers.values()
4626
+ .next().value?.id ?? undefined);
4627
+ if (!next)
4628
+ break;
4629
+ current = next;
4630
+ }
4631
+ return importer ? relative(process.cwd(), importer.split("?")[0]) : null;
4632
+ };
4633
+ return {
4634
+ name: "rsc-kit:server-renderer",
4635
+ applyToEnvironment: (environment) => environment.name === "rsc",
4636
+ resolveId(source, importer) {
4637
+ if (!SERVER_RENDERER.test(source))
4638
+ return;
4639
+ return RENDERER_STUB + encodeURIComponent(importer ?? "");
4640
+ },
4641
+ load(id) {
4642
+ if (!id.startsWith(RENDERER_STUB))
4643
+ return;
4644
+ const importer = decodeURIComponent(id.slice(RENDERER_STUB.length)) || null;
4645
+ const message = serverRendererMessage(appImporter(this, importer));
4646
+ if (this.environment.mode === "build" && !warned.has(message)) {
4647
+ warned.add(message);
4648
+ this.warn(message);
4649
+ }
4650
+ return `throw new Error(${JSON.stringify(message)});\n`;
4651
+ },
4652
+ };
4653
+ }
4654
+ /**
4655
+ * `tsc --noEmit` on the project's own tsconfig, the way `check` runs it.
4656
+ *
4657
+ * Through package.json rather than a subpath: TypeScript's exports map does
4658
+ * not expose bin/tsc, and the bin field is where the name lives. Its output
4659
+ * is the message, because that is what the developer would have read.
4660
+ */
4661
+ export function typecheckProject(root) {
4662
+ const tsconfig = join(root, "tsconfig.json");
4663
+ if (!existsSync(tsconfig))
4664
+ return { ran: false, because: "no tsconfig" };
4665
+ let tsc;
4666
+ try {
4667
+ const fromApp = createRequire(join(root, "package.json"));
4668
+ const manifest = fromApp.resolve("typescript/package.json");
4669
+ const bin = fromApp(manifest)
4670
+ .bin;
4671
+ const relative = typeof bin === "string" ? bin : bin?.tsc;
4672
+ if (!relative)
4673
+ return { ran: false, because: "no typescript" };
4674
+ tsc = join(dirname(manifest), relative);
4675
+ }
4676
+ catch {
4677
+ return { ran: false, because: "no typescript" };
4678
+ }
4679
+ const started = Date.now();
4680
+ const script = /\.[cm]?js$/.test(tsc) || !/\.\w+$/.test(tsc);
4681
+ const result = spawnSync(script ? process.execPath : tsc,
4682
+ // Plain lines, not coloured ones: they go into a build error, and the
4683
+ // test that reads them is a reader too.
4684
+ [...(script ? [tsc] : []), "--noEmit", "--pretty", "false", "-p", tsconfig], { cwd: root, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
4685
+ if (result.status === 0)
4686
+ return { ran: true, ok: true, seconds: (Date.now() - started) / 1000 };
4687
+ return {
4688
+ ran: true,
4689
+ ok: false,
4690
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(),
4691
+ };
4692
+ }
4693
+ /**
4694
+ * The project's typecheck, as part of the build.
4695
+ *
4696
+ * Runs once, in the first environment to start, after the route types have
4697
+ * been written by the config hook - so a link to a route that does not exist
4698
+ * is an error here and not a 404 found after deploying. Skipped where the
4699
+ * project is not doing this checking at all: no tsconfig.json, or no
4700
+ * typescript to run.
4701
+ */
4702
+ function typecheckPlugin() {
4703
+ let ran = false;
4704
+ return {
4705
+ name: "rsc-kit:typecheck",
4706
+ apply: "build",
4707
+ buildStart() {
4708
+ if (ran || !typecheck || isWatch)
4709
+ return;
4710
+ ran = true;
4711
+ const outcome = typecheckProject(projectRoot);
4712
+ if (!outcome.ran)
4713
+ return;
4714
+ if (outcome.ok) {
4715
+ log(`typecheck: ok (${outcome.seconds.toFixed(1)}s)`);
4716
+ return;
4717
+ }
4718
+ throw new Error("[rsc-kit] The typecheck failed, so the build stops here.\n\n" +
4719
+ outcome.output +
4720
+ "\n\nrscKit({ typecheck: false }) builds without it.");
4721
+ },
4722
+ };
4723
+ }
4724
+ /**
4725
+ * Which server files import a client library, read off the rsc environment's
4726
+ * module graph once it is built. plugin-rsc classifies the client packages
4727
+ * (every package with react among its peers) and excludes them from the
4728
+ * server optimizers; that list is the one used here, minus this package -
4729
+ * a server component importing Link is the norm - and plugin-rsc's own.
4730
+ */
4731
+ function clientImportsAudit() {
4732
+ return {
4733
+ name: "rsc-kit:client-imports",
4734
+ apply: "build",
4735
+ applyToEnvironment: (environment) => environment.name === "rsc",
4736
+ buildEnd() {
4737
+ const excluded = resolvedConfig?.environments?.rsc?.optimizeDeps?.exclude ?? [];
4738
+ const clientPackages = excluded.filter((name) => !name.startsWith("@vitejs/plugin-rsc"));
4739
+ if (clientPackages.length === 0)
4740
+ return;
4741
+ clientLibraryImports = serverImportsOfClientPackages({
4742
+ moduleIds: () => this.getModuleIds(),
4743
+ importedIds: (id) => this.getModuleInfo(id)?.importedIds ?? [],
4744
+ importers: (id) => this.getModuleInfo(id)?.importers ?? [],
4745
+ }, {
4746
+ sourceDir,
4747
+ clientPackages,
4748
+ ignore: [PACKAGE_NAME, "server-only", "client-only"],
4749
+ });
4750
+ },
4751
+ };
4752
+ }
4753
+ /**
4754
+ * A Nitro runtime plugin, so instrumentation.ts runs when the server starts.
4755
+ *
4756
+ * Nitro loads the rsc service at the first request, not at startup - so
4757
+ * without this, register() would run when the first visitor arrived, and a
4758
+ * bad environment would take down a server that had already reported itself
4759
+ * up. The plugin runs at app init and sends the service one request, which
4760
+ * loads the module; loading it begins register(), and the request waits for
4761
+ * it. Not on a Worker: there is no startup, and the first request is the
4762
+ * first chance to read a binding.
4763
+ */
4764
+ const STARTUP_PLUGIN = `import { viteServices } from "#nitro/virtual/vite-services"
4765
+
4766
+ export default function rscKitStartup() {
4767
+ if (typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers') return
4768
+
4769
+ // The ssr service is the one Nitro's renderer asks; its fetch imports the
4770
+ // rsc entry and hands the request on. The rsc service's own export is the
4771
+ // handler function, not a { fetch }, so it cannot be probed directly.
4772
+ const ssr = viteServices.ssr
4773
+ if (!ssr) return
4774
+
4775
+ // Two ways to fail, one outcome. register() rejecting is reported by the
4776
+ // module itself. instrumentation.ts throwing at import - an env schema
4777
+ // refusing - means the module never evaluated, so nothing in it can
4778
+ // report; the load rejects here instead. Either way a server that could
4779
+ // not bootstrap has nothing correct to serve, and says so and stops
4780
+ // rather than reporting itself up.
4781
+ ssr
4782
+ .fetch(new Request('http://rsc-kit.internal/_rsc/startup', { headers: { 'x-rsc-kit-startup': '1' } }))
4783
+ .then((response) => {
4784
+ if (!response.ok) throw new Error('the startup probe answered ' + response.status)
4785
+ })
4786
+ .catch((error) => {
4787
+ console.error('[rsc-kit] instrumentation.ts failed at startup:', error)
4788
+
4789
+ if (typeof process !== 'undefined' && typeof process.exit === 'function') process.exit(1)
4790
+ })
4791
+ }
4792
+ `;
4065
4793
  export function rscKit(options = {}) {
4066
4794
  resolvePaths(options);
4067
4795
  const routesPlugin = {
4068
4796
  name: "rsc-kit",
4797
+ // A Nitro module, which Nitro's Vite plugin collects from any plugin
4798
+ // that carries one. Only for a built server: the dev server evaluates
4799
+ // the entry through Vite's runner when it is first asked for, and there
4800
+ // is no earlier moment to offer.
4801
+ nitro: {
4802
+ name: "rsc-kit",
4803
+ setup(nitro) {
4804
+ if (nitro.options.dev || !instrumentationFile())
4805
+ return;
4806
+ nitro.options.virtual ??= {};
4807
+ nitro.options.virtual["#rsc-kit/startup"] = STARTUP_PLUGIN;
4808
+ nitro.options.plugins = [...(nitro.options.plugins ?? []), "#rsc-kit/startup"];
4809
+ },
4810
+ },
4069
4811
  config(_config, env) {
4070
4812
  if (!existsSync(appDir)) {
4071
4813
  throw new Error(`[rsc-kit] No app directory at ${appDir} — nothing to build.`);
4072
4814
  }
4815
+ // Both maps, not one: the config hook runs again when the config
4816
+ // changes under a dev server, and a route.ts or a synthesised
4817
+ // robots.txt left over from the previous run read as a duplicate.
4073
4818
  components.clear();
4819
+ apiRoutes.clear();
4074
4820
  discover(appDir);
4821
+ registerMetadataRoutes(appDir);
4822
+ siteHosts = ownHosts(rootMetadataBase(appDir), hostsOption);
4075
4823
  // Silent when it worked. The names were printed on every dev start and
4076
4824
  // every build — thirty of them for a middling app, above the output that
4077
4825
  // actually says something, and the build's classification table lists
@@ -4160,9 +4908,6 @@ export function rscKit(options = {}) {
4160
4908
  */
4161
4909
  define: {
4162
4910
  "process.env.NODE_ENV": JSON.stringify(env.mode === "development" ? "development" : "production"),
4163
- // A constant, so the boundary and its import fall out of the bundle
4164
- // entirely when this is off rather than shipping a branch nobody takes.
4165
- __RSC_VIEW_TRANSITIONS__: JSON.stringify(viewTransitions),
4166
4911
  __RSC_OFFLINE__: JSON.stringify(offline),
4167
4912
  },
4168
4913
  /*
@@ -4187,6 +4932,36 @@ export function rscKit(options = {}) {
4187
4932
  */
4188
4933
  optimizeDeps: {
4189
4934
  exclude: [PACKAGE_NAME, ...(_config.optimizeDeps?.exclude ?? [])],
4935
+ // Every "use client" file, so the browser's dependencies are all
4936
+ // found at startup. The client entry reaches only the engine and
4937
+ // React; the app's client components arrive through payloads, page
4938
+ // by page, and a dependency first seen on the third page visited
4939
+ // re-optimised every pre-bundle under a running page - two Reacts,
4940
+ // a blank document, then Vite's own reload. See clientEntries.
4941
+ // Naming entries replaces the input, so the browser entry is named
4942
+ // too, or the engine and React would be the ones left out.
4943
+ entries: [
4944
+ ...(Array.isArray(_config.optimizeDeps?.entries)
4945
+ ? _config.optimizeDeps.entries
4946
+ : _config.optimizeDeps?.entries
4947
+ ? [_config.optimizeDeps.entries]
4948
+ : []),
4949
+ // Entries are globs: a route group's parentheses, or a bracket
4950
+ // in a dynamic segment, would otherwise read as pattern syntax
4951
+ // and match nothing - and the scanner would crawl nothing, quietly.
4952
+ ...[
4953
+ join(genDir, "entry.browser.tsx"),
4954
+ ...engineClientEntries(),
4955
+ ...clientEntries(sourceDir),
4956
+ ].map((file) => file.replace(/[()[\]{}*?!+@]/g, "\\$&")),
4957
+ ],
4958
+ rolldownOptions: {
4959
+ ...(_config.optimizeDeps?.rolldownOptions ?? {}),
4960
+ plugins: [
4961
+ clientScanPlugin(),
4962
+ ...arrayOf(_config.optimizeDeps?.rolldownOptions?.plugins),
4963
+ ],
4964
+ },
4190
4965
  },
4191
4966
  // Public URL for browser-facing client assets, and a BUILD concern
4192
4967
  // only: it says where the built files will be served from.
@@ -4279,6 +5054,28 @@ export function rscKit(options = {}) {
4279
5054
  // and the console a 404 for a file that is right there.
4280
5055
  server.middlewares.use((req, res, next) => {
4281
5056
  const url = (req.url ?? "").split("?")[0];
5057
+ // A worker registered by a production run on this origin - `bun run
5058
+ // start` on the port the dev server uses next - outlives that run and
5059
+ // answers the dev server's documents from its cache: a stored page is
5060
+ // cache-first, so a refresh brings back yesterday's document with
5061
+ // yesterday's module hashes, and a 504 for each. The browser fetches
5062
+ // /sw.js again on every navigation to look for an update; in
5063
+ // development that fetch gets a worker whose only job is to remove
5064
+ // itself, its caches, and reload the pages it controlled.
5065
+ // robots.txt, a hand-written sitemap.xml, humans.txt: beside the root
5066
+ // layout, served at the root. The build copies them beside the client
5067
+ // output; here they are read from app/.
5068
+ if (url.startsWith("/") && rootFiles.includes(url.slice(1))) {
5069
+ res.setHeader("Content-Type", rootFileType(url.slice(1)));
5070
+ res.end(readFileSync(join(sourceDir, "app", url.slice(1))));
5071
+ return;
5072
+ }
5073
+ if (url === "/sw.js") {
5074
+ res.setHeader("Content-Type", "text/javascript");
5075
+ res.setHeader("Cache-Control", "no-store");
5076
+ res.end(DEV_WORKER);
5077
+ return;
5078
+ }
4282
5079
  if (webManifestOptions && url === MANIFEST_PATH) {
4283
5080
  res.setHeader("Content-Type", "application/manifest+json");
4284
5081
  res.end(webManifest(webManifestOptions));
@@ -4506,7 +5303,13 @@ export function rscKit(options = {}) {
4506
5303
  clientChunks: (meta) => meta.normalizedId,
4507
5304
  ...actionEncryptionKey(),
4508
5305
  }),
5306
+ barrelImportsPlugin(),
5307
+ useSsrModules(),
5308
+ serverRendererInRsc(),
5309
+ metadataRoutesPlugin(),
4509
5310
  extendableClientReferences(),
5311
+ typecheckPlugin(),
5312
+ clientImportsAudit(),
4510
5313
  routesPlugin,
4511
5314
  ];
4512
5315
  }