@rsc-kit/core 0.16.2 → 0.17.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 (71) hide show
  1. package/dist/appAssets.d.ts +4 -1
  2. package/dist/appAssets.js +55 -19
  3. package/dist/appAssets.js.map +1 -1
  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/js/Form.js +54 -6
  19. package/dist/js/Form.js.map +1 -1
  20. package/dist/js/Link.d.ts +0 -3
  21. package/dist/js/Link.js +3 -6
  22. package/dist/js/Link.js.map +1 -1
  23. package/dist/js/SegmentBoundary.d.ts +38 -2
  24. package/dist/js/SegmentBoundary.js +51 -13
  25. package/dist/js/SegmentBoundary.js.map +1 -1
  26. package/dist/js/createViteRscApp.js +72 -11
  27. package/dist/js/createViteRscApp.js.map +1 -1
  28. package/dist/js/devNotice.d.ts +13 -0
  29. package/dist/js/devNotice.js +55 -0
  30. package/dist/js/devNotice.js.map +1 -0
  31. package/dist/js/fallbackReport.d.ts +2 -0
  32. package/dist/js/fallbackReport.js +51 -0
  33. package/dist/js/fallbackReport.js.map +1 -0
  34. package/dist/js/navigate.d.ts +1 -1
  35. package/dist/js/navigate.js +55 -23
  36. package/dist/js/navigate.js.map +1 -1
  37. package/dist/js/segmentStore.d.ts +2 -0
  38. package/dist/js/segmentStore.js +21 -1
  39. package/dist/js/segmentStore.js.map +1 -1
  40. package/dist/js/staleAssets.d.ts +17 -0
  41. package/dist/js/staleAssets.js +48 -0
  42. package/dist/js/staleAssets.js.map +1 -0
  43. package/dist/js/standardSchema.d.ts +6 -0
  44. package/dist/js/standardSchema.js +19 -1
  45. package/dist/js/standardSchema.js.map +1 -1
  46. package/dist/js/useLinkStatus.d.ts +14 -1
  47. package/dist/js/useLinkStatus.js +15 -1
  48. package/dist/js/useLinkStatus.js.map +1 -1
  49. package/dist/js/useSearchParams.d.ts +6 -0
  50. package/dist/js/useSearchParams.js +16 -3
  51. package/dist/js/useSearchParams.js.map +1 -1
  52. package/dist/metadata.d.ts +94 -15
  53. package/dist/metadata.js.map +1 -1
  54. package/dist/metadataRoutes.d.ts +41 -0
  55. package/dist/metadataRoutes.js +138 -0
  56. package/dist/metadataRoutes.js.map +1 -0
  57. package/dist/prerender.js +43 -10
  58. package/dist/prerender.js.map +1 -1
  59. package/dist/reactCache.d.ts +1 -0
  60. package/dist/reactCache.js +52 -0
  61. package/dist/reactCache.js.map +1 -0
  62. package/dist/request.d.ts +18 -1
  63. package/dist/request.js +91 -5
  64. package/dist/request.js.map +1 -1
  65. package/dist/useSsr.d.ts +19 -0
  66. package/dist/useSsr.js +104 -0
  67. package/dist/useSsr.js.map +1 -0
  68. package/dist/vite.d.ts +48 -11
  69. package/dist/vite.js +526 -26
  70. package/dist/vite.js.map +1 -1
  71. package/package.json +1 -1
package/dist/vite.js CHANGED
@@ -12,17 +12,23 @@
12
12
  // carry the route composition and the worker's render contract, and supplies
13
13
  // the structural config (entries, output dirs, base). @vitejs/plugin-rsc is
14
14
  // included here so it always runs before any react() layer the app adds.
15
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
15
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, createReadStream, } from "node:fs";
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
- import { ASSET_BASE, appAssets, headTags } from "./appAssets.js";
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 { METADATA_ROUTES, ROOT_FILES, rootFileType } from "./metadataRoutes.js";
31
+ import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, } from "./useSsr.js";
26
32
  import { httpHostCalls } from "./hostCalls.js";
27
33
  // Resolved once per rscKit() call. One build runs in one process, so these are
28
34
  // module state rather than threaded through every helper.
@@ -30,8 +36,13 @@ let projectRoot;
30
36
  let sourceDir;
31
37
  let inlineStylesheets = "auto";
32
38
  let resolvedConfig = null;
39
+ /** Server files importing a client library, read off the rsc graph when it is built. */
40
+ let clientLibraryImports = [];
33
41
  /** Modules a runtime provides and no bundle should try to carry. */
34
42
  const RUNTIME_BUILTINS = ["bun", /^bun:/];
43
+ function arrayOf(value) {
44
+ return value == null ? [] : Array.isArray(value) ? value : [value];
45
+ }
35
46
  /**
36
47
  * What a client chunk is called on disk.
37
48
  *
@@ -93,10 +104,9 @@ let routeConfig;
93
104
  let prerenderAfterBuild;
94
105
  /** True during `vite build --watch`, where re-rendering every route is noise. */
95
106
  let isWatch = false;
