@rsc-kit/core 0.8.0 → 0.10.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 (44) hide show
  1. package/dist/action.d.ts +21 -0
  2. package/dist/action.js +67 -36
  3. package/dist/action.js.map +1 -1
  4. package/dist/apiPrerender.d.ts +25 -0
  5. package/dist/apiPrerender.js +195 -0
  6. package/dist/apiPrerender.js.map +1 -0
  7. package/dist/host.d.ts +12 -0
  8. package/dist/host.js +140 -2
  9. package/dist/host.js.map +1 -1
  10. package/dist/js/RouteErrorBoundary.d.ts +36 -0
  11. package/dist/js/RouteErrorBoundary.js +43 -0
  12. package/dist/js/RouteErrorBoundary.js.map +1 -0
  13. package/dist/js/queryClient.js +21 -1
  14. package/dist/js/queryClient.js.map +1 -1
  15. package/dist/manifest.d.ts +33 -0
  16. package/dist/manifest.js.map +1 -1
  17. package/dist/metadata.d.ts +69 -0
  18. package/dist/metadata.js +17 -0
  19. package/dist/metadata.js.map +1 -0
  20. package/dist/notFound.d.ts +24 -0
  21. package/dist/notFound.js +107 -0
  22. package/dist/notFound.js.map +1 -0
  23. package/dist/prerender.d.ts +48 -4
  24. package/dist/prerender.js +79 -9
  25. package/dist/prerender.js.map +1 -1
  26. package/dist/query.d.ts +23 -0
  27. package/dist/query.js +45 -3
  28. package/dist/query.js.map +1 -1
  29. package/dist/redirect.d.ts +8 -0
  30. package/dist/redirect.js +11 -1
  31. package/dist/redirect.js.map +1 -1
  32. package/dist/request.d.ts +7 -0
  33. package/dist/request.js +31 -6
  34. package/dist/request.js.map +1 -1
  35. package/dist/routeSchema.d.ts +115 -0
  36. package/dist/routeSchema.js +182 -0
  37. package/dist/routeSchema.js.map +1 -0
  38. package/dist/routing.d.ts +20 -1
  39. package/dist/routing.js +30 -7
  40. package/dist/routing.js.map +1 -1
  41. package/dist/vite.js +594 -34
  42. package/dist/vite.js.map +1 -1
  43. package/package.json +18 -6
  44. package/dist/types.d.ts +0 -82
