@rsc-kit/core 0.9.0 → 0.11.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 (50) 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 +33 -0
  5. package/dist/apiPrerender.js +200 -0
  6. package/dist/apiPrerender.js.map +1 -0
  7. package/dist/appAssets.d.ts +30 -0
  8. package/dist/appAssets.js +90 -0
  9. package/dist/appAssets.js.map +1 -0
  10. package/dist/buildReport.d.ts +43 -0
  11. package/dist/buildReport.js +40 -0
  12. package/dist/buildReport.js.map +1 -0
  13. package/dist/host.d.ts +12 -0
  14. package/dist/host.js +148 -2
  15. package/dist/host.js.map +1 -1
  16. package/dist/js/RouteErrorBoundary.d.ts +36 -0
  17. package/dist/js/RouteErrorBoundary.js +43 -0
  18. package/dist/js/RouteErrorBoundary.js.map +1 -0
  19. package/dist/js/queryClient.js +21 -1
  20. package/dist/js/queryClient.js.map +1 -1
  21. package/dist/manifest.d.ts +33 -0
  22. package/dist/manifest.js.map +1 -1
  23. package/dist/notFound.d.ts +24 -0
  24. package/dist/notFound.js +107 -0
  25. package/dist/notFound.js.map +1 -0
  26. package/dist/prerender.d.ts +48 -4
  27. package/dist/prerender.js +79 -9
  28. package/dist/prerender.js.map +1 -1
  29. package/dist/query.d.ts +23 -0
  30. package/dist/query.js +45 -3
  31. package/dist/query.js.map +1 -1
  32. package/dist/redirect.d.ts +8 -0
  33. package/dist/redirect.js +11 -1
  34. package/dist/redirect.js.map +1 -1
  35. package/dist/request.d.ts +27 -0
  36. package/dist/request.js +50 -6
  37. package/dist/request.js.map +1 -1
  38. package/dist/routeSchema.d.ts +115 -0
  39. package/dist/routeSchema.js +182 -0
  40. package/dist/routeSchema.js.map +1 -0
  41. package/dist/routing.d.ts +20 -1
  42. package/dist/routing.js +30 -7
  43. package/dist/routing.js.map +1 -1
  44. package/dist/vite.d.ts +34 -1
  45. package/dist/vite.js +837 -33
  46. package/dist/vite.js.map +1 -1
  47. package/dist/webManifest.d.ts +54 -0
  48. package/dist/webManifest.js +81 -0
  49. package/dist/webManifest.js.map +1 -0
  50. package/package.json +17 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appAssets.js","sourceRoot":"","sources":["../src/appAssets.ts"],"names":[],"mappings":"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","sourcesContent":["// Files that describe the app, found by name rather than configured.\n//\n// src/app/\n// favicon.ico <link rel=\"icon\">\n// icon.png <link rel=\"icon\">, and the manifest's icons\n// icon-192.png …as many as you like; sizes read from the name\n// apple-icon.png <link rel=\"apple-touch-icon\">\n// opengraph-image.png <meta property=\"og:image\">\n// twitter-image.png <meta name=\"twitter:image\">\n//\n// The same bargain as every other file in this directory: put it where it\n// belongs and it works, with nothing to register. Next established these names\n// and there is nothing to gain by inventing different ones — an app moving\n// between the two should not have to rename its favicon.\n//\n// They live in `app/` rather than `public/` on purpose. `public/` is \"serve\n// this verbatim\"; these are read as well as served — an icon's size decides\n// what goes in the manifest, and its presence decides what goes in the head.\n\nimport { existsSync, readdirSync } from 'node:fs'\n\n/** One found file, and where it will be served from. */\nexport interface AppAsset {\n /** The file's name in the app directory. */\n file: string\n /** The url it is served at. */\n href: string\n}\n\nexport interface AppAssets {\n favicon: AppAsset | null\n icons: AppAsset[]\n appleIcon: AppAsset | null\n openGraph: AppAsset | null\n twitter: AppAsset | null\n}\n\nconst IMAGE = /\\.(png|svg|jpg|jpeg|webp|gif|avif)$/i\n\n/**\n * Where these are served from.\n *\n * Their own directory rather than the output root, so an app that happens to\n * have a `public/icon.png` as well is not silently overwritten by one of\n * these, in either direction. The exception is the favicon, which browsers ask\n * for at `/favicon.ico` whatever any markup says.\n */\nexport const ASSET_BASE = '/_app'\n\nfunction assetsIn(dir: string, match: (name: string) => boolean): AppAsset[] {\n return readdirSync(dir)\n .filter(match)\n .sort()\n .map((file) => ({ file, href: `${ASSET_BASE}/${file}` }))\n}\n\n/** What the app declared by putting a file where it could be found. */\nexport function appAssets(appDir: string): AppAssets {\n if (!existsSync(appDir)) {\n return { favicon: null, icons: [], appleIcon: null, openGraph: null, twitter: null }\n }\n\n const names = readdirSync(appDir)\n const has = (name: string) => names.includes(name)\n\n return {\n // Served where a browser looks for it regardless of markup: a request for\n // /favicon.ico goes out whether or not a link tag exists, and answering it\n // from somewhere else means answering a 404 to the request that matters.\n favicon: has('favicon.ico') ? { file: 'favicon.ico', href: '/favicon.ico' } : null,\n icons: assetsIn(appDir, (name) => /^icon[-\\w]*/.test(name) && IMAGE.test(name)),\n appleIcon:\n assetsIn(appDir, (name) => /^apple-icon[-\\w]*/.test(name) && IMAGE.test(name))[0] ?? null,\n openGraph:\n assetsIn(appDir, (name) => /^opengraph-image[-\\w]*/.test(name) && IMAGE.test(name))[0] ?? null,\n twitter:\n assetsIn(appDir, (name) => /^twitter-image[-\\w]*/.test(name) && IMAGE.test(name))[0] ?? null,\n }\n}\n\nfunction typeOf(file: string): string {\n const extension = file.slice(file.lastIndexOf('.') + 1).toLowerCase()\n\n return extension === 'svg'\n ? 'image/svg+xml'\n : extension === 'jpg' || extension === 'jpeg'\n ? 'image/jpeg'\n : extension === 'ico'\n ? 'image/x-icon'\n : `image/${extension}`\n}\n\n/** The head tags these produce, as plain data the generated entry turns into elements. */\nexport function headTags(\n assets: AppAssets,\n origin?: string,\n): { tag: 'link' | 'meta'; props: Record<string, string> }[] {\n const tags: { tag: 'link' | 'meta'; props: Record<string, string> }[] = []\n\n if (assets.favicon) {\n tags.push({ tag: 'link', props: { rel: 'icon', href: assets.favicon.href, type: 'image/x-icon' } })\n }\n\n for (const icon of assets.icons) {\n tags.push({ tag: 'link', props: { rel: 'icon', href: icon.href, type: typeOf(icon.file) } })\n }\n\n if (assets.appleIcon) {\n tags.push({ tag: 'link', props: { rel: 'apple-touch-icon', href: assets.appleIcon.href } })\n }\n\n // Absolute when the app said where it lives. The og spec asks for an absolute\n // url and several crawlers still mean it — a relative one is read by some and\n // ignored by others, which is the worst of both. Relative is what is left\n // when nothing said, and is better than omitting the tag.\n const absolute = (href: string) => (origin ? new URL(href, origin).href : href)\n\n if (assets.openGraph) {\n tags.push({ tag: 'meta', props: { property: 'og:image', content: absolute(assets.openGraph.href) } })\n }\n\n if (assets.twitter) {\n tags.push({ tag: 'meta', props: { name: 'twitter:image', content: absolute(assets.twitter.href) } })\n tags.push({ tag: 'meta', props: { name: 'twitter:card', content: 'summary_large_image' } })\n }\n\n return tags\n}\n"]}
