@uniflowed/router 0.0.0-alpha.41 → 0.0.0-alpha.42

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.
@@ -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
+ }
@@ -88,6 +88,8 @@
88
88
  // it. Written here because this is the file somebody reads before deciding
89
89
  // otherwise.
90
90
 
91
+ import { traceRequestPhase, reportRequestError } from "@uniflowed/server/instrumentation";
92
+
91
93
  import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
92
94
 
93
95
  import {
@@ -232,7 +234,7 @@ export function createActionDispatcher(options: {|
232
234
  // `decodeActionArguments` produced, so what is unchecked here is the
233
235
  // shape of the function and not the shape of the payload.
234
236
  const call = action as $FlowFixMe;
235
- result = await call(...args);
237
+ result = await traceRequestPhase("action", () => call(...args));
236
238
  } catch (error) {
237
239
  report(record, error);
238
240
  return refusal(500);
@@ -430,6 +432,7 @@ function refusal(status: number): Response {
430
432
  * here and nowhere the caller can read.
431
433
  */
432
434
  function report(record: ActionRecord, error: mixed): void {
435
+ reportRequestError(error, "action");
433
436
  // eslint-disable-next-line no-console
434
437
  console.error(`uf: server action \`${record.export}\` in \`${record.module}\` failed`, error);
435
438
  }
@@ -722,6 +722,8 @@ export type ResolveOptions = {|
722
722
  readonly defer?: boolean,
723
723
  /** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
724
724
  readonly onMatch?: (pattern: string) => void,
725
+ /** The server supplies tracing without importing server code into the browser. */
726
+ readonly runLoader?: (body: () => mixed | Promise<mixed>) => Promise<mixed>,
725
727
  |};
726
728
 
727
729
  async function resolveRoute(
@@ -799,7 +801,9 @@ async function resolveRoute(
799
801
  let data: mixed = options?.data;
800
802
  let deferred: ?Promise<mixed> = null;
801
803
  if (options?.skipLoader !== true && typeof page.loader === "function") {
802
- const running = page.loader({ params: matched.params, searchParams, pathname });
804
+ const loader = page.loader;
805
+ const loadData = () => loader({ params: matched.params, searchParams, pathname });
806
+ const running = options?.runLoader == null ? loadData() : options.runLoader(loadData);
803
807
  const canDefer =
804
808
  options?.defer === true &&
805
809
  (matched.route.loading ?? []).length > 0 &&
@@ -20,6 +20,8 @@
20
20
 
21
21
  "use client";
22
22
 
23
+ import { observeNavigation } from "../instrumentation.js";
24
+
23
25
  import type * as React from "react";
24
26
  import {
25
27
  Suspense,
@@ -665,7 +667,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
665
667
  setResolved(next);
666
668
  };
667
669
 
668
- const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
670
+ const navigate = (to: string, options?: NavigateOptions): Promise<void> =>
671
+ observeNavigation(to, () => navigateTo(to, options));
672
+ const navigateTo = async (to: string, options?: NavigateOptions): Promise<void> => {
669
673
  if (!isBrowser()) {
670
674
  return;
671
675
  }
@@ -985,7 +989,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
985
989
  // Read once per render, for the reason `ModuleRouter` reads it once.
986
990
  const navigation = navigationMode();
987
991
 
988
- const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
992
+ const navigate = (to: string, options?: NavigateOptions): Promise<void> =>
993
+ observeNavigation(to, () => navigateTo(to, options));
994
+ const navigateTo = async (to: string, options?: NavigateOptions): Promise<void> => {
989
995
  if (!isBrowser()) {
990
996
  return;
991
997
  }
@@ -0,0 +1,12 @@
1
+ // @flow
2
+
3
+ import { traceRequestPhase } from "@uniflowed/server/instrumentation";
4
+ import { NotFoundError, RedirectError } from "./routing.js";
5
+
6
+ export function traceLoader(body: () => mixed | Promise<mixed>): Promise<mixed> {
7
+ return traceRequestPhase(
8
+ "loader",
9
+ async () => body(),
10
+ (error) => error instanceof RedirectError || error instanceof NotFoundError,
11
+ );
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.41",
3
+ "version": "0.0.0-alpha.42",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,8 @@
27
27
  "./handler": "./handler.js",
28
28
  "./middleware": "./middleware.js",
29
29
  "./native-navigation": "./native-navigation.js",
30
- "./http-client": "./http-client.js"
30
+ "./http-client": "./http-client.js",
31
+ "./instrumentation": "./instrumentation.js"
31
32
  },
32
33
  "files": [
33
34
  "action.js",
@@ -45,6 +46,7 @@
45
46
  "server.js",
46
47
  "native-navigation.js",
47
48
  "http-client.js",
49
+ "instrumentation.js",
48
50
  "!*.test.js"
49
51
  ],
50
52
  "peerDependencies": {
@@ -69,7 +71,7 @@
69
71
  }
70
72
  },
71
73
  "dependencies": {
72
- "@uniflowed/hooks": "0.0.0-alpha.41",
73
- "@uniflowed/server": "0.0.0-alpha.41"
74
+ "@uniflowed/hooks": "0.0.0-alpha.42",
75
+ "@uniflowed/server": "0.0.0-alpha.42"
74
76
  }
75
77
  }
package/rsc.js CHANGED
@@ -44,6 +44,8 @@
44
44
  // in this graph, and a boundary module without the directive is refused here,
45
45
  // with its file named, rather than by React with a sentence about functions.
46
46
 
47
+ import { traceLoader } from "./internal/server-instrumentation.js";
48
+
47
49
  import * as React from "react";
48
50
  import { use } from "react";
49
51
  // React's Flight server. Only this graph can load it: it refuses to evaluate
@@ -51,6 +53,7 @@ import { use } from "react";
51
53
  import { renderToReadableStream } from "react-server-dom-parcel/server";
52
54
 
53
55
  import { noteRoute } from "@uniflowed/server/host";
56
+ import { reportRequestError } from "@uniflowed/server/instrumentation";
54
57
 
55
58
  import { BoundaryReporter } from "./internal/boundaries.js";
56
59
  import { routeBoundaries } from "./internal/boundary-data.js";
@@ -175,7 +178,7 @@ export function createFlightRenderer(options: {|
175
178
  intercepted ??
176
179
  // `onMatch` records the route pattern on the request before the
177
180
  // loader runs, so every line the loader logs names its route.
178
- (await resolveMatch(table, url, { defer, onMatch: noteRoute }));
181
+ (await resolveMatch(table, url, { defer, onMatch: noteRoute, runLoader: traceLoader }));
179
182
  } else {
180
183
  resolved = await resolveFailure(table, url, failure.error);
181
184
  }
@@ -199,13 +202,20 @@ export function createFlightRenderer(options: {|
199
202
  </>
200
203
  );
201
204
  const root: FlightRoot = { route: state, tree };
202
- const report = settings?.onError;
205
+ const failure = renderFailure(route);
206
+ if (failure != null) reportRequestError(failure, "render");
207
+ const report = (error) => {
208
+ reportRequestError(error, "render");
209
+ if (settings?.onError != null) return settings.onError(error);
210
+ console.error(error);
211
+ return undefined;
212
+ };
203
213
  // Inside the route's store, so a server component's `useRoute()` finds the
204
214
  // route however many `await`s into the render it asks.
205
215
  const stream = withServerRoute(state, () =>
206
216
  renderToReadableStream(root, {
207
217
  // Handed over as it is, so that the digest it returns reaches the row.
208
- // Absent, React logs the exception to the console itself.
218
+ // Our callback preserves React's console fallback when none was given.
209
219
  onError: report,
210
220
  signal: settings?.signal,
211
221
  }),
@@ -225,7 +235,11 @@ async function resolveFlightInterception(
225
235
  return null;
226
236
  }
227
237
  try {
228
- const base = await resolveMatch(table, baseUrl, { defer });
238
+ const base = await resolveMatch(table, baseUrl, {
239
+ defer,
240
+ onMatch: noteRoute,
241
+ runLoader: traceLoader,
242
+ });
229
243
  return await resolveInterception(table, base, url);
230
244
  } catch (error) {
231
245
  if (error instanceof RedirectError) {
package/server.js CHANGED
@@ -24,6 +24,8 @@
24
24
  // which.
25
25
 
26
26
  import { currentNonce, noteRoute } from "@uniflowed/server/host";
27
+ import { traceLoader } from "./internal/server-instrumentation.js";
28
+
27
29
  import * as React from "react";
28
30
 
29
31
  import {
@@ -335,7 +337,11 @@ export function createRenderer(options: {|
335
337
  try {
336
338
  return {
337
339
  kind: "route",
338
- route: await resolveMatch(table, url, { defer, onMatch: noteRoute }),
340
+ route: await resolveMatch(table, url, {
341
+ defer,
342
+ onMatch: noteRoute,
343
+ runLoader: traceLoader,
344
+ }),
339
345
  };
340
346
  } catch (error) {
341
347
  if (error instanceof RedirectError) {
@@ -512,3 +518,9 @@ export function shellDocument(assets: RenderAssets): string {
512
518
  const shell = shellFor(assets);
513
519
  return `${shell.open}${shell.body}${shell.close}`;
514
520
  }
521
+
522
+ export {
523
+ createInstrumentation,
524
+ instrumentRender,
525
+ traceRequestPhase,
526
+ } from "@uniflowed/server/instrumentation";