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

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
+ }
@@ -311,9 +311,16 @@ async function* assembled(
311
311
  chunks: AsyncGenerator<string, void, void>,
312
312
  shell: DocumentShell,
313
313
  transformHead?: (html: string) => Promise<string>,
314
+ layout?: Layout,
314
315
  ): AsyncGenerator<string, void, void> {
315
316
  let held = "";
316
317
  let shape = "unknown";
318
+ // A document uf wraps is the only one whose root is not `<body>` itself.
319
+ const wrapped = () => {
320
+ if (layout != null) {
321
+ layout.rootDepth = advanced(NOTHING_WRITTEN, shell.open + shell.body, 0).depth ?? 0;
322
+ }
323
+ };
317
324
  // The opening chunk is the only one the hook sees, and every path below
318
325
  // reaches exactly one of them. Awaiting here rather than at each `yield`
319
326
  // keeps them from drifting apart. The hook sees only the document opening:
@@ -343,6 +350,7 @@ async function* assembled(
343
350
  continue;
344
351
  }
345
352
  shape = "shell-open";
353
+ wrapped();
346
354
  yield await opening(shell.open + split.head + shell.body, split.rest);
347
355
  held = "";
348
356
  continue;
@@ -382,6 +390,7 @@ async function* assembled(
382
390
  // still open is over and whatever was left of it is markup like any other.
383
391
  const split = hoisted(held);
384
392
  shape = "shell-open";
393
+ wrapped();
385
394
  yield await opening(shell.open + split.head + shell.body, split.rest);
386
395
  }
387
396
  if (shape === "shell-open") {
@@ -721,7 +730,9 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
721
730
  bodyOf(
722
731
  outgoing(
723
732
  withPayload(
724
- assembled(queue.chunks(), options.shell, options.transformHead),
733
+ queue.chunks(),
734
+ options.shell,
735
+ options.transformHead,
725
736
  options.payload,
726
737
  options.nonce,
727
738
  ),
@@ -801,7 +812,9 @@ export function renderWithReadableStream(
801
812
  bodyOf(
802
813
  outgoing(
803
814
  withPayload(
804
- assembled(decoded(stream), options.shell, options.transformHead),
815
+ decoded(stream),
816
+ options.shell,
817
+ options.transformHead,
805
818
  options.payload,
806
819
  options.nonce,
807
820
  ),
@@ -861,26 +874,54 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
861
874
  : await ReactDOMStatic.prerender(node, settings);
862
875
  return bodyOf(
863
876
  withPayload(
864
- assembled(preludeChunks(result.prelude), options.shell, options.transformHead),
877
+ preludeChunks(result.prelude),
878
+ options.shell,
879
+ options.transformHead,
865
880
  options.payload,
866
881
  ),
867
882
  ).text();
868
883
  }
869
884
 
870
- /** `chunks` unchanged when there is no payload, and [`interleaved`] with one. */
885
+ /**
886
+ * What [`assembled`] learns about a document that [`interleaved`] needs.
887
+ *
888
+ * Which shape a document has is decided on its opening bytes, inside
889
+ * `assembled`, and `interleaved` reads that document's chunks — so by the time
890
+ * it has HTML to follow, the answer is here.
891
+ */
892
+ type Layout = {|
893
+ /**
894
+ * How many elements are open inside `<body>` where React's root begins: none
895
+ * for a document React wrote, and uf's own container for one uf wraps.
896
+ */
897
+ rootDepth: number,
898
+ |};
899
+
900
+ /** [`assembled`] alone when there is no payload, and [`interleaved`] with one. */
871
901
  function withPayload(
872
902
  chunks: AsyncGenerator<string, void, void>,
903
+ shell: DocumentShell,
904
+ transformHead: ?(html: string) => Promise<string>,
873
905
  payload: ?ReadableStream<Uint8Array>,
874
906
  nonce?: string | null,
875
907
  ): AsyncGenerator<string, void, void> {
876
- return payload == null ? chunks : interleaved(chunks, payload, nonce);
908
+ if (payload == null) {
909
+ return assembled(chunks, shell, transformHead ?? undefined);
910
+ }
911
+ const layout: Layout = { rootDepth: 0 };
912
+ return interleaved(
913
+ assembled(chunks, shell, transformHead ?? undefined, layout),
914
+ payload,
915
+ nonce,
916
+ layout,
917
+ );
877
918
  }
878
919
 
879
920
  /**
880
921
  * A document's chunks, with the Flight payload it was rendered from written
881
922
  * into it as it arrives.
882
923
  *
883
- * Four rules, and each is the answer to a way the obvious version is wrong.
924
+ * Six rules, and each is the answer to a way the obvious version is wrong.
884
925
  *
885
926
  * **Nothing before the head.** The first chunk this is handed is the whole
886
927
  * opening of the document — `assembled` does not let one go until the head is
@@ -906,6 +947,19 @@ function withPayload(
906
947
  * from chunk to chunk, and otherwise waits for the HTML that finishes what is
907
948
  * open.
908
949
  *
950
+ * **Only where React steps over it.** Between elements is where the *parser*
951
+ * keeps a payload element. React is stricter: hydrating, it steps over an
952
+ * element it did not render only among the children of its root and of
953
+ * `<html>`, `<head>` and `<body>`. Anywhere deeper the element is a child the
954
+ * tree has no fiber for, the hydration fails there, and React throws the
955
+ * server's markup away and renders the document again. A shell that is one
956
+ * large element — a frame around the page, all of it rendered on the server —
957
+ * is written in many pieces, several of which end between two of its children,
958
+ * and a payload that was already waiting used to go out at the first of them.
959
+ * So [`advanced`] also follows how many elements are open inside `<body>`, and
960
+ * a payload waits for the root's own level: between React's top-level
961
+ * elements, which is where every streamed segment is written anyway.
962
+ *
909
963
  * **Outside replaceable boundaries.** Even between tags, a payload inside a
910
964
  * Suspense fallback disappears when React reveals its content. A browser that
911
965
  * loads the Flight reader afterwards then sees an incomplete stream. React's
@@ -927,6 +981,7 @@ async function* interleaved(
927
981
  chunks: AsyncGenerator<string, void, void>,
928
982
  payload: ReadableStream<Uint8Array>,
929
983
  nonce?: string | null,
984
+ layout?: Layout,
930
985
  ): AsyncGenerator<string, void, void> {
931
986
  const encoder = createChunkEncoder(nonce);
932
987
  const reader = payload.getReader();
@@ -992,7 +1047,7 @@ async function* interleaved(
992
1047
  }
993
1048
  if (text !== "") {
994
1049
  yield text;
995
- boundary = advanced(boundary, text);
1050
+ boundary = advanced(boundary, text, layout?.rootDepth ?? 0);
996
1051
  }
997
1052
  next = chunks.next();
998
1053
  }
@@ -1029,9 +1084,35 @@ type Boundary = {|
1029
1084
  readonly replaceable: number,
1030
1085
  /** A tag, or a comment, the HTML has started and not yet finished. */
1031
1086
  readonly open: string,
1087
+ /** How many elements are open inside `<body>`; `null` until it has opened. */
1088
+ readonly depth: number | null,
1032
1089
  |};
1033
1090
 
1034
- const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, replaceable: 0, open: "" };
1091
+ const NOTHING_WRITTEN: Boundary = {
1092
+ safe: false,
1093
+ rawText: null,
1094
+ replaceable: 0,
1095
+ open: "",
1096
+ depth: null,
1097
+ };
1098
+
1099
+ /** Elements with no closing tag, for markup whose `/>` React did not write. */
1100
+ const VOID_ELEMENTS: $ReadOnlySet<string> = new Set([
1101
+ "area",
1102
+ "base",
1103
+ "br",
1104
+ "col",
1105
+ "embed",
1106
+ "hr",
1107
+ "img",
1108
+ "input",
1109
+ "link",
1110
+ "meta",
1111
+ "param",
1112
+ "source",
1113
+ "track",
1114
+ "wbr",
1115
+ ]);
1035
1116
 
1036
1117
  /**
1037
1118
  * `boundary`, once `html` has been written after it.
@@ -1044,12 +1125,21 @@ const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, replaceable: 0,
1044
1125
  * the content of an inline script or stylesheet, where a `>` is code, so an
1045
1126
  * opening `<script>` or `<style>` is followed to its closing tag. A tag split
1046
1127
  * across two chunks is carried in `open` and read whole with the next one.
1128
+ *
1129
+ * And it may follow only HTML that leaves `rootDepth` elements open inside
1130
+ * `<body>`, or none: the children of React's root and of `<body>` are the ones
1131
+ * hydration steps over. Every tag from `<body>` on is counted for that, React's
1132
+ * void elements by the `/>` it writes them with. Markup React did not write —
1133
+ * `dangerouslySetInnerHTML` — can leave a tag open that the parser would close,
1134
+ * and the count then stays too deep for the rest of the document. That errs the
1135
+ * safe way: the payload waits, and goes out before `</body>` as it always can.
1047
1136
  */
1048
- function advanced(boundary: Boundary, html: string): Boundary {
1137
+ function advanced(boundary: Boundary, html: string, rootDepth: number): Boundary {
1049
1138
  const text = boundary.open + html;
1050
1139
  let rawText = boundary.rawText;
1051
1140
  let replaceable = boundary.replaceable;
1052
- const tags = /<!--([\s\S]*?)-->|<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
1141
+ let depth = boundary.depth;
1142
+ const tags = /<!--([\s\S]*?)-->|<(\/?)([a-z][^\s/>]*)[^>]*?(\/?)>/gi;
1053
1143
  let tag = tags.exec(text);
1054
1144
  while (tag != null) {
1055
1145
  const comment = tag[1];
@@ -1060,20 +1150,36 @@ function advanced(boundary: Boundary, html: string): Boundary {
1060
1150
  }
1061
1151
  } else {
1062
1152
  const name = tag[3].toLowerCase();
1063
- if (tag[2] === "/") {
1064
- if (rawText === name) rawText = null;
1065
- } else if (rawText == null) {
1066
- rawText = name;
1153
+ const closes = tag[2] === "/";
1154
+ if (rawText != null) {
1155
+ // Inside a script or a stylesheet a `<` is code, up to its own end tag.
1156
+ if (closes && rawText === name) {
1157
+ rawText = null;
1158
+ if (depth != null) depth -= 1;
1159
+ }
1160
+ } else if (closes) {
1161
+ if (depth != null) depth -= 1;
1162
+ } else if (depth == null) {
1163
+ if (name === "body") depth = 0;
1164
+ else if (name === "script" || name === "style") rawText = name;
1165
+ } else if (tag[4] !== "/" && !VOID_ELEMENTS.has(name)) {
1166
+ depth += 1;
1167
+ if (name === "script" || name === "style") rawText = name;
1067
1168
  }
1068
1169
  }
1069
1170
  tag = tags.exec(text);
1070
1171
  }
1071
1172
  const start = text.lastIndexOf("<");
1072
1173
  return {
1073
- safe: rawText == null && replaceable === 0 && text.endsWith(">"),
1174
+ safe:
1175
+ rawText == null &&
1176
+ replaceable === 0 &&
1177
+ text.endsWith(">") &&
1178
+ (depth === 0 || (depth != null && depth === rootDepth)),
1074
1179
  rawText,
1075
1180
  replaceable,
1076
1181
  open: start > text.lastIndexOf(">") ? text.slice(start) : "",
1182
+ depth,
1077
1183
  };
1078
1184
  }
1079
1185
 
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.43",
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.43",
75
+ "@uniflowed/server": "0.0.0-alpha.43"
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";