@rangojs/router 0.0.0-experimental.127 → 0.0.0-experimental.128

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.
@@ -2133,7 +2133,7 @@ import { resolve } from "node:path";
2133
2133
  // package.json
2134
2134
  var package_default = {
2135
2135
  name: "@rangojs/router",
2136
- version: "0.0.0-experimental.127",
2136
+ version: "0.0.0-experimental.128",
2137
2137
  description: "Django-inspired RSC router with composable URL patterns",
2138
2138
  keywords: [
2139
2139
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.127",
3
+ "version": "0.0.0-experimental.128",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -95,6 +95,31 @@ const router = createRouter({
95
95
  });
96
96
  ```
97
97
 
98
+ On **Cloudflare Workers**, use `createCloudflareTracing` for the `tracing` slot
99
+ instead — it emits the same phases as native Cloudflare custom spans (in the
100
+ Workers trace waterfall, next to the automatic KV/D1/fetch spans), with no
101
+ `@opentelemetry/api` dependency:
102
+
103
+ ```typescript
104
+ import { createRouter } from "@rangojs/router";
105
+ import { createCloudflareTracing } from "@rangojs/router/cloudflare";
106
+
107
+ const router = createRouter({
108
+ document: Document,
109
+ urls: urlpatterns,
110
+ tracing: createCloudflareTracing(), // all phases on by default
111
+ // tracing: createCloudflareTracing({ spans: { ssr: false } }), // toggle phases
112
+ });
113
+ ```
114
+
115
+ Both factories return a `RouterTracingConfig` for the same `tracing` slot;
116
+ `telemetry` stays independent (events only, no phase spans). Phase spans:
117
+ `rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
118
+ `rango.render`, `rango.ssr` — the same phases the `debugPerformance` timeline
119
+ shows, co-emitted from one site. Off-platform (no Cloudflare tracing destination
120
+ / no OTel SDK) every span call is a transparent pass-through, so the request
121
+ behaves as if tracing were off.
122
+
98
123
  Custom sinks implement `emit(event)`:
99
124
 
100
125
  ```typescript
@@ -112,7 +137,8 @@ const router = createRouter({
112
137
  ```
113
138
 
114
139
  Events include `request.start/end/error`, `loader.start/end/error`,
115
- `handler.error`, `cache.decision`, and `revalidation.decision`.
140
+ `handler.error`, `cache.decision`, `revalidation.decision`, `request.timeout`,
141
+ and `request.origin-rejected`.
116
142
 
117
143
  ## Debugging revalidation and stale data
118
144
 
@@ -488,6 +488,18 @@ const router = createRouter({
488
488
  });
489
489
  ```
490
490
 
491
+ ```typescript
492
+ // On Cloudflare Workers, swap the tracing factory for native custom spans
493
+ // (no @opentelemetry/api dependency); the telemetry slot is unchanged.
494
+ import { createCloudflareTracing } from "@rangojs/router/cloudflare";
495
+
496
+ const router = createRouter({
497
+ document: Document,
498
+ urls: urlpatterns,
499
+ tracing: createCloudflareTracing(), // { spans: { ssr: false } } to toggle phases
500
+ });
501
+ ```
502
+
491
503
  ```typescript
492
504
  // Custom sink
493
505
  const router = createRouter({
@@ -25,14 +25,17 @@
25
25
  * request behaves exactly as if tracing were off. Whether spans are actually
26
26
  * recorded is governed by the `observability`/tracing block in wrangler config.
27
27
  *
28
- * Span duration note: a phase span ends when its callback's returned value (or
29
- * promise) settles, which for the streaming phases (request/render/ssr) is when
30
- * the Response or HTML/RSC stream is *constructed*, not when the body finishes
31
- * draining. Loader/Suspense work that settles while the body streams therefore
32
- * extends past the parent span's end, and platform spans emitted during that
33
- * drain (deferred SSR, waitUntil-scheduled cache writes) are siblings of the
34
- * automatic request span rather than children of rango.*. Phase spans bound
35
- * setup-to-stream-handoff; they are not a full request-duration measure.
28
+ * Span duration note: enterSpan ends a span when its callback's returned value
29
+ * (or promise) settles. The streaming phases (request/render/ssr/middleware) are
30
+ * wrapped (in instrument.ts) so their callback awaits the response body's drain
31
+ * before settling the constructed Response is handed to the client immediately,
32
+ * but the callback (hence the SPAN) stays open until the body finishes draining.
33
+ * So these spans cover the full streamed request and loader/Suspense children
34
+ * that resolve mid-stream nest under a still-open parent. This uses only the
35
+ * typed enterSpan API; no startActiveSpan/end. The perf METRICS (render:total,
36
+ * the middleware own-time, handler:total) stay construction-bound — they ship in
37
+ * the Server-Timing header, flushed before drain — so a span reads at least as
38
+ * long as its same-named metric, the difference being post-construction streaming.
36
39
  */
37
40
 
38
41
  import { _getRequestContext } from "../server/request-context.js";
@@ -35,13 +35,16 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
35
35
  import { getRouterContext } from "./router-context.js";
36
36
  import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
37
37
  import { appendMetric } from "./metrics.js";
38
+ import { type MetricsStore } from "../server/context.js";
38
39
  import {
39
40
  NOOP_TRACE_SPAN,
40
41
  traceSpan,
41
42
  runThenSettle,
42
43
  type TracePhase,
43
44
  type TraceSpan,
45
+ type ResolvedTracing,
44
46
  } from "./tracing.js";
47
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
45
48
 
46
49
  /**
47
50
  * Perf-metric boundary for a phase, or `false` for span-only. `false` means the
@@ -138,6 +141,29 @@ export const PHASES = {
138
141
  } as PhaseSpec,
139
142
  } as const;
140
143
 
144
+ /** Apply a phase spec's static attributes to a span (the no-op span ignores them). */
145
+ function applyAttributes(
146
+ span: TraceSpan,
147
+ attributes: Record<string, string | number | boolean>,
148
+ ): void {
149
+ for (const key in attributes) span.setAttribute(key, attributes[key]);
150
+ }
151
+
152
+ /**
153
+ * Record a phase's perf metric for the interval [start, now]. The label may be
154
+ * lazy (resolved here, e.g. render:total needs the route name that match sets
155
+ * partway through the wrapped work).
156
+ */
157
+ function recordPhaseMetric(
158
+ store: MetricsStore,
159
+ metric: Exclude<PhaseMetric, false>,
160
+ start: number,
161
+ ): void {
162
+ const label =
163
+ typeof metric.label === "function" ? metric.label() : metric.label;
164
+ appendMetric(store, label, start, performance.now() - start, metric.depth);
165
+ }
166
+
141
167
  /**
142
168
  * Instrument one unit of work: open its span AND (unless `metric: false`) record
143
169
  * its perf metric, from a single wrap site. fn is invoked exactly once with the
@@ -145,6 +171,12 @@ export const PHASES = {
145
171
  * unchanged and thrown errors / rejected promises propagate unchanged. When fn
146
172
  * returns a promise both the metric duration and the span end when it settles.
147
173
  *
174
+ * This is the boundary for NON-streaming phases (action, loader): both the span
175
+ * and the metric settle when their own work completes. Streaming phases (request,
176
+ * middleware, render, ssr) use observeRequestPhase / observeStreamingPhase, where
177
+ * the SPAN is held open until body-drain (valid tree) while the perf metric is
178
+ * still recorded at construction (Server-Timing parity).
179
+ *
148
180
  * Reads the metrics store + tracing off the RequestContext ALS, which is active
149
181
  * for the WHOLE request — contrast observeEvent, which reads the RouterContext
150
182
  * ALS (entered later, during match).
@@ -166,7 +198,7 @@ export function observePhase<T>(
166
198
  const wrapped: (span: TraceSpan) => T =
167
199
  attributes && tracing
168
200
  ? (span) => {
169
- for (const key in attributes) span.setAttribute(key, attributes[key]);
201
+ applyAttributes(span, attributes);
170
202
  return fn(span);
171
203
  }
172
204
  : fn;
@@ -182,13 +214,191 @@ export function observePhase<T>(
182
214
  // failed loader/render still shows its timing in the perf report (parity with
183
215
  // the old track().finally() path it replaced).
184
216
  const start = performance.now();
185
- return runThenSettle(runSpan, () => {
186
- // The label may be lazy (resolved at record time, e.g. render:total needs the
187
- // route name that match sets partway through the wrapped work).
188
- const label =
189
- typeof metric.label === "function" ? metric.label() : metric.label;
190
- appendMetric(store, label, start, performance.now() - start, metric.depth);
217
+ return runThenSettle(runSpan, () => recordPhaseMetric(store, metric, start));
218
+ }
219
+
220
+ /**
221
+ * Re-stream `response`'s body through a pass-through that fires `onDrain` exactly
222
+ * once when the body finishes — on natural end, a stream error, or a client
223
+ * cancel (so a span can never leak on an aborted response). A bodyless response
224
+ * fires immediately. Only used while instrumentation is active, so the per-chunk
225
+ * relay cost never touches an untraced request.
226
+ */
227
+ function instrumentResponseDrain(
228
+ response: Response,
229
+ onDrain: () => void,
230
+ ): Response {
231
+ // WS-upgrade responses (status 101 / workerd `webSocket` property) must never
232
+ // be reconstructed: `new Response(body, { status: 101 })` throws and a copy
233
+ // drops the non-standard webSocket handoff (the invariant every other Response
234
+ // reconstruction site honors). A bodyless response has nothing to drain.
235
+ const source = response.body;
236
+ if (!source || isWebSocketUpgradeResponse(response)) {
237
+ onDrain();
238
+ return response;
239
+ }
240
+ let fired = false;
241
+ const fire = (): void => {
242
+ if (fired) return;
243
+ fired = true;
244
+ onDrain();
245
+ };
246
+ const reader = source.getReader();
247
+ const wrapped = new ReadableStream<Uint8Array>({
248
+ async pull(controller) {
249
+ try {
250
+ const { done, value } = await reader.read();
251
+ if (done) {
252
+ controller.close();
253
+ fire();
254
+ return;
255
+ }
256
+ controller.enqueue(value);
257
+ } catch (error) {
258
+ controller.error(error);
259
+ fire();
260
+ }
261
+ },
262
+ cancel(reason) {
263
+ fire();
264
+ return reader.cancel(reason);
265
+ },
266
+ });
267
+ return new Response(wrapped, response);
268
+ }
269
+
270
+ /**
271
+ * Shared engine for the streaming phases (request, middleware, render, ssr). It
272
+ * opens the span, runs fn, records the phase's perf metric at CONSTRUCTION (so it
273
+ * still reaches the Server-Timing header / [RSC Perf] table, both built before
274
+ * the body drains), hands the constructed value to the caller via a side channel
275
+ * (streaming preserved), then holds the span open until `drain` resolves. The
276
+ * SPAN therefore ends at body-drain — keeping the trace tree valid (a loader
277
+ * child that resolves mid-stream ends before its parent) — while the perf metric
278
+ * stays the construction work-time. `onDeliver` lets the request phase instrument
279
+ * the final body before handing it back; `onError` lets it release the barrier on
280
+ * failure. Fire-and-forget: the value reaches the caller via the returned
281
+ * promise, so the span promise's rejection is swallowed (already surfaced there).
282
+ */
283
+ function runDrainBoundPhase<R>(
284
+ spec: PhaseSpec,
285
+ fn: (span: TraceSpan) => R | Promise<R>,
286
+ tracing: ResolvedTracing | undefined,
287
+ store: MetricsStore | undefined,
288
+ drain: Promise<void>,
289
+ onDeliver: (value: R) => R,
290
+ onError?: () => void,
291
+ ): Promise<R> {
292
+ let deliver!: (value: R) => void;
293
+ let reject!: (error: unknown) => void;
294
+ const delivered = new Promise<R>((res, rej) => {
295
+ deliver = res;
296
+ reject = rej;
191
297
  });
298
+
299
+ const start = performance.now();
300
+ const attributes = spec.attributes;
301
+ const metric = spec.metric;
302
+ const record = (): void => {
303
+ if (store && metric !== false) recordPhaseMetric(store, metric, start);
304
+ };
305
+ const spanCallback = async (span: TraceSpan): Promise<void> => {
306
+ if (attributes && tracing) applyAttributes(span, attributes);
307
+ let value: R;
308
+ try {
309
+ value = await fn(span);
310
+ } catch (error) {
311
+ record(); // a failed phase still shows its (construction) timing
312
+ onError?.();
313
+ reject(error);
314
+ throw error; // settle the span with the error, at construction
315
+ }
316
+ record(); // construction-bound metric, before the response/header is built
317
+ deliver(onDeliver(value));
318
+ await drain; // hold the span open until the response body drains
319
+ };
320
+
321
+ traceSpan(tracing, spec.tracePhase, spec.spanName, spanCallback).catch(
322
+ () => {},
323
+ );
324
+ return delivered;
325
+ }
326
+
327
+ /**
328
+ * The request phase (rango.request, metric:false). Owns the drain barrier: it
329
+ * runs fn to construct the final Response, instruments that Response's body so
330
+ * the barrier resolves at drain, hands the Response to the caller immediately
331
+ * (streaming preserved), and holds the span open until the body drains. Every
332
+ * streaming inner phase awaits the same barrier (via observeStreamingPhase), so
333
+ * the request/middleware/render/ssr chain ends at body-drain together and the
334
+ * trace tree is valid (no child span outlives its parent). The perf metrics
335
+ * (render:total, …) are recorded at construction so they still reach the
336
+ * Server-Timing header; only the SPANS are drain-bound. ctx.waitUntil holds the
337
+ * worker alive until drain so the span end runs. Pass-through when no surface is
338
+ * active.
339
+ */
340
+ export function observeRequestPhase(
341
+ spec: PhaseSpec,
342
+ fn: (span: TraceSpan) => Promise<Response>,
343
+ ): Promise<Response> {
344
+ const reqCtx = _getRequestContext();
345
+ const store = reqCtx?._metricsStore;
346
+ const tracing = reqCtx?._tracing;
347
+
348
+ if ((!store && !tracing) || !reqCtx) return fn(NOOP_TRACE_SPAN);
349
+
350
+ let resolveDrain!: () => void;
351
+ const finalDrain = new Promise<void>((resolve) => {
352
+ resolveDrain = resolve;
353
+ });
354
+ reqCtx._finalDrain = finalDrain;
355
+
356
+ // Keep the worker alive until the body drains, so the drain-bound span end
357
+ // (and the inner phases' settle) runs before the runtime can reclaim it.
358
+ const ec = reqCtx.executionContext;
359
+ if (typeof ec?.waitUntil === "function") ec.waitUntil(finalDrain);
360
+
361
+ return runDrainBoundPhase<Response>(
362
+ spec,
363
+ fn,
364
+ tracing,
365
+ store,
366
+ finalDrain,
367
+ (response) => instrumentResponseDrain(response, resolveDrain),
368
+ resolveDrain, // release the barrier if fn fails before constructing a body
369
+ );
370
+ }
371
+
372
+ /**
373
+ * A streaming inner phase (rango.middleware / render / ssr). Its SPAN settles
374
+ * when the request's final response body drains (the barrier owned by
375
+ * observeRequestPhase), not when fn returns the constructed stream — so
376
+ * loader/Suspense children that resolve mid-stream nest under a still-open
377
+ * parent. fn's result is delivered at construction (streaming preserved) and the
378
+ * perf metric is recorded at construction (Server-Timing parity). Falls back to
379
+ * observePhase (construction-bound span) when there is no barrier — a
380
+ * non-streaming request, or instrumentation off.
381
+ */
382
+ export function observeStreamingPhase<R>(
383
+ spec: PhaseSpec,
384
+ fn: (span: TraceSpan) => R | Promise<R>,
385
+ ): Promise<R> {
386
+ const reqCtx = _getRequestContext();
387
+ const store = reqCtx?._metricsStore;
388
+ const tracing = reqCtx?._tracing;
389
+ const finalDrain = reqCtx?._finalDrain;
390
+
391
+ if ((!store && !tracing) || !finalDrain) {
392
+ return Promise.resolve(observePhase(spec, fn));
393
+ }
394
+ return runDrainBoundPhase<R>(
395
+ spec,
396
+ fn,
397
+ tracing,
398
+ store,
399
+ finalDrain,
400
+ (value) => value,
401
+ );
192
402
  }
193
403
 
194
404
  /**
@@ -19,7 +19,7 @@ import {
19
19
  } from "../redirect-origin.js";
20
20
  import { isAutoGeneratedRouteName } from "../route-name.js";
21
21
  import { appendMetric, createMetricsStore } from "./metrics.js";
22
- import { observePhase, PHASES } from "./instrument.js";
22
+ import { observeStreamingPhase, PHASES } from "./instrument.js";
23
23
  import { stripInternalParams } from "./handler-context.js";
24
24
  import { isWebSocketUpgradeResponse } from "../response-utils.js";
25
25
 
@@ -487,7 +487,7 @@ export async function executeMiddleware<TEnv>(
487
487
  // when neither surface is active.
488
488
  let result: Response | void;
489
489
  try {
490
- result = await observePhase(PHASES.middleware(metricLabel), () =>
490
+ result = await observeStreamingPhase(PHASES.middleware(metricLabel), () =>
491
491
  entry.handler(ctx, wrappedNext),
492
492
  );
493
493
  } catch (error) {
@@ -664,7 +664,7 @@ export async function executeInterceptMiddleware<TEnv>(
664
664
 
665
665
  let result: Response | void;
666
666
  try {
667
- result = await observePhase(PHASES.middleware(label), () =>
667
+ result = await observeStreamingPhase(PHASES.middleware(label), () =>
668
668
  middleware(ctx, guardedNext),
669
669
  );
670
670
  } catch (error) {
@@ -29,13 +29,18 @@
29
29
  * useLoader, plus the fetchable path), rango.render (render:total:<route>; normal AND
30
30
  * action-revalidation renders), rango.ssr (ssr:render-html).
31
31
  *
32
- * Span-duration caveat: a span ends when its callback's value (or promise)
33
- * settles. For the streaming phases (request/render/ssr) that is when the
34
- * Response / HTML / RSC stream is constructed, NOT when the body finishes
35
- * draining. Loader/Suspense work that settles during stream drain extends past
36
- * the parent span's end, so parent durations under-report streamed time and a
37
- * rango.loader child can end after its parent. This is the streaming + end-on-
38
- * settle contract, not a defect; phase spans bound setup-to-stream-handoff.
32
+ * Streaming-phase span lifetime: a span ends when its callback's value (or
33
+ * promise) settles. The streaming phases (request, middleware, render, ssr) are
34
+ * wrapped by observeRequestPhase / observeStreamingPhase (instrument.ts), whose
35
+ * callback hands the constructed Response/stream to the caller immediately
36
+ * (streaming preserved) but then awaits a request-scoped drain barrier, so the
37
+ * SPAN ends when the response BODY finishes draining, not at stream construction.
38
+ * That keeps span durations covering the real streamed work and the trace tree
39
+ * valid: a loader/Suspense child that resolves while the body streams ends before
40
+ * its now-drain-bound parent. The co-emitted perf METRIC (render:total, …) is
41
+ * still recorded at construction — it ships in the Server-Timing header, flushed
42
+ * before the body drains — so a streaming span legitimately reads longer than its
43
+ * same-named metric by the time the body spent streaming after construction.
39
44
  *
40
45
  * Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
41
46
  * platform-specific bridge lives at the edge behind the SpanRunner contract.
@@ -82,7 +82,12 @@ import {
82
82
  appendMetric,
83
83
  buildMetricsTiming,
84
84
  } from "../router/metrics.js";
85
- import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
85
+ import {
86
+ observePhase,
87
+ observeRequestPhase,
88
+ observeEvent,
89
+ PHASES,
90
+ } from "../router/instrument.js";
86
91
  import {
87
92
  startSSRSetup,
88
93
  getSSRSetup,
@@ -496,11 +501,14 @@ export function createRSCHandler<
496
501
  // The "rango.request" span is opened inside the request context so the
497
502
  // Cloudflare runner can read executionContext.tracing, and so every nested
498
503
  // phase span (and the platform's automatic KV/D1/fetch spans) nests under
499
- // it. metric:false handler:total is metered directly below (a grand total
500
- // incl. the pre-context bootstrap timings, finer than a single wrap). When
501
- // tracing is off this is a direct pass-through.
504
+ // it. observeRequestPhase owns the drain barrier: it instruments the final
505
+ // response body so this span (and the streaming inner phases) stay open
506
+ // until the body drains, keeping the tree valid. metric:false — handler:total
507
+ // is metered directly below (a grand total incl. the pre-context bootstrap
508
+ // timings) and stays construction-bound (it ships as a Server-Timing header,
509
+ // flushed before drain). When no surface is active this is a pass-through.
502
510
  return runWithRequestContext(requestContext, () =>
503
- observePhase(PHASES.request, async (span) => {
511
+ observeRequestPhase(PHASES.request, async (span) => {
504
512
  span.setAttribute("http.method", request.method);
505
513
  // The matched route template is not known until match() runs later, so
506
514
  // emit the concrete path as url.path (low-level), NOT http.route — the
@@ -11,7 +11,7 @@ import {
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
13
  import { appendMetric } from "../router/metrics.js";
14
- import { observePhase, PHASES } from "../router/instrument.js";
14
+ import { observeStreamingPhase, PHASES } from "../router/instrument.js";
15
15
  import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
16
16
  import type { RscPayload } from "./types.js";
17
17
  import type { MatchResult } from "../types.js";
@@ -36,7 +36,7 @@ export function handleRscRendering<TEnv>(
36
36
  // same boundary (match -> serialize -> SSR), so the two surfaces agree.
37
37
  // Loaders kicked off during matching nest under the span; the SSR HTML pass
38
38
  // below opens "rango.ssr" the same way.
39
- return observePhase(PHASES.render, () =>
39
+ return observeStreamingPhase(PHASES.render, () =>
40
40
  handleRscRenderingInner(
41
41
  ctx,
42
42
  request,
@@ -248,7 +248,7 @@ async function handleRscRenderingInner<TEnv>(
248
248
 
249
249
  // ssr-render-html metric + rango.ssr span from one boundary. render:total is
250
250
  // recorded by the observePhase wrapper around this function.
251
- const htmlStream = await observePhase(PHASES.ssr, () =>
251
+ const htmlStream = await observeStreamingPhase(PHASES.ssr, () =>
252
252
  ssrModule.renderHTML(rscStream, {
253
253
  nonce,
254
254
  streamMode,
@@ -20,7 +20,7 @@ import {
20
20
  setRequestContextParams,
21
21
  } from "../server/request-context.js";
22
22
  import { appendMetric } from "../router/metrics.js";
23
- import { observePhase, PHASES } from "../router/instrument.js";
23
+ import { observeStreamingPhase, PHASES } from "../router/instrument.js";
24
24
  import type { RscPayload } from "./types.js";
25
25
  import {
26
26
  hasBodyContent,
@@ -270,7 +270,7 @@ export function revalidateAfterAction<TEnv>(
270
270
  // "render:total" AND opens "rango.render" from one boundary covering
271
271
  // matchPartial -> serialize, so the revalidation loaders' rango.loader spans
272
272
  // nest under a rango.render parent instead of dangling at the request root.
273
- return observePhase(PHASES.render, () =>
273
+ return observeStreamingPhase(PHASES.render, () =>
274
274
  revalidateAfterActionInner(
275
275
  ctx,
276
276
  request,
@@ -368,6 +368,16 @@ export interface RequestContext<
368
368
  /** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
369
369
  _tracing?: ResolvedTracing;
370
370
 
371
+ /**
372
+ * @internal Drain barrier for streaming phase spans. The request phase
373
+ * (observeRequestPhase) sets this to a promise that resolves when the final
374
+ * response body finishes draining; the streaming inner phases
375
+ * (observeStreamingPhase: middleware/render/ssr) await it so their span AND
376
+ * perf metric end at body-drain rather than at stream construction. Undefined
377
+ * when neither the perf store nor tracing is active (no instrumentation).
378
+ */
379
+ _finalDrain?: Promise<void>;
380
+
371
381
  /** @internal Router basename for this request (used by redirect()) */
372
382
  _basename?: string;
373
383