@uniflowed/router 0.0.0-alpha.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1593 -1341
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -106
package/handler.js CHANGED
@@ -2,13 +2,13 @@
2
2
  //
3
3
  // Route handlers: a path that answers a request instead of rendering a page.
4
4
  //
5
- // `app/api/users/_uf.route.js` exporting `GET` and `POST` serves
5
+ // `app/api/users/$route.js` exporting `GET` and `POST` serves
6
6
  // `/api/users`. A handler takes a `Request` and returns a `Response` — the
7
7
  // platform's own types, not a framework's wrapper — because that is what runs
8
8
  // unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
9
9
  // position is that the host is a capability rather than a target.
10
10
  //
11
- // // app/api/users/[id]/_uf.route.js
11
+ // // app/api/users/[id]/$route.js
12
12
  // // @flow
13
13
  // export async function GET(request: Request, context: HandlerContext) {
14
14
  // const user = await find(context.params.id);
@@ -26,6 +26,41 @@
26
26
  // with the `Allow` header the specification requires — that is not the
27
27
  // handler's business, and every handler would otherwise write it.
28
28
  //
29
+ // # `QUERY`, and what refuses it
30
+ //
31
+ // `QUERY` is a `GET` with a body: safe, idempotent, cacheable, and the method
32
+ // that a search with more parameters than a URL can hold has been faking with a
33
+ // `POST` for twenty years. A handler exports it like any other verb, and this
34
+ // dispatcher matches it like any other verb, because there is nothing special
35
+ // about it *here*. What is special about it is the path between a client and
36
+ // this function, and that is the part worth writing down rather than leaving to
37
+ // be discovered in production.
38
+ //
39
+ // Three things refuse it, and they refuse it differently:
40
+ //
41
+ // * **A client that cannot send it.** The Fetch standard forbids `CONNECT`,
42
+ // `TRACE` and `TRACK` and allows any other token, so every browser and
43
+ // every runtime uf targets can send a `QUERY` today. `XMLHttpRequest` and
44
+ // `EventSource` cannot, and neither can a `<form>`.
45
+ // * **An intermediary that will not forward it.** This is the real one. A
46
+ // proxy, a CDN or a WAF that has a list of methods answers `405` or `501`
47
+ // itself, and the request never arrives — so the failure looks exactly like
48
+ // a route that does not exist, from a server that never saw it.
49
+ // `@uniflowed/fetch` names that case in the error rather than passing the
50
+ // status through, which is the whole of what "stated rather than
51
+ // discovered" can mean from the other end of a wire.
52
+ // * **A cache that does not know it is safe.** `QUERY` is cacheable in
53
+ // principle and the key includes the body, which almost nothing implements.
54
+ // uf's own route cache is `GET`-only and stays that way; anything in front
55
+ // of the application should be told not to store a `QUERY` at all.
56
+ //
57
+ // What uf deliberately does not do about any of it is accept a method-override
58
+ // header. `X-HTTP-Method-Override: QUERY` on a `POST` is the usual workaround
59
+ // and it is the shape of CVE-2025-29927: an inbound header steering dispatch,
60
+ // which `docs/security.md` forbids in the row about that CVE and in rule 3. A
61
+ // route that must work through hostile infrastructure exports `POST` as well
62
+ // and says so in its own file, where a reader can see it.
63
+ //
29
64
  // It also does not establish the request a handler is inside. The host does,
30
65
  // around the whole of it, so a handler and the guard above it share one
31
66
  // context; see the same section in `./middleware.js`. This module used to
@@ -34,6 +69,9 @@
34
69
  // `after()` promises. A handler that streams its body has not sent a byte at
35
70
  // that point. See ubugeeei-prod/uf#389.
36
71
 
72
+ import { asResponder, noteRoute } from "@uniflowed/server/host";
73
+
74
+ import { matchRoute } from "./internal/routing.js";
37
75
  import { requireRequest } from "./internal/request.js";
38
76
  import type { RouteParams } from "./internal/runtime.js";
39
77
 
