@rsc-kit/core 0.30.39 → 0.30.43

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
@@ -18,6 +18,8 @@ import { PARAM_PLACEHOLDER, pathKey, patternKey } from "./prerender.js";
18
18
  import { apiKey } from "./apiPrerender.js";
19
19
  import { hostPath, hostSegment, routableHost } from "./hostRouting.js";
20
20
  import { withRevalidation } from "./revalidate.js";
21
+ import { withoutPayloadKey } from "./js/payloadKey.js";
22
+ import { cachingFor, finishCacheable, noteReadsSoFar } from "./pageCache.js";
21
23
  /**
22
24
  * @internal For a host adapter that embeds the engine. An app imports this
23
25
  * from `@rsc-kit/core/revalidate`.
@@ -56,19 +58,19 @@ function saidNotFound(request, route) {
56
58
  * nearest not-found.tsx above it.
57
59
  */
58
60
  export function notFoundRouteOf(request) {
59
- return notFoundFrom.get(request);
61
+ return notFoundFrom.get(withoutPayloadKey(request));
60
62
  }
61
63
  /** Whether a page or guard of this app answered the request with notFound(). */
62
64
  export function pageSaidNotFound(request) {
63
- return notFoundRequests.has(request);
65
+ return notFoundRequests.has(withoutPayloadKey(request));
64
66
  }
65
67
  export { revalidate } from "./revalidate.js";
66
68
  import { isCrawler } from "./crawler.js";
67
69
  import { inlineFlight, inlineFlightStart } from "./inlineFlight.js";
68
70
  import { currentNotFound, withRedirect } from "./redirect.js";
69
71
  import { compressed } from "./compress.js";
70
- import { withCache } from "./cache.js";
71
- import { takeAfterWork, withRequest, withResponseDraft, withUrl } from "./request.js";
72
+ import { setCacheBuild, withCache } from "./cache.js";
73
+ import { notFoundRowSent, renderAsNobody, takeAfterWork, withRequest, withResponseDraft, withUrl } from "./request.js";
72
74
  import { criticalAssetsOf, linkHeader, mergeAssets } from "./earlyHints.js";
73
75
  /**
74
76
  * @internal For a host adapter that embeds the engine. An app imports this
@@ -141,11 +143,18 @@ function isNavigation(request) {
141
143
  function afterFormPost(to) {
142
144
  return { ...to, status: 303 };
143
145
  }
144
- function redirectResponse(to, isPayloadRequest) {
146
+ function redirectResponse(to, isPayloadRequest, revalidated = []) {
145
147
  if (isPayloadRequest) {
146
148
  return new Response(null, {
147
149
  status: 204,
148
- headers: { [HEADER.redirect]: to.location, Vary: VARY_ON_RSC },
150
+ headers: {
151
+ [HEADER.redirect]: to.location,
152
+ Vary: VARY_ON_RSC,
153
+ // What an action that redirected also revalidated - see
154
+ // followActionRedirect. Without it the client cannot tell a redirect
155
+ // that changed what a layout reads from one that did not.
156
+ ...(revalidated.length ? { [HEADER.revalidate]: revalidated.join(",") } : {}),
157
+ },
149
158
  });
150
159
  }
151
160
  return new Response(null, {
@@ -262,28 +271,36 @@ function refusalMessage(error) {
262
271
  return typeof message === "string" && message !== "" ? message : "Refused.";
263
272
  }
264
273
  /**
265
- * Whether a browser was told it could post this action.
274
+ * Whether a request that changes something came from this site.
266
275
  *
267
- * Defence in depth, not the only defence. An action already requires the
268
- * X-RSC-Action header, which makes a cross-origin post a non-simple request —
269
- * so the browser must preflight it, and nothing here answers a preflight. A
270
- * browser can be tricked into sending cookies; it cannot be tricked into
271
- * sending a header it does not know. This is the belt to that pair of braces,
272
- * and it is what Next does for server actions.
276
+ * Asked of an action, a query, a form posted to a page, and every POST, PUT,
277
+ * PATCH or DELETE a route.ts answers - the last having had no check at all, so
278
+ * a handler reading cookies() could be posted to from any site with the
279
+ * visitor's. The rule is Go's http.CrossOriginProtection, and what Laravel 13
280
+ * asks before it looks at a CSRF token:
273
281
  *
274
- * Compared against X-Forwarded-Host first, because a proxied deployment is the
275
- * normal one: the backend forwards the name the visitor typed, while Host is
276
- * this process on loopback. Comparing against Host alone would reject every
277
- * legitimate action that arrived through a proxy — a check that only fires on
278
- * correct requests is worse than none.
282
+ * 1. Sec-Fetch-Site: same-origin (or none - typed in the address bar) passes.
283
+ * The browser sets it and a page cannot, and every browser has since 2023.
284
+ * 2. An Origin listed in trustedOrigins passes.
285
+ * 3. Any other Sec-Fetch-Site - same-site, cross-site - is refused.
286
+ * 4. Without one, Origin is compared with the host, X-Forwarded-Host first.
287
+ * 5. With neither, the caller is not a browser - a webhook, a server - and
288
+ * passes: there is no visitor's cookie to have been tricked into sending.
279
289
  *
280
- * No Origin at all is allowed through. It is absent on same-origin requests in
281
- * some browsers and on anything that is not a browser, and refusing those would
282
- * break server-to-server callers to catch an attacker who is already blocked by
283
- * the preflight.
290
+ * Compared against X-Forwarded-Host before Host, because a proxied deployment
291
+ * is the normal one: the backend forwards the name the visitor typed, while
292
+ * Host is this process on loopback. Comparing against Host alone would refuse
293
+ * every legitimate post that arrived through a proxy.
284
294
  */
