@rsc-kit/core 0.18.1 → 0.19.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 (60) hide show
  1. package/dist/action.d.ts +16 -2
  2. package/dist/action.js +21 -12
  3. package/dist/action.js.map +1 -1
  4. package/dist/apiPrerender.js +24 -6
  5. package/dist/apiPrerender.js.map +1 -1
  6. package/dist/clientPackages.d.ts +30 -0
  7. package/dist/clientPackages.js +234 -0
  8. package/dist/clientPackages.js.map +1 -0
  9. package/dist/compress.d.ts +12 -0
  10. package/dist/compress.js +134 -0
  11. package/dist/compress.js.map +1 -0
  12. package/dist/compressRuntime.d.ts +8 -0
  13. package/dist/compressRuntime.js +62 -0
  14. package/dist/compressRuntime.js.map +1 -0
  15. package/dist/files.d.ts +20 -0
  16. package/dist/files.js +38 -0
  17. package/dist/files.js.map +1 -1
  18. package/dist/formSubmit.d.ts +1 -0
  19. package/dist/formSubmit.js +14 -0
  20. package/dist/formSubmit.js.map +1 -0
  21. package/dist/host.d.ts +23 -0
  22. package/dist/host.js +175 -31
  23. package/dist/host.js.map +1 -1
  24. package/dist/hostCalls.d.ts +15 -0
  25. package/dist/hostCalls.js +75 -8
  26. package/dist/hostCalls.js.map +1 -1
  27. package/dist/js/Form.d.ts +19 -2
  28. package/dist/js/Form.js +110 -83
  29. package/dist/js/Form.js.map +1 -1
  30. package/dist/js/createViteRscApp.d.ts +1 -0
  31. package/dist/js/createViteRscApp.js +34 -5
  32. package/dist/js/createViteRscApp.js.map +1 -1
  33. package/dist/js/errors.d.ts +3 -1
  34. package/dist/js/errors.js +26 -3
  35. package/dist/js/errors.js.map +1 -1
  36. package/dist/js/formEncoding.d.ts +72 -3
  37. package/dist/js/formEncoding.js +284 -20
  38. package/dist/js/formEncoding.js.map +1 -1
  39. package/dist/js/navigate.d.ts +3 -0
  40. package/dist/js/navigate.js +56 -6
  41. package/dist/js/navigate.js.map +1 -1
  42. package/dist/js/updateStore.js +8 -2
  43. package/dist/js/updateStore.js.map +1 -1
  44. package/dist/openapi.d.ts +74 -0
  45. package/dist/openapi.js +172 -0
  46. package/dist/openapi.js.map +1 -0
  47. package/dist/redirect.d.ts +2 -2
  48. package/dist/redirect.js.map +1 -1
  49. package/dist/request.d.ts +56 -2
  50. package/dist/request.js +68 -4
  51. package/dist/request.js.map +1 -1
  52. package/dist/routes.d.ts +15 -1
  53. package/dist/routes.js.map +1 -1
  54. package/dist/testing.d.ts +14 -0
  55. package/dist/testing.js +56 -2
  56. package/dist/testing.js.map +1 -1
  57. package/dist/vite.d.ts +69 -1
  58. package/dist/vite.js +451 -45
  59. package/dist/vite.js.map +1 -1
  60. package/package.json +5 -1
package/dist/host.js CHANGED
@@ -24,8 +24,9 @@ import { withRevalidation } from "./revalidate.js";
24
24
  */
25
25
  export { revalidate } from "./revalidate.js";
26
26
  import { currentNotFound, withRedirect } from "./redirect.js";
27
+ import { compressed } from "./compress.js";
27
28
  import { withCache } from "./cache.js";
