@rsc-kit/core 0.24.3 → 0.25.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.
@@ -1,14 +1,16 @@
1
+ import type { MessageOf, NamedMessageOf } from "../events.js";
2
+ import type { EventsAt, EventsUrl } from "../routes.js";
1
3
  export type EventsStatus = "connecting" | "open" | "closed";
2
- export interface EventsOptions<T> {
4
+ export interface EventsOptions<T, E extends string | undefined = string | undefined> {
3
5
  /** Off, and nothing connects. For a stream that waits on an id. */
4
6
  enabled?: boolean;
5
7
  /** Listen to one named event rather than the unnamed stream. */
6
- event?: string;
8
+ event?: E;
7
9
  /** Every message, as it arrives. */
8
10
  onMessage?: (message: T) => void;
9
11
  /**
10
- * The connection failed or dropped. EventSource reconnects on its own, so
11
- * this is for a notice, not a retry; `status` says whether it gave up.
12
+ * The connection failed or dropped. The hook reconnects on its own, so
13
+ * this is for a notice, not a retry.
12
14
  */
13
15
  onError?: (error: Event) => void;
14
16
  /** How many messages `all` keeps. Default 100; 0 keeps none. */
@@ -22,4 +24,8 @@ export interface EventsState<T> {
22
24
  /** Close the stream for good; the hook will not reconnect. */
23
25
  close: () => void;
24
26
  }
25
- export declare function useEvents<T = unknown>(url: string, options?: EventsOptions<T>): EventsState<T>;
27
+ /** What the hook receives from `Y`'s yields: the unnamed messages, or those named `E`. */
28
+ type Received<Y, E> = unknown extends Y ? unknown : E extends string ? NamedMessageOf<Y, E> : MessageOf<Y>;
29
+ export declare function useEvents<const U extends EventsUrl, const E extends string | undefined = undefined>(url: U, options?: EventsOptions<Received<EventsAt<U>, E>, E>): EventsState<Received<EventsAt<U>, E>>;
30
+ export declare function useEvents<T>(url: EventsUrl, options?: EventsOptions<T>): EventsState<T>;
31
+ export {};
@@ -4,14 +4,27 @@
4
4
  *
5
5
  * const { latest, status } = useEvents(`/api/orders/${id}/events`)
6
6
  *
7
+ * A message is typed from the route: the build records what each `events()`
8
+ * route yields, so `latest` is what the generator sends, and a route that
9
+ * changes it fails the typecheck here rather than in the browser. Another
10
+ * site's stream, or one written without `events()`, is `unknown`; name its
11
+ * type with `useEvents<Message>(url)`.
12
+ *
7
13
  * Subscribes in an effect, so it is safe in a component that also renders on
8
- * the server. The browser's EventSource reconnects on its own and resumes
9
- * with Last-Event-ID when the route yielded ids; `status` says where it is.
14
+ * the server. The browser's EventSource reconnects on its own after a dropped
15
+ * connection and resumes with Last-Event-ID when the route yielded ids. What
16
+ * it does not survive is an error answer - a 502 while a deploy restarts, a
17
+ * proxy's timeout page - after which it closes for good; the hook opens a new
18
+ * one, backing off from a second to thirty, and at once when the browser comes
19
+ * back online. `status` says where it is; `close()` stops it.
20
+ *
10
21
  * `onMessage` is for a store that already holds the value - TanStack's
11
22
  * setQueryData, SWR's mutate, a setState - and `latest` for when the hook is
12
23
  * the store.
13
24
  */
14
25
  import { useEffect, useEffectEvent, useRef, useState } from "react";
26
+ /** The longest wait between attempts after the browser gave up. */
27
+ const MAX_BACKOFF_MS = 30_000;
15
28
  export function useEvents(url, options = {}) {
16
29
  const { enabled = true, event, keep = 100 } = options;
17
30
  // Effect Events: the connection is opened once per url, and the handlers
@@ -24,16 +37,17 @@ export function useEvents(url, options = {}) {
24
37
  const [all, setAll] = useState([]);
25
38
  const [status, setStatus] = useState(enabled ? "connecting" : "closed");
26
39
  const [error, setError] = useState(null);
27
- const source = useRef(null);
40
+ // Ends this url's connection and any retry waiting to open the next one.
41
+ const stop = useRef(null);
28
42
  useEffect(() => {
29
43
  if (!enabled || typeof EventSource === "undefined") {
30
44
  setStatus("closed");
31
45
  return;
32
46
  }
33
- const es = new EventSource(url);
34
- source.current = es;
35
- setStatus("connecting");
36
- setError(null);
47
+ let es = null;
48
+ let retry;
49
+ let failures = 0;
50
+ let stopped = false;
37
51
  const receive = (e) => {
38
52
  let message;
39
53
  try {
@@ -49,20 +63,55 @@ export function useEvents(url, options = {}) {
49
63
  : [...prev, message]);
50
64
  onMessage(message);
51
65
  };
52
- es.onopen = () => setStatus("open");
53
- es.onerror = (e) => {
54
- setError(e);
55
- onError(e);
56
- // EventSource reconnects on its own; CLOSED means it gave up.
57
- setStatus(es.readyState === EventSource.CLOSED ? "closed" : "connecting");
66
+ const connect = () => {
67
+ clearTimeout(retry);
68
+ retry = undefined;
69
+ const source = new EventSource(url);
70
+ es = source;
71
+ setStatus("connecting");
72
+ source.onopen = () => {
73
+ failures = 0;
74
+ setError(null);
75
+ setStatus("open");
76
+ };
77
+ source.onerror = (e) => {
78
+ setError(e);
79
+ onError(e);
80
+ // Still CONNECTING: the browser is retrying a dropped connection
81
+ // itself. CLOSED: it gave up, and only a new EventSource comes back.
82
+ if (source.readyState !== EventSource.CLOSED) {
83
+ setStatus("connecting");
84
+ return;
85
+ }
86
+ source.close();
87
+ if (stopped)
88
+ return;
89
+ setStatus("connecting");
90
+ retry = setTimeout(connect, Math.min(MAX_BACKOFF_MS, 1000 * 2 ** failures++));
91
+ };
92
+ if (event)
93
+ source.addEventListener(event, receive);
94
+ else
95
+ source.onmessage = receive;
96
+ };
97
+ // Back online: try now rather than at the end of the backoff.
98
+ const online = () => {
99
+ if (!stopped && retry !== undefined)
100
+ connect();
101
+ };
102
+ connect();
103
+ window.addEventListener("online", online);
104
+ const end = () => {
105
+ stopped = true;
106
+ clearTimeout(retry);
107
+ es?.close();
108
+ window.removeEventListener("online", online);
58
109
  };
59
- if (event)
60
- es.addEventListener(event, receive);
61
- else
62
- es.onmessage = receive;
110
+ stop.current = end;
63
111
  return () => {
64
- es.close();
65
- source.current = null;
112
+ end();
113
+ if (stop.current === end)
114
+ stop.current = null;
66
115
  };
67
116
  }, [url, enabled, event, keep]);
68
117
  return {
@@ -71,8 +120,8 @@ export function useEvents(url, options = {}) {
71
120
  status,
72
121
  error,
73
122
  close: () => {
74
- source.current?.close();
75
- source.current = null;
123
+ stop.current?.();
124
+ stop.current = null;
76
125
  setStatus("closed");
77
126
  },
78
127
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useEvents.js","sourceRoot":"","sources":["../../src/js/useEvents.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\n/**\n * A stream of server-sent events, as state.\n *\n * const { latest, status } = useEvents(`/api/orders/${id}/events`)\n *\n * Subscribes in an effect, so it is safe in a component that also renders on\n * the server. The browser's EventSource reconnects on its own and resumes\n * with Last-Event-ID when the route yielded ids; `status` says where it is.\n * `onMessage` is for a store that already holds the value - TanStack's\n * setQueryData, SWR's mutate, a setState - and `latest` for when the hook is\n * the store.\n */\n\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\n\nexport type EventsStatus = \"connecting\" | \"open\" | \"closed\";\n\nexport interface EventsOptions<T> {\n /** Off, and nothing connects. For a stream that waits on an id. */\n enabled?: boolean;\n /** Listen to one named event rather than the unnamed stream. */\n event?: string;\n /** Every message, as it arrives. */\n onMessage?: (message: T) => void;\n /**\n * The connection failed or dropped. EventSource reconnects on its own, so\n * this is for a notice, not a retry; `status` says whether it gave up.\n */\n onError?: (error: Event) => void;\n /** How many messages `all` keeps. Default 100; 0 keeps none. */\n keep?: number;\n}\n\nexport interface EventsState<T> {\n latest: T | null;\n all: T[];\n status: EventsStatus;\n error: Event | null;\n /** Close the stream for good; the hook will not reconnect. */\n close: () => void;\n}\n\nexport function useEvents<T = unknown>(\n url: string,\n options: EventsOptions<T> = {},\n): EventsState<T> {\n const { enabled = true, event, keep = 100 } = options;\n // Effect Events: the connection is opened once per url, and the handlers\n // it calls see the callbacks of the latest render without being the\n // effect's dependencies - which would reopen the connection on every\n // render that passed a new arrow.\n const onMessage = useEffectEvent((message: T) => options.onMessage?.(message));\n const onError = useEffectEvent((event: Event) => options.onError?.(event));\n\n const [latest, setLatest] = useState<T | null>(null);\n const [all, setAll] = useState<T[]>([]);\n const [status, setStatus] = useState<EventsStatus>(\n enabled ? \"connecting\" : \"closed\",\n );\n const [error, setError] = useState<Event | null>(null);\n const source = useRef<EventSource | null>(null);\n\n useEffect(() => {\n if (!enabled || typeof EventSource === \"undefined\") {\n setStatus(\"closed\");\n\n return;\n }\n\n const es = new EventSource(url);\n\n source.current = es;\n setStatus(\"connecting\");\n setError(null);\n\n const receive = (e: MessageEvent) => {\n let message: T;\n\n try {\n message = JSON.parse(e.data) as T;\n } catch {\n message = e.data as T;\n }\n\n setLatest(message);\n if (keep > 0)\n setAll((prev) =>\n prev.length >= keep\n ? [...prev.slice(1), message]\n : [...prev, message],\n );\n onMessage(message);\n };\n\n es.onopen = () => setStatus(\"open\");\n es.onerror = (e) => {\n setError(e);\n onError(e);\n // EventSource reconnects on its own; CLOSED means it gave up.\n setStatus(es.readyState === EventSource.CLOSED ? \"closed\" : \"connecting\");\n };\n\n if (event) es.addEventListener(event, receive as EventListener);\n else es.onmessage = receive;\n\n return () => {\n es.close();\n source.current = null;\n };\n }, [url, enabled, event, keep]);\n\n return {\n latest,\n all,\n status,\n error,\n close: () => {\n source.current?.close();\n source.current = null;\n setStatus(\"closed\");\n },\n };\n}\n"]}
1
+ {"version":3,"file":"useEvents.js","sourceRoot":"","sources":["../../src/js/useEvents.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\n/**\n * A stream of server-sent events, as state.\n *\n * const { latest, status } = useEvents(`/api/orders/${id}/events`)\n *\n * A message is typed from the route: the build records what each `events()`\n * route yields, so `latest` is what the generator sends, and a route that\n * changes it fails the typecheck here rather than in the browser. Another\n * site's stream, or one written without `events()`, is `unknown`; name its\n * type with `useEvents<Message>(url)`.\n *\n * Subscribes in an effect, so it is safe in a component that also renders on\n * the server. The browser's EventSource reconnects on its own after a dropped\n * connection and resumes with Last-Event-ID when the route yielded ids. What\n * it does not survive is an error answer - a 502 while a deploy restarts, a\n * proxy's timeout page - after which it closes for good; the hook opens a new\n * one, backing off from a second to thirty, and at once when the browser comes\n * back online. `status` says where it is; `close()` stops it.\n *\n * `onMessage` is for a store that already holds the value - TanStack's\n * setQueryData, SWR's mutate, a setState - and `latest` for when the hook is\n * the store.\n */\n\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport type { MessageOf, NamedMessageOf } from \"../events.js\";\nimport type { EventsAt, EventsUrl } from \"../routes.js\";\n\nexport type EventsStatus = \"connecting\" | \"open\" | \"closed\";\n\nexport interface EventsOptions<T, E extends string | undefined = string | undefined> {\n /** Off, and nothing connects. For a stream that waits on an id. */\n enabled?: boolean;\n /** Listen to one named event rather than the unnamed stream. */\n event?: E;\n /** Every message, as it arrives. */\n onMessage?: (message: T) => void;\n /**\n * The connection failed or dropped. The hook reconnects on its own, so\n * this is for a notice, not a retry.\n */\n onError?: (error: Event) => void;\n /** How many messages `all` keeps. Default 100; 0 keeps none. */\n keep?: number;\n}\n\nexport interface EventsState<T> {\n latest: T | null;\n all: T[];\n status: EventsStatus;\n error: Event | null;\n /** Close the stream for good; the hook will not reconnect. */\n close: () => void;\n}\n\n/** What the hook receives from `Y`'s yields: the unnamed messages, or those named `E`. */\ntype Received<Y, E> = unknown extends Y\n ? unknown\n : E extends string\n ? NamedMessageOf<Y, E>\n : MessageOf<Y>;\n\n/** The longest wait between attempts after the browser gave up. */\nconst MAX_BACKOFF_MS = 30_000;\n\nexport function useEvents<const U extends EventsUrl, const E extends string | undefined = undefined>(\n url: U,\n options?: EventsOptions<Received<EventsAt<U>, E>, E>,\n): EventsState<Received<EventsAt<U>, E>>;\nexport function useEvents<T>(url: EventsUrl, options?: EventsOptions<T>): EventsState<T>;\nexport function useEvents<T>(\n url: string,\n options: EventsOptions<T> = {},\n): EventsState<T> {\n const { enabled = true, event, keep = 100 } = options;\n // Effect Events: the connection is opened once per url, and the handlers\n // it calls see the callbacks of the latest render without being the\n // effect's dependencies - which would reopen the connection on every\n // render that passed a new arrow.\n const onMessage = useEffectEvent((message: T) => options.onMessage?.(message));\n const onError = useEffectEvent((event: Event) => options.onError?.(event));\n\n const [latest, setLatest] = useState<T | null>(null);\n const [all, setAll] = useState<T[]>([]);\n const [status, setStatus] = useState<EventsStatus>(\n enabled ? \"connecting\" : \"closed\",\n );\n const [error, setError] = useState<Event | null>(null);\n // Ends this url's connection and any retry waiting to open the next one.\n const stop = useRef<(() => void) | null>(null);\n\n useEffect(() => {\n if (!enabled || typeof EventSource === \"undefined\") {\n setStatus(\"closed\");\n\n return;\n }\n\n let es: EventSource | null = null;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let failures = 0;\n let stopped = false;\n\n const receive = (e: MessageEvent) => {\n let message: T;\n\n try {\n message = JSON.parse(e.data) as T;\n } catch {\n message = e.data as T;\n }\n\n setLatest(message);\n if (keep > 0)\n setAll((prev) =>\n prev.length >= keep\n ? [...prev.slice(1), message]\n : [...prev, message],\n );\n onMessage(message);\n };\n\n const connect = () => {\n clearTimeout(retry);\n retry = undefined;\n\n const source = new EventSource(url);\n\n es = source;\n setStatus(\"connecting\");\n\n source.onopen = () => {\n failures = 0;\n setError(null);\n setStatus(\"open\");\n };\n source.onerror = (e) => {\n setError(e);\n onError(e);\n\n // Still CONNECTING: the browser is retrying a dropped connection\n // itself. CLOSED: it gave up, and only a new EventSource comes back.\n if (source.readyState !== EventSource.CLOSED) {\n setStatus(\"connecting\");\n\n return;\n }\n\n source.close();\n if (stopped) return;\n\n setStatus(\"connecting\");\n retry = setTimeout(connect, Math.min(MAX_BACKOFF_MS, 1000 * 2 ** failures++));\n };\n\n if (event) source.addEventListener(event, receive as EventListener);\n else source.onmessage = receive;\n };\n\n // Back online: try now rather than at the end of the backoff.\n const online = () => {\n if (!stopped && retry !== undefined) connect();\n };\n\n connect();\n window.addEventListener(\"online\", online);\n\n const end = () => {\n stopped = true;\n clearTimeout(retry);\n es?.close();\n window.removeEventListener(\"online\", online);\n };\n\n stop.current = end;\n\n return () => {\n end();\n if (stop.current === end) stop.current = null;\n };\n }, [url, enabled, event, keep]);\n\n return {\n latest,\n all,\n status,\n error,\n close: () => {\n stop.current?.();\n stop.current = null;\n setStatus(\"closed\");\n },\n };\n}\n"]}
package/dist/routes.d.ts CHANGED
@@ -85,6 +85,37 @@ export type ApiRoute = NoApis extends true ? string : Filled<ApiPattern> | `${Fi
85
85
  * site says so rather than one of them 404ing in production.
86
86
  */
87
87
  export declare function apiUrl(href: ApiRoute): string;
88
+ /** What a route module's GET yields, when it is `events(...)`; `unknown` for any other stream. */
89
+ export type EventsExportOf<M> = M extends {
90
+ GET: {
91
+ readonly "~events"?: infer S;
92
+ };
93
+ } ? NonNullable<S> extends {
94
+ yields: infer Y;
95
+ } ? Y : unknown : unknown;
96
+ type EventsMap = RegisterApi extends {
97
+ events: infer M;
98
+ } ? M : {};
99
+ type Split<S extends string> = S extends `${infer A}/${infer B}` ? [A, ...Split<B>] : [S];
100
+ /**
101
+ * Whether a url's segments fit a pattern's, one segment at a time. Unlike
102
+ * `Filled`, a `[id]` takes exactly one segment, so `/api/orders/42/events`
103
+ * is `/api/orders/[id]/events` and not also `/api/orders/[id]`.
104
+ */
105
+ type Fits<H extends string[], P extends string[]> = P extends [
106
+ infer P0 extends string,
107
+ ...infer PR extends string[]
108
+ ] ? P0 extends `[...${string}]` ? H extends [string, ...string[]] ? true : false : H extends [infer H0 extends string, ...infer HR extends string[]] ? P0 extends `[${string}]` ? Fits<HR, PR> : H0 extends P0 ? Fits<HR, PR> : false : false : H extends [] ? true : false;
109
+ /** The api pattern a written url belongs to: `/api/orders/42/events` is `/api/orders/[id]/events`. */
110
+ type ApiPatternOf<H extends string> = ApiPattern extends infer P ? P extends string ? Fits<Split<StripQuery<H>>, Split<P>> extends true ? P : never : never : never;
111
+ /**
112
+ * Everything the stream at `H` yields, named events included. `unknown` when
113
+ * `H` is not one api route the build found - a url computed at runtime, or
114
+ * another site's - since there is nothing to read it from.
115
+ */
116
+ export type EventsAt<H extends string> = NoApis extends true ? unknown : IsUnion<H> extends true ? unknown : [ApiPatternOf<H>] extends [never] ? unknown : ApiPatternOf<H> extends keyof EventsMap ? EventsMap[ApiPatternOf<H>] : unknown;
117
+ /** A url `useEvents` may open: an api route this app answers, or another site's stream. */
118
+ export type EventsUrl = ApiRoute | `${string}://${string}`;
88
119
  /** What a page module contributes: its `searchParams` export, or nothing. For the generated file. */
89
120
  export type SearchExportOf<M> = M extends {
90
121
  searchParams: infer S;
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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","sourcesContent":["// Typed routes: the urls this app can actually answer, as a type.\n//\n// Laravel needs route() because the url lives in PHP and can move\n// independently of the name it is called by. Here the url *is* the file path,\n// so a name would be indirection that buys nothing. What is worth having is\n// the other half — a link to a page that does not exist should fail at build\n// time rather than in the browser.\n//\n// There is no route() builder to go with this, deliberately. A template\n// literal is checked the same way — `/posts/${slug}` compiles and\n// `/postz/${slug}` does not — so a builder would only wrap what the language\n// already does. Encoding a value that is not url-safe is `encodeURIComponent`\n// in the template, the same as anywhere else.\n//\n// The build already walks app/ and knows every route's segments, so it writes\n// one line into the app's source dir:\n//\n// declare module '@rsc-kit/core/routes' {\n// interface Register { routes: '/' | '/posts/[slug]' }\n// }\n//\n// Everything below is derived from that union. An app that never runs the\n// generator — a generic host, a Laravel app that has not rebuilt — registers\n// nothing, `RoutePattern` stays `string`, and every url-taking API is exactly\n// as permissive as it was before. That fallback is the reason this can ship\n// without a flag.\n\n/**\n * Augmented by the generated `rsc-routes.d.ts`. Empty here on purpose.\n *\n * Declaration merging rather than a generic parameter, because the routes are\n * a property of the project, not of each call site — threading them through\n * every component that renders a Link is not a thing anyone would do twice.\n */\nexport interface Register {}\n\n/** The route patterns this app declared: `'/posts/[slug]'`. */\nexport type RoutePattern = Register extends { routes: infer R extends string }\n ? R\n : string;\n\n/** Whether anything was registered. `string` means the generator never ran. */\ntype Unregistered = string extends RoutePattern ? true : false;\n\n/**\n * A pattern with its dynamic segments opened up: `/posts/[slug]` accepts\n * `/posts/anything`.\n *\n * Catch-all and single params both become `${string}`, which for a catch-all\n * also swallows the slashes — `/docs/[...path]` accepts `/docs/a/b/c`.\n */\ntype Filled<P extends string> = P extends `${infer A}[...${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P extends `${infer A}[${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P;\n\n/**\n * Not a route, but a legitimate href: another site, a mail client, a phone\n * number, an anchor on this page, a bare query string.\n */\ntype OffRoute =\n | `${string}://${string}`\n | `mailto:${string}`\n | `tel:${string}`\n | `#${string}`\n | `?${string}`;\n\n/**\n * A url this app can answer, or one that deliberately leaves it.\n *\n * Cast when the destination is computed rather than written:\n * `href={path as Route}`.\n */\n/**\n * A url this app answers - a page or a route.ts - or one that deliberately\n * leaves it. One type, as Next's `Route` is one type: what `<Link href>`,\n * `visit()` and `redirect()` take. A route.ts is a full navigation rather\n * than a payload fetch, and never prefetched - the client knows which urls\n * are routes and treats a link to one as the anchor it is - so the type\n * does not have to keep them apart to keep a hover from running one.\n *\n * `Route` is the same type under the name Next uses, so a port keeps the\n * word it already has.\n */\nexport type Route = Unregistered extends true\n ? string\n : | Filled<RoutePattern>\n | `${Filled<RoutePattern>}?${string}`\n | `${Filled<RoutePattern>}#${string}`\n | Filled<ApiPattern>\n | `${Filled<ApiPattern>}?${string}`\n | OffRoute;\n\n// ── Api routes ───────────────────────────────────────────────────────────────\n//\n// Their own union rather than part of Route, because they are not pages and a\n// link to one is almost always a mistake — an <a href=\"/api/orders\"> navigates\n// the browser away to a json document. Keeping them apart means `Link` refuses\n// an api url and `apiUrl()` refuses a page, which is the pair of mistakes worth\n// catching.\n\n/** Augmented by the generated `rsc-routes.d.ts`, like `Register`. */\nexport interface RegisterApi {}\n\n// ── Regions a page can re-render on its own ──────────────────────────────────\n//\n// Every `section('name', …)` the build found, and every `@slot` directory.\n// `revalidate('orders')` is checked against them, so a name that matches no\n// section - a typo, or a section renamed since - stops compiling instead of\n// being refused by the renderer at runtime.\n\n/** Augmented by the generated `rsc-routes.d.ts`, like `Register`. */\nexport interface RegisterRegions {}\n\n/** The region names the build found: section names and slot names. */\nexport type RegionName = RegisterRegions extends {\n regions: infer R extends string;\n}\n ? R\n : string;\n\ntype NoRegions = string extends RegionName ? true : false;\n\n/**\n * What `revalidate()` takes: the page, the whole document, or a region by\n * name. `string` until the build has written the types.\n */\nexport type RevalidateTarget = NoRegions extends true\n ? string\n : \"page\" | \"all\" | RegionName;\n\n/** The api route patterns this app declared: `'/api/orders/[id]'`. */\nexport type ApiPattern = RegisterApi extends { apis: infer R extends string }\n ? R\n : string;\n\ntype NoApis = string extends ApiPattern ? true : false;\n\n/**\n * A url an api route in this app answers.\n *\n * `/api/orders/[id]` accepts `/api/orders/42`, and a query string is allowed\n * because that is how a GET is parameterised.\n */\nexport type ApiRoute = NoApis extends true\n ? string\n : Filled<ApiPattern> | `${Filled<ApiPattern>}?${string}`;\n\n/**\n * An api url, checked against the routes the build found.\n *\n * await fetch(apiUrl(`/api/orders/${id}`))\n *\n * A function rather than a bare type so it can be used inline at a call site\n * that is typed `string` — `fetch` takes any string, so nothing would check the\n * argument without somewhere to put the type. It returns what it was given.\n *\n * Wrong path, and it stops compiling. Renamed the directory, and every call\n * site says so rather than one of them 404ing in production.\n */\nexport function apiUrl(href: ApiRoute): string {\n return href;\n}\n\n// ── Search params, typed per route ───────────────────────────────────────────\n//\n// A page that exports a `searchParams` schema has said what its query string\n// means. The generated file records that schema per pattern:\n//\n// interface Register {\n// search: { '/search': SearchExportOf<typeof import('../src/app/search/page')> }\n// }\n//\n// and from there a link to `/search` is checked against the same schema the\n// page parses with — a `page` that must be a number is a number on the link,\n// a `q` the page requires is required to write the link, and a key the page\n// never reads does not compile. One schema, both ends. A route that exports\n// none takes anything; an href that is not a single route (computed, cast, or\n// off-site) takes anything too, because there is nothing to check it against.\n\n/** What a page module contributes: its `searchParams` export, or nothing. For the generated file. */\nexport type SearchExportOf<M> = M extends { searchParams: infer S }\n ? S\n : undefined;\n\ntype SearchMap = Register extends { search: infer M } ? M : {};\n\ntype StripQuery<H extends string> = H extends `${infer P}?${string}`\n ? P\n : H extends `${infer P}#${string}`\n ? P\n : H;\n\n/** The pattern a written href belongs to: `/posts/hello` is `/posts/[slug]`. */\ntype PatternOf<H extends string> = RoutePattern extends infer P\n ? P extends string\n ? StripQuery<H> extends Filled<P>\n ? P\n : never\n : never\n : never;\n\ntype IsUnion<T, U = T> = T extends unknown\n ? [U] extends [T]\n ? false\n : true\n : never;\n\ntype SchemaFor<P> = P extends keyof SearchMap ? SearchMap[P] : undefined;\n\ntype InputOf<S> = S extends { \"~standard\": { types?: { input: infer I } } }\n ? I\n : never;\ntype OutputOf<S> = S extends { \"~standard\": { types?: { output: infer O } } }\n ? O\n : never;\n\ntype OptionalKeys<T> = {\n [K in keyof T]-?: {} extends Pick<T, K> ? K : never;\n}[keyof T];\ntype RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;\ntype Simplify<T> = { [K in keyof T]: T[K] } & {};\n\n/**\n * What a link may write for a schema: the keys the schema's input requires\n * are required, the rest optional, and every value is the schema's OUTPUT\n * type. Output rather than input because `z.coerce.number()` takes `unknown`\n * in - that is what coercion means - and a link typed by it would accept\n * `page: 'two'`. The output is the number the page will actually see.\n */\ntype LinkInputOf<S> = Simplify<\n { [K in RequiredKeys<InputOf<S>> & keyof OutputOf<S>]: OutputOf<S>[K] } & {\n [K in OptionalKeys<InputOf<S>> & keyof OutputOf<S>]?: OutputOf<S>[K];\n }\n>;\n\ntype Scalar = string | number | boolean | null | undefined;\n\n/** What a link may carry when nothing declares otherwise. */\nexport type LooseSearch = Record<string, Scalar | readonly (string | number)[]>;\n\n/**\n * The search params a link to `H` may carry.\n *\n * The page's schema input when `H` is one declared route with a schema;\n * otherwise anything. \"One route\" matters: `path as Route` is every route at\n * once, and a link that could go anywhere cannot be held to one page's schema.\n */\nexport type SearchFor<H extends string> = Unregistered extends true\n ? LooseSearch\n : IsUnion<H> extends true\n ? LooseSearch\n : [PatternOf<H>] extends [never]\n ? LooseSearch\n : SchemaFor<PatternOf<H>> extends undefined\n ? LooseSearch\n : LinkInputOf<SchemaFor<PatternOf<H>>>;\n\n/**\n * The `search` prop, required exactly when the page's schema has a required\n * key. A page that needs `q` is not reachable without one, so the link that\n * omits it is the bug — caught here rather than on the page's error boundary.\n */\nexport type SearchProp<H extends string> =\n {} extends SearchFor<H>\n ? { search?: SearchFor<H> }\n : { search: SearchFor<H> };\n\n/** A query string from an object: scalars stringified, arrays repeated, null and undefined dropped. */\nexport function searchString(search: object): string {\n const params = new URLSearchParams();\n\n for (const [key, value] of Object.entries(search)) {\n if (value === null || value === undefined) continue;\n\n if (Array.isArray(value)) {\n for (const item of value) params.append(key, String(item));\n } else {\n params.set(key, String(value));\n }\n }\n\n return params.toString();\n}\n\n/** `path` with `search` appended, keeping any query and hash already on it. */\nexport function withSearch(path: string, search: object | undefined): string {\n if (!search) return path;\n\n const hashAt = path.indexOf(\"#\");\n const hash = hashAt === -1 ? \"\" : path.slice(hashAt);\n const before = hashAt === -1 ? path : path.slice(0, hashAt);\n const queryAt = before.indexOf(\"?\");\n const base = queryAt === -1 ? before : before.slice(0, queryAt);\n const existing = queryAt === -1 ? \"\" : before.slice(queryAt + 1);\n const added = searchString(search);\n const query = [existing, added].filter(Boolean).join(\"&\");\n\n return query ? `${base}?${query}${hash}` : `${base}${hash}`;\n}\n\n/**\n * A typed url with its search params, for the places that take a string.\n *\n * visit(href('/search', { q: 'shoes', page: 2 }))\n *\n * `Link` has the same check on its own `search` prop. This is for `visit`,\n * `prefetch`, `redirect` and anything else that wants the finished string.\n */\nexport function href<H extends Route>(\n path: H,\n ...rest: {} extends SearchFor<H>\n ? [search?: SearchFor<H>]\n : [search: SearchFor<H>]\n): Route {\n return withSearch(path, rest[0] as object | undefined) as Route;\n}\n"]}
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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","sourcesContent":["// Typed routes: the urls this app can actually answer, as a type.\n//\n// Laravel needs route() because the url lives in PHP and can move\n// independently of the name it is called by. Here the url *is* the file path,\n// so a name would be indirection that buys nothing. What is worth having is\n// the other half — a link to a page that does not exist should fail at build\n// time rather than in the browser.\n//\n// There is no route() builder to go with this, deliberately. A template\n// literal is checked the same way — `/posts/${slug}` compiles and\n// `/postz/${slug}` does not — so a builder would only wrap what the language\n// already does. Encoding a value that is not url-safe is `encodeURIComponent`\n// in the template, the same as anywhere else.\n//\n// The build already walks app/ and knows every route's segments, so it writes\n// one line into the app's source dir:\n//\n// declare module '@rsc-kit/core/routes' {\n// interface Register { routes: '/' | '/posts/[slug]' }\n// }\n//\n// Everything below is derived from that union. An app that never runs the\n// generator — a generic host, a Laravel app that has not rebuilt — registers\n// nothing, `RoutePattern` stays `string`, and every url-taking API is exactly\n// as permissive as it was before. That fallback is the reason this can ship\n// without a flag.\n\n/**\n * Augmented by the generated `rsc-routes.d.ts`. Empty here on purpose.\n *\n * Declaration merging rather than a generic parameter, because the routes are\n * a property of the project, not of each call site — threading them through\n * every component that renders a Link is not a thing anyone would do twice.\n */\nexport interface Register {}\n\n/** The route patterns this app declared: `'/posts/[slug]'`. */\nexport type RoutePattern = Register extends { routes: infer R extends string }\n ? R\n : string;\n\n/** Whether anything was registered. `string` means the generator never ran. */\ntype Unregistered = string extends RoutePattern ? true : false;\n\n/**\n * A pattern with its dynamic segments opened up: `/posts/[slug]` accepts\n * `/posts/anything`.\n *\n * Catch-all and single params both become `${string}`, which for a catch-all\n * also swallows the slashes — `/docs/[...path]` accepts `/docs/a/b/c`.\n */\ntype Filled<P extends string> = P extends `${infer A}[...${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P extends `${infer A}[${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P;\n\n/**\n * Not a route, but a legitimate href: another site, a mail client, a phone\n * number, an anchor on this page, a bare query string.\n */\ntype OffRoute =\n | `${string}://${string}`\n | `mailto:${string}`\n | `tel:${string}`\n | `#${string}`\n | `?${string}`;\n\n/**\n * A url this app can answer, or one that deliberately leaves it.\n *\n * Cast when the destination is computed rather than written:\n * `href={path as Route}`.\n */\n/**\n * A url this app answers - a page or a route.ts - or one that deliberately\n * leaves it. One type, as Next's `Route` is one type: what `<Link href>`,\n * `visit()` and `redirect()` take. A route.ts is a full navigation rather\n * than a payload fetch, and never prefetched - the client knows which urls\n * are routes and treats a link to one as the anchor it is - so the type\n * does not have to keep them apart to keep a hover from running one.\n *\n * `Route` is the same type under the name Next uses, so a port keeps the\n * word it already has.\n */\nexport type Route = Unregistered extends true\n ? string\n : | Filled<RoutePattern>\n | `${Filled<RoutePattern>}?${string}`\n | `${Filled<RoutePattern>}#${string}`\n | Filled<ApiPattern>\n | `${Filled<ApiPattern>}?${string}`\n | OffRoute;\n\n// ── Api routes ───────────────────────────────────────────────────────────────\n//\n// Their own union rather than part of Route, because they are not pages and a\n// link to one is almost always a mistake — an <a href=\"/api/orders\"> navigates\n// the browser away to a json document. Keeping them apart means `Link` refuses\n// an api url and `apiUrl()` refuses a page, which is the pair of mistakes worth\n// catching.\n\n/** Augmented by the generated `rsc-routes.d.ts`, like `Register`. */\nexport interface RegisterApi {}\n\n// ── Regions a page can re-render on its own ──────────────────────────────────\n//\n// Every `section('name', …)` the build found, and every `@slot` directory.\n// `revalidate('orders')` is checked against them, so a name that matches no\n// section - a typo, or a section renamed since - stops compiling instead of\n// being refused by the renderer at runtime.\n\n/** Augmented by the generated `rsc-routes.d.ts`, like `Register`. */\nexport interface RegisterRegions {}\n\n/** The region names the build found: section names and slot names. */\nexport type RegionName = RegisterRegions extends {\n regions: infer R extends string;\n}\n ? R\n : string;\n\ntype NoRegions = string extends RegionName ? true : false;\n\n/**\n * What `revalidate()` takes: the page, the whole document, or a region by\n * name. `string` until the build has written the types.\n */\nexport type RevalidateTarget = NoRegions extends true\n ? string\n : \"page\" | \"all\" | RegionName;\n\n/** The api route patterns this app declared: `'/api/orders/[id]'`. */\nexport type ApiPattern = RegisterApi extends { apis: infer R extends string }\n ? R\n : string;\n\ntype NoApis = string extends ApiPattern ? true : false;\n\n/**\n * A url an api route in this app answers.\n *\n * `/api/orders/[id]` accepts `/api/orders/42`, and a query string is allowed\n * because that is how a GET is parameterised.\n */\nexport type ApiRoute = NoApis extends true\n ? string\n : Filled<ApiPattern> | `${Filled<ApiPattern>}?${string}`;\n\n/**\n * An api url, checked against the routes the build found.\n *\n * await fetch(apiUrl(`/api/orders/${id}`))\n *\n * A function rather than a bare type so it can be used inline at a call site\n * that is typed `string` — `fetch` takes any string, so nothing would check the\n * argument without somewhere to put the type. It returns what it was given.\n *\n * Wrong path, and it stops compiling. Renamed the directory, and every call\n * site says so rather than one of them 404ing in production.\n */\nexport function apiUrl(href: ApiRoute): string {\n return href;\n}\n\n// ── What an events route sends ───────────────────────────────────────────────\n//\n// A route.ts whose GET is `events(...)` says, as a type, what its generator\n// yields. The generated file records that per api pattern:\n//\n// interface RegisterApi {\n// events: { '/api/orders/[id]/events': EventsExportOf<typeof import('../src/app/api/orders/[id]/events/route')> }\n// }\n//\n// and `useEvents` reads a url's messages from it, so the hook and the route\n// cannot disagree about a message's shape.\n\n/** What a route module's GET yields, when it is `events(...)`; `unknown` for any other stream. */\nexport type EventsExportOf<M> = M extends {\n GET: { readonly \"~events\"?: infer S };\n}\n ? NonNullable<S> extends { yields: infer Y }\n ? Y\n : unknown\n : unknown;\n\ntype EventsMap = RegisterApi extends { events: infer M } ? M : {};\n\ntype Split<S extends string> = S extends `${infer A}/${infer B}`\n ? [A, ...Split<B>]\n : [S];\n\n/**\n * Whether a url's segments fit a pattern's, one segment at a time. Unlike\n * `Filled`, a `[id]` takes exactly one segment, so `/api/orders/42/events`\n * is `/api/orders/[id]/events` and not also `/api/orders/[id]`.\n */\ntype Fits<H extends string[], P extends string[]> = P extends [\n infer P0 extends string,\n ...infer PR extends string[],\n]\n ? P0 extends `[...${string}]`\n ? H extends [string, ...string[]]\n ? true\n : false\n : H extends [infer H0 extends string, ...infer HR extends string[]]\n ? P0 extends `[${string}]`\n ? Fits<HR, PR>\n : H0 extends P0\n ? Fits<HR, PR>\n : false\n : false\n : H extends []\n ? true\n : false;\n\n/** The api pattern a written url belongs to: `/api/orders/42/events` is `/api/orders/[id]/events`. */\ntype ApiPatternOf<H extends string> = ApiPattern extends infer P\n ? P extends string\n ? Fits<Split<StripQuery<H>>, Split<P>> extends true\n ? P\n : never\n : never\n : never;\n\n/**\n * Everything the stream at `H` yields, named events included. `unknown` when\n * `H` is not one api route the build found - a url computed at runtime, or\n * another site's - since there is nothing to read it from.\n */\nexport type EventsAt<H extends string> = NoApis extends true\n ? unknown\n : IsUnion<H> extends true\n ? unknown\n : [ApiPatternOf<H>] extends [never]\n ? unknown\n : ApiPatternOf<H> extends keyof EventsMap\n ? EventsMap[ApiPatternOf<H>]\n : unknown;\n\n/** A url `useEvents` may open: an api route this app answers, or another site's stream. */\nexport type EventsUrl = ApiRoute | `${string}://${string}`;\n\n// ── Search params, typed per route ───────────────────────────────────────────\n//\n// A page that exports a `searchParams` schema has said what its query string\n// means. The generated file records that schema per pattern:\n//\n// interface Register {\n// search: { '/search': SearchExportOf<typeof import('../src/app/search/page')> }\n// }\n//\n// and from there a link to `/search` is checked against the same schema the\n// page parses with — a `page` that must be a number is a number on the link,\n// a `q` the page requires is required to write the link, and a key the page\n// never reads does not compile. One schema, both ends. A route that exports\n// none takes anything; an href that is not a single route (computed, cast, or\n// off-site) takes anything too, because there is nothing to check it against.\n\n/** What a page module contributes: its `searchParams` export, or nothing. For the generated file. */\nexport type SearchExportOf<M> = M extends { searchParams: infer S }\n ? S\n : undefined;\n\ntype SearchMap = Register extends { search: infer M } ? M : {};\n\ntype StripQuery<H extends string> = H extends `${infer P}?${string}`\n ? P\n : H extends `${infer P}#${string}`\n ? P\n : H;\n\n/** The pattern a written href belongs to: `/posts/hello` is `/posts/[slug]`. */\ntype PatternOf<H extends string> = RoutePattern extends infer P\n ? P extends string\n ? StripQuery<H> extends Filled<P>\n ? P\n : never\n : never\n : never;\n\ntype IsUnion<T, U = T> = T extends unknown\n ? [U] extends [T]\n ? false\n : true\n : never;\n\ntype SchemaFor<P> = P extends keyof SearchMap ? SearchMap[P] : undefined;\n\ntype InputOf<S> = S extends { \"~standard\": { types?: { input: infer I } } }\n ? I\n : never;\ntype OutputOf<S> = S extends { \"~standard\": { types?: { output: infer O } } }\n ? O\n : never;\n\ntype OptionalKeys<T> = {\n [K in keyof T]-?: {} extends Pick<T, K> ? K : never;\n}[keyof T];\ntype RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;\ntype Simplify<T> = { [K in keyof T]: T[K] } & {};\n\n/**\n * What a link may write for a schema: the keys the schema's input requires\n * are required, the rest optional, and every value is the schema's OUTPUT\n * type. Output rather than input because `z.coerce.number()` takes `unknown`\n * in - that is what coercion means - and a link typed by it would accept\n * `page: 'two'`. The output is the number the page will actually see.\n */\ntype LinkInputOf<S> = Simplify<\n { [K in RequiredKeys<InputOf<S>> & keyof OutputOf<S>]: OutputOf<S>[K] } & {\n [K in OptionalKeys<InputOf<S>> & keyof OutputOf<S>]?: OutputOf<S>[K];\n }\n>;\n\ntype Scalar = string | number | boolean | null | undefined;\n\n/** What a link may carry when nothing declares otherwise. */\nexport type LooseSearch = Record<string, Scalar | readonly (string | number)[]>;\n\n/**\n * The search params a link to `H` may carry.\n *\n * The page's schema input when `H` is one declared route with a schema;\n * otherwise anything. \"One route\" matters: `path as Route` is every route at\n * once, and a link that could go anywhere cannot be held to one page's schema.\n */\nexport type SearchFor<H extends string> = Unregistered extends true\n ? LooseSearch\n : IsUnion<H> extends true\n ? LooseSearch\n : [PatternOf<H>] extends [never]\n ? LooseSearch\n : SchemaFor<PatternOf<H>> extends undefined\n ? LooseSearch\n : LinkInputOf<SchemaFor<PatternOf<H>>>;\n\n/**\n * The `search` prop, required exactly when the page's schema has a required\n * key. A page that needs `q` is not reachable without one, so the link that\n * omits it is the bug — caught here rather than on the page's error boundary.\n */\nexport type SearchProp<H extends string> =\n {} extends SearchFor<H>\n ? { search?: SearchFor<H> }\n : { search: SearchFor<H> };\n\n/** A query string from an object: scalars stringified, arrays repeated, null and undefined dropped. */\nexport function searchString(search: object): string {\n const params = new URLSearchParams();\n\n for (const [key, value] of Object.entries(search)) {\n if (value === null || value === undefined) continue;\n\n if (Array.isArray(value)) {\n for (const item of value) params.append(key, String(item));\n } else {\n params.set(key, String(value));\n }\n }\n\n return params.toString();\n}\n\n/** `path` with `search` appended, keeping any query and hash already on it. */\nexport function withSearch(path: string, search: object | undefined): string {\n if (!search) return path;\n\n const hashAt = path.indexOf(\"#\");\n const hash = hashAt === -1 ? \"\" : path.slice(hashAt);\n const before = hashAt === -1 ? path : path.slice(0, hashAt);\n const queryAt = before.indexOf(\"?\");\n const base = queryAt === -1 ? before : before.slice(0, queryAt);\n const existing = queryAt === -1 ? \"\" : before.slice(queryAt + 1);\n const added = searchString(search);\n const query = [existing, added].filter(Boolean).join(\"&\");\n\n return query ? `${base}?${query}${hash}` : `${base}${hash}`;\n}\n\n/**\n * A typed url with its search params, for the places that take a string.\n *\n * visit(href('/search', { q: 'shoes', page: 2 }))\n *\n * `Link` has the same check on its own `search` prop. This is for `visit`,\n * `prefetch`, `redirect` and anything else that wants the finished string.\n */\nexport function href<H extends Route>(\n path: H,\n ...rest: {} extends SearchFor<H>\n ? [search?: SearchFor<H>]\n : [search: SearchFor<H>]\n): Route {\n return withSearch(path, rest[0] as object | undefined) as Route;\n}\n"]}
package/dist/vite.js CHANGED
@@ -1942,6 +1942,19 @@ function renderRouteTypes(manifest) {
1942
1942
  const target = relative(typesDir, join(sourceDir, route.component)).replace(/\\/g, "/");
1943
1943
  search.set(pattern, target.startsWith(".") ? target : "./" + target);
1944
1944
  }
1945
+ // Each GET api route to its module, so useEvents can read what an
1946
+ // events() route yields off its type - the same type-only import as a
1947
+ // page's searchParams. A route that is not events() resolves to unknown.
1948
+ const streams = new Map();
1949
+ for (const api of manifest.apis ?? []) {
1950
+ const route = apiRoutes.get(api.name);
1951
+ if (!route || route.generated || !api.methods.includes("GET"))
1952
+ continue;
1953
+ const pattern = patternOf(api.segments);
1954
+ const target = relative(typesDir, route.absPath.replace(/\.[cm]?[jt]sx?$/, "")).replace(/\\/g, "/");
1955
+ if (!streams.has(pattern))
1956
+ streams.set(pattern, target.startsWith(".") ? target : "./" + target);
1957
+ }
1945
1958
  // Section names are read from the source - section('orders', …) - the way
1946
1959
  // generateStaticParams is detected, because this runs before any bundle
1947
1960
  // exists. A name computed at runtime is not seen and revalidate() falls
@@ -2005,6 +2018,16 @@ function renderRouteTypes(manifest) {
2005
2018
  ? " apis:\n" +
2006
2019
  apis.map((p) => " | " + JSON.stringify(p)).join("\n")
2007
2020
  : " // No route.ts files found under the source directory.\n apis: never",
2021
+ // What each GET route streams, read off its module. This types useEvents.
2022
+ " events: {",
2023
+ ...[...streams]
2024
+ .sort()
2025
+ .map(([pattern, target]) => " " +
2026
+ JSON.stringify(pattern) +
2027
+ ": EventsExportOf<typeof import(" +
2028
+ JSON.stringify(target) +
2029
+ ")>"),
2030
+ " }",
2008
2031
  " }",
2009
2032
  "}",
2010
2033
  "",