@rangojs/router 0.0.0-experimental.129 → 0.0.0-experimental.130

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.129",
2136
+ version: "0.0.0-experimental.130",
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.129",
3
+ "version": "0.0.0-experimental.130",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -26,16 +26,13 @@
26
26
  * recorded is governed by the `observability`/tracing block in wrangler config.
27
27
  *
28
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.
29
+ * (or promise) settles. For the streaming phases (request/render/ssr) that is at
30
+ * stream CONSTRUCTION, not body-drain. Instrumentation is best-effort and never
31
+ * wraps or buffers the response body, so it cannot regress streaming or latency.
32
+ * A loader/Suspense child that resolves mid-stream therefore keeps a rango.loader
33
+ * span that can extend past its render parent overlapping spans are valid. Uses
34
+ * only the typed enterSpan API; spans bound work up to stream-handoff, matching
35
+ * the co-emitted perf metric.
39
36
  */
40
37
 
41
38
  import { _getRequestContext } from "../server/request-context.js";
@@ -42,9 +42,7 @@ import {
42
42
  runThenSettle,
43
43
  type TracePhase,
44
44
  type TraceSpan,
45
- type ResolvedTracing,
46
45
  } from "./tracing.js";
47
- import { isWebSocketUpgradeResponse } from "../response-utils.js";
48
46
 
