@rsc-kit/core 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/barrelImports.d.ts +6 -0
  2. package/dist/barrelImports.js +93 -0
  3. package/dist/barrelImports.js.map +1 -0
  4. package/dist/events.d.ts +57 -0
  5. package/dist/events.js +113 -0
  6. package/dist/events.js.map +1 -0
  7. package/dist/host.d.ts +16 -6
  8. package/dist/host.js +219 -124
  9. package/dist/host.js.map +1 -1
  10. package/dist/hostCalls.d.ts +27 -0
  11. package/dist/hostCalls.js +127 -12
  12. package/dist/hostCalls.js.map +1 -1
  13. package/dist/hostRouting.d.ts +53 -0
  14. package/dist/hostRouting.js +100 -0
  15. package/dist/hostRouting.js.map +1 -0
  16. package/dist/js/DefaultRouteError.d.ts +3 -0
  17. package/dist/js/DefaultRouteError.js +61 -0
  18. package/dist/js/DefaultRouteError.js.map +1 -0
  19. package/dist/js/LoadingBoundary.d.ts +5 -0
  20. package/dist/js/LoadingBoundary.js +19 -0
  21. package/dist/js/LoadingBoundary.js.map +1 -0
  22. package/dist/js/fallbackReport.js +10 -8
  23. package/dist/js/fallbackReport.js.map +1 -1
  24. package/dist/js/queryClient.d.ts +2 -1
  25. package/dist/js/queryClient.js +0 -12
  26. package/dist/js/queryClient.js.map +1 -1
  27. package/dist/js/router.d.ts +2 -2
  28. package/dist/js/router.js.map +1 -1
  29. package/dist/js/useEvents.d.ts +25 -0
  30. package/dist/js/useEvents.js +80 -0
  31. package/dist/js/useEvents.js.map +1 -0
  32. package/dist/js/usePolling.d.ts +29 -0
  33. package/dist/js/usePolling.js +142 -0
  34. package/dist/js/usePolling.js.map +1 -0
  35. package/dist/manifest.d.ts +18 -1
  36. package/dist/manifest.js.map +1 -1
  37. package/dist/metadataRoutes.d.ts +20 -0
  38. package/dist/metadataRoutes.js +35 -0
  39. package/dist/metadataRoutes.js.map +1 -1
  40. package/dist/prerender.js +2 -0
  41. package/dist/prerender.js.map +1 -1
  42. package/dist/redirect.d.ts +16 -2
  43. package/dist/redirect.js +10 -19
  44. package/dist/redirect.js.map +1 -1
  45. package/dist/revalidate.d.ts +6 -4
  46. package/dist/revalidate.js +6 -5
  47. package/dist/revalidate.js.map +1 -1
  48. package/dist/routeSchema.d.ts +49 -0
  49. package/dist/routeSchema.js.map +1 -1
  50. package/dist/routes.d.ts +16 -3
  51. package/dist/routes.js +5 -5
  52. package/dist/routes.js.map +1 -1
  53. package/dist/routing.d.ts +2 -2
  54. package/dist/routing.js +47 -25
  55. package/dist/routing.js.map +1 -1
  56. package/dist/testing.d.ts +8 -0
  57. package/dist/testing.js +27 -3
  58. package/dist/testing.js.map +1 -1
  59. package/dist/vite.d.ts +53 -2
  60. package/dist/vite.js +503 -38
  61. package/dist/vite.js.map +1 -1
  62. package/package.json +15 -3
package/dist/host.js CHANGED
@@ -13,21 +13,32 @@
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 { allowFor, matchApiRoute, matchIntercept, matchRoute, retentionKey, sharedDepth } from './routing.js';
17
- import { pathKey, patternKey } from './prerender.js';
18
- import { apiKey } from './apiPrerender.js';
19
- import { withRevalidation } from './revalidate.js';
20
- export { revalidate } from './revalidate.js';
21
- import { currentNotFound, withRedirect } from './redirect.js';
22
- import { withCache } from './cache.js';
23
- import { withRequest, withResponseDraft } from './request.js';
24
- export { redirect } from './redirect.js';
16
+ import { allowFor, matchApiRoute, matchIntercept, matchRoute, retentionKey, sharedDepth, } from "./routing.js";
17
+ import { pathKey, patternKey } from "./prerender.js";
18
+ import { apiKey } from "./apiPrerender.js";
19
+ import { hostPath, hostSegment, routableHost } from "./hostRouting.js";
20
+ import { withRevalidation } from "./revalidate.js";
21
+ /**
22
+ * @internal For a host adapter that embeds the engine. An app imports this
23
+ * from `@rsc-kit/core/revalidate`.
24
+ */
25
+ export { revalidate } from "./revalidate.js";
26
+ import { currentNotFound, withRedirect } from "./redirect.js";
27
+ import { withCache } from "./cache.js";
28
+ import { withRequest, withResponseDraft } from "./request.js";
29
+ /**
30
+ * @internal For a host adapter that embeds the engine. An app imports this
31
+ * from `@rsc-kit/core/redirect` - the one the guides teach, and the one an
32
+ * editor should offer first.
33
+ */
34
+ export { redirect } from "./redirect.js";
25
35
  // Re-exported, not redefined: routing.ts is the one implementation, shared with
26
36
  // the prerenderer and the generated bundle, and this stays the adapter's
27
37
  // public surface so a host imports from one place.
28
- export { matchIntercept, matchRoute, sharedDepth } from './routing.js';
29
- import { FLIGHT_TYPE, HEADER, HTML_TYPE, PER_CLIENT, REVALIDATE, VARY_ON_RSC } from './headers.js';
30
- import { ServerAuthenticationError, ServerAuthorizationError } from './js/errors.js';
38
+ /** @internal For a host adapter. An app never matches its own routes. */
39
+ export { matchIntercept, matchRoute, sharedDepth } from "./routing.js";
40
+ import { FLIGHT_TYPE, HEADER, HTML_TYPE, PER_CLIENT, REVALIDATE, VARY_ON_RSC, } from "./headers.js";
41
+ import { ServerAuthenticationError, ServerAuthorizationError, } from "./js/errors.js";
31
42
  /**
32
43
  * The answer to a redirect that was decided before anything was written.
33
44
  *
@@ -53,9 +64,9 @@ import { ServerAuthenticationError, ServerAuthorizationError } from './js/errors
53
64
  */