@@ -66,8 +104,27 @@ export type HandlerRecord = {|
66
104
  * — and a module that exports a helper would then answer requests with it.
67
105
  * `HEAD` falls back to `GET` with the body dropped, which is what a client
68
106
  * asking for headers expects and what nobody remembers to write.
107
+ *
108
+ * It was closed in name only until `QUERY` was added. `pick` looked the method
109
+ * up on the module and this list decided nothing but the order of the `Allow`
110
+ * header, so a module exporting `PURGE` answered `PURGE` — the exact behaviour
111
+ * the paragraph above says is refused. Adding a verb was the moment to make the
112
+ * sentence true, because the alternative was adding one to a list nothing read.
113
+ *
114
+ * `QUERY` is here and `CONNECT` and `TRACE` are not, and the difference is not
115
+ * taste: the `fetch` specification forbids the last two outright, so a handler
116
+ * exporting either could never be reached by a browser.
69
117
  */
70
- const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
118
+ export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
119
+ "GET",
120
+ "HEAD",
121
+ "QUERY",
122
+ "POST",
123
+ "PUT",
124
+ "PATCH",
125
+ "DELETE",
126
+ "OPTIONS",
127
+ ]);
71
128
 
72
129
  /**
73
130
  * Match a request against the handler table and run it.
@@ -79,55 +136,70 @@ const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
79
136
  export function createDispatcher(options: {|
80
137
  readonly handlers: $ReadOnlyArray<HandlerRecord>,
81
138
  |}): (request: Request) => Promise<Response | null> {
82
- // Longest path first, so `/api/users/new` wins over `/api/users/[id]` and a
83
- // catch-all is the last thing tried.
84
- const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
139
+ const table = [...options.handlers];
85
140
 
86
141
  return async function dispatch(request: Request): Promise<Response | null> {
87
142
  // The host's half of the contract, checked rather than assumed; see
88
143
  // `./internal/request.js`.
89
144
  requireRequest("dispatch");
90
145
  const url = new URL(request.url);
91
- for (const record of table) {
92
- const params = matchPath(record.path, url.pathname);
93
- if (params == null) {
94
- continue;
95
- }
96
-
97
- const module = await record.load();
98
- const method = request.method.toUpperCase();
99
- const handler = pick(module, method);
100
- if (handler == null) {
101
- return methodNotAllowed(module);
102
- }
146
+ const match = matchRoute(table, url.pathname);
147
+ if (match == null) return null;
148
+ const { route: record, params } = match;
149
+
150
+ // Before the module is loaded and before the method is checked, because
151
+ // this is the answer to "what was this request" and a `405` is as much
152
+ // this route's answer as a `200` is. A log of `/api/users/:id 405` is
153
+ // actionable; the same line with the path in it is a million lines.
154
+ noteRoute(record.path);
155
+
156
+ const module = await record.load();
157
+ const method = request.method.toUpperCase();
158
+ const handler = pick(module, method);
159
+ if (handler == null) {
160
+ return methodNotAllowed(module);
161
+ }
103
162
 
104
- // In the host's request, so a handler that calls `headers()`,
105
- // `cookies()` or `after()` answers about the same one its guard did, and
106
- // what it defers is drained once, by the host, after the bytes are out.
107
- const response = await handler(request, {
108
- params,
109
- searchParams: url.searchParams,
163
+ // In the host's request, so a handler that calls `headers()`,
164
+ // `cookies()` or `after()` answers about the same one its guard did, and
165
+ // what it defers is drained once, by the host, after the bytes are out.
166
+ //
167
+ // And inside `asResponder`, which is the other half: a route handler is
168
+ // one of the two things that owns a response, so it is one of the two
169
+ // places `draftMode().enable()` is allowed — and the `Set-Cookie` it
170
+ // decided on is written onto the response below rather than left on an
171
+ // object the host is about to discard. See ubugeeei-prod/uf#282.
172
+ const response = await asResponder("a route handler", async () =>
173
+ handler(request, { params, searchParams: url.searchParams }),
174
+ );
175
+
176
+ // A `HEAD` answered by `GET` must not carry the body. The test is
177
+ // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
178
+ // `GET`, so asking it whether a `HEAD` exists always said yes and the
179
+ // body went out anyway.
180
+ if (method === "HEAD" && typeof module.HEAD !== "function") {
181
+ return new Response(null, {
182
+ status: response.status,
183
+ statusText: response.statusText,
184
+ headers: response.headers,
110
185
  });
111
-
112
- // A `HEAD` answered by `GET` must not carry the body. The test is
113
- // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
114
- // `GET`, so asking it whether a `HEAD` exists always said yes and the
115
- // body went out anyway.
116
- if (method === "HEAD" && typeof module.HEAD !== "function") {
117
- return new Response(null, {
118
- status: response.status,
119
- statusText: response.statusText,
120
- headers: response.headers,
121
- });
122
- }
123
- return response;
124
186
  }
125
- return null;
187
+ return response;
126
188
  };
127
189
  }
128
190
 
129
- /** The function for a method, falling back to `GET` for `HEAD`. */
191
+ /**
192
+ * The function for a method, falling back to `GET` for `HEAD`.
193
+ *
194
+ * The method is checked against `METHODS` first, which is what makes that list
195
+ * closed rather than decorative: without it a request could name any export,
196
+ * and a module's `PURGE` — or its `DEFAULT`, or a name a bundler added — would
197
+ * answer one.
198
+ */
130
199
  function pick(module: HandlerModule, method: string): Handler | null {
200
+ if (!HANDLER_METHODS.includes(method)) {
201
+ return null;
202
+ }
131
203
  const own = module[method];
132
204
  if (typeof own === "function") {
133
205
  return own as $FlowFixMe;
@@ -145,7 +217,7 @@ function pick(module: HandlerModule, method: string): Handler | null {
145
217
  * do that here" from "there is nothing here".
146
218
  */
147
219
  function methodNotAllowed(module: HandlerModule): Response {
148
- const own = new Set(METHODS.filter((method) => typeof module[method] === "function"));
220
+ const own = new Set(HANDLER_METHODS.filter((method) => typeof module[method] === "function"));
149
221
  // A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
150
222
  if (own.has("GET")) {
151
223
  own.add("HEAD");
@@ -154,64 +226,6 @@ function methodNotAllowed(module: HandlerModule): Response {
154
226
  // header reads in the conventional order however the module was written.
155
227
  return new Response(null, {
156
228
  status: 405,
157
- headers: { allow: METHODS.filter((method) => own.has(method)).join(", ") },
229
+ headers: { allow: HANDLER_METHODS.filter((method) => own.has(method)).join(", ") },
158
230
  });
159
231
  }
160
-
161
- /**
162
- * Match one route path against a pathname, returning its parameters.
163
- *
164
- * `null` rather than an empty object when it does not match, so a route with
165
- * no parameters is still distinguishable from a miss.
166
- */
167
- function matchPath(routePath: string, pathname: string): RouteParams | null {
168
- const wanted = segmentsOf(routePath);
169
- const given = segmentsOf(pathname);
170
- const params: { [string]: string | Array<string> } = {};
171
-
172
- for (let index = 0; index < wanted.length; index += 1) {
173
- const segment = wanted[index];
174
- if (segment.startsWith(":") && segment.endsWith("*")) {
175
- // A catch-all takes the rest, and matches zero segments as well as many.
176
- params[segment.slice(1, -1)] = given.slice(index);
177
- return params as $FlowFixMe;
178
- }
179
- if (index >= given.length) {
180
- return null;
181
- }
182
- if (segment.startsWith(":")) {
183
- params[segment.slice(1)] = given[index];
184
- continue;
185
- }
186
- if (segment !== given[index]) {
187
- return null;
188
- }
189
- }
190
-
191
- return wanted.length === given.length ? (params as $FlowFixMe) : null;
192
- }
193
-
194
- function segmentsOf(value: string): Array<string> {
195
- return value.split("/").filter((segment) => segment !== "");
196
- }
197
-
198
- /**
199
- * How specific a path is, so the table can be tried in the right order.
200
- *
201
- * A literal segment is worth more than a parameter and a parameter more than a
202
- * catch-all, and a longer path outranks a shorter one — which is what makes
203
- * `/api/users/new` win over `/api/users/[id]`.
204
- */
205
- function specificity(routePath: string): number {
206
- let score = 0;
207
- for (const segment of segmentsOf(routePath)) {
208
- if (segment.startsWith(":") && segment.endsWith("*")) {
209
- score += 1;
210
- } else if (segment.startsWith(":")) {
211
- score += 10;
212
- } else {
213
- score += 100;
214
- }
215
- }
216
- return score;
217
- }
package/http-client.js ADDED
@@ -0,0 +1,104 @@
1
+ // @flow
2
+ import {
3
+ ACTION_CONTENT_TYPE,
4
+ ACTION_HEADER,
5
+ decodeActionResult,
6
+ encodeActionArguments,
7
+ isActionId,
8
+ type ActionValue,
9
+ } from "./internal/action-wire.js";
10
+
11
+ export type RouteClientOptions = {|
12
+ readonly origin: string,
13
+ readonly getToken?: () => Promise<string>,
14
+ readonly fetch?: (url: string, options: RequestOptions) => Promise<Response>,
15
+ readonly allowInsecureDevelopment?: boolean,
16
+ |};
17
+
18
+ export type RouteRequest = {|
19
+ readonly method?: string,
20
+ readonly headers?: { readonly [string]: string },
21
+ readonly body?: string,
22
+ readonly signal?: AbortSignal,
23
+ |};
24
+
25
+ type RequestOptions = {|
26
+ ...RouteRequest,
27
+ readonly credentials: "omit",
28
+ readonly redirect: "error",
29
+ |};
30
+
31
+ /** Fetch transport shared by generated typed route clients and native actions. */
32
+ export function createRouteClient(
33
+ options: RouteClientOptions,
34
+ ): (path: string, request?: RouteRequest) => Promise<Response> {
35
+ const origin = new URL(options.origin);
36
+ if (
37
+ origin.username ||
38
+ origin.password ||
39
+ origin.pathname !== "/" ||
40
+ origin.search ||
41
+ origin.hash
42
+ ) {
43
+ throw new TypeError("createRouteClient origin must contain only a scheme and host");
44
+ }
45
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
46
+ if (
47
+ origin.protocol !== "https:" &&
48
+ !(origin.protocol === "http:" && (loopback || options.allowInsecureDevelopment === true))
49
+ ) {
50
+ throw new TypeError(
51
+ "createRouteClient requires HTTPS outside explicit development connections",
52
+ );
53
+ }
54
+ const send = options.fetch ?? globalThis.fetch;
55
+ return async (path, request = {}) => {
56
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
57
+ throw new TypeError("route client needs a same-origin absolute path");
58
+ }
59
+ const url = new URL(path, origin);
60
+ if (url.origin !== origin.origin) throw new TypeError("route client cannot change origin");
61
+ const headers: { [string]: string } = {};
62
+ for (const [name, value] of Object.entries(request.headers ?? {})) {
63
+ const lower = name.toLowerCase();
64
+ if (
65
+ ["cookie", "authorization", "origin", "host"].includes(lower) ||
66
+ lower.startsWith("sec-fetch-")
67
+ ) {
68
+ throw new TypeError(`route client does not accept the ${name} header`);
69
+ }
70
+ if (typeof value !== "string") throw new TypeError(`route header ${name} must be a string`);
71
+ headers[name] = value;
72
+ }
73
+ if (options.getToken != null) {
74
+ const token = await options.getToken();
75
+ if (!/^[A-Za-z0-9\-._~+/]+=*$/.test(token) || token.length > 8192) {
76
+ throw new TypeError("route client received an invalid bearer credential");
77
+ }
78
+ headers.authorization = `Bearer ${token}`;
79
+ }
80
+ return send(url.href, { ...request, headers, credentials: "omit", redirect: "error" });
81
+ };
82
+ }
83
+
84
+ /** Explicit action channel for clients holding application-issued bearer tokens. */
85
+ export function createNativeActionClient(options: {|
86
+ ...RouteClientOptions,
87
+ readonly getToken: () => Promise<string>,
88
+ |}): (id: string, args: $ReadOnlyArray<mixed>, path?: string) => Promise<ActionValue | void> {
89
+ const send = createRouteClient(options);
90
+ return async (id, args, path = "/") => {
91
+ if (!isActionId(id)) throw new TypeError("native action needs an ID from the current build");
92
+ const response = await send(path, {
93
+ method: "POST",
94
+ headers: {
95
+ [ACTION_HEADER]: id,
96
+ "uf-native-action": "bearer-v1",
97
+ "content-type": ACTION_CONTENT_TYPE,
98
+ },
99
+ body: encodeActionArguments(args),
100
+ });
101
+ if (!response.ok) throw new Error(`Native action failed with status ${response.status}`);
102
+ return decodeActionResult(await response.text());
103
+ };
104
+ }
package/index.js CHANGED
@@ -2,14 +2,32 @@
2
2
  //
3
3
  // `@uniflowed/router`: the file-system router.
4
4
  //
5
- // Pages live in `app/` as `_uf.page.js` (or `.mdx`), layouts as
6
- // `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
5
+ // Pages live in `app/` as `$page.js` (or `.mdx`), layouts as
6
+ // `$layout.js`, and `app.js` exports `routerView("./app")`. The route table
7
7
  // is generated from the directory at build time; this module is the runtime
8
8
  // that matches, loads, navigates and renders it.
9
9
  //
10
- // `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
10
+ // `$not-found.js` and `$error.js` are the two boundaries: the page for a
11
11
  // path that matched nothing, and what renders in place of a subtree that threw.
12
- // Both are segment files, resolved by the nearest one above the path.
12
+ // Both are segment files, resolved by the nearest one above the path — and the
13
+ // router root always has one of each, so a project that declares neither still
14
+ // answers a 404 inside its own layouts rather than beside them.
15
+ //
16
+ // `$template.js` is a layout that remounts on every navigation, for the
17
+ // cases where a layout's persistence is the wrong default.
18
+ //
19
+ // A directory named `@team` is a parallel-route slot: it contributes no URL
20
+ // segment, and the layout of the segment that holds it receives the slot as a
21
+ // `team` prop beside `children`. The slot's pages are matched against the same
22
+ // URL the page is, so one URL renders two subtrees at once, and
23
+ // `$default.js` is what a slot renders when the URL matched none of its
24
+ // routes. See ubugeeei-prod/uf#267.
25
+ //
26
+ // A directory named `(.)photo` inside a slot is an intercepting route: a client
27
+ // navigation that starts on a page the slot is on, and reaches the URL the
28
+ // directory stands in for, renders it in the slot and leaves the page
29
+ // underneath where it was. A document request for that URL — a reload, a
30
+ // shared link, a prerender — renders the ordinary page.
13
31
 
14
32
  import * as React from "react";
15
33
 
@@ -19,6 +37,8 @@ export type {
19
37
  AppProps,
20
38
  ErrorBoundary,
21
39
  ErrorModule,
40
+ Interception,
41
+ JsonLd,
22
42
  LayoutModule,
23
43
  LinkPrefetch,
24
44
  LoaderArgs,
@@ -28,6 +48,7 @@ export type {
28
48
  NotFoundBoundary,
29
49
  PageModule,
30
50
  ResolvedRoute,
51
+ Robots,
31
52
  RouteError,
32
53
  RouteInfo,
33
54
  RouteMatch,
@@ -36,7 +57,12 @@ export type {
36
57
  RouteRecord,
37
58
  RouteTable,
38
59
  Router,
60
+ ResolvedSlot,
39
61
  SearchParams,
62
+ SlotRecord,
63
+ SlotRouteRecord,
64
+ TemplateModule,
65
+ TwitterCard,
40
66
  } from "./internal/runtime.js";
41
67
 
42
68
  export {
@@ -47,6 +73,8 @@ export {
47
73
  RouteView,
48
74
  RouterProvider,
49
75
  UnauthorizedError,
76
+ basePath,
77
+ buildRoute,
50
78
  forbidden,
51
79
  hasClientPage,
52
80
  matchRoute,
@@ -61,9 +89,11 @@ export {
61
89
  splitUrl,
62
90
  unauthorized,
63
91
  useIsServer,
92
+ useLinkStatus,
64
93
  useLoaderData,
65
94
  useRoute,
66
95
  useRouter,
96
+ useSeo,
67
97
  } from "./internal/runtime.js";
68
98
 
69
99
  /** Props a page receives. */
@@ -76,13 +106,26 @@ export type PageProps<
76
106
  readonly data: TData,
77
107
  |};
78
108
 
79
- /** Props an `_uf.error.js` component receives. */
109
+ /** Props an `$error.js` component receives. */
80
110
  export type ErrorProps = {|
81
111
  readonly error: RouteError,
82
112
  readonly reset: () => void,
83
113
  |};
84
114
 
85
- /** Props a layout receives. */
115
+ /**
116
+ * Props a layout receives.
117
+ *
118
+ * A layout on a segment that declares parallel-route slots receives one more
119
+ * prop per slot, named after the directory without its `@`, and this exact
120
+ * type does not describe those — the names are the project's. Declare them: a
121
+ * layout beside `@team` and `@analytics` is
122
+ *
123
+ * component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
124
+ *
125
+ * and the router passes `null` for a slot the URL addressed by neither a route
126
+ * of its own nor a `$default.js`, so `{team ?? <Empty />}` is a thing that
127
+ * can be written and relied on.
128
+ */
86
129
  export type LayoutProps<
87
130
  TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
88
131
  > = {|
@@ -0,0 +1,92 @@
1
+ // @flow
2
+
3
+ export type NavigationTiming = {|
4
+ readonly kind: "document" | "router",
5
+ readonly pathname: string,
6
+ readonly duration: number,
7
+ readonly status: "complete" | "error",
8
+ |};
9
+
10
+ export type ClientInstrumentation = {|
11
+ readonly register?: () => void | Promise<void>,
12
+ readonly onError?: (
13
+ error: mixed,
14
+ context: {| readonly source: "error" | "unhandledrejection" | "navigation" | "startup" |},
15
+ ) => void | Promise<void>,
16
+ readonly onNavigation?: (timing: NavigationTiming) => void | Promise<void>,
17
+ |};
18
+
19
+ const observers: Set<ClientInstrumentation> = new Set();
20
+
21
+ function notify(body: () => mixed): void {
22
+ Promise.resolve()
23
+ .then(body)
24
+ .catch((error) => console.error("uf client instrumentation failed", error));
25
+ }
26
+
27
+ /** Installed by the client entry before hydration, with disposal during HMR. */
28
+ export async function installClientInstrumentation(
29
+ hooks: ClientInstrumentation,
30
+ ): Promise<() => void> {
31
+ const error = (event: ErrorEvent) =>
32
+ notify(() => hooks.onError?.(event.error ?? event.message, { source: "error" }));
33
+ const rejection = (event: PromiseRejectionEvent) =>
34
+ notify(() => hooks.onError?.(event.reason, { source: "unhandledrejection" }));
35
+ window.addEventListener("error", error);
36
+ window.addEventListener("unhandledrejection", rejection);
37
+ observers.add(hooks);
38
+ let performanceObserver = null;
39
+ if (
40
+ typeof PerformanceObserver === "function" &&
41
+ PerformanceObserver.supportedEntryTypes.includes("navigation")
42
+ ) {
43
+ performanceObserver = new PerformanceObserver((list) => {
44
+ for (const entry of list.getEntries()) {
45
+ const timing: NavigationTiming = {
46
+ kind: "document",
47
+ pathname: new URL(entry.name).pathname,
48
+ duration: entry.duration,
49
+ status: "complete",
50
+ };
51
+ notify(() => hooks.onNavigation?.(timing));
52
+ }
53
+ });
54
+ performanceObserver.observe({ type: "navigation", buffered: true });
55
+ }
56
+ const dispose = () => {
57
+ window.removeEventListener("error", error);
58
+ window.removeEventListener("unhandledrejection", rejection);
59
+ observers.delete(hooks);
60
+ performanceObserver?.disconnect();
61
+ };
62
+ try {
63
+ await hooks.register?.();
64
+ } catch (failure) {
65
+ notify(() => hooks.onError?.(failure, { source: "startup" }));
66
+ dispose();
67
+ }
68
+ return dispose;
69
+ }
70
+
71
+ /** The router's asynchronous navigation work; a hard navigation has browser timing entries. */
72
+ export async function observeNavigation<T>(to: string, body: () => Promise<T>): Promise<T> {
73
+ if (observers.size === 0) return body();
74
+ const started = performance.now();
75
+ const pathname = new URL(to, window.location.href).pathname;
76
+ let status: NavigationTiming["status"] = "complete";
77
+ try {
78
+ return await body();
79
+ } catch (error) {
80
+ status = "error";
81
+ for (const hooks of observers) notify(() => hooks.onError?.(error, { source: "navigation" }));
82
+ throw error;
83
+ } finally {
84
+ const timing: NavigationTiming = {
85
+ kind: "router",
86
+ pathname,
87
+ duration: performance.now() - started,
88
+ status,
89
+ };
90
+ for (const hooks of observers) notify(() => hooks.onNavigation?.(timing));
91
+ }
92
+ }