49
47
  /**
50
48
  * Perf-metric boundary for a phase, or `false` for span-only. `false` means the
@@ -208,11 +206,15 @@ function recordPhaseMetric(
208
206
  * unchanged and thrown errors / rejected promises propagate unchanged. When fn
209
207
  * returns a promise both the metric duration and the span end when it settles.
210
208
  *
211
- * This is the boundary for NON-streaming phases (action, loader): both the span
212
- * and the metric settle when their own work completes. Streaming phases (request,
213
- * middleware, render, ssr) use observeRequestPhase / observeStreamingPhase, where
214
- * the SPAN is held open until body-drain (valid tree) while the perf metric is
215
- * still recorded at construction (Server-Timing parity).
209
+ * This is the ONLY phase primitive: every phase (request/middleware/action/
210
+ * loader/handler/render/ssr) is construction-bound the span and metric settle
211
+ * when fn's own work completes (for the streaming phases, when the RSC/HTML
212
+ * stream is constructed, NOT when the body drains). Instrumentation is strictly
213
+ * best-effort: it never wraps or buffers the response and adds no work on the
214
+ * streaming path, so it cannot regress response latency or streaming. A loader
215
+ * that resolves while the body streams therefore keeps a rango.loader span that
216
+ * may extend past its render parent — overlapping spans are valid; the loader
217
+ * really did take that long.
216
218
  *
217
219
  * Reads the metrics store + tracing off the RequestContext ALS, which is active
218
220
  * for the WHOLE request — contrast observeEvent, which reads the RouterContext
@@ -231,12 +233,25 @@ export function observePhase<T>(
231
233
 
232
234
  // Attributes only land on a real span, so skip the wrapper when only the perf
233
235
  // surface is active (traceSpan would apply them to NOOP_TRACE_SPAN for nothing).
236
+ // `lazyAttributes` resolve AFTER fn runs (e.g. rango.route, known post-match).
234
237
  const attributes = spec.attributes;
238
+ const lazy = spec.lazyAttributes;
235
239
  const wrapped: (span: TraceSpan) => T =
236
- attributes && tracing
240
+ (attributes || lazy) && tracing
237
241
  ? (span) => {
238
- applyAttributes(span, attributes);
239
- return fn(span);
242
+ if (attributes) applyAttributes(span, attributes);
243
+ const out = fn(span);
244
+ if (!lazy) return out;
245
+ if (out instanceof Promise) {
246
+ return out.then((value) => {
247
+ const late = lazy();
248
+ if (late) applyAttributes(span, late);
249
+ return value;
250
+ }) as T;
251
+ }
252
+ const late = lazy();
253
+ if (late) applyAttributes(span, late);
254
+ return out;
240
255
  }
241
256
  : fn;
242
257
 
@@ -254,195 +269,6 @@ export function observePhase<T>(
254
269
  return runThenSettle(runSpan, () => recordPhaseMetric(store, metric, start));
255
270
  }
256
271
 
257
- /**
258
- * Re-stream `response`'s body through a pass-through that fires `onDrain` exactly
259
- * once when the body finishes — on natural end, a stream error, or a client
260
- * cancel (so a span can never leak on an aborted response). A bodyless response
261
- * fires immediately. Only used while instrumentation is active, so the per-chunk
262
- * relay cost never touches an untraced request.
263
- */
264
- function instrumentResponseDrain(
265
- response: Response,
266
- onDrain: () => void,
267
- ): Response {
268
- // WS-upgrade responses (status 101 / workerd `webSocket` property) must never
269
- // be reconstructed: `new Response(body, { status: 101 })` throws and a copy
270
- // drops the non-standard webSocket handoff (the invariant every other Response
271
- // reconstruction site honors). A bodyless response has nothing to drain.
272
- const source = response.body;
273
- if (!source || isWebSocketUpgradeResponse(response)) {
274
- onDrain();
275
- return response;
276
- }
277
- let fired = false;
278
- const fire = (): void => {
279
- if (fired) return;
280
- fired = true;
281
- onDrain();
282
- };
283
- const reader = source.getReader();
284
- const wrapped = new ReadableStream<Uint8Array>({
285
- async pull(controller) {
286
- try {
287
- const { done, value } = await reader.read();
288
- if (done) {
289
- controller.close();
290
- fire();
291
- return;
292
- }
293
- controller.enqueue(value);
294
- } catch (error) {
295
- controller.error(error);
296
- fire();
297
- }
298
- },
299
- cancel(reason) {
300
- fire();
301
- return reader.cancel(reason);
302
- },
303
- });
304
- return new Response(wrapped, response);
305
- }
306
-
307
- /**
308
- * Shared engine for the streaming phases (request, middleware, render, ssr). It
309
- * opens the span, runs fn, records the phase's perf metric at CONSTRUCTION (so it
310
- * still reaches the Server-Timing header / [RSC Perf] table, both built before
311
- * the body drains), hands the constructed value to the caller via a side channel
312
- * (streaming preserved), then holds the span open until `drain` resolves. The
313
- * SPAN therefore ends at body-drain — keeping the trace tree valid (a loader
314
- * child that resolves mid-stream ends before its parent) — while the perf metric
315
- * stays the construction work-time. `onDeliver` lets the request phase instrument
316
- * the final body before handing it back; `onError` lets it release the barrier on
317
- * failure. Fire-and-forget: the value reaches the caller via the returned
318
- * promise, so the span promise's rejection is swallowed (already surfaced there).
319
- */
320
- function runDrainBoundPhase<R>(
321
- spec: PhaseSpec,
322
- fn: (span: TraceSpan) => R | Promise<R>,
323
- tracing: ResolvedTracing | undefined,
324
- store: MetricsStore | undefined,
325
- drain: Promise<void>,
326
- onDeliver: (value: R) => R,
327
- onError?: () => void,
328
- ): Promise<R> {
329
- let deliver!: (value: R) => void;
330
- let reject!: (error: unknown) => void;
331
- const delivered = new Promise<R>((res, rej) => {
332
- deliver = res;
333
- reject = rej;
334
- });
335
-
336
- const start = performance.now();
337
- const attributes = spec.attributes;
338
- const metric = spec.metric;
339
- const record = (): void => {
340
- if (store && metric !== false) recordPhaseMetric(store, metric, start);
341
- };
342
- const spanCallback = async (span: TraceSpan): Promise<void> => {
343
- if (attributes && tracing) applyAttributes(span, attributes);
344
- let value: R;
345
- try {
346
- value = await fn(span);
347
- } catch (error) {
348
- record(); // a failed phase still shows its (construction) timing
349
- onError?.();
350
- reject(error);
351
- throw error; // settle the span with the error, at construction
352
- }
353
- // Late attributes (e.g. rango.route) — resolved now that the work has run,
354
- // so they can read state like the matched route name that match sets midway.
355
- const lazy =
356
- tracing && spec.lazyAttributes ? spec.lazyAttributes() : undefined;
357
- if (lazy) applyAttributes(span, lazy);
358
- record(); // construction-bound metric, before the response/header is built
359
- deliver(onDeliver(value));
360
- await drain; // hold the span open until the response body drains
361
- };
362
-
363
- traceSpan(tracing, spec.tracePhase, spec.spanName, spanCallback).catch(
364
- () => {},
365
- );
366
- return delivered;
367
- }
368
-
369
- /**
370
- * The request phase (rango.request, metric:false). Owns the drain barrier: it
371
- * runs fn to construct the final Response, instruments that Response's body so
372
- * the barrier resolves at drain, hands the Response to the caller immediately
373
- * (streaming preserved), and holds the span open until the body drains. Every
374
- * streaming inner phase awaits the same barrier (via observeStreamingPhase), so
375
- * the request/middleware/render/ssr chain ends at body-drain together and the
376
- * trace tree is valid (no child span outlives its parent). The perf metrics
377
- * (render:total, …) are recorded at construction so they still reach the
378
- * Server-Timing header; only the SPANS are drain-bound. ctx.waitUntil holds the
379
- * worker alive until drain so the span end runs. Pass-through when no surface is
380
- * active.
381
- */
382
- export function observeRequestPhase(
383
- spec: PhaseSpec,
384
- fn: (span: TraceSpan) => Promise<Response>,
385
- ): Promise<Response> {
386
- const reqCtx = _getRequestContext();
387
- const store = reqCtx?._metricsStore;
388
- const tracing = reqCtx?._tracing;
389
-
390
- if ((!store && !tracing) || !reqCtx) return fn(NOOP_TRACE_SPAN);
391
-
392
- let resolveDrain!: () => void;
393
- const finalDrain = new Promise<void>((resolve) => {
394
- resolveDrain = resolve;
395
- });
396
- reqCtx._finalDrain = finalDrain;
397
-
398
- // Keep the worker alive until the body drains, so the drain-bound span end
399
- // (and the inner phases' settle) runs before the runtime can reclaim it.
400
- const ec = reqCtx.executionContext;
401
- if (typeof ec?.waitUntil === "function") ec.waitUntil(finalDrain);
402
-
403
- return runDrainBoundPhase<Response>(
404
- spec,
405
- fn,
406
- tracing,
407
- store,
408
- finalDrain,
409
- (response) => instrumentResponseDrain(response, resolveDrain),
410
- resolveDrain, // release the barrier if fn fails before constructing a body
411
- );
412
- }
413
-
414
- /**
415
- * A streaming inner phase (rango.middleware / render / ssr). Its SPAN settles
416
- * when the request's final response body drains (the barrier owned by
417
- * observeRequestPhase), not when fn returns the constructed stream — so
418
- * loader/Suspense children that resolve mid-stream nest under a still-open
419
- * parent. fn's result is delivered at construction (streaming preserved) and the
420
- * perf metric is recorded at construction (Server-Timing parity). Falls back to
421
- * observePhase (construction-bound span) when there is no barrier — a
422
- * non-streaming request, or instrumentation off.
423
- */
424
- export function observeStreamingPhase<R>(
425
- spec: PhaseSpec,
426
- fn: (span: TraceSpan) => R | Promise<R>,
427
- ): Promise<R> {
428
- const reqCtx = _getRequestContext();
429
- const store = reqCtx?._metricsStore;
430
- const tracing = reqCtx?._tracing;
431
- const finalDrain = reqCtx?._finalDrain;
432
-
433
- if ((!store && !tracing) || !finalDrain) {
434
- return Promise.resolve(observePhase(spec, fn));
435
- }
436
- return runDrainBoundPhase<R>(
437
- spec,
438
- fn,
439
- tracing,
440
- store,
441
- finalDrain,
442
- (value) => value,
443
- );
444
- }
445
-
446
272
  /**
447
273
  * Emit one discrete telemetry event (the event-shaped counterpart to
448
274
  * observePhase). Resolves the sink from the active router context and stamps the
@@ -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 { observeStreamingPhase, PHASES } from "./instrument.js";
22
+ import { observePhase, 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 observeStreamingPhase(PHASES.middleware(metricLabel), () =>
490
+ result = await observePhase(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 observeStreamingPhase(PHASES.middleware(label), () =>
667
+ result = await observePhase(PHASES.middleware(label), () =>
668
668
  middleware(ctx, guardedNext),
669
669
  );
670
670
  } catch (error) {
@@ -31,18 +31,15 @@
31
31
  * track() at the call site), rango.render (render:total:<route>; normal AND
32
32
  * action-revalidation renders), rango.ssr (ssr:render-html).
33
33
  *
34
- * Streaming-phase span lifetime: a span ends when its callback's value (or
35
- * promise) settles. The streaming phases (request, middleware, render, ssr) are
36
- * wrapped by observeRequestPhase / observeStreamingPhase (instrument.ts), whose
37
- * callback hands the constructed Response/stream to the caller immediately
38
- * (streaming preserved) but then awaits a request-scoped drain barrier, so the
39
- * SPAN ends when the response BODY finishes draining, not at stream construction.
40
- * That keeps span durations covering the real streamed work and the trace tree
41
- * valid: a loader/Suspense child that resolves while the body streams ends before
42
- * its now-drain-bound parent. The co-emitted perf METRIC (render:total, …) is
43
- * still recorded at construction — it ships in the Server-Timing header, flushed
44
- * before the body drains — so a streaming span legitimately reads longer than its
45
- * same-named metric by the time the body spent streaming after construction.
34
+ * Span-duration caveat (best-effort, never buffers): a span ends when its
35
+ * callback's value (or promise) settles. For the streaming phases (request,
36
+ * render, ssr) that is when the Response / HTML / RSC stream is CONSTRUCTED, not
37
+ * when the body finishes draining instrumentation never wraps or buffers the
38
+ * response body, so it cannot regress response latency or streaming. A loader /
39
+ * Suspense child that resolves while the body streams therefore keeps a
40
+ * rango.loader span that can extend past its render parent; overlapping spans are
41
+ * valid (the loader really did take that long). Phase spans bound the work up to
42
+ * stream-handoff, which is also what the co-emitted perf metric measures.
46
43
  *
47
44
  * Both shipped runners (Cloudflare, OTel) keep the core agnostic: the
48
45
  * platform-specific bridge lives at the edge behind the SpanRunner contract.
@@ -82,12 +82,7 @@ import {
82
82
  appendMetric,
83
83
  buildMetricsTiming,
84
84
  } from "../router/metrics.js";
85
- import {
86
- observePhase,
87
- observeRequestPhase,
88
- observeEvent,
89
- PHASES,
90
- } from "../router/instrument.js";
85
+ import { observePhase, observeEvent, PHASES } from "../router/instrument.js";
91
86
  import {
92
87
  startSSRSetup,
93
88
  getSSRSetup,
@@ -501,14 +496,12 @@ export function createRSCHandler<
501
496
  // The "rango.request" span is opened inside the request context so the
502
497
  // Cloudflare runner can read executionContext.tracing, and so every nested
503
498
  // phase span (and the platform's automatic KV/D1/fetch spans) nests under
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.
499
+ // it. Construction-bound: the span ends when the Response is built, never
500
+ // wrapping the streamed body. metric:false handler:total is metered
501
+ // directly below (a grand total incl. the pre-context bootstrap timings).
502
+ // When tracing is off this is a direct pass-through.
510
503
  return runWithRequestContext(requestContext, () =>
511
- observeRequestPhase(PHASES.request, async (span) => {
504
+ observePhase(PHASES.request, async (span) => {
512
505
  span.setAttribute("http.method", request.method);
513
506
  // The matched route template is not known until match() runs later, so
514
507
  // 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 { observeStreamingPhase, PHASES } from "../router/instrument.js";
14
+ import { observePhase, 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 observeStreamingPhase(PHASES.render, () =>
39
+ return observePhase(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 observeStreamingPhase(PHASES.ssr, () =>
251
+ const htmlStream = await observePhase(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 { observeStreamingPhase, PHASES } from "../router/instrument.js";
23
+ import { observePhase, 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 observeStreamingPhase(PHASES.render, () =>
273
+ return observePhase(PHASES.render, () =>
274
274
  revalidateAfterActionInner(
275
275
  ctx,
276
276
  request,
@@ -368,16 +368,6 @@ 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
-
381
371
  /** @internal Router basename for this request (used by redirect()) */
382
372
  _basename?: string;
383
373