54
65
  function inScript(value) {
55
66
  return JSON.stringify(value)
56
- .replace(/</g, '\\u003c')
57
- .replace(/\u2028/g, '\\u2028')
58
- .replace(/\u2029/g, '\\u2029');
67
+ .replace(/</g, "\\u003c")
68
+ .replace(/\u2028/g, "\\u2028")
69
+ .replace(/\u2029/g, "\\u2029");
59
70
  }
60
71
  function redirectResponse(to, isPayloadRequest) {
61
72
  if (isPayloadRequest) {
@@ -84,7 +95,7 @@ function appendLateRedirect(stream, taken) {
84
95
  // An engine that answered with a finished body rather than a stream has no
85
96
  // late window at all: the render was over before this was called, so the
86
97
  // caller's own check already saw everything there was to see.
87
- if (typeof stream?.getReader !== 'function')
98
+ if (typeof stream?.getReader !== "function")
88
99
  return stream;
89
100
  const encoder = new TextEncoder();
90
101
  const reader = stream.getReader();
@@ -129,22 +140,22 @@ function refusalStatus(error) {
129
140
  // raised by another copy of the module: an app's actions are bundled apart
130
141
  // from the engine, and instanceof is false across that seam.
131
142
  const name = error?.name;
132
- if (name === 'ServerAuthenticationError')
143
+ if (name === "ServerAuthenticationError")
133
144
  return 401;
134
- if (name === 'ServerAuthorizationError')
145
+ if (name === "ServerAuthorizationError")
135
146
  return 403;
136
147
  // A status the host chose — a throttle's 429, a policy's 403 — carried on
137
148
  // the error by the transport. Bounded to refusals: a host answering 500
138
149
  // should not be able to make this look like a client's fault, and one
139
150
  // answering 200 should not turn a failed render into a success.
140
151
  const carried = error?.refusalStatus;
141
- if (typeof carried === 'number' && carried >= 400 && carried <= 499)
152
+ if (typeof carried === "number" && carried >= 400 && carried <= 499)
142
153
  return carried;
143
154
  return null;
144
155
  }
145
156
  function refusalMessage(error) {
146
157
  const message = error?.message;
147
- return typeof message === 'string' && message !== '' ? message : 'Refused.';
158
+ return typeof message === "string" && message !== "" ? message : "Refused.";
148
159
  }
149
160
  /**
150
161
  * Whether a browser was told it could post this action.
@@ -168,10 +179,12 @@ function refusalMessage(error) {
168
179
  * the preflight.
169
180
  */
170
181
  export function actionOriginAllowed(request, url) {
171
- const origin = request.headers.get('origin');
182
+ const origin = request.headers.get("origin");
172
183
  if (!origin)
173
184
  return true;
174
- const expected = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host;
185
+ const expected = request.headers.get("x-forwarded-host") ??
186
+ request.headers.get("host") ??
187
+ url.host;
175
188
  try {
176
189
  return new URL(origin).host === expected;
177
190
  }
@@ -190,7 +203,7 @@ const DEFAULT_MAX_ACTION_BODY = 8 * 1024 * 1024;
190
203
  * dropped, and the caller answers 413 rather than holding the rest.
191
204
  */
192
205
  async function readBodyUpTo(request, limit) {
193
- const declared = Number(request.headers.get('content-length'));
206
+ const declared = Number(request.headers.get("content-length"));
194
207
  if (Number.isFinite(declared) && declared > limit)
195
208
  return null;
196
209
  if (!request.body)
@@ -222,11 +235,14 @@ async function readBodyUpTo(request, limit) {
222
235
  * null. A header is a string anyone can send, and `new URL` throws on one
223
236
  * that is not a url; that must not become a 500 on the action endpoint.
224
237
  */
225
- function refererPath(referer, origin) {
238
+ function refererPath(referer, origin, hosts = []) {
226
239
  if (!referer)
227
240
  return null;
228
241
  try {
229
- return new URL(referer, origin).pathname;
242
+ const parsed = new URL(referer, origin);
243
+ // The page the action was invoked from is routed the way a request for
244
+ // it would be: on a tenant host, the referer's host names the segment.
245
+ return hostPath(parsed.host, parsed.pathname, hosts);
230
246
  }
231
247
  catch {
232
248
  return null;
@@ -244,6 +260,15 @@ function refererPath(referer, origin) {
244
260
  * throws on every request instead of being undefined.
245
261
  */
246
262
  const MAX_QUERY = 8_000;
263
+ /**
264
+ * The host segment a routed url begins with, by url. Kept beside the url
265
+ * rather than in it, so the pathname stays what a page and a stored file
266
+ * are keyed by, and the matcher alone is told the leading part is the host's.
267
+ */
268
+ const hostOf = new WeakMap();
269
+ function matchPage(routes, url) {
270
+ return matchRoute(routes, url.pathname, hostOf.get(url) ?? null);
271
+ }
247
272
  export function createRscHandler(options) {
248
273
  const { engine, assets, version } = options;
249
274
  const maxActionBody = options.maxActionBody ?? DEFAULT_MAX_ACTION_BODY;
@@ -251,7 +276,7 @@ export function createRscHandler(options) {
251
276
  // closures that use it, and every one of them runs after the throw.
252
277
  const manifest = options.manifest ?? engine.manifest?.();
253
278
  if (!manifest) {
254
- throw new Error('No route table. Pass `manifest`, or build with a plugin version that embeds one in the bundle.');
279
+ throw new Error("No route table. Pass `manifest`, or build with a plugin version that embeds one in the bundle.");
255
280
  }
256
281
  const routes = manifest;
257
282
  // Only when this host has functions of its own. Installing unconditionally
@@ -272,7 +297,7 @@ export function createRscHandler(options) {
272
297
  return await remote(name, ...args);
273
298
  // Louder than returning null: a typo in a server component otherwise
274
299
  // renders as missing data with nothing anywhere saying why.
275
- throw new Error(`No host function named ${JSON.stringify(name)}. Registered: ${Object.keys(fns).join(', ') || '(none)'}`);
300
+ throw new Error(`No host function named ${JSON.stringify(name)}. Registered: ${Object.keys(fns).join(", ") || "(none)"}`);
276
301
  });
277
302
  }
278
303
  async function propsFor(match, request) {
@@ -283,7 +308,10 @@ export function createRscHandler(options) {
283
308
  return {
284
309
  component: match.route.component,
285
310
  props,
286
- layouts: match.route.layouts.map((component) => ({ component, props: {} })),
311
+ layouts: match.route.layouts.map((component) => ({
312
+ component,
313
+ props: {},
314
+ })),
287
315
  loadings: match.route.loadings,
288
316
  parallelSlots: match.route.slots,
289
317
  // What may be named in X-RSC-Revalidate — see renderRevalidated.
@@ -298,7 +326,13 @@ export function createRscHandler(options) {
298
326
  // server is a reasonable thing to do — previewing an export, or one build
299
327
  // used both ways — and without this every navigation 404s in the console
300
328
  // while the page itself looks fine.
301
- const payloadName = manifest.build?.payloadName || '';
329
+ const payloadName = manifest.build?.payloadName || "";
330
+ /** The site's own hosts; any other host is routed with its segment in front of the path. */
331
+ const siteHosts = manifest.build?.hosts ?? [];
332
+ /** Whether responses say what built them. How they were served is always said. */
333
+ const identify = Boolean(manifest.build?.identify);
334
+ /** How a response was answered, for X-RSC-Kit: a file the build wrote, or a shell of one. */
335
+ const servedFrom = new WeakMap();
302
336
  /**
303
337
  * The page a payload url belongs to, if this is one.
304
338
  *
@@ -306,14 +340,18 @@ export function createRscHandler(options) {
306
340
  * built for export asks for index.seg1.rsc when it already holds a layout,
307
341
  * and matching only the plain name leaves that as a 404 no page reports.
308
342
  */
309
- const payloadNames = new RegExp('/' + payloadName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/^index\\\./, 'index(\\.seg\\d+)?\\.') + '$');
343
+ const payloadNames = new RegExp("/" +
344
+ payloadName
345
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
346
+ .replace(/^index\\\./, "index(\\.seg\\d+)?\\.") +
347
+ "$");
310
348
  function pageForPayload(pathname) {
311
- if (payloadName === '')
349
+ if (payloadName === "")
312
350
  return null;
313
351
  const match = payloadNames.exec(pathname);
314
352
  if (!match)
315
353
  return null;
316
- return pathname.slice(0, match.index) || '/';
354
+ return pathname.slice(0, match.index) || "/";
317
355
  }
318
356
  // One memo table per request, opened at the outermost point so that
319
357
  // everything below shares it: middleware, layouts, the page, and an action. A
@@ -334,7 +372,7 @@ export function createRscHandler(options) {
334
372
  // Set-Cookie is applied below, once per cookie: iterating a Headers
335
373
  // gives it joined in some runtimes and per-cookie in others, and a
336
374
  // joined one is a single malformed cookie the browser discards.
337
- if (name.toLowerCase() === 'set-cookie')
375
+ if (name.toLowerCase() === "set-cookie")
338
376
  return;
339
377
  // set, not append: a middleware asking for a header means that
340
378
  // value, not that value added to whatever the host already chose.
@@ -342,20 +380,52 @@ export function createRscHandler(options) {
342
380
  });
343
381
  // Appended, because several cookies are several headers.
344
382
  for (const cookie of collected.getSetCookie()) {
345
- response.headers.append('Set-Cookie', cookie);
383
+ response.headers.append("Set-Cookie", cookie);
346
384
  }
385
+ // How this answer was served - the header a developer reads in the
386
+ // Network tab, the way X-Nextjs-Cache is. Always: it names no
387
+ // product, and a CDN rule or a check can key on it.
388
+ response.headers.set("X-RSC-Kit", servedFrom.get(response) ?? "rendered");
389
+ // What built it. The name only, never the version - a version in
390
+ // every response is what a vulnerability scanner filters on - and
391
+ // off for a team whose policy strips every framework identifier.
392
+ if (identify)
393
+ response.headers.set("X-Powered-By", "rsc-kit");
347
394
  return response;
348
395
  })));
349
396
  };
350
397
  async function route(request) {
351
398
  let url = new URL(request.url);
399
+ // A host other than the site's own is matched with its segment in front
400
+ // of the path, once, here: everything below - the api match, the page
401
+ // match, the stored answer, the payload - sees that path and nothing
402
+ // else knows a host was involved. The visitor's url is untouched.
403
+ // Compared against X-Forwarded-Host first, for the same reason the
404
+ // action's origin check is: a proxied deployment is the common one.
405
+ // Only when a route can begin with that segment - a [domain] directory or
406
+ // one named for the host - so an app with a metadataBase and no tenant
407
+ // tree routes every host by path, and a proxy forwarding by an internal
408
+ // name is not read as a tenant.
409
+ if (siteHosts.length > 0) {
410
+ const host = request.headers.get("x-forwarded-host") ??
411
+ request.headers.get("host") ??
412
+ url.host;
413
+ const segment = hostSegment(host, siteHosts);
414
+ if (segment !== null && routableHost(segment, routes.routes)) {
415
+ url = new URL(hostPath(host, url.pathname, siteHosts) + url.search, url.origin);
416
+ hostOf.set(url, segment);
417
+ }
418
+ }
352
419
  const asPayload = pageForPayload(url.pathname);
353
420
  if (asPayload !== null) {
354
421
  // Rewritten to the page it is asking about, with the header the rest of
355
422
  // this handler reads — one path through, however the client asked.
423
+ const host = hostOf.get(url);
356
424
  url = new URL(asPayload + url.search, url.origin);
425
+ if (host !== undefined)
426
+ hostOf.set(url, host);
357
427
  const headers = new Headers(request.headers);
358
- headers.set(HEADER.rsc, '1');
428
+ headers.set(HEADER.rsc, "1");
359
429
  request = new Request(url, { method: request.method, headers });
360
430
  }
361
431
  if (assets) {
@@ -363,9 +433,9 @@ export function createRscHandler(options) {
363
433
  if (asset)
364
434
  return asset;
365
435
  }
366
- if (request.method === 'POST' && url.pathname === HEADER.actionPath) {
436
+ if (request.method === "POST" && url.pathname === HEADER.actionPath) {
367
437
  if (!actionOriginAllowed(request, url)) {
368
- return new Response('Cross-origin action', { status: 403 });
438
+ return new Response("Cross-origin action", { status: 403 });
369
439
  }
370
440
  return await handleAction(request, url);
371
441
  }
@@ -385,13 +455,13 @@ export function createRscHandler(options) {
385
455
  return stored;
386
456
  return await engine.handleApiRoute(api.route.name, request, api.params, allowFor(api.route));
387
457
  }
388
- if (request.method === 'GET' && url.pathname === HEADER.queryPath) {
458
+ if (request.method === "GET" && url.pathname === HEADER.queryPath) {
389
459
  // Same check as an action, for a smaller reason: a cross-origin page
390
460
  // cannot read this answer — CORS sees to that — but it can still cause
391
461
  // the read to run with the visitor's cookies. A query is side-effect
392
462
  // free by contract, so this is depth rather than the only defence.
393
463
  if (!actionOriginAllowed(request, url)) {
394
- return new Response('Cross-origin query', { status: 403 });
464
+ return new Response("Cross-origin query", { status: 403 });
395
465
  }
396
466
  return await handleQuery(request, url);
397
467
  }
@@ -400,12 +470,12 @@ export function createRscHandler(options) {
400
470
  if (options.prerendered && url.pathname === HEADER.pprShellPath) {
401
471
  return await servePprShell(request, url, options.prerendered);
402
472
  }
403
- if (request.method === 'POST' &&
473
+ if (request.method === "POST" &&
404
474
  options.prerendered &&
405
475
  url.pathname === HEADER.pprResumePath) {
406
476
  return await servePprResume(request, url, options.prerendered);
407
477
  }
408
- if (request.method !== 'GET' && request.method !== 'HEAD')
478
+ if (request.method !== "GET" && request.method !== "HEAD")
409
479
  return null;
410
480
  // One named region of this page, asked for without mutating anything to
411
481
  // earn it. What an action invalidated does not come through here — that
@@ -427,7 +497,7 @@ export function createRscHandler(options) {
427
497
  // something smaller than one: answering a named region with the whole
428
498
  // document puts the entire page inside that region, and answering an
429
499
  // interception with it replaces the page the modal was opening over.
430
- const match = matchRoute(routes, url.pathname);
500
+ const match = matchPage(routes, url);
431
501
  if (options.prerendered) {
432
502
  // A guarded route can still be frozen: whether the content is the same
433
503
  // for everyone, and whether this caller may see it, are different
@@ -437,13 +507,20 @@ export function createRscHandler(options) {
437
507
  if (refusal)
438
508
  return refusal;
439
509
  const frozen = await servePrerendered(request, url, options.prerendered);
510
+ // A whole page from a file, or a shell of one with the holes rendered
511
+ // now: the header says which. servePrerendered marks the shell.
512
+ if (frozen && !servedFrom.has(frozen))
513
+ servedFrom.set(frozen, "stored");
440
514
  if (frozen)
441
515
  return frozen;
442
516
  }
443
517
  if (!match)
444
518
  return null;
445
519
  const props = await propsFor(match, request);
446
- const layouts = match.route.layouts.map((component) => ({ component, props: {} }));
520
+ const layouts = match.route.layouts.map((component) => ({
521
+ component,
522
+ props: {},
523
+ }));
447
524
  const chain = match.route.layouts;
448
525
  // A payload request says so with a header on the page's own url, so one
449
526
  // route serves both the document and the navigation that follows it.
@@ -457,7 +534,6 @@ export function createRscHandler(options) {
457
534
  // still available. Nothing is buffered to make that true.
458
535
  let htmlStream;
459
536
  try {
460
- ;
461
537
  ({ htmlStream } = await engine.handleRscHtmlStream(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, undefined, url.pathname,
462
538
  // A route that ships no runtime gets no bootstrap and no segment
463
539
  // boundary — the boundary is itself a client component, so leaving
@@ -506,10 +582,12 @@ export function createRscHandler(options) {
506
582
  return null;
507
583
  return new Response(appendLateRedirect(htmlStream, taken), {
508
584
  headers: withVersion({
509
- 'Content-Type': HTML_TYPE,
510
- [HEADER.layouts]: chain.join(','),
585
+ "Content-Type": HTML_TYPE,
586
+ [HEADER.layouts]: chain.join(","),
511
587
  Vary: VARY_ON_RSC,
512
- 'Cache-Control': match.route.middleware?.length ? PER_CLIENT : REVALIDATE,
588
+ "Cache-Control": match.route.middleware?.length
589
+ ? PER_CLIENT
590
+ : REVALIDATE,
513
591
  }),
514
592
  });
515
593
  });
@@ -522,7 +600,6 @@ export function createRscHandler(options) {
522
600
  let stream;
523
601
  let segmentDepth;
524
602
  try {
525
- ;
526
603
  ({ stream, segmentDepth } = await engine.handleRscStream(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, from, url.pathname));
527
604
  }
528
605
  catch (error) {
@@ -550,11 +627,11 @@ export function createRscHandler(options) {
550
627
  return redirectResponse(early, true);
551
628
  return new Response(stream, {
552
629
  headers: withVersion({
553
- 'Content-Type': FLIGHT_TYPE,
630
+ "Content-Type": FLIGHT_TYPE,
554
631
  [HEADER.segmentDepth]: String(segmentDepth),
555
- [HEADER.layouts]: chain.join(','),
632
+ [HEADER.layouts]: chain.join(","),
556
633
  Vary: VARY_ON_RSC,
557
- 'Cache-Control': PER_CLIENT,
634
+ "Cache-Control": PER_CLIENT,
558
635
  }),
559
636
  });
560
637
  });
@@ -602,7 +679,7 @@ export function createRscHandler(options) {
602
679
  async function frozenApi(request, url, api) {
603
680
  if (!options.prerendered)
604
681
  return null;
605
- if (request.method !== 'GET' && request.method !== 'HEAD')
682
+ if (request.method !== "GET" && request.method !== "HEAD")
606
683
  return null;
607
684
  if (api.route.middleware.length > 0)
608
685
  return null;
@@ -628,32 +705,35 @@ export function createRscHandler(options) {
628
705
  // guessing on the unsafe side.
629
706
  if (url.search && frozen.varies !== false)
630
707
  return null;
631
- return new Response(request.method === 'HEAD' ? null : frozen.body, {
708
+ const answer = new Response(request.method === "HEAD" ? null : frozen.body, {
632
709
  status: frozen.status,
633
710
  headers: withVersion(Object.fromEntries(frozen.headers)),
634
711
  });
712
+ servedFrom.set(answer, "stored");
713
+ return answer;
635
714
  }
636
715
  async function servePprShell(request, url, read) {
637
- if (request.method !== 'GET' && request.method !== 'HEAD')
716
+ if (request.method !== "GET" && request.method !== "HEAD")
638
717
  return null;
639
- const target = url.searchParams.get('url');
640
- if (!target || !target.startsWith('/')) {
641
- return new Response('A url is required', { status: 400 });
718
+ const target = url.searchParams.get("url");
719
+ if (!target || !target.startsWith("/")) {
720
+ return new Response("A url is required", { status: 400 });
642
721
  }
643
722
  // Matched from the url asked for, never from anything else the caller sent.
644
723
  const route = matchRoute(routes, new URL(target, url.origin).pathname);
645
724
  if (!route)
646
- return new Response('No such page', { status: 404 });
725
+ return new Response("No such page", { status: 404 });
647
726
  if (route.route.middleware?.length) {
648
- return new Response('This route is not edge-cacheable', { status: 404 });
727
+ return new Response("This route is not edge-cacheable", { status: 404 });
649
728
  }
650
729
  const key = pathKey(new URL(target, url.origin).pathname);
651
- const shell = (await read(`${key}.ppr.html`)) ?? (await read(`${patternKey(route.route)}.ppr.html`));
730
+ const shell = (await read(`${key}.ppr.html`)) ??
731
+ (await read(`${patternKey(route.route)}.ppr.html`));
652
732
  if (shell === null)
653
- return new Response('No shell for this page', { status: 404 });
733
+ return new Response("No shell for this page", { status: 404 });
654
734
  return new Response(JSON.stringify({ shell, version: version ?? null }), {
655
735
  headers: withVersion({
656
- 'Content-Type': 'application/json',
736
+ "Content-Type": "application/json",
657
737
  // Deliberately NOT the REVALIDATE the rest of the host sends. That is
658
738
  // `max-age=0, must-revalidate`, which a cache honours by treating the
659
739
  // entry as stale the moment it arrives — an edge would store this and
@@ -665,7 +745,7 @@ export function createRscHandler(options) {
665
745
  // route never reaches here at all, so it is genuinely cacheable. An
666
746
  // hour bounds how long a deploy can be served against a stale shell;
667
747
  // the version below catches it sooner than that.
668
- 'Cache-Control': 'public, max-age=3600',
748
+ "Cache-Control": "public, max-age=3600",
669
749
  }),
670
750
  });
671
751
  }
@@ -683,21 +763,21 @@ export function createRscHandler(options) {
683
763
  * gets an anonymous answer.
684
764
  */
685
765
  async function servePprResume(request, url, read) {
686
- const target = url.searchParams.get('url');
687
- if (!target || !target.startsWith('/')) {
688
- return new Response('A url is required', { status: 400 });
766
+ const target = url.searchParams.get("url");
767
+ if (!target || !target.startsWith("/")) {
768
+ return new Response("A url is required", { status: 400 });
689
769
  }
690
770
  const pathname = new URL(target, url.origin).pathname;
691
771
  const route = matchRoute(routes, pathname);
692
772
  if (!route)
693
- return new Response('No such page', { status: 404 });
773
+ return new Response("No such page", { status: 404 });
694
774
  // The same refusal the document would get, before a byte of the holes is
695
775
  // rendered. Nothing below runs for a caller this turns away.
696
776
  const refusal = await refuseUnlessAllowed(request, route);
697
777
  if (refusal)
698
778
  return refusal;
699
779
  if (!engine.handleRscResume) {
700
- return new Response('This engine cannot resume', { status: 500 });
780
+ return new Response("This engine cannot resume", { status: 500 });
701
781
  }
702
782
  const key = pathKey(pathname);
703
783
  const pattern = patternKey(route.route);
@@ -710,8 +790,8 @@ export function createRscHandler(options) {
710
790
  // difference between this and the protocol it is modelled on.
711
791
  const state = shellKey === null ? null : await read(`${shellKey}.postponed.json`);
712
792
  if (state === null)
713
- return new Response('Nothing to resume', { status: 404 });
714
- const { htmlStream } = await engine.handleRscResume(route.route.component, route.params, route.route.layouts.map((component) => ({ component, props: {} })), route.route.loadings, route.route.slots, {}, JSON.parse(state), undefined, shellKey === key ? pathname : '');
793
+ return new Response("Nothing to resume", { status: 404 });
794
+ const { htmlStream } = await engine.handleRscResume(route.route.component, route.params, route.route.layouts.map((component) => ({ component, props: {} })), route.route.loadings, route.route.slots, {}, JSON.parse(state), undefined, shellKey === key ? pathname : "");
715
795
  // Carries the build version so a caller holding a cached shell can tell
716
796
  // that it no longer belongs to this origin. A shell from an older build
717
797
  // does not make the resume fail — it replays against slots that have moved
@@ -719,9 +799,9 @@ export function createRscHandler(options) {
719
799
  // else would ever report it.
720
800
  return new Response(htmlStream, {
721
801
  headers: withVersion({
722
- 'Content-Type': HTML_TYPE,
802
+ "Content-Type": HTML_TYPE,
723
803
  // Rendered for whoever asked. Never cacheable.
724
- 'Cache-Control': PER_CLIENT,
804
+ "Cache-Control": PER_CLIENT,
725
805
  }),
726
806
  });
727
807
  }
@@ -740,15 +820,17 @@ export function createRscHandler(options) {
740
820
  // pattern. Nothing in a shell varies by param, so one shell serves every
741
821
  // url its route matches — which is the only way a route whose urls were
742
822
  // never listed gets anything frozen at all.
743
- const route = matchRoute(routes, url.pathname);
823
+ const route = matchPage(routes, url);
744
824
  const whole = await read(`${key}.html`);
745
825
  // A whole page is finished. Nothing to resume, nothing to render.
746
826
  if (whole !== null) {
747
827
  return new Response(whole, {
748
828
  headers: withVersion({
749
- 'Content-Type': HTML_TYPE,
829
+ "Content-Type": HTML_TYPE,
750
830
  Vary: VARY_ON_RSC,
751
- 'Cache-Control': route?.route.middleware?.length ? PER_CLIENT : REVALIDATE,
831
+ "Cache-Control": route?.route.middleware?.length
832
+ ? PER_CLIENT
833
+ : REVALIDATE,
752
834
  }),
753
835
  });
754
836
  }
@@ -785,7 +867,7 @@ export function createRscHandler(options) {
785
867
  // A shell found under the route's pattern was frozen for no particular
786
868
  // url, so it was rendered with no page key. Handing one over now would
787
869
  // key the tree differently from the one being resumed.
788
- shellKey === key ? url.pathname : '');
870
+ shellKey === key ? url.pathname : "");
789
871
  // The shell first, then whatever the resume writes. React's own script
790
872
  // travels with the resumed segments and moves them into place, so this is
791
873
  // a plain concatenation and the holes land without hydration.
@@ -806,15 +888,17 @@ export function createRscHandler(options) {
806
888
  }
807
889
  },
808
890
  });
809
- return new Response(body, {
891
+ const withHoles = new Response(body, {
810
892
  headers: withVersion({
811
- 'Content-Type': HTML_TYPE,
893
+ "Content-Type": HTML_TYPE,
812
894
  Vary: VARY_ON_RSC,
813
895
  // The shell is cacheable; this response is not. It carries the holes,
814
896
  // which were rendered for whoever asked.
815
- 'Cache-Control': PER_CLIENT,
897
+ "Cache-Control": PER_CLIENT,
816
898
  }),
817
899
  });
900
+ servedFrom.set(withHoles, "shell");
901
+ return withHoles;
818
902
  }
819
903
  // Only the document is ever served frozen for a shell. The payload is what
820
904
  // fills it in, and it has to be rendered now — answering with a frozen one
@@ -836,11 +920,11 @@ export function createRscHandler(options) {
836
920
  return null;
837
921
  return new Response(payload, {
838
922
  headers: withVersion({
839
- 'Content-Type': FLIGHT_TYPE,
923
+ "Content-Type": FLIGHT_TYPE,
840
924
  [HEADER.segmentDepth]: String(variant ? shared : 0),
841
- [HEADER.layouts]: chain.join(','),
925
+ [HEADER.layouts]: chain.join(","),
842
926
  Vary: VARY_ON_RSC,
843
- 'Cache-Control': PER_CLIENT,
927
+ "Cache-Control": PER_CLIENT,
844
928
  }),
845
929
  });
846
930
  }
@@ -861,7 +945,8 @@ export function createRscHandler(options) {
861
945
  // until the build froze the page, then silently stop.
862
946
  if (!match)
863
947
  return null;
864
- const guarded = (match.route.middleware?.length ?? 0) > 0 || (match.route.hostMiddleware?.length ?? 0) > 0;
948
+ const guarded = (match.route.middleware?.length ?? 0) > 0 ||
949
+ (match.route.hostMiddleware?.length ?? 0) > 0;
865
950
  if (!guarded)
866
951
  return null;
867
952
  // A route that declares middleware and an engine that cannot run it is not
@@ -870,8 +955,8 @@ export function createRscHandler(options) {
870
955
  // guarded page. Reachable when a host runs a bundle built by an older
871
956
  // plugin, where the export did not exist.
872
957
  if (!engine.runRouteMiddleware) {
873
- return new Response('This route declares middleware, and the engine cannot run it. ' +
874
- 'Rebuild the app against the current @rsc-kit/core.', { status: 500 });
958
+ return new Response("This route declares middleware, and the engine cannot run it. " +
959
+ "Rebuild the app against the current @rsc-kit/core.", { status: 500 });
875
960
  }
876
961
  const asPayload = request.headers.get(HEADER.rsc) !== null;
877
962
  return await withRedirect(async (taken) => {
@@ -896,17 +981,16 @@ export function createRscHandler(options) {
896
981
  }
897
982
  async function handleRevalidate(request, url, target) {
898
983
  if (!engine.handleRscRevalidate) {
899
- return new Response('This build cannot revalidate', { status: 501 });
984
+ return new Response("This build cannot revalidate", { status: 501 });
900
985
  }
901
- const match = matchRoute(routes, url.pathname);
986
+ const match = matchPage(routes, url);
902
987
  if (!match)
903
- return new Response('No such page', { status: 404 });
988
+ return new Response("No such page", { status: 404 });
904
989
  // Scoped like the render paths: a guard above the target may refuse, and
905
990
  // that refusal is an answer rather than a failure.
906
991
  return await withRedirect(async (taken) => {
907
992
  let rscPayload;
908
993
  try {
909
- ;
910
994
  ({ rscPayload } = await engine.handleRscRevalidate(target, pageContext(match, await propsFor(match, request))));
911
995
  }
912
996
  catch (error) {
@@ -920,11 +1004,11 @@ export function createRscHandler(options) {
920
1004
  return redirectResponse(refused, true);
921
1005
  return new Response(rscPayload, {
922
1006
  headers: withVersion({
923
- 'Content-Type': FLIGHT_TYPE,
1007
+ "Content-Type": FLIGHT_TYPE,
924
1008
  // Echoed so the client can tell which region it is holding.
925
1009
  [HEADER.revalidate]: target,
926
1010
  Vary: VARY_ON_RSC,
927
- 'Cache-Control': 'private, no-store',
1011
+ "Cache-Control": "private, no-store",
928
1012
  }),
929
1013
  });
930
1014
  });
@@ -932,7 +1016,7 @@ export function createRscHandler(options) {
932
1016
  async function handleIntercept(request, url, slot) {
933
1017
  const intercept = matchIntercept(routes, url.pathname, slot);
934
1018
  if (!intercept)
935
- return new Response('No interceptor for this url', { status: 404 });
1019
+ return new Response("No interceptor for this url", { status: 404 });
936
1020
  // The guards of the route being intercepted, before anything is rendered.
937
1021
  //
938
1022
  // An interceptor exists to show the same resource as the route it stands in
@@ -944,18 +1028,18 @@ export function createRscHandler(options) {
944
1028
  //
945
1029
  // Derived from the url, never from X-RSC-Referer. The referer is a header
946
1030
  // the caller writes, and guarding by it means the caller picks the guard.
947
- const intercepted = matchRoute(routes, url.pathname);
1031
+ const intercepted = matchPage(routes, url);
948
1032
  // Nothing to guard means nothing to serve. A url that matches an
949
1033
  // interceptor but no route has no middleware chain to consult, so there is
950
1034
  // no way to know whether this caller may see it — and an interceptor
951
1035
  // stands in for a route, so a url with no route behind it is not a page
952
1036
  // anyone was entitled to open a modal over.
953
1037
  if (!intercepted)
954
- return new Response('No such page', { status: 404 });
1038
+ return new Response("No such page", { status: 404 });
955
1039
  const refusal = await refuseUnlessAllowed(request, intercepted);
956
1040
  if (refusal)
957
1041
  return refusal;
958
- const from = refererPath(request.headers.get(HEADER.referer), url.origin);
1042
+ const from = refererPath(request.headers.get(HEADER.referer), url.origin, siteHosts);
959
1043
  const under = from ? matchRoute(routes, from) : null;
960
1044
  // Without a page to open over there is nothing to intercept: render the
961
1045
  // interceptor on its own rather than answering with the wrong page.
@@ -986,25 +1070,25 @@ export function createRscHandler(options) {
986
1070
  });
987
1071
  return new Response(rscPayload, {
988
1072
  headers: withVersion({
989
- 'Content-Type': FLIGHT_TYPE,
1073
+ "Content-Type": FLIGHT_TYPE,
990
1074
  // Says what this payload is, so the client puts it in the slot
991
1075
  // instead of treating it as a segment of the page.
992
1076
  [HEADER.revalidate]: slot,
993
1077
  Vary: VARY_ON_RSC,
994
1078
  // Per-client by construction: which region this is was chosen by a
995
1079
  // request header, so a shared cache has nothing useful to key on.
996
- 'Cache-Control': 'private, no-store',
1080
+ "Cache-Control": "private, no-store",
997
1081
  }),
998
1082
  });
999
1083
  }
1000
1084
  const { stream, segmentDepth } = await engine.handleRscStream(component, props, chain.map((layout) => ({ component: layout, props: {} })), loadings, slots, {}, sharedDepth(request.headers.get(HEADER.segments), chain), retentionKey(url.pathname, slot));
1001
1085
  return new Response(stream, {
1002
1086
  headers: withVersion({
1003
- 'Content-Type': FLIGHT_TYPE,
1087
+ "Content-Type": FLIGHT_TYPE,
1004
1088
  [HEADER.segmentDepth]: String(segmentDepth),
1005
- [HEADER.layouts]: chain.join(','),
1089
+ [HEADER.layouts]: chain.join(","),
1006
1090
  Vary: VARY_ON_RSC,
1007
- 'Cache-Control': PER_CLIENT,
1091
+ "Cache-Control": PER_CLIENT,
1008
1092
  }),
1009
1093
  });
1010
1094
  }
@@ -1022,8 +1106,8 @@ export function createRscHandler(options) {
1022
1106
  // A route that declares middleware and an engine that cannot run it is not
1023
1107
  // "no middleware" — it is a check that silently does not happen.
1024
1108
  if (!engine.runRouteMiddleware) {
1025
- return new Response('This route declares middleware, and the engine cannot run it. ' +
1026
- 'Rebuild the app against the current @rsc-kit/core.', { status: 500 });
1109
+ return new Response("This route declares middleware, and the engine cannot run it. " +
1110
+ "Rebuild the app against the current @rsc-kit/core.", { status: 500 });
1027
1111
  }
1028
1112
  return await withRedirect(async (taken) => {
1029
1113
  try {
@@ -1034,9 +1118,9 @@ export function createRscHandler(options) {
1034
1118
  // followed, so a client can decide for itself.
1035
1119
  const redirected = taken();
1036
1120
  if (redirected) {
1037
- return new Response('Unauthorized', {
1121
+ return new Response("Unauthorized", {
1038
1122
  status: 401,
1039
- headers: { 'X-RSC-Redirect': redirected.location },
1123
+ headers: { "X-RSC-Redirect": redirected.location },
1040
1124
  });
1041
1125
  }
1042
1126
  // A visitor who may not use this endpoint has not caused a server
@@ -1046,13 +1130,15 @@ export function createRscHandler(options) {
1046
1130
  const status = refusalStatus(error);
1047
1131
  if (status === null)
1048
1132
  throw error;
1049
- return new Response(status === 401 ? 'Unauthorized' : 'Forbidden', { status });
1133
+ return new Response(status === 401 ? "Unauthorized" : "Forbidden", {
1134
+ status,
1135
+ });
1050
1136
  }
1051
1137
  const redirected = taken();
1052
1138
  if (redirected) {
1053
- return new Response('Unauthorized', {
1139
+ return new Response("Unauthorized", {
1054
1140
  status: 401,
1055
- headers: { 'X-RSC-Redirect': redirected.location },
1141
+ headers: { "X-RSC-Redirect": redirected.location },
1056
1142
  });
1057
1143
  }
1058
1144
  return null;
@@ -1069,64 +1155,73 @@ export function createRscHandler(options) {
1069
1155
  // and nothing here answers a preflight — the same protection a POST
1070
1156
  // carrying X-RSC-Action already had.
1071
1157
  if (!request.headers.get(HEADER.query)) {
1072
- return new Response('Missing ' + HEADER.query, { status: 400 });
1158
+ return new Response("Missing " + HEADER.query, { status: 400 });
1073
1159
  }
1074
- const id = url.searchParams.get('id');
1075
- const args = url.searchParams.get('args');
1160
+ const id = url.searchParams.get("id");
1161
+ const args = url.searchParams.get("args");
1076
1162
  if (!id || args === null)
1077
- return new Response('Missing id or args', { status: 400 });
1163
+ return new Response("Missing id or args", { status: 400 });
1078
1164
  // Enforced here as well as in the client, because the limit is what keeps
1079
1165
  // an attacker from making this endpoint decode megabytes of their payload
1080
1166
  // per request.
1081
1167
  if (url.search.length > MAX_QUERY)
1082
- return new Response('Query too large', { status: 414 });
1168
+ return new Response("Query too large", { status: 414 });
1083
1169
  const answered = await engine.handleQuery(id, args);
1084
1170
  // Unknown id and registered-but-not-a-query are the same answer on purpose.
1085
1171
  if (!answered)
1086
- return new Response('No such query', { status: 404 });
1172
+ return new Response("No such query", { status: 404 });
1087
1173
  // The read refused. Answered as a status with the message in the body, so
1088
1174
  // the fetcher rejects with something a person can read — a failure rendered
1089
1175
  // into a 200 would reach the browser as React's opaque error instead.
1090
- if (!('stream' in answered)) {
1176
+ if (!("stream" in answered)) {
1091
1177
  return new Response(JSON.stringify({ message: answered.message, errors: answered.errors }), {
1092
1178
  status: answered.status,
1093
- headers: withVersion({ 'Content-Type': 'application/json', 'Cache-Control': PER_CLIENT }),
1179
+ headers: withVersion({
1180
+ "Content-Type": "application/json",
1181
+ "Cache-Control": PER_CLIENT,
1182
+ }),
1094
1183
  });
1095
1184
  }
1096
1185
  return new Response(answered.stream, {
1097
1186
  headers: withVersion({
1098
- 'Content-Type': FLIGHT_TYPE,
1099
- 'Cache-Control': answered.cacheControl,
1187
+ "Content-Type": FLIGHT_TYPE,
1188
+ "Cache-Control": answered.cacheControl,
1100
1189
  // The answer is narrowed by who is asking whenever a read touches the
1101
1190
  // session, and the request that carries that is the cookie. Without
1102
1191
  // this a shared cache keyed on the url alone hands one visitor
1103
1192
  // another's answer — for any query that opted out of no-store.
1104
- Vary: 'Cookie, ' + HEADER.referer,
1193
+ Vary: "Cookie, " + HEADER.referer,
1105
1194
  }),
1106
1195
  });
1107
1196
  }
1108
1197
  async function handleAction(request, url) {
1109
1198
  const actionId = request.headers.get(HEADER.action);
1110
1199
  if (!actionId)
1111
- return new Response('Missing X-RSC-Action', { status: 400 });
1200
+ return new Response("Missing X-RSC-Action", { status: 400 });
1112
1201
  // The body travels as application/octet-stream so a host that parses
1113
1202
  // multipart cannot consume it first; its real type rides in a header.
1114
1203
  const body = await readBodyUpTo(request, maxActionBody);
1115
1204
  if (body === null) {
1116
- return new Response(`Action body over ${maxActionBody} bytes`, { status: 413 });
1205
+ return new Response(`Action body over ${maxActionBody} bytes`, {
1206
+ status: 413,
1207
+ });
1117
1208
  }
1118
- const contentType = request.headers.get(HEADER.contentType) ?? 'text/plain;charset=UTF-8';
1209
+ const contentType = request.headers.get(HEADER.contentType) ?? "text/plain;charset=UTF-8";
1119
1210
  // Where it was invoked from, so anything the action invalidates can be
1120
1211
  // re-rendered against the page that is actually on screen.
1121
- const from = refererPath(request.headers.get(HEADER.referer), url.origin);
1212
+ const from = refererPath(request.headers.get(HEADER.referer), url.origin, siteHosts);
1122
1213
  const match = from ? matchRoute(routes, from) : null;
1123
- const page = match ? pageContext(match, await propsFor(match, request)) : undefined;
1214
+ const page = match
1215
+ ? pageContext(match, await propsFor(match, request))
1216
+ : undefined;
1124
1217
  // Scoped to this action: revalidate() called anywhere inside it, at any
1125
1218
  // depth, marks here and nowhere else — two requests can be in flight and
1126
1219
  // marking is per-request state.
1127
1220
  const { stream } = await withRevalidation((taken) => engine.handleAction(actionId, body, contentType, page, taken));
1128
1221
  return new Response(stream, {
1129
- headers: withVersion({ 'Content-Type': 'text/x-component; charset=utf-8' }),
1222
+ headers: withVersion({
1223
+ "Content-Type": "text/x-component; charset=utf-8",
1224
+ }),
1130
1225
  });
1131
1226
  }
1132
1227
  }