@@ -0,0 +1,43 @@
1
+ /** One route, as the build left it. */
2
+ export interface ReportedRoute {
3
+ url: string;
4
+ component: string;
5
+ /** frozen | shell | blocked | dynamic | error — see PrerenderResult. */
6
+ type: string;
7
+ /** Why it is not frozen, in the words the build printed. */
8
+ reason: string | null;
9
+ /** Something true and worth knowing that is not a failure. */
10
+ warning: string | null;
11
+ /** Gzipped bytes of javascript this url makes the browser download. */
12
+ clientJs: number | null;
13
+ }
14
+ export interface ReportedApiRoute {
15
+ url: string;
16
+ name: string;
17
+ type: string;
18
+ reason: string | null;
19
+ }
20
+ export interface BuildReport {
21
+ version: 1;
22
+ /** Routes that render, in the order the build reported them. */
23
+ routes: ReportedRoute[];
24
+ /** route.ts endpoints. */
25
+ apis: ReportedApiRoute[];
26
+ totals: {
27
+ static: number;
28
+ partial: number;
29
+ dynamic: number;
30
+ failed: number;
31
+ };
32
+ }
33
+ /** The name the report is written under, inside the build's own directory. */
34
+ export declare const REPORT_FILE = "build-report.json";
35
+ export declare function buildReport(routes: ReportedRoute[], apis: ReportedApiRoute[]): string;
36
+ /**
37
+ * The routes worth asking about, most interesting first.
38
+ *
39
+ * "Interesting" is not a judgement about the app — it is the order someone
40
+ * looking for a problem reads in. A failure first, then a page that could not
41
+ * be stored at all, then one that ships a shell, then the ones that are fine.
42
+ */
43
+ export declare function byInterest(routes: ReportedRoute[]): ReportedRoute[];
@@ -0,0 +1,40 @@
1
+ // What the build decided, written down.
2
+ //
3
+ // The classification exists already — it is printed as the build runs, and
4
+ // then it is gone. That is fine for a person watching a terminal and useless
5
+ // for anything that wants to ask afterwards: a CI step asserting nothing
6
+ // regressed, an editor, an agent being asked why a page is slow.
7
+ //
8
+ // So the same facts go to a file. Not a new computation and not a second
9
+ // source of truth — the report is written from the results the build already
10
+ // produced, in the same pass that prints them.
11
+ /** The name the report is written under, inside the build's own directory. */
12
+ export const REPORT_FILE = 'build-report.json';
13
+ export function buildReport(routes, apis) {
14
+ const count = (...types) => routes.filter((r) => types.includes(r.type)).length +
15
+ apis.filter((a) => types.includes(a.type)).length;
16
+ const report = {
17
+ version: 1,
18
+ routes,
19
+ apis,
20
+ totals: {
21
+ static: count('frozen'),
22
+ partial: count('shell'),
23
+ dynamic: count('blocked', 'dynamic'),
24
+ failed: count('error'),
25
+ },
26
+ };
27
+ return JSON.stringify(report, null, 2) + '\n';
28
+ }
29
+ /**
30
+ * The routes worth asking about, most interesting first.
31
+ *
32
+ * "Interesting" is not a judgement about the app — it is the order someone
33
+ * looking for a problem reads in. A failure first, then a page that could not
34
+ * be stored at all, then one that ships a shell, then the ones that are fine.
35
+ */
36
+ export function byInterest(routes) {
37
+ const rank = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 };
38
+ return [...routes].sort((a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url));
39
+ }
40
+ //# sourceMappingURL=buildReport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildReport.js","sourceRoot":"","sources":["../src/buildReport.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,iEAAiE;AACjE,EAAE;AACF,yEAAyE;AACzE,6EAA6E;AAC7E,+CAA+C;AAqC/C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAA;AAE9C,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,IAAwB;IAExB,MAAM,KAAK,GAAG,CAAC,GAAG,KAAe,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QACnD,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAA;IAEnD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,CAAC;QACV,MAAM;QACN,IAAI;QACJ,MAAM,EAAE;YACN,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,SAAS,EAAE,SAAS,CAAC;YACpC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC;SACvB;KACF,CAAA;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAA;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAuB;IAChD,MAAM,IAAI,GAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IAE9F,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CACrB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAClF,CAAA;AACH,CAAC","sourcesContent":["// What the build decided, written down.\n//\n// The classification exists already — it is printed as the build runs, and\n// then it is gone. That is fine for a person watching a terminal and useless\n// for anything that wants to ask afterwards: a CI step asserting nothing\n// regressed, an editor, an agent being asked why a page is slow.\n//\n// So the same facts go to a file. Not a new computation and not a second\n// source of truth — the report is written from the results the build already\n// produced, in the same pass that prints them.\n\n/** One route, as the build left it. */\nexport interface ReportedRoute {\n url: string\n component: string\n /** frozen | shell | blocked | dynamic | error — see PrerenderResult. */\n type: string\n /** Why it is not frozen, in the words the build printed. */\n reason: string | null\n /** Something true and worth knowing that is not a failure. */\n warning: string | null\n /** Gzipped bytes of javascript this url makes the browser download. */\n clientJs: number | null\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface BuildReport {\n version: 1\n /** Routes that render, in the order the build reported them. */\n routes: ReportedRoute[]\n /** route.ts endpoints. */\n apis: ReportedApiRoute[]\n totals: {\n static: number\n partial: number\n dynamic: number\n failed: number\n }\n}\n\n/** The name the report is written under, inside the build's own directory. */\nexport const REPORT_FILE = 'build-report.json'\n\nexport function buildReport(\n routes: ReportedRoute[],\n apis: ReportedApiRoute[],\n): string {\n const count = (...types: string[]) =>\n routes.filter((r) => types.includes(r.type)).length +\n apis.filter((a) => types.includes(a.type)).length\n\n const report: BuildReport = {\n version: 1,\n routes,\n apis,\n totals: {\n static: count('frozen'),\n partial: count('shell'),\n dynamic: count('blocked', 'dynamic'),\n failed: count('error'),\n },\n }\n\n return JSON.stringify(report, null, 2) + '\\n'\n}\n\n/**\n * The routes worth asking about, most interesting first.\n *\n * \"Interesting\" is not a judgement about the app — it is the order someone\n * looking for a problem reads in. A failure first, then a page that could not\n * be stored at all, then one that ships a shell, then the ones that are fine.\n */\nexport function byInterest(routes: ReportedRoute[]): ReportedRoute[] {\n const rank: Record<string, number> = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 }\n\n return [...routes].sort(\n (a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url),\n )\n}\n"]}
package/dist/host.d.ts CHANGED
@@ -59,7 +59,19 @@ export interface RscEngine {
59
59
  handleQuery?(id: string, args: string, report?: (error: unknown) => string): Promise<{
60
60
  stream: ReadableStream;
61
61
  cacheControl: string;
62
+ } | {
63
+ status: number;
64
+ message: string;
65
+ errors?: Record<string, string[]>;
62
66
  } | null>;
67
+ /**
68
+ * Answer a `route.ts` — an api endpoint rather than a page.
69
+ *
70
+ * Optional so a host can be pointed at a bundle built before these existed;
71
+ * without it the url falls through to page routing, which is what that
72
+ * bundle would have done anyway.
73
+ */
74
+ handleApiRoute?(name: string, request: Request, params: Record<string, string>, allow: string): Promise<Response>;
63
75
  }