285
- export function actionOriginAllowed(request, url) {
295
+ export function originAllowed(request, url, trusted = []) {
296
+ const site = request.headers.get("sec-fetch-site");
297
+ if (site === "same-origin" || site === "none")
298
+ return true;
286
299
  const origin = request.headers.get("origin");
300
+ if (origin && trusted.includes(origin.toLowerCase()))
301
+ return true;
302
+ if (site)
303
+ return false;
287
304
  if (!origin)
288
305
  return true;
289
306
  const expected = request.headers.get("x-forwarded-host") ??
@@ -293,10 +310,27 @@ export function actionOriginAllowed(request, url) {
293
310
  return new URL(origin).host === expected;
294
311
  }
295
312
  catch {
296
- // An Origin that is not a url is not one this can vouch for.
313
+ // "null" - a sandboxed iframe, a redirected cross-site post - and anything
314
+ // else that is not a url is not one this can vouch for.
297
315
  return false;
298
316
  }
299
317
  }
318
+ /** trustedOrigins as Origin headers spell them, or a thrown error naming the bad one. */
319
+ function trustedOriginsOf(origins) {
320
+ return (origins ?? []).map((origin) => {
321
+ let parsed = null;
322
+ try {
323
+ parsed = new URL(origin);
324
+ }
325
+ catch { }
326
+ if (!parsed || parsed.origin === "null" || parsed.origin !== origin.replace(/\/$/, "").toLowerCase()) {
327
+ throw new Error(`[rsc-kit] trustedOrigins: ${JSON.stringify(origin)} is not an origin. ` +
328
+ "Write the scheme and host as a browser sends them, with no path: 'https://app.example.com'.");
329
+ }
330
+ return parsed.origin;
331
+ });
332
+ }
333
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
300
334
  const DEFAULT_MAX_ACTION_BODY = 8 * 1024 * 1024;
301
335
  /** A form post whose body passed the ceiling: answered 413, never rendered. */
302
336
  const TOO_LARGE = Symbol("too large");
@@ -446,9 +480,12 @@ export function createRscHandler(options) {
446
480
  // own. Resolved on the first request: the engine reads it from a build
447
481
  // product it only has at runtime.
448
482
  let version = engine.buildId ? undefined : options.version;
483
+ // Told to the cache once, so answers kept across requests are this build's.
484
+ let cacheBuildSet = false;
449
485
  // The edge contract's marker, as the build left it beside the pages - see edgeMarker.
450
486
  let edgeMarkerRead = null;
451
487
  const maxActionBody = options.maxActionBody ?? DEFAULT_MAX_ACTION_BODY;
488
+ const trustedOrigins = trustedOriginsOf(options.trustedOrigins);
452
489
  const compress = options.compress ?? true;
453
490
  // Annotated rather than inferred: the narrowing below is lost inside the
454
491
  // closures that use it, and every one of them runs after the throw.
@@ -594,7 +631,7 @@ export function createRscHandler(options) {
594
631
  return null;
595
632
  if (!matchPage(routes, url))
596
633
  return null;
597
- if (!actionOriginAllowed(request, url))
634
+ if (!originAllowed(request, url, trustedOrigins))
598
635
  return null;
599
636
  // Through the same ceiling as an action. This path is reached with no
600
637
  // guard and no session - any page url - and formData() reads whatever
@@ -639,19 +676,39 @@ export function createRscHandler(options) {
639
676
  return async function handle(request) {
640
677
  if (version === undefined && engine.buildId)
641
678
  version = await engine.buildId();
679
+ if (!cacheBuildSet) {
680
+ setCacheBuild(version);
681
+ cacheBuildSet = true;
682
+ }
642
683
  if (!signingChecked) {
643
684
  assertSigningSecret();
644
685
  signingChecked = true;
645
686
  }
687
+ // A page that exports revalidate is judged as it arrived - its ?_rsc=
688
+ // key is part of check 1 - and answered for as the app sees it.
689
+ const arrived = request;
690
+ request = withoutPayloadKey(request);
691
+ const page = matchPage(routes, new URL(request.url));
692
+ const caching = cachingFor(arrived, page?.route.revalidate);
693
+ if (caching && (page?.route.hostMiddleware?.length ?? 0) > 0)
694
+ caching.refused ??= "host-middleware";
646
695
  return await withRequest(request, () => withCache(() =>
647
696
  // Open for the whole request and sealed the moment an answer exists,
648
697
  // so middleware — which runs before any rendering — can put headers on
649
698
  // it, and a component, which runs after, is told why it cannot.
650
699
  withResponseDraft(async ({ taken, seal }) => {
651
- const response = await route(request);
700
+ if (caching)
701
+ renderAsNobody();
702
+ const rendered = await route(request);
652
703
  seal();
653
- if (!response)
704
+ if (!rendered) {
705
+ // The page said notFound(): the entry in front renders the 404
706
+ // in a scope of its own, and needs what this one read.
707
+ if (caching)
708
+ noteReadsSoFar(caching);
654
709
  return null;
710
+ }
711
+ let response = rendered;
655
712
  const collected = taken();
656
713
  // Under whatever the answer already has, and before the middleware's,
657
714
  // so a page or a middleware that asked for its own value keeps it.
@@ -673,6 +730,14 @@ export function createRscHandler(options) {
673
730
  for (const cookie of collected.getSetCookie()) {
674
731
  response.headers.append("Set-Cookie", cookie);
675
732
  }
733
+ // A page a shared cache may keep, rendered whole and judged. Not a
734
+ // stored answer: that one was frozen for nobody, and says so.
735
+ if (caching && !servedFrom.has(response)) {
736
+ const hints = hinted.get(response);
737
+ response = await finishCacheable(request, response, caching, () => pageSaidNotFound(request) || notFoundRowSent());
738
+ if (hints)
739
+ hinted.set(response, hints);
740
+ }
676
741
  // How this answer was served - the header a developer reads in the
677
742
  // Network tab, the way X-Nextjs-Cache is. Always: it names no
678
743
  // product, and a CDN rule or a check can key on it.
@@ -760,7 +825,7 @@ export function createRscHandler(options) {
760
825
  return asset;
761
826
  }
762
827
  if (request.method === "POST" && url.pathname === HEADER.actionPath) {
763
- if (!actionOriginAllowed(request, url)) {
828
+ if (!originAllowed(request, url, trustedOrigins)) {
764
829
  return new Response("Cross-origin action", { status: 403 });
765
830
  }
766
831
  return await handleAction(request, url);
@@ -777,6 +842,12 @@ export function createRscHandler(options) {
777
842
  return stored;
778
843
  }
779
844
  if (api && engine.handleApiRoute) {
845
+ // A route.ts that changes something is a form any site could post to
846
+ // with the visitor's cookies, and nothing else here stands in the way:
847
+ // no custom header, so no preflight. Refused before its guards run.
848
+ if (!SAFE_METHODS.has(request.method) && !originAllowed(request, url, trustedOrigins)) {
849
+ return new Response("Cross-origin request", { status: 403 });
850
+ }
780
851
  // The guards above it run first, exactly as they would for a page in the
781
852
  // same directory. A route.ts is colocated with the pages it belongs
782
853
  // with, so adding one under a guarded path must not open a way around
@@ -810,7 +881,7 @@ export function createRscHandler(options) {
810
881
  // cannot read this answer — CORS sees to that — but it can still cause
811
882
  // the read to run with the visitor's cookies. A query is side-effect
812
883
  // free by contract, so this is depth rather than the only defence.
813
- if (!actionOriginAllowed(request, url)) {
884
+ if (!originAllowed(request, url, trustedOrigins)) {
814
885
  return new Response("Cross-origin query", { status: 403 });
815
886
  }
816
887
  return await handleQuery(request, url);
@@ -1961,13 +2032,20 @@ export function createRscHandler(options) {
1961
2032
  // already knows how to read.
1962
2033
  return await withRedirect(async (redirected) => {
1963
2034
  let stream;
2035
+ let marked = () => [];
1964
2036
  try {
1965
- ({ stream } = await withRevalidation((taken) => engine.handleAction(actionId, body, contentType, page, taken)));
2037
+ ({ stream } = await withRevalidation((taken) => {
2038
+ marked = taken;
2039
+ return engine.handleAction(actionId, body, contentType, page, taken);
2040
+ }));
1966
2041
  }
1967
2042
  catch (error) {
1968
2043
  const to = redirected();
2044
+ // What it revalidated before redirecting goes with the redirect:
2045
+ // dropped, revalidate('all') then redirect() left every layout as it
2046
+ // was, and the one way to ask for them did nothing.
1969
2047
  if (to)
1970
- return redirectResponse(to, true);
2048
+ return redirectResponse(to, true, marked());
1971
2049
  throw error;
1972
2050
  }
1973
2051
  return new Response(stream, {