@rsc-kit/core 0.17.0 → 0.18.1

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 (62) 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/events.d.ts +57 -0
  5. package/dist/events.js +113 -0
  6. package/dist/events.js.map +1 -0
  7. package/dist/host.d.ts +16 -6
  8. package/dist/host.js +219 -124
  9. package/dist/host.js.map +1 -1
  10. package/dist/hostCalls.d.ts +27 -0
  11. package/dist/hostCalls.js +127 -12
  12. package/dist/hostCalls.js.map +1 -1
  13. package/dist/hostRouting.d.ts +53 -0
  14. package/dist/hostRouting.js +100 -0
  15. package/dist/hostRouting.js.map +1 -0
  16. package/dist/js/DefaultRouteError.d.ts +3 -0
  17. package/dist/js/DefaultRouteError.js +61 -0
  18. package/dist/js/DefaultRouteError.js.map +1 -0
  19. package/dist/js/LoadingBoundary.d.ts +5 -0
  20. package/dist/js/LoadingBoundary.js +19 -0
  21. package/dist/js/LoadingBoundary.js.map +1 -0
  22. package/dist/js/fallbackReport.js +10 -8
  23. package/dist/js/fallbackReport.js.map +1 -1
  24. package/dist/js/queryClient.d.ts +2 -1
  25. package/dist/js/queryClient.js +0 -12
  26. package/dist/js/queryClient.js.map +1 -1
  27. package/dist/js/router.d.ts +2 -2
  28. package/dist/js/router.js.map +1 -1
  29. package/dist/js/useEvents.d.ts +25 -0
  30. package/dist/js/useEvents.js +80 -0
  31. package/dist/js/useEvents.js.map +1 -0
  32. package/dist/js/usePolling.d.ts +29 -0
  33. package/dist/js/usePolling.js +142 -0
  34. package/dist/js/usePolling.js.map +1 -0
  35. package/dist/manifest.d.ts +18 -1
  36. package/dist/manifest.js.map +1 -1
  37. package/dist/metadataRoutes.d.ts +20 -0
  38. package/dist/metadataRoutes.js +35 -0
  39. package/dist/metadataRoutes.js.map +1 -1
  40. package/dist/prerender.js +2 -0
  41. package/dist/prerender.js.map +1 -1
  42. package/dist/redirect.d.ts +16 -2
  43. package/dist/redirect.js +10 -19
  44. package/dist/redirect.js.map +1 -1
  45. package/dist/revalidate.d.ts +6 -4
  46. package/dist/revalidate.js +6 -5
  47. package/dist/revalidate.js.map +1 -1
  48. package/dist/routeSchema.d.ts +49 -0
  49. package/dist/routeSchema.js.map +1 -1
  50. package/dist/routes.d.ts +16 -3
  51. package/dist/routes.js +5 -5
  52. package/dist/routes.js.map +1 -1
  53. package/dist/routing.d.ts +2 -2
  54. package/dist/routing.js +47 -25
  55. package/dist/routing.js.map +1 -1
  56. package/dist/testing.d.ts +8 -0
  57. package/dist/testing.js +27 -3
  58. package/dist/testing.js.map +1 -1
  59. package/dist/vite.d.ts +53 -2
  60. package/dist/vite.js +503 -38
  61. package/dist/vite.js.map +1 -1
  62. package/package.json +15 -3
package/dist/vite.js CHANGED
@@ -27,7 +27,9 @@ import { ASSET_BASE, allAppAssets, appAssets, headTags, typeOf, } from "./appAss
27
27
  import { reactCacheImports } from "./reactCache.js";
28
28
  import { clientEntries, clientScanPlugin, engineClientEntries, } from "./clientEntries.js";
29
29
  import { serverImportsOfClientPackages } from "./clientImports.js";
30
- import { METADATA_ROUTES, ROOT_FILES, rootFileType } from "./metadataRoutes.js";
30
+ import { automaticSitemap, METADATA_ROUTES, ROOT_FILES, rootFileType, } from "./metadataRoutes.js";
31
+ import { ownHosts } from "./hostRouting.js";
32
+ import { unrollBarrelImports } from "./barrelImports.js";
31
33
  import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, } from "./useSsr.js";
32
34
  import { httpHostCalls } from "./hostCalls.js";
33
35
  // Resolved once per rscKit() call. One build runs in one process, so these are
@@ -40,6 +42,47 @@ let resolvedConfig = null;
40
42
  let clientLibraryImports = [];
41
43
  /** Modules a runtime provides and no bundle should try to carry. */
42
44
  const RUNTIME_BUILTINS = ["bun", /^bun:/];
45
+ /**
46
+ * Packages the server bundles import rather than inline, by default.
47
+ *
48
+ * Each of these ships a native binary, spawns one, or reads files relative to
49
+ * its own location - none of which survives being rolled into a bundle. Left
50
+ * external, Nitro traces them into .output/server/node_modules with their
51
+ * binaries, and the built server imports them the way the package expects.
52
+ * Next keeps the same list under serverExternalPackages, for the same reason.
53
+ * `rscKit({ serverExternalPackages })` adds to it.
54
+ */
55
+ const DEFAULT_SERVER_EXTERNALS = [
56
+ "sharp",
57
+ "canvas",
58
+ "bcrypt",
59
+ "argon2",
60
+ "@node-rs/argon2",
61
+ "@node-rs/bcrypt",
62
+ "better-sqlite3",
63
+ "sqlite3",
64
+ "libsql",
65
+ "@libsql/client",
66
+ "@prisma/client",
67
+ "prisma",
68
+ "puppeteer",
69
+ "puppeteer-core",
70
+ "playwright",
71
+ "playwright-core",
72
+ "jsdom",
73
+ "node-pty",
74
+ "onnxruntime-node",
75
+ "@sentry/profiling-node",
76
+ "pdfkit",
77
+ "mongodb",
78
+ "oslo",
79
+ "@resvg/resvg-js",
80
+ "@napi-rs/canvas",
81
+ ];
82
+ /** A package name as a rollup external: the package and every subpath of it. */
83
+ function externalPackage(name) {
84
+ return new RegExp("^" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?:/|$)");
85
+ }
43
86
  function arrayOf(value) {
44
87
  return value == null ? [] : Array.isArray(value) ? value : [value];
45
88
  }
