@rsc-kit/core 0.29.10 → 0.30.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 (45) hide show
  1. package/dist/action.d.ts +69 -9
  2. package/dist/action.js +78 -3
  3. package/dist/action.js.map +1 -1
  4. package/dist/conformance.d.ts +1 -0
  5. package/dist/conformance.js +24 -0
  6. package/dist/conformance.js.map +1 -1
  7. package/dist/headers.d.ts +0 -5
  8. package/dist/headers.js +0 -5
  9. package/dist/headers.js.map +1 -1
  10. package/dist/host.d.ts +3 -8
  11. package/dist/host.js +49 -73
  12. package/dist/host.js.map +1 -1
  13. package/dist/hostCalls.d.ts +7 -0
  14. package/dist/hostCalls.js +7 -1
  15. package/dist/hostCalls.js.map +1 -1
  16. package/dist/inlineFlight.d.ts +9 -0
  17. package/dist/inlineFlight.js +126 -0
  18. package/dist/inlineFlight.js.map +1 -0
  19. package/dist/js/Form.d.ts +24 -6
  20. package/dist/js/Form.js +11 -2
  21. package/dist/js/Form.js.map +1 -1
  22. package/dist/js/SegmentBoundary.js +4 -14
  23. package/dist/js/SegmentBoundary.js.map +1 -1
  24. package/dist/js/createViteRscApp.js +5 -8
  25. package/dist/js/createViteRscApp.js.map +1 -1
  26. package/dist/js/inlinePayload.d.ts +6 -0
  27. package/dist/js/inlinePayload.js +56 -0
  28. package/dist/js/inlinePayload.js.map +1 -0
  29. package/dist/js/pagePayload.d.ts +1 -1
  30. package/dist/js/pagePayload.js +2 -2
  31. package/dist/js/pagePayload.js.map +1 -1
  32. package/dist/js/useAction.d.ts +9 -6
  33. package/dist/js/useAction.js.map +1 -1
  34. package/dist/testHost.d.ts +6 -2
  35. package/dist/testHost.js +6 -2
  36. package/dist/testHost.js.map +1 -1
  37. package/dist/testing.d.ts +16 -0
  38. package/dist/testing.js +30 -1
  39. package/dist/testing.js.map +1 -1
  40. package/dist/vite.js +61 -24
  41. package/dist/vite.js.map +1 -1
  42. package/package.json +1 -1
  43. package/dist/bootPayloads.d.ts +0 -18
  44. package/dist/bootPayloads.js +0 -81
  45. package/dist/bootPayloads.js.map +0 -1
@@ -0,0 +1,56 @@
1
+ // The payload a document carries for itself - see ../inlineFlight.ts.
2
+ //
3
+ // The server writes each chunk of the render's payload into the document as a
4
+ // script, `self.__rsc_f.push(chunk)`, and a null once it has finished. This
5
+ // turns those into the stream the runtime decodes, as if it had fetched it:
6
+ // what arrived before the runtime ran, then each chunk as it is parsed.
7
+ /**
8
+ * The document's own payload as a response, or null when it carries none -
9
+ * a page from a file the build wrote, a server that writes none - and the
10
+ * runtime fetches it, as it always did.
11
+ */
12
+ export function inlinePayload() {
13
+ const w = window;
14
+ const queue = w.__rsc_f;
15
+ if (!queue || typeof queue.push !== "function")
16
+ return null;
17
+ const encoder = new TextEncoder();
18
+ const bytes = (chunk) => typeof chunk === "string"
19
+ ? encoder.encode(chunk)
20
+ : Uint8Array.from(atob(chunk.b), (c) => c.charCodeAt(0));
21
+ const body = new ReadableStream({
22
+ start(controller) {
23
+ let ended = false;
24
+ const take = (chunk) => {
25
+ if (ended)
26
+ return;
27
+ if (chunk === null) {
28
+ ended = true;
29
+ controller.close();
30
+ return;
31
+ }
32
+ controller.enqueue(bytes(chunk));
33
+ };
34
+ for (let i = 0; i < queue.length; i++)
35
+ take(queue[i]);
36
+ queue.length = 0;
37
+ queue.push = (chunk) => {
38
+ take(chunk);
39
+ return 0;
40
+ };
41
+ // A document cut off before the server's end marker: what arrived is
42
+ // all there is, and the decoder is told so rather than left waiting.
43
+ const finished = () => take(null);
44
+ if (document.readyState === "loading")
45
+ document.addEventListener("DOMContentLoaded", finished, { once: true });
46
+ else
47
+ finished();
48
+ },
49
+ });
50
+ const headers = { "Content-Type": "text/x-component", "X-RSC-Segment-Depth": "0" };
51
+ if (typeof w.__rsc_l === "string")
52
+ headers["X-RSC-Layouts"] = w.__rsc_l;
53
+ delete w.__rsc_l;
54
+ return new Response(body, { headers });
55
+ }
56
+ //# sourceMappingURL=inlinePayload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inlinePayload.js","sourceRoot":"","sources":["../../src/js/inlinePayload.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,8EAA8E;AAC9E,4EAA4E;AAC5E,4EAA4E;AAC5E,wEAAwE;AAQxE;;;;GAIG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,CAAC,GAAG,MAA0D,CAAC;IACrE,MAAM,KAAK,GAAG,CAAC,CAAC,OAAO,CAAC;IAExB,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,UAAU;QAAE,OAAO,IAAI,CAAC;IAE5D,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,CAAC,KAAc,EAAc,EAAE,CAC3C,OAAO,KAAK,KAAK,QAAQ;QACvB,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;QACvB,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAE,KAAuB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;IAEhF,MAAM,IAAI,GAAG,IAAI,cAAc,CAAa;QAC1C,KAAK,CAAC,UAAU;YACd,IAAI,KAAK,GAAG,KAAK,CAAC;YAClB,MAAM,IAAI,GAAG,CAAC,KAAc,EAAE,EAAE;gBAC9B,IAAI,KAAK;oBAAE,OAAO;gBAClB,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;oBACnB,KAAK,GAAG,IAAI,CAAC;oBACb,UAAU,CAAC,KAAK,EAAE,CAAC;oBAEnB,OAAO;gBACT,CAAC;gBACD,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;YACnC,CAAC,CAAC;YAEF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE;gBAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YACtD,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;YACjB,KAAK,CAAC,IAAI,GAAG,CAAC,KAAc,EAAE,EAAE;gBAC9B,IAAI,CAAC,KAAK,CAAC,CAAC;gBAEZ,OAAO,CAAC,CAAC;YACX,CAAC,CAAC;YAEF,qEAAqE;YACrE,qEAAqE;YACrE,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAElC,IAAI,QAAQ,CAAC,UAAU,KAAK,SAAS;gBAAE,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;;gBAC1G,QAAQ,EAAE,CAAC;QAClB,CAAC;KACF,CAAC,CAAC;IAEH,MAAM,OAAO,GAA2B,EAAE,cAAc,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,GAAG,EAAE,CAAC;IAE3G,IAAI,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ;QAAE,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC;IAExE,OAAO,CAAC,CAAC,OAAO,CAAC;IAEjB,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;AACzC,CAAC","sourcesContent":["// The payload a document carries for itself - see ../inlineFlight.ts.\n//\n// The server writes each chunk of the render's payload into the document as a\n// script, `self.__rsc_f.push(chunk)`, and a null once it has finished. This\n// turns those into the stream the runtime decodes, as if it had fetched it:\n// what arrived before the runtime ran, then each chunk as it is parsed.\n\ninterface Queue {\n push(chunk: unknown): number;\n length: number;\n [index: number]: unknown;\n}\n\n/**\n * The document's own payload as a response, or null when it carries none -\n * a page from a file the build wrote, a server that writes none - and the\n * runtime fetches it, as it always did.\n */\nexport function inlinePayload(): Response | null {\n const w = window as unknown as { __rsc_f?: Queue; __rsc_l?: string };\n const queue = w.__rsc_f;\n\n if (!queue || typeof queue.push !== \"function\") return null;\n\n const encoder = new TextEncoder();\n const bytes = (chunk: unknown): Uint8Array =>\n typeof chunk === \"string\"\n ? encoder.encode(chunk)\n : Uint8Array.from(atob((chunk as { b: string }).b), (c) => c.charCodeAt(0));\n\n const body = new ReadableStream<Uint8Array>({\n start(controller) {\n let ended = false;\n const take = (chunk: unknown) => {\n if (ended) return;\n if (chunk === null) {\n ended = true;\n controller.close();\n\n return;\n }\n controller.enqueue(bytes(chunk));\n };\n\n for (let i = 0; i < queue.length; i++) take(queue[i]);\n queue.length = 0;\n queue.push = (chunk: unknown) => {\n take(chunk);\n\n return 0;\n };\n\n // A document cut off before the server's end marker: what arrived is\n // all there is, and the decoder is told so rather than left waiting.\n const finished = () => take(null);\n\n if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", finished, { once: true });\n else finished();\n },\n });\n\n const headers: Record<string, string> = { \"Content-Type\": \"text/x-component\", \"X-RSC-Segment-Depth\": \"0\" };\n\n if (typeof w.__rsc_l === \"string\") headers[\"X-RSC-Layouts\"] = w.__rsc_l;\n\n delete w.__rsc_l;\n\n return new Response(body, { headers });\n}\n"]}
@@ -11,4 +11,4 @@
11
11
  * a build. Nothing there can be imported by a test, which is how this handling
12
12
  * came to have none.
13
13
  */
14
- export declare function fetchPagePayload(url: string, fetchImpl?: typeof fetch, headers?: Record<string, string>): Promise<Response>;
14
+ export declare function fetchPagePayload(url: string, fetchImpl?: typeof fetch): Promise<Response>;
@@ -13,10 +13,10 @@ import { reportReachable } from "./onlineStore";
13
13
  * a build. Nothing there can be imported by a test, which is how this handling
14
14
  * came to have none.
15
15
  */