96
- /** Whether navigations are wrapped in React's ViewTransition — see options. */
97
- let viewTransitions = false;
98
107
  /** Whether a service worker is generated and registered — see options. */
99
108
  let offline = false;
109
+ let typecheck = true;
100
110
  let webManifestOptions = null;
101
111
  let foundAssets = {
102
112
  favicon: null,
@@ -258,8 +268,8 @@ function resolvePaths(options) {
258
268
  // sets it, and so does a host that drives the build out of process and
259
269
  // prerenders itself afterwards with paths only it knows.
260
270
  prerenderAfterBuild = process.env.RSC_PRERENDER !== "0";
261
- viewTransitions = options.viewTransitions === true;
262
271
  offline = options.offline === true;
272
+ typecheck = options.typecheck !== false;
263
273
  inlineStylesheets = options.inlineStylesheets ?? "auto";
264
274
  maxActionBody = options.maxActionBody;
265
275
  // One place, and it is the file. A plugin option as well would be the same
@@ -474,11 +484,13 @@ function routeManifest() {
474
484
  build: { output, exportPath, payloadName: staticPayloads },
475
485
  routes,
476
486
  intercepts,
477
- apis: [...apiRoutes.values()].map(({ name, methods }) => ({
487
+ apis: [...apiRoutes.values()].map(({ name, methods, generated }) => ({
478
488
  name,
479
489
  segments: urlSegments(name),
480
490
  methods,
481
- middleware: ancestors(name, "middleware"),
491
+ // A synthesised robots.txt or sitemap.xml runs no guard: it exists to be
492
+ // read by anyone, and a guard on the root would 401 the crawler.
493
+ middleware: generated ? [] : ancestors(name, "middleware"),
482
494
  })),
483
495
  };
484
496
  }
@@ -956,13 +968,10 @@ export function declaredManifest(appDir) {
956
968
  function copyAppAssets(clientDir) {
957
969
  if (!existsSync(clientDir))
958
970
  return;
959
- const all = [
960
- ...(foundAssets.favicon ? [foundAssets.favicon] : []),
961
- ...foundAssets.icons,
962
- ...(foundAssets.appleIcon ? [foundAssets.appleIcon] : []),
963
- ...(foundAssets.openGraph ? [foundAssets.openGraph] : []),
964
- ...(foundAssets.twitter ? [foundAssets.twitter] : []),
965
- ];
971
+ for (const file of rootFiles) {
972
+ copyFileSync(join(sourceDir, "app", file), join(clientDir, file));
973
+ }
974
+ const all = allAppAssets(foundAssets);
966
975
  if (all.length === 0)
967
976
  return;
968
977
  for (const asset of all) {
@@ -1264,6 +1273,30 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
1264
1273
  console.log(" Nothing checks who calls them. Fine for a public one; otherwise build it\n" +
1265
1274
  " from an action client, so the check cannot be forgotten.");
1266
1275
  }
1276
+ // Server files importing cache from React. Its cache() memoises on the
1277
+ // dispatcher a render installs, so in a guard, an action, an api route or
1278
+ // the SSR pass it calls straight through - no dedupe, no error, the helper
1279
+ // runs twice. The difference is silent, which is why this line exists.
1280
+ const reactCache = reactCacheImports(sourceDir);
1281
+ if (reactCache.length > 0) {
1282
+ console.log(`\n \u26a0 ${reactCache.length} server ${reactCache.length === 1 ? "file imports" : "files import"} cache from 'react': ` +
1283
+ reactCache.join(", "));
1284
+ console.log(" React's cache() dedupes only inside a component render; in a guard, an action or\n" +
1285
+ " an api route it calls straight through. Import it from @rsc-kit/core/cache instead.");
1286
+ }
1287
+ // Server files importing a client library. Legal - a server component may
1288
+ // render a client component from a package - but the shape that costs an
1289
+ // afternoon is a file with no "use client" that only wraps them, so the
1290
+ // library's internals run on the server. Said, with the packages and the
1291
+ // importer, so the person reading can tell which it is.
1292
+ if (clientLibraryImports.length > 0) {
1293
+ console.log(`\n \u2139 ${clientLibraryImports.length} server ${clientLibraryImports.length === 1 ? "file imports" : "files import"} a client library: ` +
1294
+ clientLibraryImports
1295
+ .map((c) => `${c.file} (${c.packages.join(", ")}${c.from ? `; imported by ${c.from}` : ""})`)
1296
+ .join("; "));
1297
+ console.log(' Legal for a server component. A file that only wraps client components wants "use client" -\n' +
1298
+ " as shadcn ships it - so the server stops at the boundary.");
1299
+ }
1267
1300
  // Written from the rows that were just printed rather than recomputed: the
1268
1301
  // report and the terminal must not be able to disagree about what happened.
1269
1302
  writeFileSync(join(outDir, REPORT_FILE), buildReport(results.map((r) => ({
@@ -1280,7 +1313,7 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
1280
1313
  type: a.type,
1281
1314
  reason: a.reason,
1282
1315
  warning: a.warning ?? null,
1283
- })), audited));
1316
+ })), audited, reactCache, clientLibraryImports));
1284
1317
  const note = notes(results);
1285
1318
  const counted = [...results, ...apis];
1286
1319
  console.log(`
@@ -1577,6 +1610,76 @@ const components = new Map();
1577
1610
  * a default component.
1578
1611
  */
1579
1612
  const apiRoutes = new Map();
1613
+ /** Files beside the root layout served at the root as they are: robots.txt, a hand-written sitemap.xml, humans.txt. */
1614
+ let rootFiles = [];
1615
+ const METADATA_ROUTE_ID = "virtual:rsc-kit/metadata-route/";
1616
+ /**
1617
+ * robots.ts, sitemap.ts, llms.ts beside the root layout, each registered as
1618
+ * an api route at the file it stands for. The module the entry imports is
1619
+ * synthesised (metadataRoutesPlugin): the app's default export, formatted by
1620
+ * the engine, with the root layout's metadataBase for relative urls.
1621
+ *
1622
+ * Api routes, so nothing else is new: the build stores the ones that read
1623
+ * nothing per request and names the ones that do. Without middleware, on
1624
+ * purpose - these exist to be read by anyone, and a guard on the root would
1625
+ * otherwise 401 the crawler asking for robots.txt.
1626
+ */
1627
+ function registerMetadataRoutes(appDir) {
1628
+ rootFiles = existsSync(appDir)
1629
+ ? readdirSync(appDir)
1630
+ .filter((name) => ROOT_FILES.test(name))
1631
+ .sort()
1632
+ : [];
1633
+ for (const kind of Object.keys(METADATA_ROUTES)) {
1634
+ const source = ["ts", "tsx", "js", "mjs"]
1635
+ .map((ext) => join(appDir, `${kind}.${ext}`))
1636
+ .find((file) => existsSync(file));
1637
+ if (!source)
1638
+ continue;
1639
+ const { file } = METADATA_ROUTES[kind];
1640
+ if (rootFiles.includes(file)) {
1641
+ throw new Error(`[rsc-kit] app/${basename(source)} and app/${file} both answer /${file}. Keep one: the function, or the file as written.`);
1642
+ }
1643
+ const name = `app/${file}/route`;
1644
+ if (apiRoutes.has(name)) {
1645
+ throw new Error(`[rsc-kit] app/${basename(source)} and app/${file}/route.ts both answer /${file}. Keep one.`);
1646
+ }
1647
+ apiRoutes.set(name, {
1648
+ name,
1649
+ absPath: METADATA_ROUTE_ID + kind,
1650
+ methods: ["GET"],
1651
+ generated: { kind, file: source },
1652
+ });
1653
+ }
1654
+ }
1655
+ /** The module behind a synthesised metadata route. */
1656
+ function metadataRoutesPlugin() {
1657
+ return {
1658
+ name: "rsc-kit:metadata-routes",
1659
+ resolveId(id) {
1660
+ if (id.startsWith(METADATA_ROUTE_ID))
1661
+ return "\0" + id;
1662
+ },
1663
+ load(id) {
1664
+ if (!id.startsWith("\0" + METADATA_ROUTE_ID))
1665
+ return;
1666
+ const kind = id.slice(("\0" + METADATA_ROUTE_ID).length);
1667
+ const route = [...apiRoutes.values()].find((r) => r.generated?.kind === kind);
1668
+ if (!route?.generated)
1669
+ return;
1670
+ const rootLayout = components.get("app/layout")?.absPath ?? null;
1671
+ return [
1672
+ `import produce from ${JSON.stringify(route.generated.file)}`,
1673
+ rootLayout
1674
+ ? `import * as __root from ${JSON.stringify(rootLayout)}`
1675
+ : "const __root = {}",
1676
+ `import { metadataResponse } from ${JSON.stringify(join(packageDir, "metadataRoutes"))}`,
1677
+ `export const GET = () => metadataResponse(${JSON.stringify(kind)}, produce, __root.metadata?.metadataBase ?? null)`,
1678
+ "",
1679
+ ].join("\n");
1680
+ },
1681
+ };
1682
+ }
1580
1683
  function register(absPath) {
1581
1684
  const name = componentName(absPath);
1582
1685
  const existing = components.get(name);
@@ -1913,6 +2016,9 @@ import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
1913
2016
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
1914
2017
  import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, "query"))}
1915
2018
  import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, "action"))}
2019
+ import { noteFallback as noteCaughtRead } from ${JSON.stringify(join(packageDir, "request"))}
2020
+ import { isOutdatedOptimizedDep, outdatedDepResponse } from ${JSON.stringify(join(packageDir, "devReload"))}
2021
+ import { sharedDepth } from ${JSON.stringify(join(packageDir, "routing"))}
1916
2022
  import { Suspense, createElement, Fragment } from 'react'
1917
2023
  import { AsyncLocalStorage } from 'node:async_hooks'
1918
2024
  ${imports.join("\n")}
@@ -2555,6 +2661,38 @@ async function renderTree(
2555
2661
  }
2556
2662
  if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
2557
2663
 
2664
+ // robots is a string, or the object Next takes: index and follow as
2665
+ // their no- forms, the flags by name, the limits as name:value. googleBot
2666
+ // is the same shape for the googlebot tag. The object used to fall
2667
+ // through to the catch-all below as "[object Object]" - which no crawler
2668
+ // reads, on the one page that asked not to be indexed.
2669
+ const robotsContent = (value: unknown): string => {
2670
+ if (typeof value !== 'object' || value === null) return String(value)
2671
+
2672
+ const r = value as Record<string, unknown>
2673
+ const parts: string[] = []
2674
+
2675
+ if (r.index != null) parts.push(r.index ? 'index' : 'noindex')
2676
+ if (r.follow != null) parts.push(r.follow ? 'follow' : 'nofollow')
2677
+ for (const flag of ['noarchive', 'nosnippet', 'noimageindex', 'nocache', 'notranslate', 'indexifembedded', 'nositelinkssearchbox']) {
2678
+ if (r[flag]) parts.push(flag)
2679
+ }
2680
+ if (r.unavailable_after != null) parts.push('unavailable_after: ' + String(r.unavailable_after))
2681
+ for (const limit of ['max-video-preview', 'max-image-preview', 'max-snippet']) {
2682
+ if (r[limit] != null) parts.push(limit + ':' + String(r[limit]))
2683
+ }
2684
+
2685
+ return parts.join(', ')
2686
+ }
2687
+
2688
+ if (md.robots != null) {
2689
+ head.push(createElement('meta', { key: '__r', name: 'robots', content: robotsContent(md.robots) }))
2690
+
2691
+ const bot = typeof md.robots === 'object' ? (md.robots as { googleBot?: unknown }).googleBot : null
2692
+
2693
+ if (bot != null) head.push(createElement('meta', { key: '__rg', name: 'googlebot', content: robotsContent(bot) }))
2694
+ }
2695
+
2558
2696
  // og: and its relatives are PROPERTY, not name. Facebook's scraper - and
2559
2697
  // Slack's, and LinkedIn's - reads only property=, so every og tag this
2560
2698
  // used to emit with name= was invisible to the thing it existed for.
@@ -2659,7 +2797,7 @@ async function renderTree(
2659
2797
  // meta tags rather than a meta tag by that name. A key at the top level
2660
2798
  // still renders - the type no longer invites one, but an app written
2661
2799
  // against the old shape must not silently lose its tags.
2662
- const structured = new Set(['title', 'description', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
2800
+ const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
2663
2801
  const named = Object.entries(md).filter(([k]) => !structured.has(k))
2664
2802
  const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
2665
2803
 
@@ -3512,12 +3650,22 @@ export async function handleRscPprShell(
3512
3650
  // page, stored, with the build reporting success.
3513
3651
  let renderFailure: string | undefined
3514
3652
 
3515
- const noteFailure = (e: unknown): string | undefined => {
3653
+ const noteFailure = (e: unknown, info?: { componentStack?: string }): string | undefined => {
3516
3654
  const digest = redirectDigest(e)
3517
3655
 
3518
3656
  // A redirect is a classification here, not a failure.
3519
3657
  if (digest) return digest
3520
3658
 
3659
+ // A read the server could not answer, caught at a boundary: the fallback
3660
+ // there is what gets stored. Noted with its component so the build can
3661
+ // say so on the route's line; the digest goes into the document for the
3662
+ // browser to recognise.
3663
+ if ((e as { digest?: string } | null)?.digest === 'rsc-kit:search-params-fallback') {
3664
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
3665
+ noteCaughtRead('useSearchParams()' + (where ? ' in ' + where : ''))
3666
+ return 'rsc-kit:search-params-fallback'
3667
+ }
3668
+
3521
3669
  // Every probe ends by aborting, so React reports that abort. Asked of the
3522
3670
  // signal rather than matched against the message: a string test would be a
3523
3671
  // guess about wording, and would quietly stop working when React changed it.
@@ -3630,6 +3778,18 @@ ${fallbackOrigin ? FALLBACK_CONSTS.replace("__ORIGIN__", JSON.stringify(fallback
3630
3778
  let devHandler: ((request: Request) => Promise<Response | null>) | null = null
3631
3779
 
3632
3780
  export default async function handler(request: Request): Promise<Response> {
3781
+ try {
3782
+ return await serve(request)
3783
+ } catch (error) {
3784
+ // Vite re-optimised a server pre-bundle under this render. The condition
3785
+ // is over already; the page is asked to load again.
3786
+ if (import.meta.env.DEV && isOutdatedOptimizedDep(error)) return outdatedDepResponse(request)
3787
+
3788
+ throw error
3789
+ }
3790
+ }
3791
+
3792
+ async function serve(request: Request): Promise<Response> {
3633
3793
  installHostCallsOnce()
3634
3794
 
3635
3795
  devHandler ??= createRscHandler({
@@ -3653,7 +3813,7 @@ export default async function handler(request: Request): Promise<Response> {
3653
3813
  ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} maxActionBody: ${maxActionBody === undefined ? "undefined" : String(maxActionBody)},
3654
3814
  })
3655
3815
 
3656
- ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (await notFound())\n"}}
3816
+ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (await notFound(request))\n"}}
3657
3817
 
3658
3818
  /**
3659
3819
  * The page for a url nothing answers.
@@ -3664,9 +3824,42 @@ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (awa
3664
3824
  *
3665
3825
  * Without a not-found.tsx this is the string it always was.
3666
3826
  */
3667
- async function notFound(): Promise<Response> {
3827
+ async function notFound(request: Request): Promise<Response> {
3668
3828
  ${notFoundComponent
3669
3829
  ? `
3830
+ // A navigation, not a document: answer with the not-found tree as a
3831
+ // payload, at the depth the client already holds, so the router renders
3832
+ // it in place - the layout stays, the url changes, nothing reloads. The
3833
+ // status is still 404; a payload is a payload whatever it says.
3834
+ if (request.headers.get('X-RSC')) {
3835
+ try {
3836
+ const chain = ${JSON.stringify(notFoundLayouts)}
3837
+ const from = sharedDepth(request.headers.get('X-RSC-Segments'), chain)
3838
+ const { rscPayload } = await handleRscPayload(
3839
+ ${JSON.stringify(notFoundComponent)},
3840
+ {},
3841
+ ${JSON.stringify(notFoundLayouts.map((component) => ({ component, props: {} })))},
3842
+ [],
3843
+ {},
3844
+ from,
3845
+ '/404',
3846
+ )
3847
+
3848
+ return new Response(rscPayload, {
3849
+ status: 404,
3850
+ headers: {
3851
+ 'Content-Type': 'text/x-component; charset=utf-8',
3852
+ 'X-RSC-Segment-Depth': String(from),
3853
+ 'X-RSC-Layouts': chain.join(','),
3854
+ 'Cache-Control': 'no-store',
3855
+ Vary: 'X-RSC',
3856
+ },
3857
+ })
3858
+ } catch {
3859
+ // Fall through to the document answer below.
3860
+ }
3861
+ }
3862
+
3670
3863
  try {
3671
3864
  const { htmlStream } = await handleRscHtmlStream(
3672
3865
  ${JSON.stringify(notFoundComponent)},
@@ -3695,11 +3888,15 @@ async function notFound(): Promise<Response> {
3695
3888
  }
3696
3889
  function generateEntrySsr() {
3697
3890
  const devUrls = join(packageDir, "devUrls");
3891
+ const request = join(packageDir, "request");
3892
+ const fallbackReport = join(packageDir, "js/fallbackReport");
3698
3893
  return `// GENERATED by rscKit() — do not edit.
3699
3894
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
3700
3895
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
3701
3896
  import { prerender } from 'react-dom/static.edge'
3702
3897
  import { rewriteViteDevUrlStream } from ${JSON.stringify(devUrls)}
3898
+ import { noteFallback } from ${JSON.stringify(request)}
3899
+ import { cancelledByConsumer, caughtByLoading } from ${JSON.stringify(fallbackReport)}
3703
3900
 
3704
3901
  // Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
3705
3902
  // links root-relative in dev, which would send the browser to the host for
@@ -3727,7 +3924,35 @@ export async function handleSsr(
3727
3924
  const html = await renderToReadableStream(root as any, {
3728
3925
  bootstrapScriptContent,
3729
3926
  nonce,
3730
- onError: onError ?? ((error: unknown) => { console.error('[rsc-kit:ssr]', error) }),
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
+ }),
3731
3956
  })
3732
3957
 
3733
3958
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -3763,8 +3988,14 @@ export async function handleSsrPrerender(
3763
3988
  bootstrapScriptContent,
3764
3989
  nonce: options.nonce,
3765
3990
  signal: options.signal,
3766
- // Aborting is how this ends, so React's report of it is not news.
3767
- onError: () => {},
3991
+ // Aborting is how this ends, so React's report of it is not news. A read
3992
+ // caught at a boundary is: the build attaches it to the route.
3993
+ onError: (error: unknown, info?: { componentStack?: string }) => {
3994
+ if ((error as { digest?: string } | null)?.digest !== 'rsc-kit:search-params-fallback') return
3995
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
3996
+ noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
3997
+ return 'rsc-kit:search-params-fallback'
3998
+ },
3768
3999
  })
3769
4000
 
3770
4001
  return {
@@ -4056,6 +4287,197 @@ function extendableClientReferences() {
4056
4287
  },
4057
4288
  };
4058
4289
  }
4290
+ /**
4291
+ * The worker the dev server serves at /sw.js: it removes the one a production
4292
+ * run left on this origin, and the caches with it, then reloads each page it
4293
+ * was controlling so they load uncontrolled. Nothing else runs in development.
4294
+ */
4295
+ export const DEV_WORKER = `self.addEventListener('install', () => self.skipWaiting())
4296
+ self.addEventListener('activate', (event) => {
4297
+ event.waitUntil((async () => {
4298
+ const keys = await caches.keys()
4299
+ await Promise.all(keys.filter((k) => k.startsWith('rsc-kit-')).map((k) => caches.delete(k)))
4300
+ await self.clients.claim()
4301
+ const pages = await self.clients.matchAll({ type: 'window' })
4302
+ await self.registration.unregister()
4303
+ for (const page of pages) page.navigate(page.url).catch(() => {})
4304
+ })())
4305
+ })
4306
+ `;
4307
+ /**
4308
+ * "use ssr" modules, rewritten for the server-components environment into
4309
+ * proxies that call the real module in the ssr environment. See useSsr.ts.
4310
+ *
4311
+ * Before every other transform, so the cross-environment import this emits
4312
+ * is still ahead of plugin-rsc's handling of it - and so the module's own
4313
+ * imports, react-dom/server among them, are never resolved here at all.
4314
+ */
4315
+ function useSsrModules() {
4316
+ return {
4317
+ name: "rsc-kit:use-ssr",
4318
+ enforce: "pre",
4319
+ applyToEnvironment: (environment) => environment.name === "rsc",
4320
+ transform(code, id) {
4321
+ if (!code.includes("use ssr"))
4322
+ return;
4323
+ try {
4324
+ const proxy = ssrProxyModule(code, id);
4325
+ return proxy === null ? undefined : { code: proxy, map: null };
4326
+ }
4327
+ catch (error) {
4328
+ if (error instanceof UseSsrError)
4329
+ this.error(error.message);
4330
+ throw error;
4331
+ }
4332
+ },
4333
+ };
4334
+ }
4335
+ /**
4336
+ * react-dom/server, imported where server components render, becomes a
4337
+ * module that throws the fix instead of React's refusal - naming the app
4338
+ * file that imported it (through @react-email/render, say) and the
4339
+ * directive that moves it. The build says the same once, as a warning.
4340
+ */
4341
+ const RENDERER_STUB = "\0rsc-kit:react-dom-server?from=";
4342
+ function serverRendererInRsc() {
4343
+ const warned = new Set();
4344
+ const appImporter = (ctx, importer) => {
4345
+ let current = importer;
4346
+ for (let hop = 0; current && hop < 12; hop++) {
4347
+ if (!current.includes("/node_modules/"))
4348
+ return relative(process.cwd(), current.split("?")[0]);
4349
+ const next = ctx.environment.mode === "build"
4350
+ ? ctx.getModuleInfo(current)?.importers[0]
4351
+ : (ctx.environment.moduleGraph
4352
+ ?.getModuleById(current)
4353
+ ?.importers.values()
4354
+ .next().value?.id ?? undefined);
4355
+ if (!next)
4356
+ break;
4357
+ current = next;
4358
+ }
4359
+ return importer ? relative(process.cwd(), importer.split("?")[0]) : null;
4360
+ };
4361
+ return {
4362
+ name: "rsc-kit:server-renderer",
4363
+ applyToEnvironment: (environment) => environment.name === "rsc",
4364
+ resolveId(source, importer) {
4365
+ if (!SERVER_RENDERER.test(source))
4366
+ return;
4367
+ return RENDERER_STUB + encodeURIComponent(importer ?? "");
4368
+ },
4369
+ load(id) {
4370
+ if (!id.startsWith(RENDERER_STUB))
4371
+ return;
4372
+ const importer = decodeURIComponent(id.slice(RENDERER_STUB.length)) || null;
4373
+ const message = serverRendererMessage(appImporter(this, importer));
4374
+ if (this.environment.mode === "build" && !warned.has(message)) {
4375
+ warned.add(message);
4376
+ this.warn(message);
4377
+ }
4378
+ return `throw new Error(${JSON.stringify(message)});\n`;
4379
+ },
4380
+ };
4381
+ }
4382
+ /**
4383
+ * `tsc --noEmit` on the project's own tsconfig, the way `check` runs it.
4384
+ *
4385
+ * Through package.json rather than a subpath: TypeScript's exports map does
4386
+ * not expose bin/tsc, and the bin field is where the name lives. Its output
4387
+ * is the message, because that is what the developer would have read.
4388
+ */
4389
+ export function typecheckProject(root) {
4390
+ const tsconfig = join(root, "tsconfig.json");
4391
+ if (!existsSync(tsconfig))
4392
+ return { ran: false, because: "no tsconfig" };
4393
+ let tsc;
4394
+ try {
4395
+ const fromApp = createRequire(join(root, "package.json"));
4396
+ const manifest = fromApp.resolve("typescript/package.json");
4397
+ const bin = fromApp(manifest)
4398
+ .bin;
4399
+ const relative = typeof bin === "string" ? bin : bin?.tsc;
4400
+ if (!relative)
4401
+ return { ran: false, because: "no typescript" };
4402
+ tsc = join(dirname(manifest), relative);
4403
+ }
4404
+ catch {
4405
+ return { ran: false, because: "no typescript" };
4406
+ }
4407
+ const started = Date.now();
4408
+ const script = /\.[cm]?js$/.test(tsc) || !/\.\w+$/.test(tsc);
4409
+ const result = spawnSync(script ? process.execPath : tsc,
4410
+ // Plain lines, not coloured ones: they go into a build error, and the
4411
+ // test that reads them is a reader too.
4412
+ [...(script ? [tsc] : []), "--noEmit", "--pretty", "false", "-p", tsconfig], { cwd: root, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
4413
+ if (result.status === 0)
4414
+ return { ran: true, ok: true, seconds: (Date.now() - started) / 1000 };
4415
+ return {
4416
+ ran: true,
4417
+ ok: false,
4418
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(),
4419
+ };
4420
+ }
4421
+ /**
4422
+ * The project's typecheck, as part of the build.
4423
+ *
4424
+ * Runs once, in the first environment to start, after the route types have
4425
+ * been written by the config hook - so a link to a route that does not exist
4426
+ * is an error here and not a 404 found after deploying. Skipped where the
4427
+ * project is not doing this checking at all: no tsconfig.json, or no
4428
+ * typescript to run.
4429
+ */
4430
+ function typecheckPlugin() {
4431
+ let ran = false;
4432
+ return {
4433
+ name: "rsc-kit:typecheck",
4434
+ apply: "build",
4435
+ buildStart() {
4436
+ if (ran || !typecheck || isWatch)
4437
+ return;
4438
+ ran = true;
4439
+ const outcome = typecheckProject(projectRoot);
4440
+ if (!outcome.ran)
4441
+ return;
4442
+ if (outcome.ok) {
4443
+ log(`typecheck: ok (${outcome.seconds.toFixed(1)}s)`);
4444
+ return;
4445
+ }
4446
+ throw new Error("[rsc-kit] The typecheck failed, so the build stops here.\n\n" +
4447
+ outcome.output +
4448
+ "\n\nrscKit({ typecheck: false }) builds without it.");
4449
+ },
4450
+ };
4451
+ }
4452
+ /**
4453
+ * Which server files import a client library, read off the rsc environment's
4454
+ * module graph once it is built. plugin-rsc classifies the client packages
4455
+ * (every package with react among its peers) and excludes them from the
4456
+ * server optimizers; that list is the one used here, minus this package -
4457
+ * a server component importing Link is the norm - and plugin-rsc's own.
4458
+ */
4459
+ function clientImportsAudit() {
4460
+ return {
4461
+ name: "rsc-kit:client-imports",
4462
+ apply: "build",
4463
+ applyToEnvironment: (environment) => environment.name === "rsc",
4464
+ buildEnd() {
4465
+ const excluded = resolvedConfig?.environments?.rsc?.optimizeDeps?.exclude ?? [];
4466
+ const clientPackages = excluded.filter((name) => !name.startsWith("@vitejs/plugin-rsc"));
4467
+ if (clientPackages.length === 0)
4468
+ return;
4469
+ clientLibraryImports = serverImportsOfClientPackages({
4470
+ moduleIds: () => this.getModuleIds(),
4471
+ importedIds: (id) => this.getModuleInfo(id)?.importedIds ?? [],
4472
+ importers: (id) => this.getModuleInfo(id)?.importers ?? [],
4473
+ }, {
4474
+ sourceDir,
4475
+ clientPackages,
4476
+ ignore: [PACKAGE_NAME, "server-only", "client-only"],
4477
+ });
4478
+ },
4479
+ };
4480
+ }
4059
4481
  export function rscKit(options = {}) {
4060
4482
  resolvePaths(options);
4061
4483
  const routesPlugin = {
@@ -4064,8 +4486,13 @@ export function rscKit(options = {}) {
4064
4486
  if (!existsSync(appDir)) {
4065
4487
  throw new Error(`[rsc-kit] No app directory at ${appDir} — nothing to build.`);
4066
4488
  }
4489
+ // Both maps, not one: the config hook runs again when the config
4490
+ // changes under a dev server, and a route.ts or a synthesised
4491
+ // robots.txt left over from the previous run read as a duplicate.
4067
4492
  components.clear();
4493
+ apiRoutes.clear();
4068
4494
  discover(appDir);
4495
+ registerMetadataRoutes(appDir);
4069
4496
  // Silent when it worked. The names were printed on every dev start and
4070
4497
  // every build — thirty of them for a middling app, above the output that
4071
4498
  // actually says something, and the build's classification table lists
@@ -4154,9 +4581,6 @@ export function rscKit(options = {}) {
4154
4581
  */
4155
4582
  define: {
4156
4583
  "process.env.NODE_ENV": JSON.stringify(env.mode === "development" ? "development" : "production"),
4157
- // A constant, so the boundary and its import fall out of the bundle
4158
- // entirely when this is off rather than shipping a branch nobody takes.
4159
- __RSC_VIEW_TRANSITIONS__: JSON.stringify(viewTransitions),
4160
4584
  __RSC_OFFLINE__: JSON.stringify(offline),
4161
4585
  },
4162
4586
  /*
@@ -4181,6 +4605,36 @@ export function rscKit(options = {}) {
4181
4605
  */
4182
4606
  optimizeDeps: {
4183
4607
  exclude: [PACKAGE_NAME, ...(_config.optimizeDeps?.exclude ?? [])],
4608
+ // Every "use client" file, so the browser's dependencies are all
4609
+ // found at startup. The client entry reaches only the engine and
4610
+ // React; the app's client components arrive through payloads, page
4611
+ // by page, and a dependency first seen on the third page visited
4612
+ // re-optimised every pre-bundle under a running page - two Reacts,
4613
+ // a blank document, then Vite's own reload. See clientEntries.
4614
+ // Naming entries replaces the input, so the browser entry is named
4615
+ // too, or the engine and React would be the ones left out.
4616
+ entries: [
4617
+ ...(Array.isArray(_config.optimizeDeps?.entries)
4618
+ ? _config.optimizeDeps.entries
4619
+ : _config.optimizeDeps?.entries
4620
+ ? [_config.optimizeDeps.entries]
4621
+ : []),
4622
+ // Entries are globs: a route group's parentheses, or a bracket
4623
+ // in a dynamic segment, would otherwise read as pattern syntax
4624
+ // and match nothing - and the scanner would crawl nothing, quietly.
4625
+ ...[
4626
+ join(genDir, "entry.browser.tsx"),
4627
+ ...engineClientEntries(),
4628
+ ...clientEntries(sourceDir),
4629
+ ].map((file) => file.replace(/[()[\]{}*?!+@]/g, "\\$&")),
4630
+ ],
4631
+ rolldownOptions: {
4632
+ ...(_config.optimizeDeps?.rolldownOptions ?? {}),
4633
+ plugins: [
4634
+ clientScanPlugin(),
4635
+ ...arrayOf(_config.optimizeDeps?.rolldownOptions?.plugins),
4636
+ ],
4637
+ },
4184
4638
  },
4185
4639
  // Public URL for browser-facing client assets, and a BUILD concern
4186
4640
  // only: it says where the built files will be served from.
@@ -4266,6 +4720,47 @@ export function rscKit(options = {}) {
4266
4720
  * nothing.
4267
4721
  */
4268
4722
  configureServer(server) {
4723
+ // The icons and share images live in app/, which nothing serves; the
4724
+ // build copies them beside the client output. There is no output while
4725
+ // developing, so the same hrefs the head tags carry are answered from
4726
+ // app/ here - and the web manifest with them - or the tab has no icon
4727
+ // and the console a 404 for a file that is right there.
4728
+ server.middlewares.use((req, res, next) => {
4729
+ const url = (req.url ?? "").split("?")[0];
4730
+ // A worker registered by a production run on this origin - `bun run
4731
+ // start` on the port the dev server uses next - outlives that run and
4732
+ // answers the dev server's documents from its cache: a stored page is
4733
+ // cache-first, so a refresh brings back yesterday's document with
4734
+ // yesterday's module hashes, and a 504 for each. The browser fetches
4735
+ // /sw.js again on every navigation to look for an update; in
4736
+ // development that fetch gets a worker whose only job is to remove
4737
+ // itself, its caches, and reload the pages it controlled.
4738
+ // robots.txt, a hand-written sitemap.xml, humans.txt: beside the root
4739
+ // layout, served at the root. The build copies them beside the client
4740
+ // output; here they are read from app/.
4741
+ if (url.startsWith("/") && rootFiles.includes(url.slice(1))) {
4742
+ res.setHeader("Content-Type", rootFileType(url.slice(1)));
4743
+ res.end(readFileSync(join(sourceDir, "app", url.slice(1))));
4744
+ return;
4745
+ }
4746
+ if (url === "/sw.js") {
4747
+ res.setHeader("Content-Type", "text/javascript");
4748
+ res.setHeader("Cache-Control", "no-store");
4749
+ res.end(DEV_WORKER);
4750
+ return;
4751
+ }
4752
+ if (webManifestOptions && url === MANIFEST_PATH) {
4753
+ res.setHeader("Content-Type", "application/manifest+json");
4754
+ res.end(webManifest(webManifestOptions));
4755
+ return;
4756
+ }
4757
+ const asset = allAppAssets(foundAssets).find((a) => a.href === url);
4758
+ const file = asset ? join(sourceDir, "app", asset.file) : null;
4759
+ if (!asset || !file || !existsSync(file))
4760
+ return next();
4761
+ res.setHeader("Content-Type", typeOf(asset.file));
4762
+ createReadStream(file).pipe(res);
4763
+ });
4269
4764
  // rpc() has to reach the backend while the dev server is serving.
4270
4765
  //
4271
4766
  // A built deployment installs this itself: the server running
@@ -4481,7 +4976,12 @@ export function rscKit(options = {}) {
4481
4976
  clientChunks: (meta) => meta.normalizedId,
4482
4977
  ...actionEncryptionKey(),
4483
4978
  }),
4979
+ useSsrModules(),
4980
+ serverRendererInRsc(),
4981
+ metadataRoutesPlugin(),
4484
4982
  extendableClientReferences(),
4983
+ typecheckPlugin(),
4984
+ clientImportsAudit(),
4485
4985
  routesPlugin,
4486
4986
  ];
4487
4987
  }