64
76
  export interface RscHostOptions {
65
77
  /** The built server bundle — `import * as engine from './build/rsc/index.js'`. */
package/dist/host.js CHANGED
@@ -13,11 +13,12 @@
13
13
  //
14
14
  // const rsc = createRscHandler({ engine, manifest, assets })
15
15
  // Bun.serve({ fetch: (req) => rsc(req).then((r) => r ?? new Response('', { status: 404 })) })
16
- import { matchIntercept, matchRoute, retentionKey, sharedDepth } from './routing.js';
16
+ import { allowFor, matchApiRoute, matchIntercept, matchRoute, retentionKey, sharedDepth } from './routing.js';
17
17
  import { pathKey, patternKey } from './prerender.js';
18
+ import { apiKey } from './apiPrerender.js';
18
19
  import { withRevalidation } from './revalidate.js';
19
20
  export { revalidate } from './revalidate.js';
20
- import { withRedirect } from './redirect.js';
21
+ import { currentNotFound, withRedirect } from './redirect.js';
21
22
  import { withCache } from './cache.js';
22
23
  import { withRequest, withResponseDraft } from './request.js';
23
24
  export { redirect } from './redirect.js';
@@ -315,6 +316,22 @@ export function createRscHandler(options) {
315
316
  }
316
317
  return await handleAction(request, url);
317
318
  }
