@rsc-kit/core 0.9.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.
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,50 @@ 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 (url.search)
555
+ return null;
556
+ if (api.route.middleware.length > 0)
557
+ return null;
558
+ const stored = await options.prerendered(apiKey(url.pathname));
559
+ if (stored === null)
560
+ return null;
561
+ let frozen;
562
+ try {
563
+ frozen = JSON.parse(stored);
564
+ }
565
+ catch {
566
+ // A file this host wrote and cannot read back is a bug, not a request
567
+ // to answer badly. Falling through runs the route, which is correct.
568
+ return null;
569
+ }
570
+ return new Response(request.method === 'HEAD' ? null : frozen.body, {
571
+ status: frozen.status,
572
+ headers: withVersion(Object.fromEntries(frozen.headers)),
573
+ });
574
+ }
496
575
  async function servePprShell(request, url, read) {
497
576
  if (request.method !== 'GET' && request.method !== 'HEAD')
498
577
  return null;
@@ -868,6 +947,56 @@ export function createRscHandler(options) {
868
947
  }),
869
948
  });
870
949
  }
950
+ /**
951
+ * Run an api route's middleware, and answer instead of it if one refuses.
952
+ *
953
+ * Separate from the page version because the answer is different. A caller
954
+ * that is not a browser gets a status rather than a redirect to a login page
955
+ * it cannot render — a fetch would follow the 302 and hand back the login
956
+ * HTML as though it were the api's answer.
957
+ */
958
+ async function refuseApiUnlessAllowed(request, api) {
959
+ if (!(api.route.middleware?.length ?? 0))
960
+ return null;
961
+ // A route that declares middleware and an engine that cannot run it is not
962
+ // "no middleware" — it is a check that silently does not happen.
963
+ if (!engine.runRouteMiddleware) {
964
+ return new Response('This route declares middleware, and the engine cannot run it. ' +
965
+ 'Rebuild the app against the current @rsc-kit/core.', { status: 500 });
966
+ }
967
+ return await withRedirect(async (taken) => {
968
+ try {
969
+ await engine.runRouteMiddleware(api.route.name, api.params);
970
+ }
971
+ catch (error) {
972
+ // A redirect is a refusal here. Where it was going is told rather than
973
+ // followed, so a client can decide for itself.
974
+ const redirected = taken();
975
+ if (redirected) {
976
+ return new Response('Unauthorized', {
977
+ status: 401,
978
+ headers: { 'X-RSC-Redirect': redirected.location },
979
+ });
980
+ }
981
+ // A visitor who may not use this endpoint has not caused a server
982
+ // error, and answering 500 makes a guarded route indistinguishable
983
+ // from a broken one. Null means the middleware threw something that is
984
+ // not a refusal, which is a real fault and says so.
985
+ const status = refusalStatus(error);
986
+ if (status === null)
987
+ throw error;
988
+ return new Response(status === 401 ? 'Unauthorized' : 'Forbidden', { status });
989
+ }
990
+ const redirected = taken();
991
+ if (redirected) {
992
+ return new Response('Unauthorized', {
993
+ status: 401,
994
+ headers: { 'X-RSC-Redirect': redirected.location },
995
+ });
996
+ }
997
+ return null;
998
+ });
999
+ }
871
1000
  async function handleQuery(request, url) {
872
1001
  if (!engine.handleQuery)
873
1002
  return null;
@@ -894,6 +1023,15 @@ export function createRscHandler(options) {
894
1023
  // Unknown id and registered-but-not-a-query are the same answer on purpose.
895
1024
  if (!answered)
896
1025
  return new Response('No such query', { status: 404 });
1026
+ // The read refused. Answered as a status with the message in the body, so
1027
+ // the fetcher rejects with something a person can read — a failure rendered
1028
+ // into a 200 would reach the browser as React's opaque error instead.
1029
+ if (!('stream' in answered)) {
1030
+ return new Response(JSON.stringify({ message: answered.message, errors: answered.errors }), {
1031
+ status: answered.status,
1032
+ headers: withVersion({ 'Content-Type': 'application/json', 'Cache-Control': PER_CLIENT }),
1033
+ });
1034
+ }
897
1035
  return new Response(answered.stream, {
898
1036
  headers: withVersion({
899
1037
  'Content-Type': FLIGHT_TYPE,