28
- import { withRequest, withResponseDraft } from "./request.js";
29
+ import { takeAfterWork, withRequest, withResponseDraft } from "./request.js";
29
30
  /**
30
31
  * @internal For a host adapter that embeds the engine. An app imports this
31
32
  * from `@rsc-kit/core/redirect` - the one the guides teach, and the one an
@@ -68,6 +69,21 @@ function inScript(value) {
68
69
  .replace(/\u2028/g, "\\u2028")
69
70
  .replace(/\u2029/g, "\\u2029");
70
71
  }
72
+ /**
73
+ * Whether the browser itself asked, for a document.
74
+ *
75
+ * Sec-Fetch-Mode is what every current browser sends and nothing else does;
76
+ * the Accept fallback is for the ones that do not, and for a test's Request.
77
+ * A payload request never counts, whatever it accepts.
78
+ */
79
+ function isNavigation(request) {
80
+ if (request.headers.has(HEADER.rsc))
81
+ return false;
82
+ const mode = request.headers.get("sec-fetch-mode");
83
+ if (mode)
84
+ return mode === "navigate";
85
+ return (request.headers.get("accept") ?? "").includes("text/html");
86
+ }
71
87
  function redirectResponse(to, isPayloadRequest) {
72
88
  if (isPayloadRequest) {
73
89
  return new Response(null, {
@@ -272,6 +288,7 @@ function matchPage(routes, url) {
272
288
  export function createRscHandler(options) {
273
289
  const { engine, assets, version } = options;
274
290
  const maxActionBody = options.maxActionBody ?? DEFAULT_MAX_ACTION_BODY;
291
+ const compress = options.compress ?? true;
275
292
  // Annotated rather than inferred: the narrowing below is lost inside the
276
293
  // closures that use it, and every one of them runs after the throw.
277
294
  const manifest = options.manifest ?? engine.manifest?.();
@@ -357,6 +374,52 @@ export function createRscHandler(options) {
357
374
  // everything below shares it: middleware, layouts, the page, and an action. A
358
375
  // guard that reads the session and a layout that reads it again are one
359
376
  // query, not two.
377
+ /**
378
+ * The fields of a form posted to a page, or null for anything else.
379
+ *
380
+ * A POST with a form body, from this origin, to a url that is a page and
381
+ * not the action endpoint, with an engine that can run what the form
382
+ * names. Read here so the decision is made once; the action reads the
383
+ * same fields from what is returned.
384
+ */
385
+ async function formPostOf(request, url) {
386
+ if (request.method !== "POST" || !engine.handleRscFormPost)
387
+ return null;
388
+ if (request.headers.has(HEADER.rsc))
389
+ return null;
390
+ const type = request.headers.get("content-type") ?? "";
391
+ if (!/^(?:application\/x-www-form-urlencoded|multipart\/form-data)/i.test(type))
392
+ return null;
393
+ if (!matchPage(routes, url))
394
+ return null;
395
+ if (!actionOriginAllowed(request, url))
396
+ return null;
397
+ try {
398
+ return await request.formData();
399
+ }
400
+ catch {
401
+ return null;
402
+ }
403
+ }
404
+ /**
405
+ * What a stored answer's compressed bytes are kept under.
406
+ *
407
+ * The build, the url, and the value of every request header the answer
408
+ * says it varies on - which is what tells the document for /login apart
409
+ * from the payload for /login. Keyed by the path alone, the payload
410
+ * request found the document's bytes waiting and hydration decoded HTML
411
+ * as Flight, silently, on every stored page in production.
412
+ */
413
+ function storedKey(request, response) {
414
+ const url = new URL(request.url);
415
+ const varies = (response.headers.get("Vary") ?? "")
416
+ .split(",")
417
+ .map((name) => name.trim().toLowerCase())
418
+ .filter((name) => name && name !== "*" && name !== "accept-encoding")
419
+ .sort()
420
+ .map((name) => `${name}=${request.headers.get(name) ?? ""}`);
421
+ return [version ?? "", url.pathname + url.search, ...varies].join("\n");
422
+ }
360
423
  return async function handle(request) {
361
424
  return await withRequest(request, () => withCache(() =>
362
425
  // Open for the whole request and sealed the moment an answer exists,
@@ -391,6 +454,26 @@ export function createRscHandler(options) {
391
454
  // off for a team whose policy strips every framework identifier.
392
455
  if (identify)
393
456
  response.headers.set("X-Powered-By", "rsc-kit");
457
+ // Work after() queued, now that the answer exists. A Worker keeps
458
+ // the isolate alive only for what is handed to waitUntil - Nitro's
459
+ // Cloudflare preset puts the execution context on the request - so
460
+ // it goes there where it can; a process keeps a detached promise.
461
+ const pending = takeAfterWork();
462
+ if (pending) {
463
+ const context = request.context;
464
+ if (typeof context?.waitUntil === "function")
465
+ context.waitUntil(pending);
466
+ }
467
+ // Last, over the finished answer, headers and all. A stored answer
468
+ // is the same bytes for everyone and is compressed once, keyed by
469
+ // the build and the url it was stored for.
470
+ if (compress) {
471
+ const answer = await compressed(request, response, servedFrom.get(response) === "stored" ? storedKey(request, response) : undefined);
472
+ const from = servedFrom.get(response);
473
+ if (answer !== response && from)
474
+ servedFrom.set(answer, from);
475
+ return answer;
476
+ }
394
477
  return response;
395
478
  })));
396
479
  };
@@ -453,7 +536,23 @@ export function createRscHandler(options) {
453
536
  const stored = await frozenApi(request, url, api);
454
537
  if (stored)
455
538
  return stored;
456
- return await engine.handleApiRoute(api.route.name, request, api.params, allowFor(api.route));
539
+ // A redirect() thrown from the handler is the route's answer, not a
540
+ // fault: a real Location, because whoever asked is meant to go there
541
+ // - a browser that followed a link to this route, or a fetch of an
542
+ // export that lives on a signed url. notFound() is its 404.
543
+ return await withRedirect(async (taken) => {
544
+ try {
545
+ return await engine.handleApiRoute(api.route.name, request, api.params, allowFor(api.route));
546
+ }
547
+ catch (error) {
548
+ const redirected = taken();
549
+ if (redirected)
550
+ return redirectResponse(redirected, false);
551
+ if (currentNotFound())
552
+ return new Response("Not found", { status: 404 });
553
+ throw error;
554
+ }
555
+ });
457
556
  }
458
557
  if (request.method === "GET" && url.pathname === HEADER.queryPath) {
459
558
  // Same check as an action, for a smaller reason: a cross-origin page
@@ -475,7 +574,14 @@ export function createRscHandler(options) {
475
574
  url.pathname === HEADER.pprResumePath) {
476
575
  return await servePprResume(request, url, options.prerendered);
477
576
  }
478
- if (request.method !== "GET" && request.method !== "HEAD")
577
+ // A form submitted before the page had a runtime: the browser posts it
578
+ // to the page's own url, as React wrote it, with the action's id among
579
+ // the fields. The action runs and the page renders with the result -
580
+ // what the guide promises for a form that had to work without
581
+ // javascript. Same-origin, as an action is; anything else that is not
582
+ // a read is not this host's.
583
+ const formPost = await formPostOf(request, url);
584
+ if (!formPost && request.method !== "GET" && request.method !== "HEAD")
479
585
  return null;
480
586
  // One named region of this page, asked for without mutating anything to
481
587
  // earn it. What an action invalidated does not come through here — that
@@ -498,7 +604,7 @@ export function createRscHandler(options) {
498
604
  // document puts the entire page inside that region, and answering an
499
605
  // interception with it replaces the page the modal was opening over.
500
606
  const match = matchPage(routes, url);
501
- if (options.prerendered) {
607
+ if (options.prerendered && !formPost) {
502
608
  // A guarded route can still be frozen: whether the content is the same
503
609
  // for everyone, and whether this caller may see it, are different
504
610
  // questions. The build answers the first; this answers the second, and
@@ -534,11 +640,13 @@ export function createRscHandler(options) {
534
640
  // still available. Nothing is buffered to make that true.
535
641
  let htmlStream;
536
642
  try {
537
- ({ htmlStream } = await engine.handleRscHtmlStream(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, undefined, url.pathname,
538
- // A route that ships no runtime gets no bootstrap and no segment
539
- // boundary — the boundary is itself a client component, so leaving
540
- // it in means no page could ever be JS-free.
541
- true));
643
+ ({ htmlStream } = formPost
644
+ ? await engine.handleRscFormPost(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, undefined, url.pathname, true, formPost)
645
+ : await engine.handleRscHtmlStream(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, undefined, url.pathname,
646
+ // A route that ships no runtime gets no bootstrap and no segment
647
+ // boundary — the boundary is itself a client component, so leaving
648
+ // it in means no page could ever be JS-free.
649
+ true));
542
650
  }
543
651
  catch (error) {
544
652
  // A rejected shell is how a redirect above every boundary arrives:
@@ -585,9 +693,13 @@ export function createRscHandler(options) {
585
693
  "Content-Type": HTML_TYPE,
586
694
  [HEADER.layouts]: chain.join(","),
587
695
  Vary: VARY_ON_RSC,
588
- "Cache-Control": match.route.middleware?.length
589
- ? PER_CLIENT
590
- : REVALIDATE,
696
+ // The answer to a post is the result of something that happened
697
+ // once; nothing may keep it.
698
+ "Cache-Control": formPost
699
+ ? "no-store"
700
+ : match.route.middleware?.length
701
+ ? PER_CLIENT
702
+ : REVALIDATE,
591
703
  }),
592
704
  });
593
705
  });
@@ -918,13 +1030,19 @@ export function createRscHandler(options) {
918
1030
  const payload = variant ?? (await read(`${key}.flight`));
919
1031
  if (payload === null)
920
1032
  return null;
1033
+ // As cacheable as the document it boots: the same build-time bytes for
1034
+ // everyone, unless a guard above the route decides who may have them.
1035
+ // Marked no-store, the service worker refused to keep it, and a
1036
+ // precached page rendered offline and never hydrated - the markup was
1037
+ // there and the payload it boots from was not.
1038
+ const guarded = matchPage(routes, url)?.route.middleware?.length ?? 0;
921
1039
  return new Response(payload, {
922
1040
  headers: withVersion({
923
1041
  "Content-Type": FLIGHT_TYPE,
924
1042
  [HEADER.segmentDepth]: String(variant ? shared : 0),
925
1043
  [HEADER.layouts]: chain.join(","),
926
1044
  Vary: VARY_ON_RSC,
927
- "Cache-Control": PER_CLIENT,
1045
+ "Cache-Control": guarded ? PER_CLIENT : REVALIDATE,
928
1046
  }),
929
1047
  });
930
1048
  }
@@ -1117,12 +1235,8 @@ export function createRscHandler(options) {
1117
1235
  // A redirect is a refusal here. Where it was going is told rather than
1118
1236
  // followed, so a client can decide for itself.
1119
1237
  const redirected = taken();
1120
- if (redirected) {
1121
- return new Response("Unauthorized", {
1122
- status: 401,
1123
- headers: { "X-RSC-Redirect": redirected.location },
1124
- });
1125
- }
1238
+ if (redirected)
1239
+ return apiRedirect(request, redirected);
1126
1240
  // A visitor who may not use this endpoint has not caused a server
1127
1241
  // error, and answering 500 makes a guarded route indistinguishable
1128
1242
  // from a broken one. Null means the middleware threw something that is
@@ -1135,15 +1249,29 @@ export function createRscHandler(options) {
1135
1249
  });
1136
1250
  }
1137
1251
  const redirected = taken();
1138
- if (redirected) {
1139
- return new Response("Unauthorized", {
1140
- status: 401,
1141
- headers: { "X-RSC-Redirect": redirected.location },
1142
- });
1143
- }
1252
+ if (redirected)
1253
+ return apiRedirect(request, redirected);
1144
1254
  return null;
1145
1255
  });
1146
1256
  }
1257
+ /**
1258
+ * A guard's redirect on a route.ts, answered for whoever asked.
1259
+ *
1260
+ * A browser that navigated here - followed a link to the route, typed the
1261
+ * url - is sent on with a real Location; a 401 would show it "Unauthorized"
1262
+ * over a page it cannot see. Code that fetched the route is told instead:
1263
+ * fetch follows a Location on its own and would hand back the login page's
1264
+ * html as the endpoint's answer, so it gets the 401 with the destination in
1265
+ * a header, and decides for itself.
1266
+ */
1267
+ function apiRedirect(request, to) {
1268
+ if (isNavigation(request))
1269
+ return redirectResponse(to, false);
1270
+ return new Response("Unauthorized", {
1271
+ status: 401,
1272
+ headers: { "X-RSC-Redirect": to.location },
1273
+ });
1274
+ }
1147
1275
  async function handleQuery(request, url) {
1148
1276
  if (!engine.handleQuery)
1149
1277
  return null;
@@ -1216,12 +1344,28 @@ export function createRscHandler(options) {
1216
1344
  : undefined;
1217
1345
  // Scoped to this action: revalidate() called anywhere inside it, at any
1218
1346
  // depth, marks here and nowhere else — two requests can be in flight and
1219
- // marking is per-request state.
1220
- const { stream } = await withRevalidation((taken) => engine.handleAction(actionId, body, contentType, page, taken));
1221
- return new Response(stream, {
1222
- headers: withVersion({
1223
- "Content-Type": "text/x-component; charset=utf-8",
1224
- }),
1347
+ // marking is per-request state. And redirect(): the guide says "throw
1348
+ // from the action and the client follows it", and the client does
1349
+ // follow an X-RSC-Redirect on an action's response — but the signal the
1350
+ // throw raises was never caught here, so every login that redirected
1351
+ // after signing in was a 500. Caught, it is the instruction the client
1352
+ // already knows how to read.
1353
+ return await withRedirect(async (redirected) => {
1354
+ let stream;
1355
+ try {
1356
+ ({ stream } = await withRevalidation((taken) => engine.handleAction(actionId, body, contentType, page, taken)));
1357
+ }
1358
+ catch (error) {
1359
+ const to = redirected();
1360
+ if (to)
1361
+ return redirectResponse(to, true);
1362
+ throw error;
1363
+ }
1364
+ return new Response(stream, {
1365
+ headers: withVersion({
1366
+ "Content-Type": "text/x-component; charset=utf-8",
1367
+ }),
1368
+ });
1225
1369
  });
1226
1370
  }
1227
1371
  }