@@ -1 +1 @@
1
- {"version":3,"file":"queryClient.js","sourceRoot":"","sources":["../../src/js/queryClient.ts"],"names":[],"mappings":"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","sourcesContent":["// The browser half of `query()`: send a read as a GET.\n//\n// That is the whole of it. There is no cache here, no batching and no\n// deduplication, because TanStack Query and SWR already do those and do them\n// better — this owns the transport and they own everything above it.\n//\n// The awkward fact it is built around: a server function's id is not readable\n// from the client. React keeps it in a module-private WeakMap and exposes no\n// getter, so there is no `ref.$$id` to build a url from. What the client *can*\n// do is call the reference and be handed the id by React itself — the stub's\n// whole body is `callServer(id, args)`, and this package already owns\n// `callServer`.\n//\n// So a read opens a one-shot slot and calls the reference; the transport claims\n// the slot and sends a GET instead of posting. The slot is also what says this\n// is a read at all: calling `getListings(kind)` directly is still a POST, and\n// `fetchQuery(getListings, [kind])` is the same call as a GET. Nothing has to\n// know a list of query ids, on either side of the build.\n\n/** Where reads are answered. Must match HEADER.queryPath on the server. */\nconst QUERY_PATH = '/_rsc/query'\n\n/**\n * Sent on every read, and required by the endpoint.\n *\n * Not decoration. A GET with no unusual header is a *simple* request, so any\n * page anywhere can trigger one with `<img src=\"…/_rsc/query?…\">` and it goes\n * out with the visitor's cookies — CORS stops the attacker reading the answer,\n * but the read still runs. This header is not CORS-safelisted, so the browser\n * preflights it, and nothing here answers a preflight.\n *\n * That restores exactly the protection a POST had: a `POST` carrying\n * `X-RSC-Action` is non-simple for the same reason.\n */\nconst QUERY_HEADER = 'X-RSC-Query'\n\n/**\n * How long a url may get before the read goes as a POST instead.\n *\n * Conservative. Proxies and CDNs start refusing somewhere between 8k and 16k,\n * and the failure is a 414 from a machine that is not ours.\n */\nconst MAX_URL = 6_000\n\ntype Reader = (...args: unknown[]) => Promise<unknown>\n\n/**\n * The slot a read opens before calling the reference.\n *\n * One-shot and synchronous: React's stub calls `callServer` in its own body\n * with nothing awaited in between, so exactly one transport call can claim it.\n */\nlet slot: { claimed: boolean; promise: Promise<unknown> | null } | null = null\n\n/**\n * Claim the open slot, if a read opened one.\n *\n * Called from the app's `callServer` before it posts. Returns null when this is\n * an ordinary action, which is every call that did not come through\n * `fetchQuery`.\n */\nexport function claimRead(id: string, args: unknown[]): Promise<unknown> | null {\n if (!slot || slot.claimed) return null\n\n slot.claimed = true\n slot.promise = send(id, args)\n\n return slot.promise\n}\n\n/**\n * Read a query, over GET.\n *\n * Hand this to a cache library and let it decide everything else:\n *\n * queryFn: () => fetchQuery(getListings, [kind])\n * useSWR(['listings', kind], () => fetchQuery(getListings, [kind]))\n *\n * Every call goes to the server, which is what a fetcher needs — staleness,\n * revalidation, retries, polling and deduplication all belong to the library\n * holding the answer, not to the thing that fetches it.\n */\nexport function fetchQuery<Data>(\n reference: (...args: never[]) => Promise<Data>,\n args: unknown[] = [],\n): Promise<Data> {\n if (typeof window === 'undefined') {\n // React's SSR runtime refuses a server-function call during the initial\n // render, and reaching a query's id means calling its reference — so this\n // cannot work here. Raised with the fix in it rather than left to surface\n // as React's more general message about fetch waterfalls.\n //\n // A cache library runs its fetcher in an effect, so the server render never\n // reaches this; a server component that wants the data during render should\n // await the query directly, which needs none of this.\n throw new Error(\n 'A query was read during server rendering. Read it in a server component with await, or through a cache library, whose fetcher runs after hydration.',\n )\n }\n\n const opened = { claimed: false, promise: null as Promise<unknown> | null }\n\n slot = opened\n\n try {\n // React's stub reaches callServer synchronously, so the slot is claimed by\n // the time this returns. The stub's own promise is discarded: the one the\n // transport made is the one that settles with the answer.\n ;(reference as unknown as Reader)(...(args as unknown[]))\n } finally {\n // Cleared here and nowhere else, so a reference that throws on the way in\n // does not leave the slot open for the next read to claim by accident.\n // Nothing is returned from this block: a `return` in `finally` discards\n // whatever the `try` was throwing, which would turn a reference that threw\n // synchronously into the misleading bound-reference error below.\n slot = null\n }\n\n if (!opened.claimed || !opened.promise) {\n // The only way here is a reference whose call path is asynchronous, which\n // today means one that was `.bind()`-ed. Loud, because the quiet version is\n // a read that silently went out as a POST.\n throw new Error(\n 'A query was called through a bound reference. Pass the exported query itself, unbound.',\n )\n }\n\n return opened.promise as Promise<Data>\n}\n\nasync function send(id: string, args: unknown[]): Promise<unknown> {\n const encoded = await encode(args)\n\n // encodeReply answers with FormData the moment an argument holds a File, and\n // a url cannot carry one. Rather than refuse a call that would have worked as\n // an action, the read falls back to a POST: it stops being cacheable, which\n // is the only thing it loses.\n if (typeof encoded !== 'string') return await post(id, args, 'an argument contained a File')\n\n const url = `${QUERY_PATH}?id=${encodeURIComponent(id)}&args=${encodeURIComponent(encoded)}`\n\n if (url.length > MAX_URL) return await post(id, args, 'the arguments are too large for a url')\n\n const res = await fetch(url, {\n headers: {\n [QUERY_HEADER]: '1',\n Accept: 'text/x-component',\n // Where the read came from. Same reason an action sends it: a host that\n // guards by route needs to know which page is asking.\n 'X-RSC-Referer': window.location.pathname + window.location.search,\n },\n })\n\n if (!res.ok || !res.body) {\n throw new Error((await res.text().catch(() => '')) || `Query failed: ${res.status}`)\n }\n\n return await deserialize(res.body)\n}\n\n/**\n * The same read, as an action.\n *\n * Reached only when the arguments cannot ride in a url. Warned about in\n * development rather than silently tolerated, because the read still works and\n * the thing that changed — it is no longer a GET, so nothing can cache it — is\n * otherwise invisible.\n */\nasync function post(id: string, args: unknown[], why: string): Promise<unknown> {\n if (import.meta.env?.DEV) {\n console.warn(`[rsc-kit] A query was sent as a POST because ${why}. It will not be cached.`)\n }\n\n return await asAction(id, args)\n}\n\n/**\n * The Flight codec and the action transport, installed by the app bootstrap.\n *\n * Injected rather than imported, and not for testing: this module is reached\n * from client components, so importing the browser runtime here would pull a\n * second copy of it into that graph — two client-reference registries, where\n * components resolve to undefined with nothing logged. The bootstrap already\n * holds the one true copy.\n */\nlet deserialize: (stream: ReadableStream) => Promise<unknown> = () => {\n throw new Error('No Flight decoder installed. createViteRscApp() sets one up.')\n}\n\nlet encode: (args: unknown[]) => Promise<string | FormData> = () => {\n throw new Error('No Flight encoder installed. createViteRscApp() sets one up.')\n}\n\nlet asAction: (id: string, args: unknown[]) => Promise<unknown> = () => {\n throw new Error('No action transport installed. createViteRscApp() sets one up.')\n}\n\nexport function setQueryCodec(codec: {\n deserialize: (stream: ReadableStream) => Promise<unknown>\n encode: (args: unknown[]) => Promise<string | FormData>\n asAction: (id: string, args: unknown[]) => Promise<unknown>\n}): void {\n deserialize = codec.deserialize\n encode = codec.encode\n asAction = codec.asAction\n}\n"]}
1
+ {"version":3,"file":"queryClient.js","sourceRoot":"","sources":["../../src/js/queryClient.ts"],"names":[],"mappings":"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","sourcesContent":["// The browser half of `query()`: send a read as a GET.\n//\n// That is the whole of it. There is no cache here, no batching and no\n// deduplication, because TanStack Query and SWR already do those and do them\n// better — this owns the transport and they own everything above it.\n//\n// The awkward fact it is built around: a server function's id is not readable\n// from the client. React keeps it in a module-private WeakMap and exposes no\n// getter, so there is no `ref.$$id` to build a url from. What the client *can*\n// do is call the reference and be handed the id by React itself — the stub's\n// whole body is `callServer(id, args)`, and this package already owns\n// `callServer`.\n//\n// So a read opens a one-shot slot and calls the reference; the transport claims\n// the slot and sends a GET instead of posting. The slot is also what says this\n// is a read at all: calling `getListings(kind)` directly is still a POST, and\n// `fetchQuery(getListings, [kind])` is the same call as a GET. Nothing has to\n// know a list of query ids, on either side of the build.\n\n/** Where reads are answered. Must match HEADER.queryPath on the server. */\nconst QUERY_PATH = '/_rsc/query'\n\n/**\n * Sent on every read, and required by the endpoint.\n *\n * Not decoration. A GET with no unusual header is a *simple* request, so any\n * page anywhere can trigger one with `<img src=\"…/_rsc/query?…\">` and it goes\n * out with the visitor's cookies — CORS stops the attacker reading the answer,\n * but the read still runs. This header is not CORS-safelisted, so the browser\n * preflights it, and nothing here answers a preflight.\n *\n * That restores exactly the protection a POST had: a `POST` carrying\n * `X-RSC-Action` is non-simple for the same reason.\n */\nconst QUERY_HEADER = 'X-RSC-Query'\n\n/**\n * How long a url may get before the read goes as a POST instead.\n *\n * Conservative. Proxies and CDNs start refusing somewhere between 8k and 16k,\n * and the failure is a 414 from a machine that is not ours.\n */\nconst MAX_URL = 6_000\n\ntype Reader = (...args: unknown[]) => Promise<unknown>\n\n/**\n * The slot a read opens before calling the reference.\n *\n * One-shot and synchronous: React's stub calls `callServer` in its own body\n * with nothing awaited in between, so exactly one transport call can claim it.\n */\nlet slot: { claimed: boolean; promise: Promise<unknown> | null } | null = null\n\n/**\n * Claim the open slot, if a read opened one.\n *\n * Called from the app's `callServer` before it posts. Returns null when this is\n * an ordinary action, which is every call that did not come through\n * `fetchQuery`.\n */\nexport function claimRead(id: string, args: unknown[]): Promise<unknown> | null {\n if (!slot || slot.claimed) return null\n\n slot.claimed = true\n slot.promise = send(id, args)\n\n return slot.promise\n}\n\n/**\n * Read a query, over GET.\n *\n * Hand this to a cache library and let it decide everything else:\n *\n * queryFn: () => fetchQuery(getListings, [kind])\n * useSWR(['listings', kind], () => fetchQuery(getListings, [kind]))\n *\n * Every call goes to the server, which is what a fetcher needs — staleness,\n * revalidation, retries, polling and deduplication all belong to the library\n * holding the answer, not to the thing that fetches it.\n */\nexport function fetchQuery<Data>(\n reference: (...args: never[]) => Promise<Data>,\n args: unknown[] = [],\n): Promise<Data> {\n if (typeof window === 'undefined') {\n // React's SSR runtime refuses a server-function call during the initial\n // render, and reaching a query's id means calling its reference — so this\n // cannot work here. Raised with the fix in it rather than left to surface\n // as React's more general message about fetch waterfalls.\n //\n // A cache library runs its fetcher in an effect, so the server render never\n // reaches this; a server component that wants the data during render should\n // await the query directly, which needs none of this.\n throw new Error(\n 'A query was read during server rendering. Read it in a server component with await, or through a cache library, whose fetcher runs after hydration.',\n )\n }\n\n const opened = { claimed: false, promise: null as Promise<unknown> | null }\n\n slot = opened\n\n try {\n // React's stub reaches callServer synchronously, so the slot is claimed by\n // the time this returns. The stub's own promise is discarded: the one the\n // transport made is the one that settles with the answer.\n ;(reference as unknown as Reader)(...(args as unknown[]))\n } finally {\n // Cleared here and nowhere else, so a reference that throws on the way in\n // does not leave the slot open for the next read to claim by accident.\n // Nothing is returned from this block: a `return` in `finally` discards\n // whatever the `try` was throwing, which would turn a reference that threw\n // synchronously into the misleading bound-reference error below.\n slot = null\n }\n\n if (!opened.claimed || !opened.promise) {\n // The only way here is a reference whose call path is asynchronous, which\n // today means one that was `.bind()`-ed. Loud, because the quiet version is\n // a read that silently went out as a POST.\n throw new Error(\n 'A query was called through a bound reference. Pass the exported query itself, unbound.',\n )\n }\n\n return opened.promise as Promise<Data>\n}\n\nasync function send(id: string, args: unknown[]): Promise<unknown> {\n const encoded = await encode(args)\n\n // encodeReply answers with FormData the moment an argument holds a File, and\n // a url cannot carry one. Rather than refuse a call that would have worked as\n // an action, the read falls back to a POST: it stops being cacheable, which\n // is the only thing it loses.\n if (typeof encoded !== 'string') return await post(id, args, 'an argument contained a File')\n\n const url = `${QUERY_PATH}?id=${encodeURIComponent(id)}&args=${encodeURIComponent(encoded)}`\n\n if (url.length > MAX_URL) return await post(id, args, 'the arguments are too large for a url')\n\n const res = await fetch(url, {\n headers: {\n [QUERY_HEADER]: '1',\n Accept: 'text/x-component',\n // Where the read came from. Same reason an action sends it: a host that\n // guards by route needs to know which page is asking.\n 'X-RSC-Referer': window.location.pathname + window.location.search,\n },\n })\n\n if (!res.ok || !res.body) {\n throw await failureFrom(res)\n }\n\n return await deserialize(res.body)\n}\n\n/**\n * What a refused read rejects with.\n *\n * A cache library reports failure by rejection, so this has to be an Error —\n * and one carrying the message the server meant, rather than a status code. A\n * validation refusal keeps its fields on `errors`, where a form can read them.\n */\nasync function failureFrom(res: Response): Promise<Error> {\n const body = await res.text().catch(() => '')\n\n try {\n const parsed = JSON.parse(body) as { message?: string; errors?: Record<string, string[]> }\n const error = new Error(parsed.message || `Query failed: ${res.status}`)\n\n if (parsed.errors) (error as Error & { errors?: unknown }).errors = parsed.errors\n\n return error\n } catch {\n return new Error(body || `Query failed: ${res.status}`)\n }\n}\n\n/**\n * The same read, as an action.\n *\n * Reached only when the arguments cannot ride in a url. Warned about in\n * development rather than silently tolerated, because the read still works and\n * the thing that changed — it is no longer a GET, so nothing can cache it — is\n * otherwise invisible.\n */\nasync function post(id: string, args: unknown[], why: string): Promise<unknown> {\n if (import.meta.env?.DEV) {\n console.warn(`[rsc-kit] A query was sent as a POST because ${why}. It will not be cached.`)\n }\n\n return await asAction(id, args)\n}\n\n/**\n * The Flight codec and the action transport, installed by the app bootstrap.\n *\n * Injected rather than imported, and not for testing: this module is reached\n * from client components, so importing the browser runtime here would pull a\n * second copy of it into that graph — two client-reference registries, where\n * components resolve to undefined with nothing logged. The bootstrap already\n * holds the one true copy.\n */\nlet deserialize: (stream: ReadableStream) => Promise<unknown> = () => {\n throw new Error('No Flight decoder installed. createViteRscApp() sets one up.')\n}\n\nlet encode: (args: unknown[]) => Promise<string | FormData> = () => {\n throw new Error('No Flight encoder installed. createViteRscApp() sets one up.')\n}\n\nlet asAction: (id: string, args: unknown[]) => Promise<unknown> = () => {\n throw new Error('No action transport installed. createViteRscApp() sets one up.')\n}\n\nexport function setQueryCodec(codec: {\n deserialize: (stream: ReadableStream) => Promise<unknown>\n encode: (args: unknown[]) => Promise<string | FormData>\n asAction: (id: string, args: unknown[]) => Promise<unknown>\n}): void {\n deserialize = codec.deserialize\n encode = codec.encode\n asAction = codec.asAction\n}\n"]}
@@ -7,6 +7,14 @@ export interface ManifestRoute {
7
7
  segments: RouteSegment[];
8
8
  layouts: string[];
9
9
  loadings: string[];
10
+ /**
11
+ * `error.tsx` files above this route, outermost first.
12
+ *
13
+ * The nearest one to a failure catches it, the same way the nearest
14
+ * `loading.tsx` is the fallback. Optional: a manifest from a build before
15
+ * error boundaries existed has none.
16
+ */
17
+ errors?: string[];
10
18
  /**
11
19
  * `middleware.ts` files above this route, outermost first.
12
20
  *
@@ -68,6 +76,29 @@ export interface ManifestIntercept {
68
76
  /** (.) same level, (..) one up, (...) from the root. */
69
77
  marker: string;
70
78
  }