16
- export async function fetchPagePayload(url, fetchImpl = fetch, headers = {}) {
16
+ export async function fetchPagePayload(url, fetchImpl = fetch) {
17
17
  let response;
18
18
  try {
19
- response = await fetchImpl(url, { headers: { "X-RSC": "1", ...headers } });
19
+ response = await fetchImpl(url, { headers: { "X-RSC": "1" } });
20
20
  }
21
21
  catch (err) {
22
22
  // Nothing answered, so the app is offline as far as the router is
@@ -1 +1 @@
1
- {"version":3,"file":"pagePayload.js","sourceRoot":"","sources":["../../src/js/pagePayload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,GAAW,EACX,SAAS,GAAiB,KAAK,EAC/B,OAAO,GAA2B,EAAE;IAEpC,IAAI,QAAkB,CAAC;IAEvB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,OAAO,EAAE,EAAE,CAAC,CAAC;IAC7E,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,kEAAkE;QAClE,8CAA8C;QAC9C,eAAe,CAAC,KAAK,CAAC,CAAC;QACvB,mBAAmB,CAAC,kCAAkC,EAAE,GAAG,CAAC,CAAC;QAE7D,MAAM,GAAG,CAAC;IACZ,CAAC;IAED,wCAAwC;IACxC,eAAe,CAAC,IAAI,CAAC,CAAC;IAEtB,IAAI,CAAC;QACH,qBAAqB,CAAC,QAAQ,CAAC,CAAC;IAClC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,mBAAmB,CAAC,uCAAuC,EAAE,GAAG,CAAC,CAAC;QAElE,MAAM,GAAG,CAAC;IACZ,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["import { reportClientFailure, throwForFailedPayload } from \"./errors\";\nimport { reportReachable } from \"./onlineStore\";\n\n/**\n * Fetch the payload the page hydrates from, and account for it failing.\n *\n * This is the one request with nothing watching it. A PPR route's shell is\n * real HTML with a 200, so a page whose payload never arrives looks like it\n * loaded and then sits on its Suspense fallbacks indefinitely — no error, no\n * fallback, nothing an app can react to.\n *\n * Kept apart from createViteRscApp because that module imports the Flight\n * browser runtime, which reaches a Vite virtual module that exists only inside\n * a build. Nothing there can be imported by a test, which is how this handling\n * came to have none.\n */\nexport async function fetchPagePayload(\n url: string,\n fetchImpl: typeof fetch = fetch,\n headers: Record<string, string> = {},\n): Promise<Response> {\n let response: Response;\n\n try {\n response = await fetchImpl(url, { headers: { \"X-RSC\": \"1\", ...headers } });\n } catch (err) {\n // Nothing answered, so the app is offline as far as the router is\n // concerned — which is what useOffline reads.\n reportReachable(false);\n reportClientFailure(\"could not fetch the page payload\", err);\n\n throw err;\n }\n\n // Something answered, whatever it said.\n reportReachable(true);\n\n try {\n throwForFailedPayload(response);\n } catch (err) {\n reportClientFailure(\"the server could not render this page\", err);\n\n throw err;\n }\n\n return response;\n}\n"]}
1
+ {"version":3,"file":"pagePayload.js","sourceRoot":"","sources":["../../src/js/pagePayload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,GAAW,EACX,SAAS,GAAiB,KAAK;IAE/B,IAAI,QAAkB,CAAC;IAEvB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACjE,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,kEAAkE;QAClE,8CAA8C;QAC9C,eAAe,CAAC,KAAK,CAAC,CAAC;QACvB,mBAAmB,CAAC,kCAAkC,EAAE,GAAG,CAAC,CAAC;QAE7D,MAAM,GAAG,CAAC;IACZ,CAAC;IAED,wCAAwC;IACxC,eAAe,CAAC,IAAI,CAAC,CAAC;IAEtB,IAAI,CAAC;QACH,qBAAqB,CAAC,QAAQ,CAAC,CAAC;IAClC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,mBAAmB,CAAC,uCAAuC,EAAE,GAAG,CAAC,CAAC;QAElE,MAAM,GAAG,CAAC;IACZ,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["import { reportClientFailure, throwForFailedPayload } from \"./errors\";\nimport { reportReachable } from \"./onlineStore\";\n\n/**\n * Fetch the payload the page hydrates from, and account for it failing.\n *\n * This is the one request with nothing watching it. A PPR route's shell is\n * real HTML with a 200, so a page whose payload never arrives looks like it\n * loaded and then sits on its Suspense fallbacks indefinitely — no error, no\n * fallback, nothing an app can react to.\n *\n * Kept apart from createViteRscApp because that module imports the Flight\n * browser runtime, which reaches a Vite virtual module that exists only inside\n * a build. Nothing there can be imported by a test, which is how this handling\n * came to have none.\n */\nexport async function fetchPagePayload(\n url: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<Response> {\n let response: Response;\n\n try {\n response = await fetchImpl(url, { headers: { \"X-RSC\": \"1\" } });\n } catch (err) {\n // Nothing answered, so the app is offline as far as the router is\n // concerned — which is what useOffline reads.\n reportReachable(false);\n reportClientFailure(\"could not fetch the page payload\", err);\n\n throw err;\n }\n\n // Something answered, whatever it said.\n reportReachable(true);\n\n try {\n throwForFailedPayload(response);\n } catch (err) {\n reportClientFailure(\"the server could not render this page\", err);\n\n throw err;\n }\n\n return response;\n}\n"]}
@@ -1,6 +1,9 @@
1
1
  import type { ActionResult } from "../action.js";
2
- type AnyAction = (...args: any[]) => Promise<ActionResult<any>>;
3
- type DataOf<A extends AnyAction> = Awaited<ReturnType<A>> extends ActionResult<infer D> ? D : never;
2
+ type AnyAction = (...args: any[]) => Promise<ActionResult<any, any>>;
3
+ type DataOf<A extends AnyAction> = Awaited<ReturnType<A>> extends ActionResult<infer D, any> ? D : never;
4
+ type RefusalOf<A extends AnyAction> = Awaited<ReturnType<A>> extends ActionResult<any, infer R> ? R : never;
5
+ /** The whole result, refusal type included - see `.refusal(schema)`. */
6
+ type ResultOf<A extends AnyAction> = ActionResult<DataOf<A>, RefusalOf<A>>;
4
7
  export type UseActionStatus = "idle" | "executing" | "hasSucceeded" | "hasErrored";
5
8
  export interface UseActionOptions<A extends AnyAction> {
6
9
  /**
@@ -17,9 +20,9 @@ export interface UseActionOptions<A extends AnyAction> {
17
20
  * validationErrors, the first one - so a button with no field beside it
18
21
  * still has something to show.
19
22
  */
20
- onError?: (message: string, result: ActionResult<DataOf<A>>) => void;
23
+ onError?: (message: string, result: ResultOf<A>) => void;
21
24
  /** After either, for state that tracks the call rather than its outcome. */
22
- onSettled?: (result: ActionResult<DataOf<A>>) => void;
25
+ onSettled?: (result: ResultOf<A>) => void;
23
26
  }
24
27
  /**
25
28
  * Call an action from a button rather than a form.
@@ -46,10 +49,10 @@ export interface UseActionOptions<A extends AnyAction> {
46
49
  */
47
50
  export declare function useAction<A extends AnyAction>(action: A, options?: UseActionOptions<A>): {
48
51
  execute: (...args: Parameters<A>) => void;
49
- executeAsync: (...args: Parameters<A>) => Promise<ActionResult<DataOf<A>>>;
52
+ executeAsync: (...args: Parameters<A>) => Promise<ResultOf<A>>;
50
53
  isPending: boolean;
51
54
  status: UseActionStatus;
52
- result: ActionResult<DataOf<A>>;
55
+ result: ResultOf<A>;
53
56
  reset: () => void;
54
57
  };
55
58
  export default useAction;
@@ -1 +1 @@
1
- {"version":3,"file":"useAction.js","sourceRoot":"","sources":["../../src/js/useAction.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AA+BtF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,SAAS,CAAsB,MAAS,EAAE,OAAO,GAAwB,EAAE;IAGzF,MAAM,CAAC,SAAS,EAAE,eAAe,CAAC,GAAG,aAAa,EAAE,CAAC;IACrD,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAS,EAAE,CAAC,CAAC;IACjD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAuC,IAAI,CAAC,CAAC;IAEnF,0EAA0E;IAC1E,2EAA2E;IAC3E,6DAA6D;IAC7D,MAAM,MAAM,GAAG,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IAE3C,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,CAAC,OAAO,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,WAAW,CAAC,KAAK,EAAE,IAAmB,EAAmB,EAAE;QACrE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC;QAE3C,OAAO,CAAC,UAAU,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QAE9B,IAAI,IAAY,CAAC;QAEjB,IAAI,CAAC;YACH,IAAI,GAAG,CAAC,MAAM,MAAM,CAAC,GAAG,IAAI,CAAC,CAAW,CAAC;QAC3C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,GAAG,EAAE,WAAW,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACjF,CAAC;QAED,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC;QAE/C,SAAS,CAAC,IAAI,CAAC,CAAC;QAChB,UAAU,CAAC,IAAI,CAAC,WAAW,KAAK,SAAS,IAAI,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC;QAEpG,IAAI,IAAI,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;YACnC,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;QAC5C,CAAC;aAAM,IAAI,IAAI,CAAC,gBAAgB,EAAE,CAAC;YACjC,OAAO,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,gBAAgB,EAAE,IAAI,CAAC,CAAC;QAC9F,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,IAAiB,CAAC,CAAC;QAC9C,CAAC;QAED,OAAO,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC,CAAC;QAE1B,OAAO,IAAI,CAAC;IACd,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,MAAM,YAAY,GAAG,WAAW,CAC9B,CAAC,GAAG,IAAmB,EAAmB,EAAE,CAC1C,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QACtB,eAAe,CAAC,KAAK,IAAI,EAAE;YACzB,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3B,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,EACJ,CAAC,GAAG,CAAC,CACN,CAAC;IAEF,MAAM,OAAO,GAAG,WAAW,CACzB,CAAC,GAAG,IAAmB,EAAQ,EAAE;QAC/B,KAAK,YAAY,CAAC,GAAG,IAAI,CAAC,CAAC;IAC7B,CAAC,EACD,CAAC,YAAY,CAAC,CACf,CAAC;IAEF,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;QAC7B,SAAS,CAAC,EAAE,CAAC,CAAC;QACd,UAAU,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,MAAM,MAAM,GAAoB,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC;IAE9E,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;AACrE,CAAC;AAED,eAAe,SAAS,CAAC","sourcesContent":["\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState, useTransition } from \"react\";\nimport type { ActionResult } from \"../action.js\";\n\n// Any built action: createActionClient's handler(), with or without a schema.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype AnyAction = (...args: any[]) => Promise<ActionResult<any>>;\n\ntype DataOf<A extends AnyAction> = Awaited<ReturnType<A>> extends ActionResult<infer D> ? D : never;\n\nexport type UseActionStatus = \"idle\" | \"executing\" | \"hasSucceeded\" | \"hasErrored\";\n\nexport interface UseActionOptions<A extends AnyAction> {\n /**\n * Called inside the action's transition, before the action runs, with what\n * it was called with - where React's `useOptimistic` setter goes. React\n * puts the optimistic state back when the transition ends, so a failed\n * action takes its change back on its own.\n */\n optimistic?: (...args: Parameters<A>) => void;\n /** The action answered with data: no serverError, no validationErrors. */\n onSuccess?: (data: DataOf<A>) => void;\n /**\n * The action refused or failed. With a serverError, that message; with\n * validationErrors, the first one - so a button with no field beside it\n * still has something to show.\n */\n onError?: (message: string, result: ActionResult<DataOf<A>>) => void;\n /** After either, for state that tracks the call rather than its outcome. */\n onSettled?: (result: ActionResult<DataOf<A>>) => void;\n}\n\n/**\n * Call an action from a button rather than a form.\n *\n * const { execute, isPending } = useAction(archivePost, {\n * optimistic: (input) => hide(input.id),\n * onError: (message) => toast.error(message),\n * })\n *\n * <button disabled={isPending} onClick={() => execute({ id })}>Archive</button>\n *\n * The action's own types carry through: `execute` takes what the action's\n * schema takes, and `onSuccess` is handed what its handler returns. A form does\n * not need this - <Form> reads the same result on its own.\n *\n * Neither `execute` nor `executeAsync` throws. An action built on the action\n * client returns its failures, and anything thrown on the way - the network,\n * a server that answered with a page - becomes a serverError: left thrown,\n * the transition never settled and an optimistic change stayed on screen as\n * though the write had worked.\n *\n * An action that redirected settles nothing: no callback runs and no state is\n * set, because the page is on its way somewhere else.\n */\nexport function useAction<A extends AnyAction>(action: A, options: UseActionOptions<A> = {}) {\n type Result = ActionResult<DataOf<A>>;\n\n const [isPending, startTransition] = useTransition();\n const [result, setResult] = useState<Result>({});\n const [settled, setSettled] = useState<\"hasSucceeded\" | \"hasErrored\" | null>(null);\n\n // Read when the action runs, not when execute was made: execute keeps one\n // identity across renders, so an onClick built on it is not rebuilt, and a\n // callback closing over newer state is still the one called.\n const latest = useRef({ action, options });\n\n useLayoutEffect(() => {\n latest.current = { action, options };\n });\n\n const run = useCallback(async (args: Parameters<A>): Promise<Result> => {\n const { action, options } = latest.current;\n\n options.optimistic?.(...args);\n\n let next: Result;\n\n try {\n next = (await action(...args)) as Result;\n } catch (error) {\n next = { serverError: error instanceof Error ? error.message : String(error) };\n }\n\n if (next.redirected !== undefined) return next;\n\n setResult(next);\n setSettled(next.serverError !== undefined || next.validationErrors ? \"hasErrored\" : \"hasSucceeded\");\n\n if (next.serverError !== undefined) {\n options.onError?.(next.serverError, next);\n } else if (next.validationErrors) {\n options.onError?.(Object.values(next.validationErrors).flat()[0] ?? \"Invalid input.\", next);\n } else {\n options.onSuccess?.(next.data as DataOf<A>);\n }\n\n options.onSettled?.(next);\n\n return next;\n }, []);\n\n const executeAsync = useCallback(\n (...args: Parameters<A>): Promise<Result> =>\n new Promise((resolve) => {\n startTransition(async () => {\n resolve(await run(args));\n });\n }),\n [run],\n );\n\n const execute = useCallback(\n (...args: Parameters<A>): void => {\n void executeAsync(...args);\n },\n [executeAsync],\n );\n\n const reset = useCallback(() => {\n setResult({});\n setSettled(null);\n }, []);\n\n const status: UseActionStatus = isPending ? \"executing\" : (settled ?? \"idle\");\n\n return { execute, executeAsync, isPending, status, result, reset };\n}\n\nexport default useAction;\n"]}
1
+ {"version":3,"file":"useAction.js","sourceRoot":"","sources":["../../src/js/useAction.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState, useTransition } from \"react\";\nimport type { ActionResult } from \"../action.js\";\n\n// Any built action: createActionClient's handler(), with or without a schema.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype AnyAction = (...args: any[]) => Promise<ActionResult<any, any>>;\n\ntype DataOf<A extends AnyAction> = Awaited<ReturnType<A>> extends ActionResult<infer D, any> ? D : never;\n\ntype RefusalOf<A extends AnyAction> = Awaited<ReturnType<A>> extends ActionResult<any, infer R> ? R : never;\n\n/** The whole result, refusal type included - see `.refusal(schema)`. */\ntype ResultOf<A extends AnyAction> = ActionResult<DataOf<A>, RefusalOf<A>>;\n\nexport type UseActionStatus = \"idle\" | \"executing\" | \"hasSucceeded\" | \"hasErrored\";\n\nexport interface UseActionOptions<A extends AnyAction> {\n /**\n * Called inside the action's transition, before the action runs, with what\n * it was called with - where React's `useOptimistic` setter goes. React\n * puts the optimistic state back when the transition ends, so a failed\n * action takes its change back on its own.\n */\n optimistic?: (...args: Parameters<A>) => void;\n /** The action answered with data: no serverError, no validationErrors. */\n onSuccess?: (data: DataOf<A>) => void;\n /**\n * The action refused or failed. With a serverError, that message; with\n * validationErrors, the first one - so a button with no field beside it\n * still has something to show.\n */\n onError?: (message: string, result: ResultOf<A>) => void;\n /** After either, for state that tracks the call rather than its outcome. */\n onSettled?: (result: ResultOf<A>) => void;\n}\n\n/**\n * Call an action from a button rather than a form.\n *\n * const { execute, isPending } = useAction(archivePost, {\n * optimistic: (input) => hide(input.id),\n * onError: (message) => toast.error(message),\n * })\n *\n * <button disabled={isPending} onClick={() => execute({ id })}>Archive</button>\n *\n * The action's own types carry through: `execute` takes what the action's\n * schema takes, and `onSuccess` is handed what its handler returns. A form does\n * not need this - <Form> reads the same result on its own.\n *\n * Neither `execute` nor `executeAsync` throws. An action built on the action\n * client returns its failures, and anything thrown on the way - the network,\n * a server that answered with a page - becomes a serverError: left thrown,\n * the transition never settled and an optimistic change stayed on screen as\n * though the write had worked.\n *\n * An action that redirected settles nothing: no callback runs and no state is\n * set, because the page is on its way somewhere else.\n */\nexport function useAction<A extends AnyAction>(action: A, options: UseActionOptions<A> = {}) {\n type Result = ResultOf<A>;\n\n const [isPending, startTransition] = useTransition();\n const [result, setResult] = useState<Result>({});\n const [settled, setSettled] = useState<\"hasSucceeded\" | \"hasErrored\" | null>(null);\n\n // Read when the action runs, not when execute was made: execute keeps one\n // identity across renders, so an onClick built on it is not rebuilt, and a\n // callback closing over newer state is still the one called.\n const latest = useRef({ action, options });\n\n useLayoutEffect(() => {\n latest.current = { action, options };\n });\n\n const run = useCallback(async (args: Parameters<A>): Promise<Result> => {\n const { action, options } = latest.current;\n\n options.optimistic?.(...args);\n\n let next: Result;\n\n try {\n next = (await action(...args)) as Result;\n } catch (error) {\n next = { serverError: error instanceof Error ? error.message : String(error) };\n }\n\n if (next.redirected !== undefined) return next;\n\n setResult(next);\n setSettled(next.serverError !== undefined || next.validationErrors ? \"hasErrored\" : \"hasSucceeded\");\n\n if (next.serverError !== undefined) {\n options.onError?.(next.serverError, next);\n } else if (next.validationErrors) {\n options.onError?.(Object.values(next.validationErrors).flat()[0] ?? \"Invalid input.\", next);\n } else {\n options.onSuccess?.(next.data as DataOf<A>);\n }\n\n options.onSettled?.(next);\n\n return next;\n }, []);\n\n const executeAsync = useCallback(\n (...args: Parameters<A>): Promise<Result> =>\n new Promise((resolve) => {\n startTransition(async () => {\n resolve(await run(args));\n });\n }),\n [run],\n );\n\n const execute = useCallback(\n (...args: Parameters<A>): void => {\n void executeAsync(...args);\n },\n [executeAsync],\n );\n\n const reset = useCallback(() => {\n setResult({});\n setSettled(null);\n }, []);\n\n const status: UseActionStatus = isPending ? \"executing\" : (settled ?? \"idle\");\n\n return { execute, executeAsync, isPending, status, result, reset };\n}\n\nexport default useAction;\n"]}
@@ -102,8 +102,12 @@ export declare const hostReply: {
102
102
  readonly unauthorized: (message?: string) => Reply;
103
103
  /** Send the visitor somewhere else, as the host's redirect does. */
104
104
  readonly redirect: (to: string, status?: number) => Reply;
105
- /** Refuse with a status - a 404 for a record this caller cannot see, a 429. */
106
- readonly refuse: (status: number, message?: string) => Reply;
105
+ /**
106
+ * Refuse with a status - a 404 for a record this caller cannot see, a 429 -
107
+ * and, optionally, data for the page to act on: what is blocking a delete.
108
+ * With data it reaches an action as `refuse()` does, as `result.refusal`.
109
+ */
110
+ readonly refuse: (status: number, message?: string, data?: unknown) => Reply;
107
111
  /** The input was refused, by field: an action returns these as validationErrors. */
108
112
  readonly invalid: (errors: Record<string, string[]>) => Reply;
109
113
  /** An unexpected failure, as an adapter answers a crash: an error, not a refusal. */
package/dist/testHost.js CHANGED
@@ -40,8 +40,12 @@ export const hostReply = {
40
40
  unauthorized: (message) => reply({ unauthorized: true, error: message }),
41
41
  /** Send the visitor somewhere else, as the host's redirect does. */
42
42
  redirect: (to, status = 307) => reply({ redirect: to, redirectStatus: status }),
43
- /** Refuse with a status - a 404 for a record this caller cannot see, a 429. */
44
- refuse: (status, message = 'Refused.') => reply({ error: message, refusalStatus: status }),
43
+ /**
44
+ * Refuse with a status - a 404 for a record this caller cannot see, a 429 -
45
+ * and, optionally, data for the page to act on: what is blocking a delete.
46
+ * With data it reaches an action as `refuse()` does, as `result.refusal`.
47
+ */
48
+ refuse: (status, message = 'Refused.', data) => reply(data === undefined ? { error: message, refusalStatus: status } : { error: message, refusalStatus: status, refusalData: data }),
45
49
  /** The input was refused, by field: an action returns these as validationErrors. */
46
50
  invalid: (errors) => reply({ validationErrors: errors }),
47
51
  /** An unexpected failure, as an adapter answers a crash: an error, not a refusal. */
@@ -1 +1 @@
1
- {"version":3,"file":"testHost.js","sourceRoot":"","sources":["../src/testHost.ts"],"names":[],"mappings":"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","sourcesContent":["// A backend for tests: answers rpc() calls and host middleware the way a Go\n// or Laravel host answers them over HTTP, without one running.\n//\n// The app under test talks to it through the real client, httpHostCalls,\n// handed a fetch that answers in-process. So a test exercises what ships -\n// batching, a redirect raised as the engine's own, an unauthenticated reply\n// becoming a 401 - and only the backend is stood in for.\n\nimport type { HostCallReply } from './hostCalls.js'\nimport { CHANGED_FUNCTION, memoryVersions } from './changed.js'\nimport type { ChangedAnswer, ChangedQuery } from './changed.js'\n\nconst REPLY = Symbol.for('rsc-kit.test-host-reply')\n/** Cookies a reply sets: sent as Set-Cookie headers, never in the body. */\nconst COOKIES = Symbol.for('rsc-kit.test-host-cookies')\n\n/** The name the engine asks a host's route guards under. */\nexport const HOST_MIDDLEWARE = '__rsc.middleware'\n\n/**\n * Tag versions for a test host, kept in the test.\n *\n * const names = testChanges()\n * const app = await createTestApp({ host: { ...names.host, 'Repos.list': () => repos } })\n * names.changed('team:1:repos') // what the backend's webhook would say\n *\n * `host` answers `__rsc.changed` the way an adapter does - holding the call up\n * to `wait` for a name to move - and `changed` moves one.\n */\n/**\n * The test host's answer to `__rsc.changed`: given what any test host is\n * given, answering a `ChangedAnswer`.\n *\n * Both halves matter. It takes `HostCallInput`, so an untyped host of\n * `HostHandler`s accepts it; it returns a `ChangedAnswer`, so a host typed\n * from the app's rsc-host.json - whose `__rsc.changed` must answer one -\n * accepts it too. Typed as `HostHandler`, it returned `unknown`, and\n * spreading it into a typed host did not compile.\n */\nexport interface ChangedHost {\n [CHANGED_FUNCTION]: (call: HostCallInput) => Promise<ChangedAnswer>\n}\n\nexport function testChanges(): { host: ChangedHost; changed: (...names: string[]) => void } {\n const source = memoryVersions()\n\n return {\n host: {\n [CHANGED_FUNCTION]: async ({ args }) => {\n const query = args[0] as Partial<ChangedQuery> | undefined\n\n return { versions: await source.changed(query?.since ?? {}, Math.min(query?.wait ?? 0, 5_000)) }\n },\n },\n changed: (...names) => void source.bump(names),\n }\n}\n\n/** What a test handler is given: the call's arguments, and the headers the renderer forwarded. */\nexport interface HostCallInput {\n args: unknown[]\n /** The visitor's request headers the renderer passed on - the cookie, the Accept-Language. */\n headers: Headers\n}\n\n/** Answers one function. Return a value for its result, or one of `hostReply`'s answers. */\nexport type HostHandler = (call: HostCallInput) => unknown\n\ntype Reply = Omit<HostCallReply, 'result'> & { [REPLY]: true }\n\n/** One of the protocol's own answers, from `hostReply`. */\nexport type HostReply = Reply\n\ndeclare global {\n /**\n * The backend's functions, as the build read them from rsc-host.json:\n * each one's arguments and result. Written into .rsc-kit/rsc-env.d.ts;\n * empty without a manifest.\n */\n // eslint-disable-next-line @typescript-eslint/no-empty-object-type\n interface RscHostFunctions {}\n}\n\ntype Answer<T> = T | HostReply | Promise<T | HostReply>\n\ntype Guards = {\n [HOST_MIDDLEWARE]?: (call: { args: [names: string[]]; headers: Headers }) => Answer<true>\n [CHANGED_FUNCTION]?: (call: { args: [query: ChangedQuery]; headers: Headers }) => Answer<ChangedAnswer>\n}\n\n/**\n * The backend's functions, by the name rpc() calls them with.\n *\n * Typed from the app's rsc-host.json when it has one: a handler may only\n * answer for a function the backend has, is given its arguments, and returns\n * its result or one of `hostReply`'s answers - never a shape the backend\n * would not send, which is how a fake drifts from the real thing while its\n * tests stay green. Without a manifest, any name and any value.\n *\n * Route guards declared for the host are asked as `__rsc.middleware`\n * (`HOST_MIDDLEWARE`), with the guard names as the first argument; answer\n * `true` to let the page render.\n */\nexport type TestHost = [keyof RscHostFunctions] extends [never]\n ? Record<string, HostHandler>\n : {\n [K in keyof RscHostFunctions]?: RscHostFunctions[K] extends { args: infer A extends unknown[]; result: infer R }\n ? (call: { args: A; headers: Headers }) => Answer<R>\n : never\n } & Guards\n\nconst reply = (fields: Omit<Reply, typeof REPLY>): Reply => ({ ...fields, [REPLY]: true })\n\n/**\n * The protocol's own answers, for a handler to return.\n *\n * host: {\n * 'Apps.list': ({ headers }) =>\n * headers.get('cookie')?.includes('session=') ? [{ id: 1 }] : hostReply.unauthenticated(),\n * 'Apps.create': ({ args }) => hostReply.invalid({ name: ['Taken.'] }),\n * }\n */\nexport const hostReply = {\n /** No session: the page answers 401, an action its auth error. */\n unauthenticated: (message?: string): Reply => reply({ unauthenticated: true, error: message }),\n /** A session that still may not: 403. */\n unauthorized: (message?: string): Reply => reply({ unauthorized: true, error: message }),\n /** Send the visitor somewhere else, as the host's redirect does. */\n redirect: (to: string, status = 307): Reply => reply({ redirect: to, redirectStatus: status }),\n /** Refuse with a status - a 404 for a record this caller cannot see, a 429. */\n refuse: (status: number, message = 'Refused.'): Reply => reply({ error: message, refusalStatus: status }),\n /** The input was refused, by field: an action returns these as validationErrors. */\n invalid: (errors: Record<string, string[]>): Reply => reply({ validationErrors: errors }),\n /** An unexpected failure, as an adapter answers a crash: an error, not a refusal. */\n fail: (message = 'Something went wrong.'): Reply => reply({ error: message }),\n /** A result, and the regions of the page the call says it changed. */\n revalidating: (result: unknown, ...targets: string[]): Reply =>\n reply({ result, revalidate: targets } as Omit<Reply, typeof REPLY>),\n /** A result, and cookies set on the response - a login's session: `'session=abc; Path=/; HttpOnly'`. */\n settingCookies: (result: unknown, ...cookies: string[]): Reply =>\n reply({ result, [COOKIES]: cookies } as unknown as Omit<Reply, typeof REPLY>),\n} as const\n\nfunction isReply(value: unknown): value is Reply {\n return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[REPLY] === true\n}\n\nasync function answer(host: TestHost, name: string, args: unknown[], headers: Headers): Promise<HostCallReply> {\n // By a name only known at runtime: a typed host has no index signature.\n const handler = (host as Record<string, HostHandler | undefined>)[name]\n\n // Fails the call rather than answering null: a function the test forgot\n // is a test that would otherwise pass against nothing.\n if (!handler) {\n return {\n error:\n name === HOST_MIDDLEWARE\n ? `the page asked its host guards (${JSON.stringify(args[0])}) and the test host has no \"${HOST_MIDDLEWARE}\" handler`\n : `the test host has no handler for ${JSON.stringify(name)}`,\n }\n }\n\n const value = await handler({ args, headers })\n\n if (isReply(value)) {\n // Symbol keys survive the rest spread; COOKIES is read off by the fetch\n // below, and JSON leaves it out of the body.\n const { [REPLY]: _, ...fields } = value\n\n return fields\n }\n\n return { result: value === undefined ? null : value }\n}\n\n/**\n * A fetch that answers the host-call endpoint from `host`: one call as\n * itself, a batch as `{ replies }`, in order.\n */\nexport function testHostFetch(host: TestHost): typeof fetch {\n return (async (_input: RequestInfo | URL, init?: RequestInit) => {\n const headers = new Headers(init?.headers)\n const body = JSON.parse(String(init?.body ?? '{}')) as\n | { function: string; args?: unknown[] }\n | { calls: { function: string; args?: unknown[] }[] }\n\n // The renderer's own headers are not the visitor's.\n headers.delete('x-rsc-host-secret')\n headers.delete('content-type')\n\n const json = (value: unknown) =>\n new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } })\n\n if ('calls' in body) {\n return json({\n replies: await Promise.all(body.calls.map((c) => answer(host, c.function, c.args ?? [], headers))),\n })\n }\n\n const single = await answer(host, body.function, body.args ?? [], headers)\n const cookies = (single as { [COOKIES]?: string[] })[COOKIES] ?? []\n const response = json(single)\n\n for (const cookie of cookies) response.headers.append('Set-Cookie', cookie)\n\n return response\n }) as typeof fetch\n}\n"]}
1
+ {"version":3,"file":"testHost.js","sourceRoot":"","sources":["../src/testHost.ts"],"names":[],"mappings":"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","sourcesContent":["// A backend for tests: answers rpc() calls and host middleware the way a Go\n// or Laravel host answers them over HTTP, without one running.\n//\n// The app under test talks to it through the real client, httpHostCalls,\n// handed a fetch that answers in-process. So a test exercises what ships -\n// batching, a redirect raised as the engine's own, an unauthenticated reply\n// becoming a 401 - and only the backend is stood in for.\n\nimport type { HostCallReply } from './hostCalls.js'\nimport { CHANGED_FUNCTION, memoryVersions } from './changed.js'\nimport type { ChangedAnswer, ChangedQuery } from './changed.js'\n\nconst REPLY = Symbol.for('rsc-kit.test-host-reply')\n/** Cookies a reply sets: sent as Set-Cookie headers, never in the body. */\nconst COOKIES = Symbol.for('rsc-kit.test-host-cookies')\n\n/** The name the engine asks a host's route guards under. */\nexport const HOST_MIDDLEWARE = '__rsc.middleware'\n\n/**\n * Tag versions for a test host, kept in the test.\n *\n * const names = testChanges()\n * const app = await createTestApp({ host: { ...names.host, 'Repos.list': () => repos } })\n * names.changed('team:1:repos') // what the backend's webhook would say\n *\n * `host` answers `__rsc.changed` the way an adapter does - holding the call up\n * to `wait` for a name to move - and `changed` moves one.\n */\n/**\n * The test host's answer to `__rsc.changed`: given what any test host is\n * given, answering a `ChangedAnswer`.\n *\n * Both halves matter. It takes `HostCallInput`, so an untyped host of\n * `HostHandler`s accepts it; it returns a `ChangedAnswer`, so a host typed\n * from the app's rsc-host.json - whose `__rsc.changed` must answer one -\n * accepts it too. Typed as `HostHandler`, it returned `unknown`, and\n * spreading it into a typed host did not compile.\n */\nexport interface ChangedHost {\n [CHANGED_FUNCTION]: (call: HostCallInput) => Promise<ChangedAnswer>\n}\n\nexport function testChanges(): { host: ChangedHost; changed: (...names: string[]) => void } {\n const source = memoryVersions()\n\n return {\n host: {\n [CHANGED_FUNCTION]: async ({ args }) => {\n const query = args[0] as Partial<ChangedQuery> | undefined\n\n return { versions: await source.changed(query?.since ?? {}, Math.min(query?.wait ?? 0, 5_000)) }\n },\n },\n changed: (...names) => void source.bump(names),\n }\n}\n\n/** What a test handler is given: the call's arguments, and the headers the renderer forwarded. */\nexport interface HostCallInput {\n args: unknown[]\n /** The visitor's request headers the renderer passed on - the cookie, the Accept-Language. */\n headers: Headers\n}\n\n/** Answers one function. Return a value for its result, or one of `hostReply`'s answers. */\nexport type HostHandler = (call: HostCallInput) => unknown\n\ntype Reply = Omit<HostCallReply, 'result'> & { [REPLY]: true }\n\n/** One of the protocol's own answers, from `hostReply`. */\nexport type HostReply = Reply\n\ndeclare global {\n /**\n * The backend's functions, as the build read them from rsc-host.json:\n * each one's arguments and result. Written into .rsc-kit/rsc-env.d.ts;\n * empty without a manifest.\n */\n // eslint-disable-next-line @typescript-eslint/no-empty-object-type\n interface RscHostFunctions {}\n}\n\ntype Answer<T> = T | HostReply | Promise<T | HostReply>\n\ntype Guards = {\n [HOST_MIDDLEWARE]?: (call: { args: [names: string[]]; headers: Headers }) => Answer<true>\n [CHANGED_FUNCTION]?: (call: { args: [query: ChangedQuery]; headers: Headers }) => Answer<ChangedAnswer>\n}\n\n/**\n * The backend's functions, by the name rpc() calls them with.\n *\n * Typed from the app's rsc-host.json when it has one: a handler may only\n * answer for a function the backend has, is given its arguments, and returns\n * its result or one of `hostReply`'s answers - never a shape the backend\n * would not send, which is how a fake drifts from the real thing while its\n * tests stay green. Without a manifest, any name and any value.\n *\n * Route guards declared for the host are asked as `__rsc.middleware`\n * (`HOST_MIDDLEWARE`), with the guard names as the first argument; answer\n * `true` to let the page render.\n */\nexport type TestHost = [keyof RscHostFunctions] extends [never]\n ? Record<string, HostHandler>\n : {\n [K in keyof RscHostFunctions]?: RscHostFunctions[K] extends { args: infer A extends unknown[]; result: infer R }\n ? (call: { args: A; headers: Headers }) => Answer<R>\n : never\n } & Guards\n\nconst reply = (fields: Omit<Reply, typeof REPLY>): Reply => ({ ...fields, [REPLY]: true })\n\n/**\n * The protocol's own answers, for a handler to return.\n *\n * host: {\n * 'Apps.list': ({ headers }) =>\n * headers.get('cookie')?.includes('session=') ? [{ id: 1 }] : hostReply.unauthenticated(),\n * 'Apps.create': ({ args }) => hostReply.invalid({ name: ['Taken.'] }),\n * }\n */\nexport const hostReply = {\n /** No session: the page answers 401, an action its auth error. */\n unauthenticated: (message?: string): Reply => reply({ unauthenticated: true, error: message }),\n /** A session that still may not: 403. */\n unauthorized: (message?: string): Reply => reply({ unauthorized: true, error: message }),\n /** Send the visitor somewhere else, as the host's redirect does. */\n redirect: (to: string, status = 307): Reply => reply({ redirect: to, redirectStatus: status }),\n /**\n * Refuse with a status - a 404 for a record this caller cannot see, a 429 -\n * and, optionally, data for the page to act on: what is blocking a delete.\n * With data it reaches an action as `refuse()` does, as `result.refusal`.\n */\n refuse: (status: number, message = 'Refused.', data?: unknown): Reply =>\n reply(data === undefined ? { error: message, refusalStatus: status } : { error: message, refusalStatus: status, refusalData: data }),\n /** The input was refused, by field: an action returns these as validationErrors. */\n invalid: (errors: Record<string, string[]>): Reply => reply({ validationErrors: errors }),\n /** An unexpected failure, as an adapter answers a crash: an error, not a refusal. */\n fail: (message = 'Something went wrong.'): Reply => reply({ error: message }),\n /** A result, and the regions of the page the call says it changed. */\n revalidating: (result: unknown, ...targets: string[]): Reply =>\n reply({ result, revalidate: targets } as Omit<Reply, typeof REPLY>),\n /** A result, and cookies set on the response - a login's session: `'session=abc; Path=/; HttpOnly'`. */\n settingCookies: (result: unknown, ...cookies: string[]): Reply =>\n reply({ result, [COOKIES]: cookies } as unknown as Omit<Reply, typeof REPLY>),\n} as const\n\nfunction isReply(value: unknown): value is Reply {\n return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[REPLY] === true\n}\n\nasync function answer(host: TestHost, name: string, args: unknown[], headers: Headers): Promise<HostCallReply> {\n // By a name only known at runtime: a typed host has no index signature.\n const handler = (host as Record<string, HostHandler | undefined>)[name]\n\n // Fails the call rather than answering null: a function the test forgot\n // is a test that would otherwise pass against nothing.\n if (!handler) {\n return {\n error:\n name === HOST_MIDDLEWARE\n ? `the page asked its host guards (${JSON.stringify(args[0])}) and the test host has no \"${HOST_MIDDLEWARE}\" handler`\n : `the test host has no handler for ${JSON.stringify(name)}`,\n }\n }\n\n const value = await handler({ args, headers })\n\n if (isReply(value)) {\n // Symbol keys survive the rest spread; COOKIES is read off by the fetch\n // below, and JSON leaves it out of the body.\n const { [REPLY]: _, ...fields } = value\n\n return fields\n }\n\n return { result: value === undefined ? null : value }\n}\n\n/**\n * A fetch that answers the host-call endpoint from `host`: one call as\n * itself, a batch as `{ replies }`, in order.\n */\nexport function testHostFetch(host: TestHost): typeof fetch {\n return (async (_input: RequestInfo | URL, init?: RequestInit) => {\n const headers = new Headers(init?.headers)\n const body = JSON.parse(String(init?.body ?? '{}')) as\n | { function: string; args?: unknown[] }\n | { calls: { function: string; args?: unknown[] }[] }\n\n // The renderer's own headers are not the visitor's.\n headers.delete('x-rsc-host-secret')\n headers.delete('content-type')\n\n const json = (value: unknown) =>\n new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } })\n\n if ('calls' in body) {\n return json({\n replies: await Promise.all(body.calls.map((c) => answer(host, c.function, c.args ?? [], headers))),\n })\n }\n\n const single = await answer(host, body.function, body.args ?? [], headers)\n const cookies = (single as { [COOKIES]?: string[] })[COOKIES] ?? []\n const response = json(single)\n\n for (const cookie of cookies) response.headers.append('Set-Cookie', cookie)\n\n return response\n }) as typeof fetch\n}\n"]}
package/dist/testing.d.ts CHANGED
@@ -15,6 +15,22 @@ export interface TestApp {
15
15
  * The names a tab would be handed, without reading them out of the payload.
16
16
  */
17
17
  watched(path: string, init?: RequestInit): Promise<Record<string, string[]>>;
18
+ /**
19
+ * The page at `path` as markup: the whole streamed document, with every
20
+ * `<script>` taken out.
21
+ *
22
+ * The document carries the page's payload for the browser to hydrate from,
23
+ * and the payload holds everything the page was given - a client
24
+ * component's props included, shown or not. A test asserting that something
25
+ * is NOT on the page reads this, not `fetch`:
26
+ *
27
+ * expect(await app.markup('/teams/1')).not.toContain('internal note')
28
+ *
29
+ * Regions that streamed in later are included where they arrived; a
30
+ * Suspense fallback they replaced is still in the markup too, as the
31
+ * server sent it.
32
+ */
33
+ markup(path: string, init?: RequestInit): Promise<string>;
18
34
  /** Where the build was read from, for a test that wants to look. */
19
35
  bundle: string;
20
36
  }
package/dist/testing.js CHANGED
@@ -33,11 +33,34 @@ export { hostReply, testChanges, HOST_MIDDLEWARE } from './testHost.js';
33
33
  function findBundle(root) {
34
34
  const candidates = [
35
35
  join(root, 'node_modules/.nitro/vite/services/rsc/index.js'),
36
+ // The default outDir since 0.29.8; .rsc before it.
37
+ join(root, '.rsc-kit/dist/rsc/index.js'),
36
38
  join(root, '.rsc/dist/rsc/index.js'),
37
39
  join(root, 'build/dist/rsc/index.js'),
38
40
  ];
39
41
  return candidates.find((path) => existsSync(path)) ?? null;
40
42
  }
43
+ /**
44
+ * When anything a build is made from last changed: the app's source, its
45
+ * Vite config, its dependencies - package.json, the lockfile, and the
46
+ * installed engine itself. Only src/ used to count, so an app that upgraded
47
+ * @rsc-kit/core kept testing the build the old one made, and passed.
48
+ */
49
+ function newestInput(root) {
50
+ const files = [
51
+ 'vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs',
52
+ 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
53
+ ];
54
+ let latest = newest(join(root, 'src'));
55
+ for (const file of files) {
56
+ const path = join(root, file);
57
+ if (existsSync(path))
58
+ latest = Math.max(latest, statSync(path).mtimeMs);
59
+ }
60
+ // The engine as installed: a workspace link changes without the lockfile.
61
+ const engine = join(root, 'node_modules/@rsc-kit/core/dist');
62
+ return Math.max(latest, newest(engine));
63
+ }
41
64
  /** The newest mtime under a directory, for deciding whether a build is stale. */
42
65
  function newest(dir) {
43
66
  if (!existsSync(dir))
@@ -135,7 +158,7 @@ async function ensureBuilt(root, build) {
135
158
  }
136
159
  return existing;
137
160
  }
138
- const fresh = existing && statSync(existing).mtimeMs > newest(join(root, 'src'));
161
+ const fresh = existing && statSync(existing).mtimeMs > newestInput(root);
139
162
  if (fresh)
140
163
  return existing;
141
164
  // The user's own build command, so what is tested is what ships. A test
@@ -199,6 +222,12 @@ export async function createTestApp(options = {}) {
199
222
  return {
200
223
  bundle,
201
224
  fetch,
225
+ markup: async (path, init) => {
226
+ const html = await (await fetch(path, init)).text();
227
+ // A script's body never holds its own closing tag: the payload writes
228
+ // "<" as \u003c, and React's scripts are its own.
229
+ return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
230
+ },
202
231
  watched: async (path, init) => {
203
232
  // The render records what each region resolves to while this is set:
204
233
  // the app's bundle runs in this process, so it sees the same global.
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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","sourcesContent":["// The whole app as a function, for tests.\n//\n// import { createTestApp } from '@rsc-kit/core/testing'\n//\n// const app = await createTestApp()\n// const res = await app.fetch('/api/orders', { headers: { Cookie: 'user=ada' } })\n//\n// No port, no browser, no server process. The built entry already exports the\n// same Request → Response handler the dev server and the production server\n// both call, so a test can hand it a Request and read the Response — through\n// the real router, the real middleware, the real api routes, and the pages the\n// build stored.\n//\n// That is the tier between a unit test and a browser. An action or a route is\n// a function and can be imported and called; a browser test proves the page\n// works; this proves the app answers a url the way it is deployed, which is\n// where a route that renders fine and is served wrong shows up.\n\nimport { spawnSync } from 'node:child_process'\nimport { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'\nimport { extname, join, resolve, sep } from 'node:path'\nimport { pathToFileURL } from 'node:url'\nimport { httpHostCalls } from './hostCalls.js'\nimport { configureChanged } from './changed.js'\nimport { testHostFetch, type TestHost } from './testHost.js'\n\nexport { hostReply, testChanges, HOST_MIDDLEWARE, type HostCallInput, type HostHandler, type TestHost } from './testHost.js'\n\nexport interface TestApp {\n /** A path, not a url. The origin is whatever the app was told it is. */\n fetch(path: string, init?: RequestInit): Promise<Response>\n /**\n * What each region of the page at `path` refreshes on, rendered for this\n * request: `{ page: ['ledger'], repos: ['team:1:repos'] }` - `page` for the\n * page's own `refreshOn`, a section's name for each section's. A region\n * whose refreshOn gave no names is there with `[]`; one with no refreshOn\n * is not there at all.\n *\n * expect(await app.watched('/teams/1')).toMatchObject({ repos: ['team:1:repos'] })\n *\n * The names a tab would be handed, without reading them out of the payload.\n */\n watched(path: string, init?: RequestInit): Promise<Record<string, string[]>>\n /** Where the build was read from, for a test that wants to look. */\n bundle: string\n}\n\nexport interface TestAppOptions {\n /** The project. Defaults to the working directory. */\n root?: string\n /**\n * Whether to build first.\n *\n * `true` builds when the source is newer than the last build, which is what\n * a test run wants: the first run pays for it, the rest do not, and an edit\n * is picked up. `false` never builds and fails loudly if there is nothing to\n * read — for a ci step that already built.\n */\n build?: boolean\n /** The origin requests are made against. Nothing reads it; it is a url. */\n origin?: string\n /**\n * The backend, answered in the test: rpc() calls and host guards by name.\n * The app reaches it through the same client it uses against a real one,\n * so nothing listens and RSC_BACKEND is not called. See `hostReply`.\n */\n host?: TestHost\n /**\n * What answers a url this app does not own - a Go route, a Laravel page,\n * /login - in place of the backend the app forwards it to. Handed the\n * request as the backend would receive it. Without one, such a url is\n * forwarded to RSC_BACKEND, and in a test that is usually a 502.\n */\n backend?: (request: Request) => Response | Promise<Response>\n}\n\n/**\n * Where the build put the server bundle.\n *\n * Two layouts, because two ways of building. Under Nitro — every scaffolded\n * app — it is inside Nitro's own directory. On its own the plugin writes\n * <outDir>/dist/rsc. The first that exists wins.\n */\nfunction findBundle(root: string): string | null {\n const candidates = [\n join(root, 'node_modules/.nitro/vite/services/rsc/index.js'),\n join(root, '.rsc/dist/rsc/index.js'),\n join(root, 'build/dist/rsc/index.js'),\n ]\n\n return candidates.find((path) => existsSync(path)) ?? null\n}\n\n/** The newest mtime under a directory, for deciding whether a build is stale. */\nfunction newest(dir: string): number {\n if (!existsSync(dir)) return 0\n\n let latest = 0\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue\n\n const path = join(dir, entry.name)\n const time = entry.isDirectory() ? newest(path) : statSync(path).mtimeMs\n\n if (time > latest) latest = time\n }\n\n return latest\n}\n\n/** One build per process per root, however many test files ask. */\nconst built = new Map<string, Promise<string>>()\n\n/** The content types a test is likely to assert on; anything else is octet-stream. */\nconst CONTENT_TYPES: Record<string, string> = {\n '.html': 'text/html; charset=utf-8',\n '.js': 'text/javascript; charset=utf-8',\n '.mjs': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.json': 'application/json; charset=utf-8',\n '.webmanifest': 'application/manifest+json',\n '.xml': 'application/xml',\n '.txt': 'text/plain; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.gif': 'image/gif',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.wasm': 'application/wasm',\n '.map': 'application/json',\n}\n\n/**\n * A file the build put in .output/public, answered the way Nitro's static\n * layer would - which the handler this harness loads does not include. The\n * handler is the rsc service: routing, pages, actions, api routes. Assets,\n * the service worker, the manifest and the icons are files Nitro serves in\n * production from .output/public, and a test that asked for /sw.js used to\n * get the router's 404 for a file the deployment serves fine - the one\n * shape this harness exists to catch, in the other direction.\n *\n * Files only, for GET and HEAD, within the directory: a path that escapes it\n * is a request for the handler, not a file.\n */\n/** @internal Exported for its test. */\nexport function staticFile(root: string, request: Request): Response | null {\n if (request.method !== 'GET' && request.method !== 'HEAD') return null\n\n const publicDir = join(root, '.output/public')\n const pathname = decodeURIComponent(new URL(request.url).pathname)\n const file = resolve(publicDir, '.' + pathname)\n\n if (!file.startsWith(publicDir + sep) || !existsSync(file) || !statSync(file).isFile()) return null\n\n const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'\n const body = request.method === 'HEAD' ? null : readFileSync(file)\n\n return new Response(body, {\n status: 200,\n headers: { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-RSC-Kit-Test': 'static' },\n })\n}\n\n/**\n * How this project builds: its own `build` script, run by the package manager\n * this test is running under. Without a script, Vite directly - on Bun's\n * runtime when the tests are, since a bin's node shebang would otherwise\n * start it under Node.\n */\n/** @internal Exported for its test. */\nexport function buildCommand(root: string): [string, string[]] {\n const onBun = typeof process.versions.bun === 'string'\n\n let scripts: Record<string, string> = {}\n\n try {\n scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).scripts ?? {}\n } catch {\n // No package.json, or not JSON: there is no script to run.\n }\n\n if (typeof scripts.build === 'string') {\n return onBun ? ['bun', ['run', 'build']] : ['npm', ['run', 'build']]\n }\n\n return onBun ? ['bun', ['--bun', 'vite', 'build']] : ['npx', ['vite', 'build']]\n}\n\nasync function ensureBuilt(root: string, build: boolean): Promise<string> {\n const existing = findBundle(root)\n\n if (!build) {\n if (!existing) {\n throw new Error(\n `[rsc-kit] No build to test against under ${root}. Run the build first, or let createTestApp() do it by leaving \\`build\\` on.`,\n )\n }\n\n return existing\n }\n\n const fresh = existing && statSync(existing).mtimeMs > newest(join(root, 'src'))\n\n if (fresh) return existing\n\n // The user's own build command, so what is tested is what ships. A test\n // that built some other way would pass against a bundle nobody deploys -\n // and used to: this ran `npx vite build` whatever package.json said, which\n // on a Bun project built under Node and failed on the first `import 'bun'`.\n const [command, args] = buildCommand(root)\n const run = spawnSync(command, args, {\n cwd: root,\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, NODE_ENV: 'production' },\n })\n\n if (run.status !== 0) {\n throw new Error(`[rsc-kit] The build failed, so there is nothing to test:\\n${run.stderr}`)\n }\n\n const bundle = findBundle(root)\n\n if (!bundle) {\n throw new Error(\n `[rsc-kit] The build finished but no server bundle was found under ${root}. Is rscKit() in vite.config?`,\n )\n }\n\n return bundle\n}\n\n/**\n * Build the app if it needs it, load it, and hand back something to fetch from.\n *\n * Memoised per root: every test file in a run shares one build and one loaded\n * module, which is both the fast path and the correct one — two copies of the\n * server bundle in one process would be two client-reference registries.\n */\nexport async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {\n const root = resolve(options.root ?? process.cwd())\n const origin = options.origin ?? 'https://app.test'\n\n built.set(root, built.get(root) ?? ensureBuilt(root, options.build ?? true))\n\n // The app is built for production, which refuses to sign refreshOn names\n // without a key. One process, one key: a fixed one, unless the test set its own.\n if (!process.env.RSC_SIGNING_SECRET) {\n configureChanged({ secret: 'rsc-kit-test' })\n }\n\n const bundle = await built.get(root)!\n const entry = (await import(pathToFileURL(bundle).href)) as {\n default: (request: Request) => Promise<Response>\n installHostFn?: (fn: (name: string, ...args: unknown[]) => Promise<unknown>) => () => void\n installBackendForward?: (fn: ((request: Request) => Response | Promise<Response>) | null) => void\n }\n\n if (typeof entry.default !== 'function') {\n throw new Error(`[rsc-kit] ${bundle} does not export a request handler.`)\n }\n\n const host = options.host\n ? httpHostCalls({ endpoint: 'http://test-host/__rsc/host-call', secret: 'test', fetch: testHostFetch(options.host) })\n : null\n\n if (host && !entry.installHostFn) {\n throw new Error(`[rsc-kit] ${bundle} cannot take a host. Rebuild against the current @rsc-kit/core.`)\n }\n\n const fetch: TestApp['fetch'] = async (path, init) => {\n const request = new Request(new URL(path, origin), init)\n\n // Per request, not once: the loaded bundle is shared by every app a\n // run creates, and the host is this app's.\n if (host) entry.installHostFn!(host)\n if (options.backend && !entry.installBackendForward) {\n throw new Error(`[rsc-kit] ${bundle} cannot take a backend. Rebuild against the current @rsc-kit/core.`)\n }\n entry.installBackendForward?.(options.backend ?? null)\n\n return staticFile(root, request) ?? entry.default(request)\n }\n\n return {\n bundle,\n fetch,\n watched: async (path, init) => {\n // The render records what each region resolves to while this is set:\n // the app's bundle runs in this process, so it sees the same global.\n const recording: Record<string, string[]> = {}\n const into = globalThis as { [WATCHED]?: Record<string, string[]> }\n\n into[WATCHED] = recording\n\n try {\n // Read to the end: the names resolve under their own Suspense, so\n // they are in the stream's tail, not its head.\n await (await fetch(path, init)).text()\n } finally {\n delete into[WATCHED]\n }\n\n return recording\n },\n }\n}\n\n/** Where a render records what its regions refresh on, while watched() asks (js/refreshOn). */\nconst WATCHED = Symbol.for('rsc-kit.test-watched')\n"]}
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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","sourcesContent":["// The whole app as a function, for tests.\n//\n// import { createTestApp } from '@rsc-kit/core/testing'\n//\n// const app = await createTestApp()\n// const res = await app.fetch('/api/orders', { headers: { Cookie: 'user=ada' } })\n//\n// No port, no browser, no server process. The built entry already exports the\n// same Request → Response handler the dev server and the production server\n// both call, so a test can hand it a Request and read the Response — through\n// the real router, the real middleware, the real api routes, and the pages the\n// build stored.\n//\n// That is the tier between a unit test and a browser. An action or a route is\n// a function and can be imported and called; a browser test proves the page\n// works; this proves the app answers a url the way it is deployed, which is\n// where a route that renders fine and is served wrong shows up.\n\nimport { spawnSync } from 'node:child_process'\nimport { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'\nimport { extname, join, resolve, sep } from 'node:path'\nimport { pathToFileURL } from 'node:url'\nimport { httpHostCalls } from './hostCalls.js'\nimport { configureChanged } from './changed.js'\nimport { testHostFetch, type TestHost } from './testHost.js'\n\nexport { hostReply, testChanges, HOST_MIDDLEWARE, type HostCallInput, type HostHandler, type TestHost } from './testHost.js'\n\nexport interface TestApp {\n /** A path, not a url. The origin is whatever the app was told it is. */\n fetch(path: string, init?: RequestInit): Promise<Response>\n /**\n * What each region of the page at `path` refreshes on, rendered for this\n * request: `{ page: ['ledger'], repos: ['team:1:repos'] }` - `page` for the\n * page's own `refreshOn`, a section's name for each section's. A region\n * whose refreshOn gave no names is there with `[]`; one with no refreshOn\n * is not there at all.\n *\n * expect(await app.watched('/teams/1')).toMatchObject({ repos: ['team:1:repos'] })\n *\n * The names a tab would be handed, without reading them out of the payload.\n */\n watched(path: string, init?: RequestInit): Promise<Record<string, string[]>>\n /**\n * The page at `path` as markup: the whole streamed document, with every\n * `<script>` taken out.\n *\n * The document carries the page's payload for the browser to hydrate from,\n * and the payload holds everything the page was given - a client\n * component's props included, shown or not. A test asserting that something\n * is NOT on the page reads this, not `fetch`:\n *\n * expect(await app.markup('/teams/1')).not.toContain('internal note')\n *\n * Regions that streamed in later are included where they arrived; a\n * Suspense fallback they replaced is still in the markup too, as the\n * server sent it.\n */\n markup(path: string, init?: RequestInit): Promise<string>\n /** Where the build was read from, for a test that wants to look. */\n bundle: string\n}\n\nexport interface TestAppOptions {\n /** The project. Defaults to the working directory. */\n root?: string\n /**\n * Whether to build first.\n *\n * `true` builds when the source is newer than the last build, which is what\n * a test run wants: the first run pays for it, the rest do not, and an edit\n * is picked up. `false` never builds and fails loudly if there is nothing to\n * read — for a ci step that already built.\n */\n build?: boolean\n /** The origin requests are made against. Nothing reads it; it is a url. */\n origin?: string\n /**\n * The backend, answered in the test: rpc() calls and host guards by name.\n * The app reaches it through the same client it uses against a real one,\n * so nothing listens and RSC_BACKEND is not called. See `hostReply`.\n */\n host?: TestHost\n /**\n * What answers a url this app does not own - a Go route, a Laravel page,\n * /login - in place of the backend the app forwards it to. Handed the\n * request as the backend would receive it. Without one, such a url is\n * forwarded to RSC_BACKEND, and in a test that is usually a 502.\n */\n backend?: (request: Request) => Response | Promise<Response>\n}\n\n/**\n * Where the build put the server bundle.\n *\n * Two layouts, because two ways of building. Under Nitro — every scaffolded\n * app — it is inside Nitro's own directory. On its own the plugin writes\n * <outDir>/dist/rsc. The first that exists wins.\n */\nfunction findBundle(root: string): string | null {\n const candidates = [\n join(root, 'node_modules/.nitro/vite/services/rsc/index.js'),\n // The default outDir since 0.29.8; .rsc before it.\n join(root, '.rsc-kit/dist/rsc/index.js'),\n join(root, '.rsc/dist/rsc/index.js'),\n join(root, 'build/dist/rsc/index.js'),\n ]\n\n return candidates.find((path) => existsSync(path)) ?? null\n}\n\n/**\n * When anything a build is made from last changed: the app's source, its\n * Vite config, its dependencies - package.json, the lockfile, and the\n * installed engine itself. Only src/ used to count, so an app that upgraded\n * @rsc-kit/core kept testing the build the old one made, and passed.\n */\nfunction newestInput(root: string): number {\n const files = [\n 'vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs',\n 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',\n ]\n let latest = newest(join(root, 'src'))\n\n for (const file of files) {\n const path = join(root, file)\n\n if (existsSync(path)) latest = Math.max(latest, statSync(path).mtimeMs)\n }\n\n // The engine as installed: a workspace link changes without the lockfile.\n const engine = join(root, 'node_modules/@rsc-kit/core/dist')\n\n return Math.max(latest, newest(engine))\n}\n\n/** The newest mtime under a directory, for deciding whether a build is stale. */\nfunction newest(dir: string): number {\n if (!existsSync(dir)) return 0\n\n let latest = 0\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue\n\n const path = join(dir, entry.name)\n const time = entry.isDirectory() ? newest(path) : statSync(path).mtimeMs\n\n if (time > latest) latest = time\n }\n\n return latest\n}\n\n/** One build per process per root, however many test files ask. */\nconst built = new Map<string, Promise<string>>()\n\n/** The content types a test is likely to assert on; anything else is octet-stream. */\nconst CONTENT_TYPES: Record<string, string> = {\n '.html': 'text/html; charset=utf-8',\n '.js': 'text/javascript; charset=utf-8',\n '.mjs': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.json': 'application/json; charset=utf-8',\n '.webmanifest': 'application/manifest+json',\n '.xml': 'application/xml',\n '.txt': 'text/plain; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.gif': 'image/gif',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.wasm': 'application/wasm',\n '.map': 'application/json',\n}\n\n/**\n * A file the build put in .output/public, answered the way Nitro's static\n * layer would - which the handler this harness loads does not include. The\n * handler is the rsc service: routing, pages, actions, api routes. Assets,\n * the service worker, the manifest and the icons are files Nitro serves in\n * production from .output/public, and a test that asked for /sw.js used to\n * get the router's 404 for a file the deployment serves fine - the one\n * shape this harness exists to catch, in the other direction.\n *\n * Files only, for GET and HEAD, within the directory: a path that escapes it\n * is a request for the handler, not a file.\n */\n/** @internal Exported for its test. */\nexport function staticFile(root: string, request: Request): Response | null {\n if (request.method !== 'GET' && request.method !== 'HEAD') return null\n\n const publicDir = join(root, '.output/public')\n const pathname = decodeURIComponent(new URL(request.url).pathname)\n const file = resolve(publicDir, '.' + pathname)\n\n if (!file.startsWith(publicDir + sep) || !existsSync(file) || !statSync(file).isFile()) return null\n\n const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'\n const body = request.method === 'HEAD' ? null : readFileSync(file)\n\n return new Response(body, {\n status: 200,\n headers: { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-RSC-Kit-Test': 'static' },\n })\n}\n\n/**\n * How this project builds: its own `build` script, run by the package manager\n * this test is running under. Without a script, Vite directly - on Bun's\n * runtime when the tests are, since a bin's node shebang would otherwise\n * start it under Node.\n */\n/** @internal Exported for its test. */\nexport function buildCommand(root: string): [string, string[]] {\n const onBun = typeof process.versions.bun === 'string'\n\n let scripts: Record<string, string> = {}\n\n try {\n scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).scripts ?? {}\n } catch {\n // No package.json, or not JSON: there is no script to run.\n }\n\n if (typeof scripts.build === 'string') {\n return onBun ? ['bun', ['run', 'build']] : ['npm', ['run', 'build']]\n }\n\n return onBun ? ['bun', ['--bun', 'vite', 'build']] : ['npx', ['vite', 'build']]\n}\n\nasync function ensureBuilt(root: string, build: boolean): Promise<string> {\n const existing = findBundle(root)\n\n if (!build) {\n if (!existing) {\n throw new Error(\n `[rsc-kit] No build to test against under ${root}. Run the build first, or let createTestApp() do it by leaving \\`build\\` on.`,\n )\n }\n\n return existing\n }\n\n const fresh = existing && statSync(existing).mtimeMs > newestInput(root)\n\n if (fresh) return existing\n\n // The user's own build command, so what is tested is what ships. A test\n // that built some other way would pass against a bundle nobody deploys -\n // and used to: this ran `npx vite build` whatever package.json said, which\n // on a Bun project built under Node and failed on the first `import 'bun'`.\n const [command, args] = buildCommand(root)\n const run = spawnSync(command, args, {\n cwd: root,\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, NODE_ENV: 'production' },\n })\n\n if (run.status !== 0) {\n throw new Error(`[rsc-kit] The build failed, so there is nothing to test:\\n${run.stderr}`)\n }\n\n const bundle = findBundle(root)\n\n if (!bundle) {\n throw new Error(\n `[rsc-kit] The build finished but no server bundle was found under ${root}. Is rscKit() in vite.config?`,\n )\n }\n\n return bundle\n}\n\n/**\n * Build the app if it needs it, load it, and hand back something to fetch from.\n *\n * Memoised per root: every test file in a run shares one build and one loaded\n * module, which is both the fast path and the correct one — two copies of the\n * server bundle in one process would be two client-reference registries.\n */\nexport async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {\n const root = resolve(options.root ?? process.cwd())\n const origin = options.origin ?? 'https://app.test'\n\n built.set(root, built.get(root) ?? ensureBuilt(root, options.build ?? true))\n\n // The app is built for production, which refuses to sign refreshOn names\n // without a key. One process, one key: a fixed one, unless the test set its own.\n if (!process.env.RSC_SIGNING_SECRET) {\n configureChanged({ secret: 'rsc-kit-test' })\n }\n\n const bundle = await built.get(root)!\n const entry = (await import(pathToFileURL(bundle).href)) as {\n default: (request: Request) => Promise<Response>\n installHostFn?: (fn: (name: string, ...args: unknown[]) => Promise<unknown>) => () => void\n installBackendForward?: (fn: ((request: Request) => Response | Promise<Response>) | null) => void\n }\n\n if (typeof entry.default !== 'function') {\n throw new Error(`[rsc-kit] ${bundle} does not export a request handler.`)\n }\n\n const host = options.host\n ? httpHostCalls({ endpoint: 'http://test-host/__rsc/host-call', secret: 'test', fetch: testHostFetch(options.host) })\n : null\n\n if (host && !entry.installHostFn) {\n throw new Error(`[rsc-kit] ${bundle} cannot take a host. Rebuild against the current @rsc-kit/core.`)\n }\n\n const fetch: TestApp['fetch'] = async (path, init) => {\n const request = new Request(new URL(path, origin), init)\n\n // Per request, not once: the loaded bundle is shared by every app a\n // run creates, and the host is this app's.\n if (host) entry.installHostFn!(host)\n if (options.backend && !entry.installBackendForward) {\n throw new Error(`[rsc-kit] ${bundle} cannot take a backend. Rebuild against the current @rsc-kit/core.`)\n }\n entry.installBackendForward?.(options.backend ?? null)\n\n return staticFile(root, request) ?? entry.default(request)\n }\n\n return {\n bundle,\n fetch,\n markup: async (path, init) => {\n const html = await (await fetch(path, init)).text()\n\n // A script's body never holds its own closing tag: the payload writes\n // \"<\" as \\u003c, and React's scripts are its own.\n return html.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n },\n watched: async (path, init) => {\n // The render records what each region resolves to while this is set:\n // the app's bundle runs in this process, so it sees the same global.\n const recording: Record<string, string[]> = {}\n const into = globalThis as { [WATCHED]?: Record<string, string[]> }\n\n into[WATCHED] = recording\n\n try {\n // Read to the end: the names resolve under their own Suspense, so\n // they are in the stream's tail, not its head.\n await (await fetch(path, init)).text()\n } finally {\n delete into[WATCHED]\n }\n\n return recording\n },\n }\n}\n\n/** Where a render records what its regions refresh on, while watched() asks (js/refreshOn). */\nconst WATCHED = Symbol.for('rsc-kit.test-watched')\n"]}
package/dist/vite.js CHANGED
@@ -3023,10 +3023,9 @@ import { isSharedSection, sectionComponent } from ${JSON.stringify(join(packageD
3023
3023
  import { shareRender } from ${JSON.stringify(join(packageDir, "sharedRenders"))}
3024
3024
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
3025
3025
  import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultRouteError"))}
3026
- import { requestFromCrawler, requestSearch, searchParams as requestSearchParams, withUrl } from ${JSON.stringify(join(packageDir, "request"))}
3026
+ import { requestFromCrawler, requestSearch, searchParams as requestSearchParams, withRequest, withUrl } from ${JSON.stringify(join(packageDir, "request"))}
3027
3027
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
3028
3028
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
3029
- import { bootToken } from ${JSON.stringify(join(packageDir, "bootPayloads"))}
3030
3029
  import { noteRequestRead, notePageProps, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
3031
3030
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
3032
3031
  import { cancelledByConsumer } from ${JSON.stringify(join(packageDir, "js/fallbackReport"))}
@@ -4538,7 +4537,7 @@ export async function handleRscHtmlStream(
4538
4537
  nonce?: string,
4539
4538
  pageKey = '',
4540
4539
  bootstrap = true,
4541
- ): Promise<{ htmlStream: ReadableStream; rscPayload: ReadableStream; bootToken?: string; clientChunks: unknown }> {
4540
+ ): Promise<{ htmlStream: ReadableStream; rscPayload?: ReadableStream; clientChunks: unknown }> {
4542
4541
  await instrumented()
4543
4542
  applyHost()
4544
4543
  await runMiddleware(component, props)
@@ -4546,18 +4545,21 @@ export async function handleRscHtmlStream(
4546
4545
  await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, 0, pageKey, bootstrap),
4547
4546
  { onError: flightOnError },
4548
4547
  )
4549
- // Written into the bootstrap script, so it is in the document before the
4550
- // runtime that reads it. Only where there is a runtime: a page shipping
4551
- // none never comes back for a payload.
4552
- const token = bootstrap ? bootToken() : undefined
4553
- // The same tree twice: one copy becomes the HTML, the other is the payload
4554
- // the browser comes back for to hydrate. The host holds it for that fetch
4555
- // - see bootPayloads.ts - so a visit renders once. Streamed, not read to a
4556
- // string: it used to be buffered whole here and never read by anyone.
4557
- const [forHtml, rscPayload] = flight.tee()
4558
4548
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4559
- const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch(), requestFromCrawler(), token)
4560
- return { htmlStream, rscPayload, bootToken: token, clientChunks: {} }
4549
+
4550
+ // A page with no runtime hydrates nothing: straight into the HTML.
4551
+ if (!bootstrap) {
4552
+ const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, undefined, requestSearch(), requestFromCrawler())
4553
+ return { htmlStream, clientChunks: {} }
4554
+ }
4555
+
4556
+ // One render: the HTML from one copy, and from the other the payload the
4557
+ // browser hydrates from, which the host streams into the document - see
4558
+ // inlineFlight.ts. It used to be fetched by the browser, and that fetch
4559
+ // rendered the whole page again.
4560
+ const [forHtml, rscPayload] = flight.tee()
4561
+ const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap, undefined, requestSearch(), requestFromCrawler(), true)
4562
+ return { htmlStream, rscPayload, clientChunks: {} }
4561
4563
  }
4562
4564
 
4563
4565
  /**
@@ -4641,7 +4643,7 @@ export async function handleRscResume(
4641
4643
  // hole by construction: at build the params never settled, so anything
4642
4644
  // reading them was postponed.
4643
4645
  params?: Record<string, string>,
4644
- ): Promise<{ htmlStream: ReadableStream; replayed: () => boolean; rscPayload: ReadableStream }> {
4646
+ ): Promise<{ htmlStream: ReadableStream }> {
4645
4647
  await instrumented()
4646
4648
  applyHost()
4647
4649
  await runMiddleware(component, params ?? props)
@@ -4691,9 +4693,8 @@ export async function handleRscResume(
4691
4693
  // client-rendered. Set before the stream ends, because that is when
4692
4694
  // React reports it.
4693
4695
  let replayFailed = false
4694
- // The same tree twice, as handleRscHtmlStream: the holes for the HTML, and
4695
- // the payload the browser comes back for. The host holds the second for
4696
- // that fetch - one render per visit.
4696
+ // The holes for the HTML, and the payload the browser hydrates from, from
4697
+ // the same render.
4697
4698
  const [forHtml, rscPayload] = flight.tee()
4698
4699
  const htmlStream = await ssr.handleSsrResume(
4699
4700
  forHtml,
@@ -5650,6 +5651,43 @@ ${fallbackOrigin === false ? NO_FALLBACK_BODY : FALLBACK_BODY}}
5650
5651
  * Without a not-found.tsx this is the string it always was.
5651
5652
  */
5652
5653
  async function notFound(request: Request): Promise<Response> {
5654
+ // In the request's own scope. The host opens one around everything it
5655
+ // answers, and this is what is left once it has declined the url - so the
5656
+ // scope it had has closed. not-found.tsx, and the layouts it renders in,
5657
+ // read the request like any page: a cart badge, who is signed in. Outside
5658
+ // a scope that was "No request in scope", and the visitor got the bare
5659
+ // string below instead of the app.
5660
+ //
5661
+ // Only for a request that can show a page. A url no route owns, asked for
5662
+ // by an <img>, a <script>, a stylesheet - a browser's /favicon.ico, which
5663
+ // Lighthouse asks for on every run - cannot display not-found.tsx, and
5664
+ // rendering it ran every layout above it, cookie reads and queries
5665
+ // included, for an image request. Those get a plain 404 at once.
5666
+ if (!pageSaidNotFound(request) && !wantsPage(request)) {
5667
+ return new Response('Not found', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
5668
+ }
5669
+
5670
+ return await withRequest(request, () => renderNotFound(request))
5671
+ }
5672
+
5673
+ /**
5674
+ * Whether the request can display a page: a navigation, an iframe, the
5675
+ * router's own payload request, or - from a client that does not say where
5676
+ * the answer goes - one that accepts HTML. Decided by what the request says
5677
+ * it wants, never by the url's extension: a route serving /sitemap.xml never
5678
+ * reaches this.
5679
+ */
5680
+ function wantsPage(request: Request): boolean {
5681
+ if (request.headers.get('X-RSC')) return true
5682
+
5683
+ const dest = request.headers.get('Sec-Fetch-Dest')
5684
+
5685
+ if (dest) return dest === 'document' || dest === 'iframe' || dest === 'frame'
5686
+
5687
+ return (request.headers.get('Accept') ?? '').includes('text/html')
5688
+ }
5689
+
5690
+ async function renderNotFound(request: Request): Promise<Response> {
5653
5691
  ${notFoundComponent
5654
5692
  ? `
5655
5693
  // A navigation, not a document: answer with the not-found tree as a
@@ -5764,9 +5802,9 @@ export async function handleSsr(
5764
5802
  // A crawler's request: answered once everything has rendered - see
5765
5803
  // crawler.ts - so the host reads the page's final word on its status.
5766
5804
  whole = false,
5767
- // The boot token: the document's render kept its payload under it, and
5768
- // the runtime redeems it instead of asking for a second render.
5769
- bootToken?: string,
5805
+ // The host streams the payload into this document - see inlineFlight.ts.
5806
+ // Declared here so the runtime knows before its first chunk arrives.
5807
+ inline = false,
5770
5808
  ): Promise<ReadableStream> {
5771
5809
  const root = withSearch(await createFromReadableStream(rscStream), search)
5772
5810
 
@@ -5777,9 +5815,8 @@ export async function handleSsr(
5777
5815
  // seconds before the runtime hydrates is held for it rather than given
5778
5816
  // to the browser as a document load. See earlyClicks.ts.
5779
5817
  const bootstrapScriptContent = bootstrap
5780
- ? (bootToken ? 'self.__rsc_boot=' + JSON.stringify(bootToken) + ';' : '') +
5781
- EARLY +
5782
- (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
5818
+ ? (inline ? 'self.__rsc_f=self.__rsc_f||[];' : '') +
5819
+ EARLY + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
5783
5820
  : undefined
5784
5821
 
5785
5822
  // Without an onError handler React rejects each abortable task on its own,