319
+ // Api routes first. A url is one or the other, and a page that shares a
320
+ // path with a route.ts would otherwise win by accident of ordering.
321
+ const api = matchApiRoute(routes, url.pathname);
322
+ if (api && engine.handleApiRoute) {
323
+ // The guards above it run first, exactly as they would for a page in the
324
+ // same directory. A route.ts is colocated with the pages it belongs
325
+ // with, so adding one under a guarded path must not open a way around
326
+ // the guard.
327
+ const refused = await refuseApiUnlessAllowed(request, api);
328
+ if (refused)
329
+ return refused;
330
+ const stored = await frozenApi(request, url, api);
331
+ if (stored)
332
+ return stored;
333
+ return await engine.handleApiRoute(api.route.name, request, api.params, allowFor(api.route));
334
+ }
318
335
  if (request.method === 'GET' && url.pathname === HEADER.queryPath) {
319
336
  // Same check as an action, for a smaller reason: a cross-origin page
320
337
  // cannot read this answer — CORS sees to that — but it can still cause
@@ -407,6 +424,14 @@ export function createRscHandler(options) {
407
424
  const refused = taken();
408
425
  if (refused)
409
426
  return redirectResponse(refused, false);
427
+ // The page said this url names nothing. Null rather than a rendered
428
+ // 404: null is already how this host says "not mine", and the caller
429
+ // in front answers it with not-found.tsx and the right status. One
430
+ // path, so a page that calls notFound() and a url that matched no
431
+ // route are indistinguishable to whoever is asking — which is the
432
+ // point of a 404.
433
+ if (currentNotFound())
434
+ return null;
410
435
  // A guard refusing is not a failed render. Without this a visitor
411
436
  // who may not see the page gets a 500, which reads as the
412
437
  // application being broken rather than them being turned away —
@@ -420,6 +445,12 @@ export function createRscHandler(options) {
420
445
  const early = taken();
421
446
  if (early)
422
447
  return redirectResponse(early, false);
448
+ // Above every boundary, so the shell resolving means the page did not
449
+ // refuse itself. Deeper than that and the shell is already on the wire
450
+ // — the digest carries it to the boundary instead, and the status
451
+ // stays 200 because the status line has gone.
452
+ if (currentNotFound())
453
+ return null;
423
454
  return new Response(appendLateRedirect(htmlStream, taken), {
424
455
  headers: withVersion({
425
456
  'Content-Type': HTML_TYPE,
@@ -447,6 +478,10 @@ export function createRscHandler(options) {
447
478
  const refused = taken();
448
479
  if (refused)
449
480
  return redirectResponse(refused, true);
481
+ // Same answer the document path gives, so a client navigating to a
482
+ // url and a browser loading it fresh agree about whether it exists.
483
+ if (currentNotFound())
484
+ return null;
450
485
  // A payload request is guarded exactly as the document is. Narrowing
451
486
  // a request must never narrow what is checked.
452
487
  const status = refusalStatus(error);
@@ -493,6 +528,58 @@ export function createRscHandler(options) {
493
528
  * not cacheable by a shared cache at all, so handing one to an edge that
494
529
  * exists to cache things is an invitation to a mistake nobody would see.
495
530
  */
531
+ /**
532
+ * The answer the build stored for this route, if it stored one.
533
+ *
534
+ * Three conditions, each closing a way the stored answer could be wrong:
535
+ *
536
+ * GET or HEAD, because a stored answer to a POST is a stored answer to
537
+ * something that was meant to happen once.
538
+ *
539
+ * No query string. The build answered the bare url, and a route that reads
540
+ * the query would answer differently for every one — so rather than trying
541
+ * to detect that during the probe, anything carrying a query goes to the
542
+ * route itself. A stored answer is for the url it was stored for.
543
+ *
544
+ * No middleware. A guarded route answers differently depending on who is
545
+ * asking, which is the point of the guard; one stored answer served to
546
+ * everyone is how a guard is quietly removed. The build refuses to store one
547
+ * for the same reason, so this is the second of two locks on the same door.
548
+ */
549
+ async function frozenApi(request, url, api) {
550
+ if (!options.prerendered)
551
+ return null;
552
+ if (request.method !== 'GET' && request.method !== 'HEAD')
553
+ return null;
554
+ if (api.route.middleware.length > 0)
555
+ return null;
556
+ const stored = await options.prerendered(apiKey(url.pathname));
557
+ if (stored === null)
558
+ return null;
559
+ let frozen;
560
+ try {
561
+ frozen = JSON.parse(stored);
562
+ }
563
+ catch {
564
+ // A file this host wrote and cannot read back is a bug, not a request
565
+ // to answer badly. Falling through runs the route, which is correct.
566
+ return null;
567
+ }
568
+ // The build said whether this route's answer depends on the query. A route
569
+ // that never awaited searchParams gives the same answer whatever is on the
570
+ // end of the url — which matters more than it sounds, because every
571
+ // ?utm_source= and ?fbclid= would otherwise miss the stored answer.
572
+ //
573
+ // Defaulting to varying when the field is absent: a file written by an
574
+ // older build did not record this, and serving it for every query would be
575
+ // guessing on the unsafe side.
576
+ if (url.search && frozen.varies !== false)
577
+ return null;
578
+ return new Response(request.method === 'HEAD' ? null : frozen.body, {
579
+ status: frozen.status,
580
+ headers: withVersion(Object.fromEntries(frozen.headers)),
581
+ });
582
+ }
496
583
  async function servePprShell(request, url, read) {
497
584
  if (request.method !== 'GET' && request.method !== 'HEAD')
498
585
  return null;
@@ -868,6 +955,56 @@ export function createRscHandler(options) {
868
955
  }),
869
956
  });
870
957
  }
958
+ /**
959
+ * Run an api route's middleware, and answer instead of it if one refuses.
960
+ *
961
+ * Separate from the page version because the answer is different. A caller
962
+ * that is not a browser gets a status rather than a redirect to a login page
963
+ * it cannot render — a fetch would follow the 302 and hand back the login
964
+ * HTML as though it were the api's answer.
965
+ */
966
+ async function refuseApiUnlessAllowed(request, api) {
967
+ if (!(api.route.middleware?.length ?? 0))
968
+ return null;
969
+ // A route that declares middleware and an engine that cannot run it is not
970
+ // "no middleware" — it is a check that silently does not happen.
971
+ if (!engine.runRouteMiddleware) {
972
+ return new Response('This route declares middleware, and the engine cannot run it. ' +
973
+ 'Rebuild the app against the current @rsc-kit/core.', { status: 500 });
974
+ }
975
+ return await withRedirect(async (taken) => {
976
+ try {
977
+ await engine.runRouteMiddleware(api.route.name, api.params);
978
+ }
979
+ catch (error) {
980
+ // A redirect is a refusal here. Where it was going is told rather than
981
+ // followed, so a client can decide for itself.
982
+ const redirected = taken();
983
+ if (redirected) {
984
+ return new Response('Unauthorized', {
985
+ status: 401,
986
+ headers: { 'X-RSC-Redirect': redirected.location },
987
+ });
988
+ }
989
+ // A visitor who may not use this endpoint has not caused a server
990
+ // error, and answering 500 makes a guarded route indistinguishable
991
+ // from a broken one. Null means the middleware threw something that is
992
+ // not a refusal, which is a real fault and says so.
993
+ const status = refusalStatus(error);
994
+ if (status === null)
995
+ throw error;
996
+ return new Response(status === 401 ? 'Unauthorized' : 'Forbidden', { status });
997
+ }
998
+ const redirected = taken();
999
+ if (redirected) {
1000
+ return new Response('Unauthorized', {
1001
+ status: 401,
1002
+ headers: { 'X-RSC-Redirect': redirected.location },
1003
+ });
1004
+ }
1005
+ return null;
1006
+ });
1007
+ }
871
1008
  async function handleQuery(request, url) {
872
1009
  if (!engine.handleQuery)
873
1010
  return null;
@@ -894,6 +1031,15 @@ export function createRscHandler(options) {
894
1031
  // Unknown id and registered-but-not-a-query are the same answer on purpose.
895
1032
  if (!answered)
896
1033
  return new Response('No such query', { status: 404 });
1034
+ // The read refused. Answered as a status with the message in the body, so
1035
+ // the fetcher rejects with something a person can read — a failure rendered
1036
+ // into a 200 would reach the browser as React's opaque error instead.
1037
+ if (!('stream' in answered)) {
1038
+ return new Response(JSON.stringify({ message: answered.message, errors: answered.errors }), {
1039
+ status: answered.status,
1040
+ headers: withVersion({ 'Content-Type': 'application/json', 'Cache-Control': PER_CLIENT }),
1041
+ });
1042
+ }
897
1043
  return new Response(answered.stream, {
898
1044
  headers: withVersion({
899
1045
  'Content-Type': FLIGHT_TYPE,