79
+ /**
80
+ * A `route.ts` — an api endpoint rather than a page.
81
+ *
82
+ * Separate from `routes` because it is matched before them and answered
83
+ * without rendering anything: no layouts, no payload, no client. A url cannot
84
+ * be both, and the build refuses one that is.
85
+ */
86
+ export interface ManifestApiRoute {
87
+ /** The module name, as the engine's registry keys it. */
88
+ name: string;
89
+ segments: RouteSegment[];
90
+ /** Which methods the file exports, so a 405 can name the rest. */
91
+ methods: string[];
92
+ /**
93
+ * `middleware.ts` files above this route, outermost first.
94
+ *
95
+ * The same chain a page in that directory runs. A route.ts sits among the
96
+ * pages it belongs with, so a guard on the directory covers it too —
97
+ * anything else would mean adding an endpoint under a guarded path silently
98
+ * opened a hole in it.
99
+ */
100
+ middleware: string[];
101
+ }
71
102
  export interface RouteManifest {
72
103
  version: number;
73
104
  build: {
@@ -77,4 +108,6 @@ export interface RouteManifest {
77
108
  };
78
109
  routes: ManifestRoute[];
79
110
  intercepts: ManifestIntercept[];
111
+ /** Optional: a manifest from a build before api routes existed has none. */
112
+ apis?: ManifestApiRoute[];
80
113
  }
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,uCAAuC;AACvC,EAAE;AACF,6EAA6E;AAC7E,+EAA+E;AAC/E,oBAAoB","sourcesContent":["// The shape of routes.json — what the build discovered, for a host to read.\n//\n// The build already walks app/ to generate its entries, and every host needs\n// the same facts: which url a component answers, what layouts wrap it, which\n// slots and sections belong to it. Laravel used to scan the tree a second time\n// to work that out; a JS host would have had to write a third walk. This is the\n// one answer, and these are its types.\n//\n// Urls are segments rather than a pattern string, because the pattern is the\n// host's dialect: Laravel writes {slug}, Hono writes :slug, and neither is the\n// build's business.\n\nexport interface RouteSegment {\n type: 'static' | 'param' | 'catchAll'\n value: string\n}\n\nexport interface ManifestRoute {\n component: string\n segments: RouteSegment[]\n layouts: string[]\n loadings: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * Run before anything at or below them renders, on every path. A check is\n * not UI, and making it a layout meant the client could decline it: layouts\n * are skipped on a partial navigation, and what gets skipped is named in a\n * header nothing can verify.\n */\n middleware: string[]\n slots: Record<string, string>\n sections: string[]\n /**\n * The host's route-config file beside this page, if it named one, and the\n * ancestor ones that also apply — outermost first, this page's excluded.\n *\n * Relative to the project root: an absolute path is true only on the machine\n * that produced it, and building in a container is ordinary.\n */\n config: string | null\n ancestorConfigs: string[]\n /**\n * Host middleware names for this route, outermost first.\n *\n * Declared in a route.ts beside or above the page. The engine does not know\n * what they mean — they are the host's own vocabulary — it only runs them\n * past the host before anything at or below this route renders.\n *\n * Empty on a route that named none, and on every route in an app that never\n * wrote a route.ts, which is why this needs no flag.\n *\n * Optional because registration boots from the PREVIOUS build's manifest: a\n * shape change takes two builds to settle, and a required field would make\n * the first of those a hard failure rather than a route with no guards.\n */\n hostMiddleware?: string[]\n /**\n * Whether the page exports generateStaticParams.\n *\n * Recorded here so a host can plan a build — which routes to ask for urls,\n * which to leave on demand — without loading the server bundle first. The\n * function itself is reached through the bundle's getStaticParams(), because\n * only the bundle can run it.\n */\n staticParams: boolean\n /**\n * Whether this route ships the client runtime.\n *\n * False renders to HTML and stops: no bootstrap, so no React, no Flight\n * client, no router. A client component on such a route is inert markup — a\n * button that does nothing — so the build refuses the combination rather\n * than shipping it.\n */\n clientJs: boolean\n}\n\nexport interface ManifestIntercept {\n component: string\n slot: string\n segments: RouteSegment[]\n /** (.) same level, (..) one up, (...) from the root. */\n marker: string\n}\n\nexport interface RouteManifest {\n version: number\n build: { output: string; exportPath: string; payloadName: string }\n routes: ManifestRoute[]\n intercepts: ManifestIntercept[]\n}\n"]}
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,uCAAuC;AACvC,EAAE;AACF,6EAA6E;AAC7E,+EAA+E;AAC/E,oBAAoB","sourcesContent":["// The shape of routes.json — what the build discovered, for a host to read.\n//\n// The build already walks app/ to generate its entries, and every host needs\n// the same facts: which url a component answers, what layouts wrap it, which\n// slots and sections belong to it. Laravel used to scan the tree a second time\n// to work that out; a JS host would have had to write a third walk. This is the\n// one answer, and these are its types.\n//\n// Urls are segments rather than a pattern string, because the pattern is the\n// host's dialect: Laravel writes {slug}, Hono writes :slug, and neither is the\n// build's business.\n\nexport interface RouteSegment {\n type: 'static' | 'param' | 'catchAll'\n value: string\n}\n\nexport interface ManifestRoute {\n component: string\n segments: RouteSegment[]\n layouts: string[]\n loadings: string[]\n /**\n * `error.tsx` files above this route, outermost first.\n *\n * The nearest one to a failure catches it, the same way the nearest\n * `loading.tsx` is the fallback. Optional: a manifest from a build before\n * error boundaries existed has none.\n */\n errors?: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * Run before anything at or below them renders, on every path. A check is\n * not UI, and making it a layout meant the client could decline it: layouts\n * are skipped on a partial navigation, and what gets skipped is named in a\n * header nothing can verify.\n */\n middleware: string[]\n slots: Record<string, string>\n sections: string[]\n /**\n * The host's route-config file beside this page, if it named one, and the\n * ancestor ones that also apply — outermost first, this page's excluded.\n *\n * Relative to the project root: an absolute path is true only on the machine\n * that produced it, and building in a container is ordinary.\n */\n config: string | null\n ancestorConfigs: string[]\n /**\n * Host middleware names for this route, outermost first.\n *\n * Declared in a route.ts beside or above the page. The engine does not know\n * what they mean — they are the host's own vocabulary — it only runs them\n * past the host before anything at or below this route renders.\n *\n * Empty on a route that named none, and on every route in an app that never\n * wrote a route.ts, which is why this needs no flag.\n *\n * Optional because registration boots from the PREVIOUS build's manifest: a\n * shape change takes two builds to settle, and a required field would make\n * the first of those a hard failure rather than a route with no guards.\n */\n hostMiddleware?: string[]\n /**\n * Whether the page exports generateStaticParams.\n *\n * Recorded here so a host can plan a build — which routes to ask for urls,\n * which to leave on demand — without loading the server bundle first. The\n * function itself is reached through the bundle's getStaticParams(), because\n * only the bundle can run it.\n */\n staticParams: boolean\n /**\n * Whether this route ships the client runtime.\n *\n * False renders to HTML and stops: no bootstrap, so no React, no Flight\n * client, no router. A client component on such a route is inert markup — a\n * button that does nothing — so the build refuses the combination rather\n * than shipping it.\n */\n clientJs: boolean\n}\n\nexport interface ManifestIntercept {\n component: string\n slot: string\n segments: RouteSegment[]\n /** (.) same level, (..) one up, (...) from the root. */\n marker: string\n}\n\n/**\n * A `route.ts` — an api endpoint rather than a page.\n *\n * Separate from `routes` because it is matched before them and answered\n * without rendering anything: no layouts, no payload, no client. A url cannot\n * be both, and the build refuses one that is.\n */\nexport interface ManifestApiRoute {\n /** The module name, as the engine's registry keys it. */\n name: string\n segments: RouteSegment[]\n /** Which methods the file exports, so a 405 can name the rest. */\n methods: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * The same chain a page in that directory runs. A route.ts sits among the\n * pages it belongs with, so a guard on the directory covers it too —\n * anything else would mean adding an endpoint under a guarded path silently\n * opened a hole in it.\n */\n middleware: string[]\n}\n\nexport interface RouteManifest {\n version: number\n build: { output: string; exportPath: string; payloadName: string }\n routes: ManifestRoute[]\n intercepts: ManifestIntercept[]\n /** Optional: a manifest from a build before api routes existed has none. */\n apis?: ManifestApiRoute[]\n}\n"]}
@@ -0,0 +1,69 @@
1
+ export interface IconDescriptor {
2
+ url: string | URL;
3
+ type?: string;
4
+ sizes?: string;
5
+ color?: string;
6
+ rel?: string;
7
+ media?: string;
8
+ fetchPriority?: 'high' | 'low' | 'auto';
9
+ }
10
+ export type IconURL = string | URL;
11
+ export interface Icons {
12
+ icon?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[];
13
+ apple?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[];
14
+ shortcut?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[];
15
+ other?: IconDescriptor | IconDescriptor[];
16
+ }
17
+ /** A layout's title, wrapping the titles of the pages beneath it. */
18
+ export interface TitleTemplate {
19
+ /** `%s` stands in for the page's own title. */
20
+ template?: string;
21
+ /** Used by a page that exports no title of its own. */
22
+ default?: string;
23
+ }
24
+ export interface Metadata {
25
+ /** A string on a page; a template on a layout, applied to the pages below it. */
26
+ title?: string | TitleTemplate;
27
+ description?: string;
28
+ keywords?: string | string[];
29
+ author?: string;
30
+ robots?: string;
31
+ icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null;
32
+ 'og:title'?: string;
33
+ 'og:description'?: string;
34
+ 'og:image'?: string;
35
+ 'og:url'?: string;
36
+ 'og:type'?: string;
37
+ 'og:site_name'?: string;
38
+ 'twitter:card'?: string;
39
+ 'twitter:title'?: string;
40
+ 'twitter:description'?: string;
41
+ 'twitter:image'?: string;
42
+ 'twitter:site'?: string;
43
+ /**
44
+ * Any other meta tag, by name.
45
+ *
46
+ * other: { 'fb:app_id': '123', 'theme-color': '#000' }
47
+ *
48
+ * Here rather than alongside the named keys, and that is what makes the rest
49
+ * of this interface worth annotating. An index signature on the interface
50
+ * itself made every key legal — so `titel` was accepted in silence, and an
51
+ * editor offered no completions at all, because with any identifier valid
52
+ * TypeScript reads an unfinished key as a shorthand property and goes looking
53
+ * for a variable by that name.
54
+ */
55
+ other?: Record<string, string | string[] | null | undefined>;
56
+ }
57
+ /**
58
+ * Metadata that depends on the request.
59
+ *
60
+ * Receives the same awaitables a page does, so one shape is learned rather
61
+ * than two:
62
+ *
63
+ * export const generateMetadata: GenerateMetadata<{ slug: string }> =
64
+ * async ({ params }) => ({ title: (await params).slug })
65
+ */
66
+ export type GenerateMetadata<P = Record<string, string>> = (args: {
67
+ params: Promise<P>;
68
+ searchParams: Promise<URLSearchParams>;
69
+ }) => Metadata | Promise<Metadata>;
@@ -0,0 +1,17 @@
1
+ // What a page says about itself, as importable types.
2
+ //
3
+ // import type { Metadata } from '@rsc-kit/core/metadata'
4
+ //
5
+ // export const metadata: Metadata = { title: 'Orders' }
6
+ //
7
+ // Imported rather than ambient, and that is the whole point of the move. An
8
+ // ambient declaration has to be COPIED into the project, which means it is not
9
+ // there until the build has run once — so a freshly cloned app reports "Cannot
10
+ // find name 'Metadata'" on every page until someone runs the dev server. An
11
+ // import resolves from node_modules the moment dependencies are installed.
12
+ //
13
+ // The ambient names still work. `.rsc-kit/rsc-types.d.ts` now aliases these
14
+ // rather than restating them, so there is one definition and two ways to reach
15
+ // it.
16
+ export {};
17
+ //# sourceMappingURL=metadata.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.js","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,EAAE;AACF,6DAA6D;AAC7D,EAAE;AACF,4DAA4D;AAC5D,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,MAAM","sourcesContent":["// What a page says about itself, as importable types.\n//\n// import type { Metadata } from '@rsc-kit/core/metadata'\n//\n// export const metadata: Metadata = { title: 'Orders' }\n//\n// Imported rather than ambient, and that is the whole point of the move. An\n// ambient declaration has to be COPIED into the project, which means it is not\n// there until the build has run once — so a freshly cloned app reports \"Cannot\n// find name 'Metadata'\" on every page until someone runs the dev server. An\n// import resolves from node_modules the moment dependencies are installed.\n//\n// The ambient names still work. `.rsc-kit/rsc-types.d.ts` now aliases these\n// rather than restating them, so there is one definition and two ways to reach\n// it.\n\nexport interface IconDescriptor {\n url: string | URL\n type?: string\n sizes?: string\n color?: string\n rel?: string\n media?: string\n fetchPriority?: 'high' | 'low' | 'auto'\n}\n\nexport type IconURL = string | URL\n\nexport interface Icons {\n icon?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n apple?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n shortcut?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n other?: IconDescriptor | IconDescriptor[]\n}\n\n/** A layout's title, wrapping the titles of the pages beneath it. */\nexport interface TitleTemplate {\n /** `%s` stands in for the page's own title. */\n template?: string\n /** Used by a page that exports no title of its own. */\n default?: string\n}\n\nexport interface Metadata {\n /** A string on a page; a template on a layout, applied to the pages below it. */\n title?: string | TitleTemplate\n description?: string\n keywords?: string | string[]\n author?: string\n robots?: string\n icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null\n 'og:title'?: string\n 'og:description'?: string\n 'og:image'?: string\n 'og:url'?: string\n 'og:type'?: string\n 'og:site_name'?: string\n 'twitter:card'?: string\n 'twitter:title'?: string\n 'twitter:description'?: string\n 'twitter:image'?: string\n 'twitter:site'?: string\n\n /**\n * Any other meta tag, by name.\n *\n * other: { 'fb:app_id': '123', 'theme-color': '#000' }\n *\n * Here rather than alongside the named keys, and that is what makes the rest\n * of this interface worth annotating. An index signature on the interface\n * itself made every key legal — so `titel` was accepted in silence, and an\n * editor offered no completions at all, because with any identifier valid\n * TypeScript reads an unfinished key as a shorthand property and goes looking\n * for a variable by that name.\n */\n other?: Record<string, string | string[] | null | undefined>\n}\n\n/**\n * Metadata that depends on the request.\n *\n * Receives the same awaitables a page does, so one shape is learned rather\n * than two:\n *\n * export const generateMetadata: GenerateMetadata<{ slug: string }> =\n * async ({ params }) => ({ title: (await params).slug })\n */\nexport type GenerateMetadata<P = Record<string, string>> = (args: {\n params: Promise<P>\n searchParams: Promise<URLSearchParams>\n}) => Metadata | Promise<Metadata>\n"]}
@@ -0,0 +1,24 @@
1
+ /** Thrown to stop rendering a page whose subject does not exist. */
2
+ export declare class NotFoundSignal extends Error {
3
+ constructor(message?: string);
4
+ }
5
+ /** Whether this is a missing page, whichever copy of the class threw it. */
6
+ export declare function isNotFoundSignal(error: unknown): error is NotFoundSignal;
7
+ /** The digest for a missing page, or null for any other error. */
8
+ export declare function notFoundDigest(error: unknown): string | null;
9
+ /** Whether a digest describes a missing page. */
10
+ export declare function isNotFoundDigest(digest: unknown): boolean;
11
+ /**
12
+ * Answer this url with the not-found page.
13
+ *
14
+ * Never returns: it throws, which stops the component that called it. Do not
15
+ * wrap a call in `try`/`catch` without rethrowing what you do not recognise —
16
+ * swallowing this turns a missing page into a blank region.
17
+ *
18
+ * Called above every Suspense boundary, the response is a real 404 carrying
19
+ * not-found.tsx, which is what a crawler and a cache need to see. Called
20
+ * inside one, the shell is already on the wire, so the boundary shows its
21
+ * fallback instead and the status stays 200 — put the lookup above the
22
+ * boundary when the status matters.
23
+ */
24
+ export declare function notFound(): never;
@@ -0,0 +1,107 @@
1
+ // Saying a url does not name a page, from inside a render.
2
+ //
3
+ // import { notFound } from '@rsc-kit/core/not-found'
4
+ //
5
+ // export default async function PostPage({ params }) {
6
+ // const post = await findPost((await params).slug)
7
+ // if (!post) notFound()
8
+ // ...
9
+ // }
10
+ //
11
+ // Until this existed, "no such page" could only be decided by the router,
12
+ // before anything rendered. But most of the time it is not knowable then: the
13
+ // route matched, the url is well-formed, and whether the thing exists is a
14
+ // question only the page can ask. The alternative was an error boundary and a
15
+ // 500, which tells a crawler the page is broken rather than absent.
16
+ //
17
+ // Delivery follows the same two windows a redirect has, and for the same
18
+ // reason — headers flush when the shell resolves:
19
+ //
20
+ // Before the shell — nothing is written yet, so the host answers 404 and
21
+ // renders not-found.tsx. This is where a params check lands, and where a
22
+ // lookup at the top of a page lands.
23
+ //
24
+ // After the shell — the status line is gone. React carries an error digest
25
+ // for every server error, so the mark travels there and the boundary around
26
+ // it shows its fallback rather than a stack.
27
+ /**
28
+ * The mark that says an error is a missing page.
29
+ *
30
+ * A property rather than `instanceof`, for the reason this project keeps
31
+ * meeting: the app's components are bundled apart from the engine that renders
32
+ * them, so each side evaluates its own copy of this module and its own copy of
33
+ * the class. `instanceof` is simply false across that seam — the page would
34
+ * render as an unhandled error, and the only clue would be a 500 where a 404
35
+ * was meant.
36
+ */
37
+ const MARK = Symbol.for('@rsc-kit/core.not-found-signal');
38
+ /**
39
+ * Where a render records that it wants the not-found page.
40
+ *
41
+ * The same scope a redirect records in, reached through the well-known symbol
42
+ * rather than by importing it: this module is evaluated in the app bundle, the
43
+ * engine and the browser, and only one of those should be pulling in the
44
+ * redirect machinery.
45
+ *
46
+ * Recording is necessary because the throw alone does not survive. React
47
+ * catches what a component threw and re-raises its own error, whose message is
48
+ * stripped in production — so the host testing the caught value would work in
49
+ * development and answer 500 in production, which is the worst of the two
50
+ * possible bugs.
51
+ */
52
+ const SCOPE = Symbol.for('@rsc-kit/core.redirect-scope');
53
+ function record() {
54
+ const scope = globalThis[SCOPE];
55
+ const store = scope?.getStore();
56
+ if (store)
57
+ store.notFound = true;
58
+ }
59
+ /** Thrown to stop rendering a page whose subject does not exist. */
60
+ export class NotFoundSignal extends Error {
61
+ constructor(message = 'Not found') {
62
+ super(message);
63
+ this.name = 'NotFoundSignal';
64
+ this[MARK] = true;
65
+ // In the constructor rather than in notFound(), so every way of raising
66
+ // one is recorded — a params schema refusing is not routed through the
67
+ // public function and must answer the same way.
68
+ record();
69
+ }
70
+ }
71
+ /** Whether this is a missing page, whichever copy of the class threw it. */
72
+ export function isNotFoundSignal(error) {
73
+ return (typeof error === 'object' && error !== null && error[MARK] === true);
74
+ }
75
+ /**
76
+ * The prefix that carries a missing page inside React's error digest.
77
+ *
78
+ * React replaces a server error's message with an opaque digest in production,
79
+ * and the digest is the one part it is guaranteed to transmit. Kept distinct
80
+ * from the redirect prefix so neither is ever read as the other.
81
+ */
82
+ const PREFIX = 'RSC_NOT_FOUND';
83
+ /** The digest for a missing page, or null for any other error. */
84
+ export function notFoundDigest(error) {
85
+ return isNotFoundSignal(error) ? PREFIX : null;
86
+ }
87
+ /** Whether a digest describes a missing page. */
88
+ export function isNotFoundDigest(digest) {
89
+ return digest === PREFIX;
90
+ }
91
+ /**
92
+ * Answer this url with the not-found page.
93
+ *
94
+ * Never returns: it throws, which stops the component that called it. Do not
95
+ * wrap a call in `try`/`catch` without rethrowing what you do not recognise —
96
+ * swallowing this turns a missing page into a blank region.
97
+ *
98
+ * Called above every Suspense boundary, the response is a real 404 carrying
99
+ * not-found.tsx, which is what a crawler and a cache need to see. Called
100
+ * inside one, the shell is already on the wire, so the boundary shows its
101
+ * fallback instead and the status stays 200 — put the lookup above the
102
+ * boundary when the status matters.
103
+ */
104
+ export function notFound() {
105
+ throw new NotFoundSignal();
106
+ }
107
+ //# sourceMappingURL=notFound.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notFound.js","sourceRoot":"","sources":["../src/notFound.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,EAAE;AACF,uDAAuD;AACvD,EAAE;AACF,yDAAyD;AACzD,uDAAuD;AACvD,4BAA4B;AAC5B,UAAU;AACV,MAAM;AACN,EAAE;AACF,0EAA0E;AAC1E,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,oEAAoE;AACpE,EAAE;AACF,yEAAyE;AACzE,kDAAkD;AAClD,EAAE;AACF,2EAA2E;AAC3E,2EAA2E;AAC3E,uCAAuC;AACvC,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,+CAA+C;AAE/C;;;;;;;;;GASG;AACH,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAA;AAEzD;;;;;;;;;;;;;GAaG;AACH,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,8BAA8B,CAAC,CAAA;AAExD,SAAS,MAAM;IACb,MAAM,KAAK,GAAI,UAAsC,CAAC,KAAK,CAE9C,CAAA;IAEb,MAAM,KAAK,GAAG,KAAK,EAAE,QAAQ,EAAE,CAAA;IAE/B,IAAI,KAAK;QAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAA;AAClC,CAAC;AAED,oEAAoE;AACpE,MAAM,OAAO,cAAe,SAAQ,KAAK;IACvC,YAAY,OAAO,GAAG,WAAW;QAC/B,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAC3B;QAAC,IAA2C,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;QAE1D,wEAAwE;QACxE,uEAAuE;QACvE,gDAAgD;QAChD,MAAM,EAAE,CAAA;IACV,CAAC;CACF;AAED,4EAA4E;AAC5E,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAK,KAAiC,CAAC,IAAI,CAAC,KAAK,IAAI,CACjG,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,eAAe,CAAA;AAE9B,kEAAkE;AAClE,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAA;AAChD,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,gBAAgB,CAAC,MAAe;IAC9C,OAAO,MAAM,KAAK,MAAM,CAAA;AAC1B,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,QAAQ;IACtB,MAAM,IAAI,cAAc,EAAE,CAAA;AAC5B,CAAC","sourcesContent":["// Saying a url does not name a page, from inside a render.\n//\n// import { notFound } from '@rsc-kit/core/not-found'\n//\n// export default async function PostPage({ params }) {\n// const post = await findPost((await params).slug)\n// if (!post) notFound()\n// ...\n// }\n//\n// Until this existed, \"no such page\" could only be decided by the router,\n// before anything rendered. But most of the time it is not knowable then: the\n// route matched, the url is well-formed, and whether the thing exists is a\n// question only the page can ask. The alternative was an error boundary and a\n// 500, which tells a crawler the page is broken rather than absent.\n//\n// Delivery follows the same two windows a redirect has, and for the same\n// reason — headers flush when the shell resolves:\n//\n// Before the shell — nothing is written yet, so the host answers 404 and\n// renders not-found.tsx. This is where a params check lands, and where a\n// lookup at the top of a page lands.\n//\n// After the shell — the status line is gone. React carries an error digest\n// for every server error, so the mark travels there and the boundary around\n// it shows its fallback rather than a stack.\n\n/**\n * The mark that says an error is a missing page.\n *\n * A property rather than `instanceof`, for the reason this project keeps\n * meeting: the app's components are bundled apart from the engine that renders\n * them, so each side evaluates its own copy of this module and its own copy of\n * the class. `instanceof` is simply false across that seam — the page would\n * render as an unhandled error, and the only clue would be a 500 where a 404\n * was meant.\n */\nconst MARK = Symbol.for('@rsc-kit/core.not-found-signal')\n\n/**\n * Where a render records that it wants the not-found page.\n *\n * The same scope a redirect records in, reached through the well-known symbol\n * rather than by importing it: this module is evaluated in the app bundle, the\n * engine and the browser, and only one of those should be pulling in the\n * redirect machinery.\n *\n * Recording is necessary because the throw alone does not survive. React\n * catches what a component threw and re-raises its own error, whose message is\n * stripped in production — so the host testing the caught value would work in\n * development and answer 500 in production, which is the worst of the two\n * possible bugs.\n */\nconst SCOPE = Symbol.for('@rsc-kit/core.redirect-scope')\n\nfunction record(): void {\n const scope = (globalThis as Record<symbol, unknown>)[SCOPE] as\n | { getStore(): { notFound?: boolean } | undefined }\n | undefined\n\n const store = scope?.getStore()\n\n if (store) store.notFound = true\n}\n\n/** Thrown to stop rendering a page whose subject does not exist. */\nexport class NotFoundSignal extends Error {\n constructor(message = 'Not found') {\n super(message)\n this.name = 'NotFoundSignal'\n ;(this as unknown as Record<symbol, boolean>)[MARK] = true\n\n // In the constructor rather than in notFound(), so every way of raising\n // one is recorded — a params schema refusing is not routed through the\n // public function and must answer the same way.\n record()\n }\n}\n\n/** Whether this is a missing page, whichever copy of the class threw it. */\nexport function isNotFoundSignal(error: unknown): error is NotFoundSignal {\n return (\n typeof error === 'object' && error !== null && (error as Record<symbol, unknown>)[MARK] === true\n )\n}\n\n/**\n * The prefix that carries a missing page inside React's error digest.\n *\n * React replaces a server error's message with an opaque digest in production,\n * and the digest is the one part it is guaranteed to transmit. Kept distinct\n * from the redirect prefix so neither is ever read as the other.\n */\nconst PREFIX = 'RSC_NOT_FOUND'\n\n/** The digest for a missing page, or null for any other error. */\nexport function notFoundDigest(error: unknown): string | null {\n return isNotFoundSignal(error) ? PREFIX : null\n}\n\n/** Whether a digest describes a missing page. */\nexport function isNotFoundDigest(digest: unknown): boolean {\n return digest === PREFIX\n}\n\n/**\n * Answer this url with the not-found page.\n *\n * Never returns: it throws, which stops the component that called it. Do not\n * wrap a call in `try`/`catch` without rethrowing what you do not recognise —\n * swallowing this turns a missing page into a blank region.\n *\n * Called above every Suspense boundary, the response is a real 404 carrying\n * not-found.tsx, which is what a crawler and a cache need to see. Called\n * inside one, the shell is already on the wire, so the boundary shows its\n * fallback instead and the status stays 200 — put the lookup above the\n * boundary when the status matters.\n */\nexport function notFound(): never {\n throw new NotFoundSignal()\n}\n"]}
@@ -1,6 +1,11 @@
1
1
  import type { ManifestRoute, RouteManifest } from './manifest.js';
2
2
  export interface PrerenderEngine {
3
3
  manifest?(): RouteManifest;
4
+ /**
5
+ * A route.ts, answered. Optional: a bundle built before api routes existed
6
+ * has none, and the build then simply stores no route.
7
+ */
8
+ handleApiRoute?(name: string, request: Request, params: Record<string, string>, allow: string): Promise<Response>;
4
9
  getStaticParams?(component: string): Promise<Record<string, string>[] | null>;
5
10
  handleRscPprShell(component: string, props?: Record<string, unknown>, layouts?: {
6
11
  component: string;
@@ -11,6 +16,14 @@ export interface PrerenderEngine {
11
16
  shellHtml: string;
12
17
  timedOut: boolean;
13
18
  usedDynamicApis: boolean;
19
+ /**
20
+ * The host calls this render made, by name.
21
+ *
22
+ * So the build can say which one kept the page from being frozen rather
23
+ * than only that something did. Optional: an engine built before this
24
+ * existed reports nothing, and the line is printed without a reason.
25
+ */
26
+ dynamicBecause?: string[];
14
27
  error?: string;
15
28
  /**
16
29
  * Where the render stopped, when it stopped — React's own resumable state.
@@ -109,11 +122,15 @@ export interface PrerenderResult {
109
122
  * blocked — nothing could be stored. Fails the build.
110
123
  * error — the render itself failed, or the build refused what it produced.
111
124
  *
112
- * There is no outcome for "rendered per request". A route that cannot be
113
- * stored has its boundary in the wrong place, and the fix is to move it —
114
- * not to declare the problem away, which is how the slow ones get forgotten.
125
+ * dynamic — answered per request, and correctly so. Api routes only.
126
+ *
127
+ * There is no such outcome for a PAGE. A page that cannot be stored has its
128
+ * boundary in the wrong place, and the fix is to move it — not to declare
129
+ * the problem away, which is how the slow ones get forgotten. An api route
130
+ * has no boundary to move: it either depends on the request or it does not,
131
+ * and depending on it is the ordinary case rather than a mistake.
115
132
  */
116
- type: 'frozen' | 'shell' | 'blocked' | 'error';
133
+ type: 'frozen' | 'shell' | 'blocked' | 'error' | 'dynamic';
117
134
  reason: string | null;
118
135
  /**
119
136
  * Something worth knowing that is not a failure.
@@ -158,6 +175,33 @@ export declare function closeDocument(html: string): string;
158
175
  export declare function legend(results: {
159
176
  type: string;
160
177
  }[]): string;
178
+ /**
179
+ * How much JavaScript a route makes the browser download.
180
+ *
181
+ * Gzipped, because that is what crosses the wire — uncompressed bytes are a
182
+ * number nobody is served.
183
+ *
184
+ * A real per-route figure only because client components are chunked per
185
+ * module. Grouped the way plugin-rsc groups them by default, every route in an
186
+ * app loads every client component and this column is one number repeated down
187
+ * the page.
188
+ */
189
+ export declare function clientJsSize(bytes: number): string;
190
+ /**
191
+ * The paragraph a mark cannot hold.
192
+ *
193
+ * "dynamic — called rpc(\"getUser\")" says what happened and not why the build
194
+ * could not simply make the call, which is the first thing someone asks when a
195
+ * page they expected to be static is not. The backend is running; the build is
196
+ * not talking to it.
197
+ *
198
+ * Printed once under the summary rather than beside each route: an app where
199
+ * every page reads from the backend would otherwise repeat the same paragraph
200
+ * forty times.
201
+ */
202
+ export declare function notes(results: {
203
+ reason?: string | null;
204
+ }[]): string;
161
205
  /**
162
206
  * The one-line tally under the legend, in the legend's own words.
163
207
  */
package/dist/prerender.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // shapes and anything that reads them works for either.
17
17
  import { withRedirect } from './redirect.js';
18
18
  import { withCache } from './cache.js';
19
- import { requestWasRead, withRequest } from './request.js';
19
+ import { requestReadBy, requestWasRead, withRequest } from './request.js';
20
20
  import { watchNondeterminism, whileRendering } from './nondeterminism.js';
21
21
  /** What a prerenderer needs from the built bundle, beyond serving a request. */
22
22
  /**
@@ -129,7 +129,7 @@ export function legend(results) {
129
129
  if (has('shell')) {
130
130
  lines.push(' \u25D0 (Partial Prerender) prerendered as static HTML with dynamic server-streamed content');
131
131
  }
132
- if (has('blocked')) {
132
+ if (has('blocked') || has('dynamic')) {
133
133
  lines.push(' \u0192 (Dynamic) server-rendered on demand');
134
134
  }
135
135
  if (has('error')) {
@@ -137,6 +137,47 @@ export function legend(results) {
137
137
  }
138
138
  return lines.join('\n');
139
139
  }
140
+ /**
141
+ * How much JavaScript a route makes the browser download.
142
+ *
143
+ * Gzipped, because that is what crosses the wire — uncompressed bytes are a
144
+ * number nobody is served.
145
+ *
146
+ * A real per-route figure only because client components are chunked per
147
+ * module. Grouped the way plugin-rsc groups them by default, every route in an
148
+ * app loads every client component and this column is one number repeated down
149
+ * the page.
150
+ */
151
+ export function clientJsSize(bytes) {
152
+ if (bytes === 0)
153
+ return 'no js';
154
+ const kb = bytes / 1000;
155
+ return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} kB`;
156
+ }
157
+ /**
158
+ * The paragraph a mark cannot hold.
159
+ *
160
+ * "dynamic — called rpc(\"getUser\")" says what happened and not why the build
161
+ * could not simply make the call, which is the first thing someone asks when a
162
+ * page they expected to be static is not. The backend is running; the build is
163
+ * not talking to it.
164
+ *
165
+ * Printed once under the summary rather than beside each route: an app where
166
+ * every page reads from the backend would otherwise repeat the same paragraph
167
+ * forty times.
168
+ */
169
+ export function notes(results) {
170
+ const said = (text) => results.some((r) => r.reason?.includes(text));
171
+ if (!said('rpc('))
172
+ return '';
173
+ return (' A build has no backend to call. rpc() suspends instead of answering, so a page\n' +
174
+ ' that reads through one ships a shell and finishes for whoever asks — which is\n' +
175
+ ' almost always what you want, since that data is rarely the same for everyone.\n' +
176
+ '\n' +
177
+ ' If a page really should be frozen, move the read out of rpc(). If it really\n' +
178
+ ' should be per visitor, say so with await connection() and the intent is on the\n' +
179
+ ' page rather than inferred from a call that happened to suspend.');
180
+ }
140
181
  /**
141
182
  * The one-line tally under the legend, in the legend's own words.
142
183
  */
@@ -147,8 +188,9 @@ export function summary(results) {
147
188
  parts.push(`${count('frozen')} static`);
148
189
  if (count('shell'))
149
190
  parts.push(`${count('shell')} partial prerender`);
150
- if (count('blocked'))
151
- parts.push(`${count('blocked')} dynamic`);
191
+ if (count('blocked') + count('dynamic')) {
192
+ parts.push(`${count('blocked') + count('dynamic')} dynamic`);
193
+ }
152
194
  if (count('error'))
153
195
  parts.push(`${count('error')} failed`);
154
196
  return parts.join(', ') || 'nothing to store';
@@ -273,6 +315,26 @@ export async function prerender(options) {
273
315
  const props = options.props ? options.props(route, params) : params;
274
316
  const layouts = route.layouts.map((component) => ({ component, props: {} }));
275
317
  const unlistedNow = route.segments.some((seg) => seg.type !== 'static') && !route.staticParams;
318
+ /**
319
+ * Why this route ships a shell rather than a whole page.
320
+ *
321
+ * In the order that answers the question soonest. A named call is the most
322
+ * actionable — you can go and look at it — so it wins over the two
323
+ * structural reasons, either of which may also be true.
324
+ */
325
+ const shellReason = (calls) => {
326
+ if (calls.length)
327
+ return 'dynamic — called ' + calls.join(', ');
328
+ // One shell serving every url the route matches. generateStaticParams is
329
+ // what turns it into a page per url.
330
+ if (unlistedNow)
331
+ return 'one shell for every url — add generateStaticParams to store each';
332
+ // Nothing reached for the request; the data simply took too long. The
333
+ // page is fine, it just finishes per visitor.
334
+ if (shell?.timedOut)
335
+ return 'data took longer than the build budget';
336
+ return null;
337
+ };
276
338
  const said = (type, reason) => ({
277
339
  // A route standing in for many urls reports the pattern. Reporting the
278
340
  // placeholder url instead prints `/posts/_`, which looks like a page.
@@ -295,7 +357,7 @@ export async function prerender(options) {
295
357
  // must not share an answer just because they were built in the same run.
296
358
  // No request, deliberately: a page that reads one is caught below rather
297
359
  // than frozen holding whatever the build machine happened to send.
298
- const [{ shell, redirected, readRequest }, nondeterministic] = await whileRendering(() => withRequest(null, () => withCache(() => withRedirect(async (taken) => {
360
+ const [{ shell, redirected, readRequest, readBy }, nondeterministic] = await whileRendering(() => withRequest(null, () => withCache(() => withRedirect(async (taken) => {
299
361
  try {
300
362
  return {
301
363
  shell: await engine.handleRscPprShell(route.component, props, layouts, route.loadings, route.slots,
@@ -305,6 +367,9 @@ export async function prerender(options) {
305
367
  unlistedNow ? '' : url),
306
368
  redirected: taken(),
307
369
  readRequest: requestWasRead(),
370
+ // What reached for it, so the build can name the call rather than
371
+ // only report that the page is dynamic.
372
+ readBy: requestReadBy(),
308
373
  };
309
374
  }
310
375
  catch (error) {
@@ -315,7 +380,7 @@ export async function prerender(options) {
315
380
  const refused = taken();
316
381
  if (!refused)
317
382
  throw error;
318
- return { shell: null, redirected: refused, readRequest: requestWasRead() };
383
+ return { shell: null, redirected: refused, readRequest: requestWasRead(), readBy: requestReadBy() };
319
384
  }
320
385
  }))));
321
386
  // A page that leaves rather than renders is not a build failure, and it is
@@ -407,7 +472,11 @@ export async function prerender(options) {
407
472
  '<Suspense> above the waiting, and it has a skeleton to store.');
408
473
  }
409
474
  await writeShell(route, url, body, shell.postponed);
410
- return await withRootFallbackChecked(said('shell', null));
475
+ // Why it is a shell rather than a whole page. "◐ /orders" on its own
476
+ // leaves someone reading the build output to guess what did it, which is
477
+ // the question this line exists to answer.
478
+ const calls = [...(shell.dynamicBecause ?? []), ...(readBy ?? [])];
479
+ return await withRootFallbackChecked(said('shell', shellReason(calls)));
411
480
  }
412
481
  // Warned, not refused.
413
482
  //
@@ -429,8 +498,9 @@ export async function prerender(options) {
429
498
  return result;
430
499
  result.warning =
431
500
  `froze ${nondeterministic.join(' and ')} — a stored page keeps whatever that ` +
432
- 'returned at build time. For a value that should differ per visitor, read it ' +
433
- 'through something the build can suspend on, such as an rpc() call.';
501
+ 'returned at build time. If it should differ per visitor, await connection() so ' +
502
+ 'the page renders per request; if only the browser needs it, use(browser()) keeps ' +
503
+ 'it out of the build entirely.';
434
504
  return result;
435
505
  };
436
506
  // Rendered whole, with params that were invented because the route listed