@@ -108,6 +151,11 @@ let isWatch = false;
108
151
  let offline = false;
109
152
  let typecheck = true;
110
153
  let webManifestOptions = null;
154
+ /** The site's own hosts, from the root layout's metadataBase and rscKit({ hosts }). */
155
+ let siteHosts = [];
156
+ let hostsOption = [];
157
+ let barrelImports = true;
158
+ let identify = true;
111
159
  let foundAssets = {
112
160
  favicon: null,
113
161
  icons: [],
@@ -270,6 +318,9 @@ function resolvePaths(options) {
270
318
  prerenderAfterBuild = process.env.RSC_PRERENDER !== "0";
271
319
  offline = options.offline === true;
272
320
  typecheck = options.typecheck !== false;
321
+ hostsOption = options.hosts ?? [];
322
+ barrelImports = options.barrelImports !== false;
323
+ identify = options.identify !== false;
273
324
  inlineStylesheets = options.inlineStylesheets ?? "auto";
274
325
  maxActionBody = options.maxActionBody;
275
326
  // One place, and it is the file. A plugin option as well would be the same
@@ -323,7 +374,12 @@ function urlSegments(componentName) {
323
374
  continue;
324
375
  }
325
376
  if (part.startsWith("[") && part.endsWith("]")) {
326
- segments.push({ type: "param", value: part.slice(1, -1) });
377
+ // At the top of app/ - the first segment the url has - a parameter is
378
+ // the host's: bound from acme.example.com, never from example.com/acme.
379
+ segments.push({
380
+ type: segments.length === 0 ? "host" : "param",
381
+ value: part.slice(1, -1),
382
+ });
327
383
  continue;
328
384
  }
329
385
  // An interception marker says which url this replaces, not what it is
@@ -395,7 +451,11 @@ function routeManifest() {
395
451
  * to whoever knows what they mean.
396
452
  */
397
453
  const middlewareIn = (absDir) => {
398
- for (const file of ["route.ts", "route.tsx"]) {
454
+ // middleware.ts first: it is the file named for what this is. A guard
455
+ // the engine runs is its default export; the names a host runs are its
456
+ // `middleware` export, and a file may carry either or both. route.ts is
457
+ // read too, for the apps written before middleware.ts could.
458
+ for (const file of ["middleware.ts", "middleware.tsx", "route.ts", "route.tsx"]) {
399
459
  const path = join(absDir, file);
400
460
  if (!existsSync(path))
401
461
  continue;
@@ -481,7 +541,13 @@ function routeManifest() {
481
541
  // writing the site out, and knowing which filename the client will ask for.
482
542
  return {
483
543
  version: 1,
484
- build: { output, exportPath, payloadName: staticPayloads },
544
+ build: {
545
+ output,
546
+ exportPath,
547
+ payloadName: staticPayloads,
548
+ hosts: siteHosts,
549
+ identify,
550
+ },
485
551
  routes,
486
552
  intercepts,
487
553
  apis: [...apiRoutes.values()].map(({ name, methods, generated }) => ({
@@ -1236,6 +1302,41 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
1236
1302
  // places to look for one fact.
1237
1303
  const manifest = engine.manifest();
1238
1304
  const apis = await prerenderApiRoutes(engine, manifest, writeTo(staticDir));
1305
+ // A sitemap the build writes itself, when the app wrote none: every url
1306
+ // it stored or was told about, minus the guarded ones. It needs the site's
1307
+ // host, which the root layout's metadataBase is for; without one there is
1308
+ // nothing to write and the report says so once.
1309
+ const writesSitemap = manifest.apis?.some((api) => api.name === "app/sitemap.xml/route");
1310
+ const hasSitemapFile = rootFiles.includes("sitemap.xml");
1311
+ if (!writesSitemap && !hasSitemapFile) {
1312
+ const base = rootMetadataBase(join(sourceDir, "app"));
1313
+ if (base) {
1314
+ const xml = automaticSitemap(results, manifest.routes, base);
1315
+ const stored = JSON.stringify({
1316
+ status: 200,
1317
+ headers: [["content-type", "application/xml; charset=utf-8"]],
1318
+ body: xml,
1319
+ varies: false,
1320
+ });
1321
+ await writeTo(staticDir)("sitemap.xml.api.json", stored);
1322
+ pending.push({
1323
+ line: " ○ /sitemap.xml",
1324
+ bytes: null,
1325
+ extra: [
1326
+ ` written by the build: ${xml.split("<url>").length - 1} urls; a sitemap.ts beside the root layout replaces it`,
1327
+ ],
1328
+ });
1329
+ }
1330
+ else {
1331
+ pending.push({
1332
+ line: " - /sitemap.xml",
1333
+ bytes: null,
1334
+ extra: [
1335
+ " not written: the root layout has no metadataBase to make the urls absolute",
1336
+ ],
1337
+ });
1338
+ }
1339
+ }
1239
1340
  for (const api of apis) {
1240
1341
  pending.push({
1241
1342
  line: ` ${api.type === "frozen" ? "○" : "ƒ"} ${api.url}`,
@@ -1480,6 +1581,23 @@ function renderRouteTypes(manifest) {
1480
1581
  const target = relative(typesDir, join(sourceDir, route.component)).replace(/\\/g, "/");
1481
1582
  search.set(pattern, target.startsWith(".") ? target : "./" + target);
1482
1583
  }
1584
+ // Section names are read from the source - section('orders', …) - the way
1585
+ // generateStaticParams is detected, because this runs before any bundle
1586
+ // exists. A name computed at runtime is not seen and revalidate() falls
1587
+ // back to refusing it at the renderer, as before.
1588
+ const regions = [
1589
+ ...new Set([
1590
+ ...[...components.values()]
1591
+ .filter((c) => SECTION_FILE.test(c.absPath))
1592
+ .flatMap((c) => [
1593
+ ...readFileSync(c.absPath, "utf-8").matchAll(/\bsection\(\s*['"`]([^'"`]+)['"`]/g),
1594
+ ].map((m) => m[1])),
1595
+ ...[...components.keys()].flatMap((name) => {
1596
+ const slot = name.split("/").find((part) => part.startsWith("@"));
1597
+ return slot ? [slot.slice(1)] : [];
1598
+ }),
1599
+ ]),
1600
+ ].sort();
1483
1601
  return [
1484
1602
  "// @generated — do not edit. Written by the RSC build from the route tree.",
1485
1603
  "//",
@@ -1513,6 +1631,14 @@ function renderRouteTypes(manifest) {
1513
1631
  // Api routes are a separate union, so Link refuses an api url and apiUrl()
1514
1632
  // refuses a page. Linking to an api route navigates the browser away to a
1515
1633
  // json document, which is the mistake worth catching.
1634
+ // Regions: every section('name', …) the build read, and every @slot
1635
+ // directory. This is what types revalidate().
1636
+ " interface RegisterRegions {",
1637
+ regions.length > 0
1638
+ ? " regions:\n" +
1639
+ regions.map((r) => " | " + JSON.stringify(r)).join("\n")
1640
+ : " // No sections or slots found under the source directory.\n regions: never",
1641
+ " }",
1516
1642
  " interface RegisterApi {",
1517
1643
  apis.length > 0
1518
1644
  ? " apis:\n" +
@@ -1714,10 +1840,20 @@ function registerApiRoute(absPath) {
1714
1840
  }
1715
1841
  apiRoutes.set(name, { name, absPath, methods });
1716
1842
  }
1843
+ /**
1844
+ * Whether a middleware.ts is a guard the engine runs, or only names guards
1845
+ * the host runs. The engine imports a guard's default export; a file with
1846
+ * none - `export const middleware = ['auth']` and nothing else - has nothing
1847
+ * to import, and registering it would make the build fail on an export that
1848
+ * was never meant to exist.
1849
+ */
1850
+ function isEngineGuard(absPath) {
1851
+ return /export\s+default\b/.test(readFileSync(absPath, "utf-8"));
1852
+ }
1717
1853
  function discover(dir) {
1718
1854
  for (const base of ROUTE_FILES) {
1719
1855
  const p = findRouteFile(dir, base);
1720
- if (p)
1856
+ if (p && (base !== "middleware" || isEngineGuard(p)))
1721
1857
  register(p);
1722
1858
  }
1723
1859
  // route.ts — an api endpoint, colocated with the pages it sits among. Read
@@ -1748,6 +1884,21 @@ function discover(dir) {
1748
1884
  * pages export only the static object, so referencing both meant that warning
1749
1885
  * for almost every route in an app.
1750
1886
  */
1887
+ /**
1888
+ * The root layout's metadataBase, read from the source. Wanted while the
1889
+ * manifest is built, before there is a bundle to execute - the same reason
1890
+ * generateStaticParams is detected by reading. A `new URL('…')` or a string
1891
+ * literal; anything computed is not seen, and rscKit({ hosts }) says it.
1892
+ */
1893
+ function rootMetadataBase(appDir) {
1894
+ const layout = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]
1895
+ .map((name) => join(appDir, name))
1896
+ .find((file) => existsSync(file));
1897
+ if (!layout)
1898
+ return null;
1899
+ const match = /metadataBase\s*:\s*(?:new\s+URL\(\s*)?["'`](https?:\/\/[^"'`]+)["'`]/.exec(readFileSync(layout, "utf-8"));
1900
+ return match ? match[1] : null;
1901
+ }
1751
1902
  function metadataExports(absPath) {
1752
1903
  const src = readFileSync(absPath, "utf-8");
1753
1904
  return {
@@ -1942,7 +2093,36 @@ function installHostCallsOnce(): void {
1942
2093
  }
1943
2094
 
1944
2095
  `;
2096
+ /**
2097
+ * The app's process bootstrap, if it has one: `instrumentation.ts` beside
2098
+ * `app/`, the name Next uses so a reader arriving from there knows what it
2099
+ * is.
2100
+ *
2101
+ * Two things make it a framework concern rather than an import the app
2102
+ * adds. Module evaluation order: a page that configures a shared package at
2103
+ * import time runs before any module the app could put first, and only the
2104
+ * generated entry can import something before the pages. And "before the
2105
+ * first request": an async `register()` — connecting, validating env — has
2106
+ * to be awaited by every entry point a host or a prerender can call, which
2107
+ * is a list the app cannot see.
2108
+ */
2109
+ function instrumentationFile() {
2110
+ for (const ext of ["ts", "tsx", "mts", "js", "mjs"]) {
2111
+ const file = join(sourceDir, `instrumentation.${ext}`);
2112
+ if (!existsSync(file))
2113
+ continue;
2114
+ // Both halves are optional: a file of imports is a bootstrap, a file
2115
+ // with register() is a hook. The entry must only name `register` when it
2116
+ // exists - a namespace import's missing member is a bundler warning on
2117
+ // every build (IMPORT_IS_UNDEFINED), which is the app being told off for
2118
+ // a file written exactly as documented.
2119
+ const hasRegister = /export\s+(?:async\s+)?(?:function\s+register\b|const\s+register\b|let\s+register\b|\{[^}]*\bregister\b[^}]*\})/.test(readFileSync(file, "utf-8"));
2120
+ return { file, hasRegister };
2121
+ }
2122
+ return null;
2123
+ }
1945
2124
  function generateEntryRsc(fallbackOrigin = "") {
2125
+ const instrumentation = instrumentationFile();
1946
2126
  // The 404 page, if the app has one, and the layouts it renders inside.
1947
2127
  // Computed here rather than looked up at runtime: not-found is not a route,
1948
2128
  // so the manifest has no entry to read its chain from.
@@ -1999,12 +2179,23 @@ function generateEntryRsc(fallbackOrigin = "") {
1999
2179
  // from src/ in its own repo and from dist/ once published, and Vite resolves
2000
2180
  // either. Naming .tsx here builds fine from source and fails after publish.
2001
2181
  return `// GENERATED by rscKit() — do not edit.
2182
+ ${
2183
+ // First, before any page: an import's side effects run in import order,
2184
+ // and a package configured here has to be configured before a page module
2185
+ // that reads it at evaluation time.
2186
+ instrumentation?.hasRegister
2187
+ ? `import * as __instrumentation from ${JSON.stringify(instrumentation.file)}`
2188
+ : instrumentation
2189
+ ? `import ${JSON.stringify(instrumentation.file)}\nconst __instrumentation: { register?: () => unknown } = {}`
2190
+ : "const __instrumentation: { register?: () => unknown } = {}"}
2002
2191
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
2192
+ import { LoadingBoundary } from ${JSON.stringify(join(packageDir, "js/LoadingBoundary"))}
2003
2193
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
2004
2194
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
2005
2195
  import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
2006
2196
  import { sectionComponent } from ${JSON.stringify(join(packageDir, "js/section"))}
2007
2197
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
2198
+ import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultRouteError"))}
2008
2199
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
2009
2200
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
2010
2201
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
@@ -2144,6 +2335,7 @@ export async function handleApiRoute(
2144
2335
  params: Record<string, string>,
2145
2336
  allow: string,
2146
2337
  ): Promise<Response> {
2338
+ await instrumented()
2147
2339
  applyHost()
2148
2340
 
2149
2341
  const mod = apiRoutes[name]
@@ -2251,6 +2443,8 @@ export async function auditActions(ids: string[]): Promise<{ id: string; client:
2251
2443
  * that asked for everything, or the reverse.
2252
2444
  */
2253
2445
  export async function getStaticParams(component: string): Promise<Record<string, string>[] | null> {
2446
+ await instrumented()
2447
+
2254
2448
  const generate = staticParamsMap[component]
2255
2449
 
2256
2450
  if (!generate) return null
@@ -2274,6 +2468,57 @@ const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
2274
2468
  */
2275
2469
  const HOST_MIDDLEWARE_FN = '__rsc.middleware'
2276
2470
 
2471
+ /**
2472
+ * instrumentation.ts's register(), once.
2473
+ *
2474
+ * On a long-lived server it runs at startup: the entry is evaluated when the
2475
+ * process starts, and this begins then, so env that fails validation fails
2476
+ * the boot rather than the first visitor, and the first request does not
2477
+ * pay for it. On a Worker there is no startup — an isolate is created for a
2478
+ * request, and a binding is only readable once one has arrived — so it runs
2479
+ * at the first request of each isolate instead. Every entry point awaits it
2480
+ * either way, which is what makes "before the first render" hold whether the
2481
+ * caller is the built server, the dev server or the prerender.
2482
+ *
2483
+ * A rejection is not memoised. Env that fails validation fails the same way
2484
+ * on every request, which is right; a database that was not up yet gets
2485
+ * asked again rather than leaving the process permanently refusing. In
2486
+ * production a startup failure is reported and the process exits, because a
2487
+ * server that could not bootstrap has nothing correct to serve; the dev
2488
+ * server stays up and reports it on the page instead.
2489
+ */
2490
+ let __instrumented: Promise<void> | null = null
2491
+
2492
+ function instrumented(): Promise<void> {
2493
+ if (!__instrumented) {
2494
+ __instrumented = Promise.resolve()
2495
+ .then(() => __instrumentation.register?.())
2496
+ .then(
2497
+ () => undefined,
2498
+ (error) => {
2499
+ __instrumented = null
2500
+ throw error
2501
+ },
2502
+ )
2503
+ }
2504
+
2505
+ return __instrumented
2506
+ }
2507
+
2508
+ // Workers name themselves; nothing else does. Where there is a process to
2509
+ // start, start now.
2510
+ const isolateRuntime = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
2511
+
2512
+ if (__instrumentation.register && !isolateRuntime) {
2513
+ instrumented().catch((error) => {
2514
+ console.error('[rsc-kit] instrumentation.ts register() failed:', error)
2515
+
2516
+ if (!import.meta.env.DEV && typeof process !== 'undefined' && typeof process.exit === 'function') {
2517
+ process.exit(1)
2518
+ }
2519
+ })
2520
+ }
2521
+
2277
2522
  let currentHost: HostFn | null = null
2278
2523
 
2279
2524
  export function installHostFn(fn: HostFn) {
@@ -2481,9 +2726,17 @@ function buildElement(
2481
2726
  searchParams: checkedSearchParams(schemas, pageSearchParams()),
2482
2727
  })
2483
2728
 
2729
+ // Through LoadingBoundary when the runtime is shipped, so a server render
2730
+ // can tell the engine's boundary from one the developer wrote by name -
2731
+ // see that file. A page shipping no runtime gets a plain Suspense: a
2732
+ // client component would drag React in for a wrapper nothing can use.
2484
2733
  for (let i = loadings.length - 1; i >= 0; i--) {
2485
2734
  const Loading = components[loadings[i]]
2486
- element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
2735
+ const fallback = Loading ? createElement(Loading) : null
2736
+
2737
+ element = bootstrap
2738
+ ? createElement(LoadingBoundary, { fallback }, element)
2739
+ : createElement(Suspense, { fallback }, element)
2487
2740
  }
2488
2741
 
2489
2742
  // Outside the Suspense boundary, innermost first — the nearest error.tsx to
@@ -2507,6 +2760,19 @@ function buildElement(
2507
2760
  )
2508
2761
  }
2509
2762
 
2763
+ // Outermost, for a throw no error.tsx covers - including one in a layout,
2764
+ // which the boundary inside that layout cannot see. Without it React
2765
+ // unmounted the document on hydration: a black page with the cause nowhere
2766
+ // near it. Only with the runtime: it is a client component, and a route
2767
+ // shipping none has nothing to catch with.
2768
+ if (bootstrap) {
2769
+ element = createElement(
2770
+ RouteErrorBoundary,
2771
+ { fallback: DefaultRouteError as never, resetKey: pageKey || component },
2772
+ element,
2773
+ )
2774
+ }
2775
+
2510
2776
  // <title>/<meta> go OUTSIDE the Suspense boundaries so they reach the shell
2511
2777
  // immediately — inside, they would be withheld until the page's data
2512
2778
  // resolves, delaying the whole document on a slow page.
@@ -2660,6 +2926,8 @@ async function renderTree(
2660
2926
  if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
2661
2927
  }
2662
2928
  if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
2929
+ // The name only, never the version - see the identify option.
2930
+ if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
2663
2931
 
2664
2932
  // robots is a string, or the object Next takes: index and follow as
2665
2933
  // their no- forms, the flags by name, the limits as name:value. googleBot
@@ -2961,6 +3229,7 @@ async function runMiddleware(component: string, props: Record<string, unknown> =
2961
3229
  * throws, exactly as it does mid-render.
2962
3230
  */
2963
3231
  export async function runRouteMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
3232
+ await instrumented()
2964
3233
  applyHost()
2965
3234
 
2966
3235
  return runMiddleware(component, props)
@@ -2976,6 +3245,7 @@ export async function handleRscStream(
2976
3245
  from = 0,
2977
3246
  pageKey = '',
2978
3247
  ): Promise<{ stream: ReadableStream; clientChunks: unknown; segmentDepth: number }> {
3248
+ await instrumented()
2979
3249
  applyHost()
2980
3250
 
2981
3251
  // The host proposes how much the client already has; the engine decides what
@@ -3031,6 +3301,7 @@ export async function handleRscHtmlStream(
3031
3301
  pageKey = '',
3032
3302
  bootstrap = true,
3033
3303
  ): Promise<{ htmlStream: ReadableStream; rscPayloadPromise: Promise<string>; clientChunks: unknown }> {
3304
+ await instrumented()
3034
3305
  applyHost()
3035
3306
  await runMiddleware(component, props)
3036
3307
  const flight = renderToReadableStream(
@@ -3066,6 +3337,7 @@ export async function handleRscResume(
3066
3337
  nonce?: string,
3067
3338
  pageKey = '',
3068
3339
  ): Promise<{ htmlStream: ReadableStream }> {
3340
+ await instrumented()
3069
3341
  applyHost()
3070
3342
  await runMiddleware(component, props)
3071
3343
 
@@ -3216,6 +3488,7 @@ export async function handleQuery(
3216
3488
  | { status: number; message: string; errors?: Record<string, string[]> }
3217
3489
  | null
3218
3490
  > {
3491
+ await instrumented()
3219
3492
  applyHost()
3220
3493
 
3221
3494
  let fn: unknown
@@ -3264,6 +3537,7 @@ export async function handleAction(
3264
3537
  page?: PageContext,
3265
3538
  takeRevalidated?: () => string[],
3266
3539
  ): Promise<{ stream: ReadableStream }> {
3540
+ await instrumented()
3267
3541
  applyHost()
3268
3542
 
3269
3543
  // Every body arrives as bytes on its own socket frame — an upload because it
@@ -3356,6 +3630,8 @@ export async function resolveMetadata(
3356
3630
  props: Record<string, unknown> = {},
3357
3631
  layouts: LayoutEntry[] = [],
3358
3632
  ): Promise<Record<string, unknown> | null> {
3633
+ await instrumented()
3634
+
3359
3635
  const pageEntry = metadataMap[component]
3360
3636
  const page: Record<string, unknown> = pageEntry
3361
3637
  ? (pageEntry.generate
@@ -3425,6 +3701,7 @@ export async function handleRsc(
3425
3701
  bootstrap = true,
3426
3702
  canReachHost = true,
3427
3703
  ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; dynamicBecause: string[]; clientComponents: string[]; serverReferences: boolean }> {
3704
+ await instrumented()
3428
3705
  applyHost()
3429
3706
 
3430
3707
  // A build renders this with no host installed, so every rpc() has to suspend
@@ -3538,6 +3815,7 @@ export async function handleRscRevalidate(
3538
3815
  target: string,
3539
3816
  page: PageContext,
3540
3817
  ): Promise<{ rscPayload: string }> {
3818
+ await instrumented()
3541
3819
  applyHost()
3542
3820
 
3543
3821
  const flight = renderToReadableStream(await renderRevalidated(target, page))
@@ -3554,6 +3832,7 @@ export async function handleRscPayload(
3554
3832
  from = 0,
3555
3833
  pageKey = '',
3556
3834
  ): Promise<{ rscPayload: string }> {
3835
+ await instrumented()
3557
3836
  applyHost()
3558
3837
 
3559
3838
  const flight = renderToReadableStream(
@@ -3621,6 +3900,7 @@ export async function handleRscPprShell(
3621
3900
  // and make every guarded route dynamic for the wrong reason.
3622
3901
  let usedDynamicApis = false
3623
3902
 
3903
+ await instrumented()
3624
3904
  applyHost()
3625
3905
 
3626
3906
  const probe = (..._args: unknown[]) => {
@@ -3790,6 +4070,17 @@ export default async function handler(request: Request): Promise<Response> {
3790
4070
  }
3791
4071
 
3792
4072
  async function serve(request: Request): Promise<Response> {
4073
+ // The startup plugin's probe. Nitro loads this module at the first request
4074
+ // rather than when the server starts, so a plugin that runs at startup
4075
+ // sends one request to load it; the module's evaluation began register(),
4076
+ // and this waits for it so a failure is reported as the startup failure it
4077
+ // is. No page is rendered and nothing is disclosed.
4078
+ if (request.headers.get('x-rsc-kit-startup') !== null) {
4079
+ await instrumented()
4080
+
4081
+ return new Response(null, { status: 204 })
4082
+ }
4083
+
3793
4084
  installHostCallsOnce()
3794
4085
 
3795
4086
  devHandler ??= createRscHandler({
@@ -3924,35 +4215,7 @@ export async function handleSsr(
3924
4215
  const html = await renderToReadableStream(root as any, {
3925
4216
  bootstrapScriptContent,
3926
4217
  nonce,
3927
- // The query-string fallback is the designed path for a stored page, and
3928
- // its digest is what lets the client tell it from a fault on hydration;
3929
- // returned here so React writes it into the document.
3930
- onError: onError ?? ((error: unknown, info?: { componentStack?: string }) => {
3931
- // The consumer cancelled - a browser that left mid-stream, a prefetch
3932
- // abandoned. React reports it as an error; the page had none.
3933
- if (cancelledByConsumer(error)) return
3934
- const digest = (error as { digest?: string } | null)?.digest
3935
- if (digest === 'rsc-kit:search-params-fallback') {
3936
- // The component is the first frame of React's stack. Noted on the
3937
- // request so the build attaches it to the route; printed as one line,
3938
- // not a stack, so the dev server says which boundary the build wants.
3939
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
3940
- noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
3941
- // Under a boundary the developer wrote, nothing to say. With nothing
3942
- // closer than a loading.tsx, one line: the whole segment is the
3943
- // fallback until the query arrives.
3944
- if (caughtByLoading(info?.componentStack)) {
3945
- console.error(
3946
- '[rsc-kit] ' + (where ? where + ': ' : '') +
3947
- 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
3948
- 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
3949
- 'keeps the rest of the page painted.',
3950
- )
3951
- }
3952
- return digest
3953
- }
3954
- console.error('[rsc-kit:ssr]', error)
3955
- }),
4218
+ onError: onError ?? reportRenderError('ssr'),
3956
4219
  })
3957
4220
 
3958
4221
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4004,6 +4267,46 @@ export async function handleSsrPrerender(
4004
4267
  }
4005
4268
  }
4006
4269
 
4270
+ /**
4271
+ * What a server render says about an error, first render and resume alike.
4272
+ *
4273
+ * The query-string fallback is the designed path for a stored page, and its
4274
+ * digest is what lets the client tell it from a fault on hydration; returned
4275
+ * so React writes it into the document. Under a boundary the developer wrote
4276
+ * there is nothing to say; with nothing closer than a loading.tsx, one line.
4277
+ */
4278
+ function reportRenderError(phase: 'ssr' | 'resume') {
4279
+ return (error: unknown, info?: { componentStack?: string }) => {
4280
+ // The consumer cancelled - a browser that left mid-stream, a prefetch
4281
+ // abandoned. React reports it as an error; the page had none.
4282
+ if (cancelledByConsumer(error)) return
4283
+
4284
+ const digest = (error as { digest?: string } | null)?.digest
4285
+
4286
+ if (digest === 'rsc-kit:search-params-fallback') {
4287
+ // The component is the first frame of React's stack. Noted on the
4288
+ // request so the build attaches it to the route; printed as one line,
4289
+ // not a stack, so the server says which boundary it wants.
4290
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4291
+
4292
+ noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4293
+
4294
+ if (caughtByLoading(info?.componentStack)) {
4295
+ console.error(
4296
+ '[rsc-kit] ' + (where ? where + ': ' : '') +
4297
+ 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4298
+ 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4299
+ 'keeps the rest of the page painted.',
4300
+ )
4301
+ }
4302
+
4303
+ return digest
4304
+ }
4305
+
4306
+ console.error('[rsc-kit:' + phase + ']', error)
4307
+ }
4308
+ }
4309
+
4007
4310
  /**
4008
4311
  * Pick a build-time render back up, against data that exists now.
4009
4312
  *
@@ -4023,7 +4326,12 @@ export async function handleSsrResume(
4023
4326
 
4024
4327
  const html = await resume(root as any, postponed as any, {
4025
4328
  nonce,
4026
- onError: (error: unknown) => { console.error('[rsc-kit:resume]', error) },
4329
+ // The same reading of an error the first render has. This used to log
4330
+ // every error raw, so a query read the developer's own boundary caught -
4331
+ // the designed path, on every resume of a shell whose hole holds one -
4332
+ // printed as "[rsc-kit:resume] Error: useSearchParams() was read..." on
4333
+ // every request, with advice the app had already followed.
4334
+ onError: reportRenderError('resume'),
4027
4335
  })
4028
4336
 
4029
4337
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4304,6 +4612,46 @@ self.addEventListener('activate', (event) => {
4304
4612
  })())
4305
4613
  })
4306
4614
  `;
4615
+ /**
4616
+ * Named imports from a barrel package, unrolled on the server environments.
4617
+ *
4618
+ * The browser gets lucide pre-bundled; the server environments do not, so
4619
+ * `import { ArrowRight } from 'lucide-react'` loaded and transformed every
4620
+ * icon module - 3,700 transforms, twenty-five seconds before the first
4621
+ * document, for thirteen icons. See barrelImports. Development only: a
4622
+ * build tree-shakes the barrel itself.
4623
+ */
4624
+ function barrelImportsPlugin() {
4625
+ const entries = new Map();
4626
+ return {
4627
+ name: "rsc-kit:barrel-imports",
4628
+ apply: () => barrelImports && process.env.NODE_ENV !== "production",
4629
+ enforce: "pre",
4630
+ applyToEnvironment: (environment) => environment.name === "rsc" || environment.name === "ssr",
4631
+ async transform(code, id) {
4632
+ if (id.includes("/node_modules/") ||
4633
+ !/\.[cm]?[jt]sx?$/.test(id.split("?")[0]))
4634
+ return;
4635
+ if (!code.includes("from"))
4636
+ return;
4637
+ const pending = [];
4638
+ const wanted = new Set();
4639
+ for (const m of code.matchAll(/import\s*(?:type\s+)?\{[^}]*\}\s*from\s*['"]([^'"./][^'"]*)['"]/g)) {
4640
+ wanted.add(m[1]);
4641
+ }
4642
+ for (const specifier of wanted) {
4643
+ if (entries.has(specifier))
4644
+ continue;
4645
+ pending.push(this.resolve(specifier, id).then((resolved) => {
4646
+ entries.set(specifier, resolved && !resolved.external ? resolved.id.split("?")[0] : null);
4647
+ }));
4648
+ }
4649
+ await Promise.all(pending);
4650
+ const out = unrollBarrelImports(code, (specifier) => entries.get(specifier) ?? null);
4651
+ return out === null ? undefined : { code: out, map: null };
4652
+ },
4653
+ };
4654
+ }
4307
4655
  /**
4308
4656
  * "use ssr" modules, rewritten for the server-components environment into
4309
4657
  * proxies that call the real module in the ssr environment. See useSsr.ts.
@@ -4478,10 +4826,107 @@ function clientImportsAudit() {
4478
4826
  },
4479
4827
  };
4480
4828
  }
4829
+ /**
4830
+ * A Nitro runtime plugin, so instrumentation.ts runs when the server starts.
4831
+ *
4832
+ * Nitro loads the rsc service at the first request, not at startup - so
4833
+ * without this, register() would run when the first visitor arrived, and a
4834
+ * bad environment would take down a server that had already reported itself
4835
+ * up. The plugin runs at app init and sends the service one request, which
4836
+ * loads the module; loading it begins register(), and the request waits for
4837
+ * it. Not on a Worker: there is no startup, and the first request is the
4838
+ * first chance to read a binding.
4839
+ */
4840
+ const STARTUP_PLUGIN = `import { viteServices } from "#nitro/virtual/vite-services"
4841
+
4842
+ export default function rscKitStartup() {
4843
+ if (typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers') return
4844
+
4845
+ // The ssr service is the one Nitro's renderer asks; its fetch imports the
4846
+ // rsc entry and hands the request on. The rsc service's own export is the
4847
+ // handler function, not a { fetch }, so it cannot be probed directly.
4848
+ const ssr = viteServices.ssr
4849
+ if (!ssr) return
4850
+
4851
+ // Two ways to fail, one outcome. register() rejecting is reported by the
4852
+ // module itself. instrumentation.ts throwing at import - an env schema
4853
+ // refusing - means the module never evaluated, so nothing in it can
4854
+ // report; the load rejects here instead. Either way a server that could
4855
+ // not bootstrap has nothing correct to serve, and says so and stops
4856
+ // rather than reporting itself up.
4857
+ ssr
4858
+ .fetch(new Request('http://rsc-kit.internal/_rsc/startup', { headers: { 'x-rsc-kit-startup': '1' } }))
4859
+ .then((response) => {
4860
+ if (!response.ok) throw new Error('the startup probe answered ' + response.status)
4861
+ })
4862
+ .catch((error) => {
4863
+ console.error('[rsc-kit] instrumentation.ts failed at startup:', error)
4864
+
4865
+ if (typeof process !== 'undefined' && typeof process.exit === 'function') process.exit(1)
4866
+ })
4867
+ }
4868
+ `;
4869
+ /**
4870
+ * Where NODE_ENV=development came from, and what to do about it.
4871
+ *
4872
+ * Vite reads NODE_ENV from its env files in this order, later winning, and
4873
+ * only when the process did not already set it - so the last file that
4874
+ * names it is the one that decided. No file naming it means the shell or
4875
+ * `--mode` did.
4876
+ */
4877
+ function nodeEnvSetIn(mode, envDir) {
4878
+ const files = [".env", ".env.local", `.env.${mode}`, `.env.${mode}.local`];
4879
+ let culprit = null;
4880
+ // The project's root, not Vite's: this plugin points Vite's root at the
4881
+ // build directory, and the app's .env is read from the project root - by
4882
+ // Nitro's dotenv loading into process.env, and by Vite where envDir says
4883
+ // so. Both places are looked at; the project's is where the file is.
4884
+ const dirs = [...new Set([projectRoot, envDir || projectRoot])];
4885
+ for (const dir of dirs)
4886
+ for (const name of files) {
4887
+ const path = join(dir, name);
4888
+ if (!existsSync(path))
4889
+ continue;
4890
+ const lines = readFileSync(path, "utf-8").split("\n");
4891
+ const at = lines.findIndex((line) => /^\s*(?:export\s+)?NODE_ENV\s*=/.test(line));
4892
+ if (at !== -1)
4893
+ culprit = `${path}:${at + 1}`;
4894
+ }
4895
+ return culprit;
4896
+ }
4897
+ function refuseDevelopmentBuild(config) {
4898
+ const culprit = nodeEnvSetIn(config.mode, config.envDir);
4899
+ return ("[rsc-kit] vite build is running as a development build, and the output cannot work: " +
4900
+ "the pages are compiled against React's development JSX runtime (jsxDEV), the server " +
4901
+ "bundles carry React's production build, and every route fails to render.\n\n" +
4902
+ (culprit
4903
+ ? `NODE_ENV=development is set in ${culprit}. Remove that line - Vite sets NODE_ENV ` +
4904
+ "itself: development under `vite`, production under `vite build`."
4905
+ : `NODE_ENV is "${process.env.NODE_ENV ?? ""}" from the environment or --mode ` +
4906
+ "(mode: " + JSON.stringify(config.mode) + "). Build with NODE_ENV=production, or unset it."));
4907
+ }
4481
4908
  export function rscKit(options = {}) {
4482
4909
  resolvePaths(options);
4910
+ const serverExternals = [
4911
+ ...RUNTIME_BUILTINS,
4912
+ ...[...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])].map(externalPackage),
4913
+ ];
4483
4914
  const routesPlugin = {
4484
4915
  name: "rsc-kit",
4916
+ // A Nitro module, which Nitro's Vite plugin collects from any plugin
4917
+ // that carries one. Only for a built server: the dev server evaluates
4918
+ // the entry through Vite's runner when it is first asked for, and there
4919
+ // is no earlier moment to offer.
4920
+ nitro: {
4921
+ name: "rsc-kit",
4922
+ setup(nitro) {
4923
+ if (nitro.options.dev || !instrumentationFile())
4924
+ return;
4925
+ nitro.options.virtual ??= {};
4926
+ nitro.options.virtual["#rsc-kit/startup"] = STARTUP_PLUGIN;
4927
+ nitro.options.plugins = [...(nitro.options.plugins ?? []), "#rsc-kit/startup"];
4928
+ },
4929
+ },
4485
4930
  config(_config, env) {
4486
4931
  if (!existsSync(appDir)) {
4487
4932
  throw new Error(`[rsc-kit] No app directory at ${appDir} — nothing to build.`);
@@ -4493,6 +4938,7 @@ export function rscKit(options = {}) {
4493
4938
  apiRoutes.clear();
4494
4939
  discover(appDir);
4495
4940
  registerMetadataRoutes(appDir);
4941
+ siteHosts = ownHosts(rootMetadataBase(appDir), hostsOption);
4496
4942
  // Silent when it worked. The names were printed on every dev start and
4497
4943
  // every build — thirty of them for a middling app, above the output that
4498
4944
  // actually says something, and the build's classification table lists
@@ -4680,7 +5126,7 @@ export function rscKit(options = {}) {
4680
5126
  build: {
4681
5127
  rollupOptions: {
4682
5128
  input: { index: join(genDir, "entry.rsc.tsx") },
4683
- external: RUNTIME_BUILTINS,
5129
+ external: serverExternals,
4684
5130
  },
4685
5131
  },
4686
5132
  },
@@ -4688,7 +5134,7 @@ export function rscKit(options = {}) {
4688
5134
  build: {
4689
5135
  rollupOptions: {
4690
5136
  input: { index: join(genDir, "entry.ssr.tsx") },
4691
- external: RUNTIME_BUILTINS,
5137
+ external: serverExternals,
4692
5138
  },
4693
5139
  },
4694
5140
  },
@@ -4927,6 +5373,24 @@ export function rscKit(options = {}) {
4927
5373
  }
4928
5374
  },
4929
5375
  configResolved(config) {
5376
+ // A build that is not a production build cannot work here, and the
5377
+ // way it fails is opaque: plugin-react emits the development JSX
5378
+ // runtime (jsxDEV), the server bundles resolve React's production
5379
+ // build, and every route fails to render with React's "message
5380
+ // omitted in production builds". The usual cause is NODE_ENV=development
5381
+ // in a .env file, which Vite honours - a line the scaffold itself once
5382
+ // wrote. Named, with the file and line, before any of that happens.
5383
+ //
5384
+ // Refused rather than overridden, because a plugin cannot override it:
5385
+ // Vite notes whether NODE_ENV was set before it loads the config file
5386
+ // and applies the .env value after the config hooks have run, so an
5387
+ // assignment here is overwritten - and patching the resolved config
5388
+ // afterwards leaves the client bundle's process.env.NODE_ENV and
5389
+ // import.meta.env.DEV already decided, which is a production server
5390
+ // serving a development client.
5391
+ if (config.command === "build" && !config.isProduction) {
5392
+ throw new Error(refuseDevelopmentBuild(config));
5393
+ }
4930
5394
  isWatch = config.build?.watch != null;
4931
5395
  resolvedConfig = config;
4932
5396
  const nitroMain = config.plugins.find((p) => p.name === "nitro:main");
@@ -4976,6 +5440,7 @@ export function rscKit(options = {}) {
4976
5440
  clientChunks: (meta) => meta.normalizedId,
4977
5441
  ...actionEncryptionKey(),
4978
5442
  }),
5443
+ barrelImportsPlugin(),
4979
5444
  useSsrModules(),
4980
5445
  serverRendererInRsc(),
4981
5446
  metadataRoutesPlugin(),