@rangojs/router 0.0.0-experimental.126 → 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.
Files changed (44) hide show
  1. package/dist/bin/rango.js +5 -1
  2. package/dist/vite/index.js +55 -40
  3. package/package.json +23 -19
  4. package/skills/observability/SKILL.md +39 -4
  5. package/skills/prerender/SKILL.md +30 -11
  6. package/skills/router-setup/SKILL.md +23 -3
  7. package/src/build/route-types/codegen.ts +12 -1
  8. package/src/cache/cache-scope.ts +20 -0
  9. package/src/cloudflare/index.ts +11 -0
  10. package/src/cloudflare/tracing.ts +112 -0
  11. package/src/index.rsc.ts +19 -2
  12. package/src/index.ts +16 -1
  13. package/src/route-definition/dsl-helpers.ts +19 -0
  14. package/src/router/instrument.ts +440 -0
  15. package/src/router/loader-resolution.ts +15 -10
  16. package/src/router/match-middleware/cache-lookup.ts +9 -14
  17. package/src/router/match-middleware/cache-store.ts +12 -0
  18. package/src/router/middleware.ts +23 -2
  19. package/src/router/prerender-match.ts +5 -2
  20. package/src/router/router-context.ts +2 -1
  21. package/src/router/router-interfaces.ts +8 -0
  22. package/src/router/router-options.ts +58 -4
  23. package/src/router/segment-resolution/fresh.ts +15 -18
  24. package/src/router/segment-resolution/helpers.ts +6 -0
  25. package/src/router/segment-resolution/loader-cache.ts +5 -0
  26. package/src/router/segment-resolution/revalidation.ts +9 -18
  27. package/src/router/segment-wrappers.ts +3 -2
  28. package/src/router/telemetry-otel.ts +161 -179
  29. package/src/router/tracing.ts +203 -0
  30. package/src/router.ts +9 -0
  31. package/src/rsc/handler.ts +140 -134
  32. package/src/rsc/loader-fetch.ts +7 -1
  33. package/src/rsc/progressive-enhancement.ts +9 -2
  34. package/src/rsc/rsc-rendering.ts +38 -14
  35. package/src/rsc/server-action.ts +28 -7
  36. package/src/segment-system.tsx +4 -1
  37. package/src/server/request-context.ts +23 -5
  38. package/src/vite/discovery/prerender-collection.ts +26 -37
  39. package/src/vite/discovery/state.ts +6 -0
  40. package/src/vite/plugin-types.ts +25 -0
  41. package/src/vite/plugins/expose-ids/router-transform.ts +10 -0
  42. package/src/vite/rango.ts +1 -0
  43. package/src/vite/router-discovery.ts +9 -3
  44. package/src/vite/utils/prerender-utils.ts +36 -0
package/dist/bin/rango.js CHANGED
@@ -190,9 +190,13 @@ export const NamedRoutes = {
190
190
  ${objectBody}
191
191
  } as const;
192
192
 
193
+ // Aliased so the augmentation below does not pay a homomorphic mapped-type
194
+ // instantiation per route; \`as const\` already makes the members readonly.
195
+ type NamedRoutesShape = typeof NamedRoutes;
196
+
193
197
  declare global {
194
198
  namespace Rango {
195
- interface GeneratedRouteMap extends Readonly<typeof NamedRoutes> {}
199
+ interface GeneratedRouteMap extends NamedRoutesShape {}
196
200
  }
197
201
  }
198
202
  `;
@@ -1192,7 +1192,10 @@ function transformRouter(code, filePath, routerFnNames, absolutePath) {
1192
1192
  const basename2 = path3.basename(filePath).replace(/\.(tsx?|jsx?)$/, "");
1193
1193
  const routeNamesImport = `./${basename2}.named-routes.gen.js`;
1194
1194
  const routeNamesVar = `__rsc_rn`;
1195
+ const codeOffsets = new Set(codeMatchIndices(code, pat));
1196
+ pat.lastIndex = 0;
1195
1197
  while ((match = pat.exec(code)) !== null) {
1198
+ if (!codeOffsets.has(match.index)) continue;
1196
1199
  const callStart = match.index;
1197
1200
  const parenPos = match.index + match[0].length - 1;
1198
1201
  const closeParen = findMatchingParen(code, parenPos + 1);
@@ -2130,7 +2133,7 @@ import { resolve } from "node:path";
2130
2133
  // package.json
2131
2134
  var package_default = {
2132
2135
  name: "@rangojs/router",
2133
- version: "0.0.0-experimental.126",
2136
+ version: "0.0.0-experimental.128",
2134
2137
  description: "Django-inspired RSC router with composable URL patterns",
2135
2138
  keywords: [
2136
2139
  "react",
@@ -2240,6 +2243,11 @@ var package_default = {
2240
2243
  "react-server": "./src/cache/cache-runtime.ts",
2241
2244
  default: "./src/cache/cache-runtime.ts"
2242
2245
  },
2246
+ "./cloudflare": {
2247
+ types: "./src/cloudflare/index.ts",
2248
+ "react-server": "./src/cloudflare/index.ts",
2249
+ default: "./src/cloudflare/index.ts"
2250
+ },
2243
2251
  "./theme": {
2244
2252
  types: "./src/theme/index.ts",
2245
2253
  default: "./src/theme/index.ts"
@@ -2532,9 +2540,13 @@ export const NamedRoutes = {
2532
2540
  ${objectBody}
2533
2541
  } as const;
2534
2542
 
2543
+ // Aliased so the augmentation below does not pay a homomorphic mapped-type
2544
+ // instantiation per route; \`as const\` already makes the members readonly.
2545
+ type NamedRoutesShape = typeof NamedRoutes;
2546
+
2535
2547
  declare global {
2536
2548
  namespace Rango {
2537
- interface GeneratedRouteMap extends Readonly<typeof NamedRoutes> {}
2549
+ interface GeneratedRouteMap extends NamedRoutesShape {}
2538
2550
  }
2539
2551
  }
2540
2552
  `;
@@ -4521,6 +4533,23 @@ function notifyOnError(registry, error, phase, routeKey, pathname, skipped) {
4521
4533
  break;
4522
4534
  }
4523
4535
  }
4536
+ function resolvePrerenderError(registry, error, onError, label, elapsed, phase, routeKey, pathname) {
4537
+ const isSkip = error?.name === "Skip";
4538
+ if (isSkip || onError === "warn") {
4539
+ if (isSkip) {
4540
+ console.log(`[rango] SKIP ${label} (${elapsed}ms) - ${error.message}`);
4541
+ } else {
4542
+ console.warn(
4543
+ `[rango] WARN ${label} (${elapsed}ms) - render error, not pre-rendered (prerender.onError: "warn"): ${error.message}`
4544
+ );
4545
+ }
4546
+ notifyOnError(registry, error, phase, routeKey, pathname, true);
4547
+ return;
4548
+ }
4549
+ console.error(`[rango] FAIL ${label} (${elapsed}ms) - ${error.message}`);
4550
+ notifyOnError(registry, error, phase, routeKey, pathname);
4551
+ throw error;
4552
+ }
4524
4553
  function getStagedAssetDir(projectRoot) {
4525
4554
  return resolve5(projectRoot, "node_modules/.rangojs-router-build/rsc-assets");
4526
4555
  }
@@ -4722,6 +4751,7 @@ async function expandPrerenderRoutes(state, rscEnv, registry, allManifests) {
4722
4751
  const manifestEntries = {};
4723
4752
  let doneCount = 0;
4724
4753
  let skipCount = 0;
4754
+ const prerenderOnError = state.opts?.prerenderOnError ?? "fail";
4725
4755
  const startTotal = performance.now();
4726
4756
  const groups = groupByConcurrency(entries);
4727
4757
  for (const group of groups) {
@@ -4781,34 +4811,19 @@ async function expandPrerenderRoutes(state, rscEnv, registry, allManifests) {
4781
4811
  doneCount++;
4782
4812
  break;
4783
4813
  } catch (err) {
4784
- if (err.name === "Skip") {
4785
- const elapsed2 = (performance.now() - startUrl).toFixed(0);
4786
- console.log(
4787
- `[rango] SKIP ${entry.urlPath.padEnd(40)} (${elapsed2}ms) - ${err.message}`
4788
- );
4789
- skipCount++;
4790
- notifyOnError(
4791
- registry,
4792
- err,
4793
- "prerender",
4794
- entry.routeName,
4795
- entry.urlPath,
4796
- true
4797
- );
4798
- break;
4799
- }
4800
4814
  const elapsed = (performance.now() - startUrl).toFixed(0);
4801
- console.error(
4802
- `[rango] FAIL ${entry.urlPath.padEnd(40)} (${elapsed}ms) - ${err.message}`
4803
- );
4804
- notifyOnError(
4815
+ resolvePrerenderError(
4805
4816
  registry,
4806
4817
  err,
4818
+ prerenderOnError,
4819
+ entry.urlPath.padEnd(40),
4820
+ elapsed,
4807
4821
  "prerender",
4808
4822
  entry.routeName,
4809
4823
  entry.urlPath
4810
4824
  );
4811
- throw err;
4825
+ skipCount++;
4826
+ break;
4812
4827
  }
4813
4828
  }
4814
4829
  }
@@ -4843,6 +4858,7 @@ async function renderStaticHandlers(state, rscEnv, registry) {
4843
4858
  let staticDone = 0;
4844
4859
  let staticSkip = 0;
4845
4860
  let totalStaticCount = 0;
4861
+ const prerenderOnError = state.opts?.prerenderOnError ?? "fail";
4846
4862
  for (const [, exportNames] of state.resolvedStaticModules) {
4847
4863
  totalStaticCount += exportNames.length;
4848
4864
  }
@@ -4890,22 +4906,18 @@ async function renderStaticHandlers(state, rscEnv, registry) {
4890
4906
  break;
4891
4907
  }
4892
4908
  } catch (err) {
4893
- if (err.name === "Skip") {
4894
- const elapsed2 = (performance.now() - startHandler).toFixed(0);
4895
- console.log(
4896
- `[rango] SKIP ${name.padEnd(40)} (${elapsed2}ms) - ${err.message}`
4897
- );
4898
- staticSkip++;
4899
- notifyOnError(registry, err, "static", void 0, void 0, true);
4900
- handled = true;
4901
- break;
4902
- }
4903
4909
  const elapsed = (performance.now() - startHandler).toFixed(0);
4904
- console.error(
4905
- `[rango] FAIL ${name.padEnd(40)} (${elapsed}ms) - ${err.message}`
4910
+ resolvePrerenderError(
4911
+ registry,
4912
+ err,
4913
+ prerenderOnError,
4914
+ name.padEnd(40),
4915
+ elapsed,
4916
+ "static"
4906
4917
  );
4907
- notifyOnError(registry, err, "static");
4908
- throw err;
4918
+ staticSkip++;
4919
+ handled = true;
4920
+ break;
4909
4921
  }
4910
4922
  }
4911
4923
  if (!handled) {
@@ -6271,9 +6283,11 @@ ${err.stack}`
6271
6283
  logResult(200, `match ${result.routeName}`);
6272
6284
  return;
6273
6285
  } catch (err) {
6274
- console.warn(
6275
- `[rango] Dev prerender failed for ${pathname}: ${err.message}`
6276
- );
6286
+ if (err?.name !== "Skip") {
6287
+ console.warn(
6288
+ `[rango] Dev prerender error for ${pathname} (serving live instead): ${err.message}`
6289
+ );
6290
+ }
6277
6291
  }
6278
6292
  }
6279
6293
  res.statusCode = 404;
@@ -7063,6 +7077,7 @@ ${list}`);
7063
7077
  enableBuildPrerender: prerenderEnabled,
7064
7078
  buildEnv: options?.buildEnv,
7065
7079
  preset,
7080
+ prerenderOnError: options?.prerender?.onError,
7066
7081
  discovery: options?.discovery,
7067
7082
  clientChunkCtx
7068
7083
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.126",
3
+ "version": "0.0.0-experimental.128",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -110,6 +110,11 @@
110
110
  "react-server": "./src/cache/cache-runtime.ts",
111
111
  "default": "./src/cache/cache-runtime.ts"
112
112
  },
113
+ "./cloudflare": {
114
+ "types": "./src/cloudflare/index.ts",
115
+ "react-server": "./src/cloudflare/index.ts",
116
+ "default": "./src/cloudflare/index.ts"
117
+ },
113
118
  "./theme": {
114
119
  "types": "./src/theme/index.ts",
115
120
  "default": "./src/theme/index.ts"
@@ -157,17 +162,6 @@
157
162
  "access": "public",
158
163
  "tag": "experimental"
159
164
  },
160
- "scripts": {
161
- "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
162
- "prepublishOnly": "pnpm build",
163
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
164
- "test": "playwright test",
165
- "test:ui": "playwright test --ui",
166
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
167
- "test:unit": "vitest run",
168
- "test:unit:watch": "vitest",
169
- "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
170
- },
171
165
  "dependencies": {
172
166
  "@types/debug": "^4.1.12",
173
167
  "@vitejs/plugin-rsc": "^0.5.26",
@@ -179,19 +173,19 @@
179
173
  },
180
174
  "devDependencies": {
181
175
  "@playwright/test": "^1.49.1",
182
- "@shared/e2e": "workspace:*",
183
176
  "@testing-library/dom": "^10.4.1",
184
177
  "@testing-library/react": "^16.3.2",
185
178
  "@types/node": "^24.10.1",
186
- "@types/react": "catalog:",
187
- "@types/react-dom": "catalog:",
179
+ "@types/react": "^19.2.7",
180
+ "@types/react-dom": "^19.2.3",
188
181
  "esbuild": "^0.27.0",
189
182
  "happy-dom": "^20.10.1",
190
183
  "jiti": "^2.6.1",
191
- "react": "catalog:",
192
- "react-dom": "catalog:",
184
+ "react": "^19.2.6",
185
+ "react-dom": "^19.2.6",
193
186
  "typescript": "^5.3.0",
194
- "vitest": "^4.0.0"
187
+ "vitest": "^4.0.0",
188
+ "@shared/e2e": "0.0.1"
195
189
  },
196
190
  "peerDependencies": {
197
191
  "@cloudflare/vite-plugin": "^1.38.0",
@@ -219,5 +213,15 @@
219
213
  "vitest": {
220
214
  "optional": true
221
215
  }
216
+ },
217
+ "scripts": {
218
+ "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
219
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
220
+ "test": "playwright test",
221
+ "test:ui": "playwright test --ui",
222
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
223
+ "test:unit": "vitest run",
224
+ "test:unit:watch": "vitest",
225
+ "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
222
226
  }
223
- }
227
+ }
@@ -73,19 +73,53 @@ const router = createRouter({
73
73
  });
74
74
  ```
75
75
 
76
- For OpenTelemetry:
76
+ For OpenTelemetry — phase spans come from the `tracing` slot
77
+ (`createOTelTracing`), discrete-fact spans from the `telemetry` sink
78
+ (`createOTelSink`):
77
79
 
78
80
  ```typescript
79
- import { createRouter, createOTelSink } from "@rangojs/router";
81
+ import {
82
+ createRouter,
83
+ createOTelTracing,
84
+ createOTelSink,
85
+ } from "@rangojs/router";
80
86
  import { trace } from "@opentelemetry/api";
81
87
 
88
+ const tracer = trace.getTracer("my-app");
89
+
90
+ const router = createRouter({
91
+ document: Document,
92
+ urls: urlpatterns,
93
+ tracing: createOTelTracing(tracer), // request/loader/render/… phase spans
94
+ telemetry: createOTelSink(tracer), // handler errors, cache decisions, …
95
+ });
96
+ ```
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
+
82
107
  const router = createRouter({
83
108
  document: Document,
84
109
  urls: urlpatterns,
85
- telemetry: createOTelSink(trace.getTracer("my-app")),
110
+ tracing: createCloudflareTracing(), // all phases on by default
111
+ // tracing: createCloudflareTracing({ spans: { ssr: false } }), // toggle phases
86
112
  });
87
113
  ```
88
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
+
89
123
  Custom sinks implement `emit(event)`:
90
124
 
91
125
  ```typescript
@@ -103,7 +137,8 @@ const router = createRouter({
103
137
  ```
104
138
 
105
139
  Events include `request.start/end/error`, `loader.start/end/error`,
106
- `handler.error`, `cache.decision`, and `revalidation.decision`.
140
+ `handler.error`, `cache.decision`, `revalidation.decision`, `request.timeout`,
141
+ and `request.origin-rejected`.
107
142
 
108
143
  ## Debugging revalidation and stale data
109
144
 
@@ -343,14 +343,31 @@ export const TocSidebar = Static(() => {
343
343
 
344
344
  ### Error behavior at build time
345
345
 
346
- | Handler outcome | Effect |
347
- | --------------------------- | ----------------------------------------------------- |
348
- | JSX / `null` | Normal prerender entry, log OK |
349
- | `return ctx.passthrough()` | Skip entry, log PASS, continue (Passthrough routes) |
350
- | `throw new Skip("reason")` | Skip entry, log SKIP, continue with remaining entries |
351
- | `throw new Error("reason")` | Log FAIL, stop ALL pre-rendering, fail the build |
352
-
353
- Both error types propagate to the router's `onError` callback with phase
346
+ When a render throws a non-`Skip` error, it is **surfaced to the build** — never
347
+ baked into a frozen error page served as a 200 (issue #587). What happens next is
348
+ controlled by `prerender.onError` in your `rango()` options:
349
+
350
+ ```ts
351
+ rango({ prerender: { onError: "warn" } }); // default is "fail"
352
+ ```
353
+
354
+ | Handler outcome | `onError: "fail"` (default) | `onError: "warn"` |
355
+ | --------------------------- | -------------------------------------------- | -------------------------------- |
356
+ | JSX / `null` | Normal prerender entry, log OK | Normal prerender entry, log OK |
357
+ | `return ctx.passthrough()` | Skip entry, log PASS (Passthrough routes) | Skip entry, log PASS |
358
+ | `throw new Skip("reason")` | Skip entry, log SKIP, continue | Skip entry, log SKIP, continue |
359
+ | `throw new Error("reason")` | Log FAIL, stop ALL pre-rendering, fail build | Log WARN, skip the URL, continue |
360
+
361
+ With `"warn"` the errored entry is logged and left un-baked (never served as a baked
362
+ 200 error page). `"warn"` is a build-unblock, not a runtime contract: the route falls
363
+ through to normal resolution — it may render live (its handler is still bundled) or
364
+ 404 (once other baked entries trigger prerender handler eviction), so the outcome
365
+ depends on the rest of the build, and a skipped `Static()` handler's evicted code can
366
+ surface as an error. For DEFINED runtime behavior reach for `Passthrough()` (a live
367
+ fallback) or `throw new Skip()` (an intentional skip — works in the render fn, not
368
+ only `getParams()`); otherwise prefer the default `"fail"`.
369
+
370
+ Both `Skip` and hard errors propagate to the router's `onError` callback with phase
354
371
  `"prerender"` or `"static"`.
355
372
 
356
373
  ### Build logs
@@ -370,9 +387,11 @@ The build produces per-URL timing logs:
370
387
  [rango] Static render complete: 2 done, 1 skipped (120ms total)
371
388
  ```
372
389
 
373
- A `FAIL` line is logged per-URL when a handler throws a non-Skip error. The
374
- error is re-thrown immediately, so no summary line is printed — the build
375
- stops at the first failure.
390
+ A `FAIL` line is logged per-URL when a handler throws a non-Skip error (with the
391
+ default `prerender.onError: "fail"`). The error is re-thrown immediately, so no
392
+ summary line is printed — the build stops at the first failure. Under
393
+ `prerender.onError: "warn"` the same case logs a `WARN` line, skips that URL, and
394
+ the build continues.
376
395
 
377
396
  ### Dev mode behavior
378
397
 
@@ -469,14 +469,34 @@ const router = createRouter({
469
469
  ```
470
470
 
471
471
  ```typescript
472
- // OpenTelemetry for production
473
- import { createRouter, createOTelSink } from "@rangojs/router";
472
+ // OpenTelemetry for production: phase spans via the tracing slot,
473
+ // discrete-fact spans via the telemetry sink.
474
+ import {
475
+ createRouter,
476
+ createOTelTracing,
477
+ createOTelSink,
478
+ } from "@rangojs/router";
474
479
  import { trace } from "@opentelemetry/api";
475
480
 
481
+ const tracer = trace.getTracer("my-app");
482
+
483
+ const router = createRouter({
484
+ document: Document,
485
+ urls: urlpatterns,
486
+ tracing: createOTelTracing(tracer),
487
+ telemetry: createOTelSink(tracer),
488
+ });
489
+ ```
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
+
476
496
  const router = createRouter({
477
497
  document: Document,
478
498
  urls: urlpatterns,
479
- telemetry: createOTelSink(trace.getTracer("my-app")),
499
+ tracing: createCloudflareTracing(), // { spans: { ssr: false } } to toggle phases
480
500
  });
481
501
  ```
482
502
 
@@ -88,14 +88,25 @@ export function generateRouteTypesSource(
88
88
  })
89
89
  .join("\n");
90
90
 
91
+ // The global augmentation extends an alias of `typeof NamedRoutes` rather than
92
+ // `Readonly<typeof NamedRoutes>`. `as const` already makes the members readonly,
93
+ // so the two are behaviour-identical, but `Readonly<>` is a homomorphic mapped
94
+ // type the compiler instantiates once per route at the augmentation site
95
+ // (~4 instantiations/route). An interface `extends` clause cannot name a bare
96
+ // `typeof` query, so the alias is what lets us drop the wrapper. Measured: ~35%
97
+ // fewer type instantiations on a 14k-route app; negligible (but free) on small.
91
98
  return `// Auto-generated by @rangojs/router - do not edit
92
99
  export const NamedRoutes = {
93
100
  ${objectBody}
94
101
  } as const;
95
102
 
103
+ // Aliased so the augmentation below does not pay a homomorphic mapped-type
104
+ // instantiation per route; \`as const\` already makes the members readonly.
105
+ type NamedRoutesShape = typeof NamedRoutes;
106
+
96
107
  declare global {
97
108
  namespace Rango {
98
- interface GeneratedRouteMap extends Readonly<typeof NamedRoutes> {}
109
+ interface GeneratedRouteMap extends NamedRoutesShape {}
99
110
  }
100
111
  }
101
112
  `;
@@ -299,6 +299,26 @@ export class CacheScope {
299
299
  }
300
300
  }
301
301
 
302
+ /**
303
+ * Record this scope's segment-DSL cache({ tags }) into the request tag union
304
+ * synchronously, under the same gate cacheRoute() uses for a write.
305
+ *
306
+ * cacheRoute() already records these tags, but it is invoked inside
307
+ * requestCtx.waitUntil() by the cache-store middleware (and the proactive path
308
+ * re-resolves the whole tree before calling it), so its recording is deferred
309
+ * and RACES the document cache's post-body-drain snapshot of _requestTags. On a
310
+ * first-write (segment-cache miss) the document tag union could miss these
311
+ * tags, and updateTag()/revalidateTag() would then fail to invalidate the
312
+ * cached document until a later write reseeded it. Calling this synchronously
313
+ * in the request pipeline (before the snapshot) closes that window. Idempotent
314
+ * (the tag union is a Set), so the duplicate record in cacheRoute is harmless.
315
+ */
316
+ recordTags(requestCtx: RequestContext | undefined): void {
317
+ if (!this.enabled) return;
318
+ if (!this.conditionAllows("write")) return;
319
+ recordRequestTags(resolveCacheTags(this.config, requestCtx), requestCtx);
320
+ }
321
+
302
322
  /**
303
323
  * Cache all segments for a route (non-blocking via waitUntil)
304
324
  * Single cache entry per route request.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Cloudflare preset integration surface for @rangojs/router.
3
+ *
4
+ * Imported via `@rangojs/router/cloudflare`. Cloudflare-only helpers live here
5
+ * so Node/other-platform consumers never pull them in.
6
+ */
7
+
8
+ export {
9
+ createCloudflareTracing,
10
+ type CloudflareTracingOptions,
11
+ } from "./tracing.js";
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Cloudflare custom-spans integration.
3
+ *
4
+ * Bridges the router's performance phases (request, middleware, action,
5
+ * loaders, render, ssr) onto Cloudflare Workers custom spans so they show up in the
6
+ * trace waterfall and OpenTelemetry exports next to the platform's automatic
7
+ * spans (KV reads, D1 queries, fetch calls), with correct parent-child nesting.
8
+ *
9
+ * Usage (Cloudflare preset only):
10
+ *
11
+ * import { createRouter } from "@rangojs/router";
12
+ * import { createCloudflareTracing } from "@rangojs/router/cloudflare";
13
+ *
14
+ * export const router = createRouter({
15
+ * tracing: createCloudflareTracing(),
16
+ * });
17
+ *
18
+ * The runner reads `executionContext.tracing` (the same object as
19
+ * `import { tracing } from "cloudflare:workers"`) at call time. It is therefore
20
+ * dependency-free: no `cloudflare:workers` import and no hard dependency on
21
+ * `@cloudflare/workers-types`, matching the convention in cache/cf. When the
22
+ * worker is not running on a tracing-enabled Cloudflare runtime — Node, dev
23
+ * without a tracing destination, an older runtime — `executionContext.tracing`
24
+ * is undefined and every span call falls through to the work directly, so the
25
+ * request behaves exactly as if tracing were off. Whether spans are actually
26
+ * recorded is governed by the `observability`/tracing block in wrangler config.
27
+ *
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.
39
+ */
40
+
41
+ import { _getRequestContext } from "../server/request-context.js";
42
+ import {
43
+ type RouterTracingConfig,
44
+ type SpanRunner,
45
+ type TracePhaseToggles,
46
+ NOOP_TRACE_SPAN,
47
+ } from "../router/tracing.js";
48
+
49
+ /**
50
+ * Minimal local view of Cloudflare's `Span`. Declared here (not imported from
51
+ * `@cloudflare/workers-types`) to avoid a hard type dependency.
52
+ */
53
+ interface CloudflareSpan {
54
+ readonly isTraced: boolean;
55
+ setAttribute(key: string, value?: boolean | number | string): void;
56
+ }
57
+
58
+ /** Minimal local view of Cloudflare's `Tracing` (only enterSpan is used). */
59
+ interface CloudflareTracing {
60
+ enterSpan<T>(name: string, callback: (span: CloudflareSpan) => T): T;
61
+ }
62
+
63
+ /** Options for createCloudflareTracing. */
64
+ export interface CloudflareTracingOptions {
65
+ /** Master switch. Defaults to true. */
66
+ enabled?: boolean;
67
+ /** Per-phase span toggles. Omitted phases default to enabled. */
68
+ spans?: TracePhaseToggles;
69
+ }
70
+
71
+ /**
72
+ * Resolve the per-request Cloudflare tracer from the active execution context.
73
+ * Returns undefined off-Cloudflare or when tracing is not enabled for the
74
+ * worker, in which case the caller runs the work without a span.
75
+ */
76
+ function getRequestTracer(): CloudflareTracing | undefined {
77
+ const executionContext = _getRequestContext()?.executionContext as
78
+ | { tracing?: CloudflareTracing }
79
+ | undefined;
80
+ const tracing = executionContext?.tracing;
81
+ return tracing && typeof tracing.enterSpan === "function"
82
+ ? tracing
83
+ : undefined;
84
+ }
85
+
86
+ const cloudflareSpanRunner: SpanRunner = (name, fn) => {
87
+ const tracer = getRequestTracer();
88
+ if (!tracer) return fn(NOOP_TRACE_SPAN);
89
+ // enterSpan runs the callback now and ends the span when its return value
90
+ // (or returned promise) settles; spans nest by JS async context. fn's param
91
+ // is the narrower TraceSpan, so the wider CloudflareSpan satisfies it directly.
92
+ return tracer.enterSpan(name, fn);
93
+ };
94
+
95
+ /**
96
+ * Create the tracing config for a Cloudflare router. Pass the result to
97
+ * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
98
+ * action, loaders, render, and ssr phases; pass `spans` to turn individual
99
+ * phases off.
100
+ *
101
+ * @see createOTelTracing (`@rangojs/router`) for the same slot on any platform
102
+ * with an OpenTelemetry SDK.
103
+ */
104
+ export function createCloudflareTracing(
105
+ options: CloudflareTracingOptions = {},
106
+ ): RouterTracingConfig {
107
+ return {
108
+ runner: cloudflareSpanRunner,
109
+ enabled: options.enabled ?? true,
110
+ spans: options.spans,
111
+ };
112
+ }
package/src/index.rsc.ts CHANGED
@@ -258,10 +258,17 @@ export {
258
258
  // Path and response types are ambient on the `Rango` namespace (`Rango.Path`,
259
259
  // `Rango.PathResponse`, declared in href-client.ts) — no import needed.
260
260
 
261
- // Telemetry sink
261
+ // Telemetry sink (event-shaped facts)
262
262
  export { createConsoleSink } from "./router/telemetry.js";
263
263
  export { createOTelSink } from "./router/telemetry-otel.js";
264
- export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
264
+ // OTel phase-span adapter for the `tracing` slot (the canonical span layer).
265
+ export { createOTelTracing } from "./router/telemetry-otel.js";
266
+ export type {
267
+ OTelTracer,
268
+ OTelActiveSpanTracer,
269
+ OTelSpan,
270
+ OTelTracingOptions,
271
+ } from "./router/telemetry-otel.js";
265
272
  // The full TelemetryEvent union PLUS its member types, so a consumer writing a
266
273
  // TelemetrySink can annotate a per-`type` handler (or construct an event literal
267
274
  // in a test) instead of only narrowing the opaque union.
@@ -283,6 +290,16 @@ export type {
283
290
  OriginCheckRejectedEvent,
284
291
  } from "./router/telemetry.js";
285
292
 
293
+ // Span tracing config types a consumer annotates. SpanRunner/TraceSpan are the
294
+ // internal runner contract (consumers go through createOTelTracing /
295
+ // createCloudflareTracing, which return a ready RouterTracingConfig) and are not
296
+ // exported.
297
+ export type {
298
+ RouterTracingConfig,
299
+ TracePhase,
300
+ TracePhaseToggles,
301
+ } from "./router/tracing.js";
302
+
286
303
  // Timeout types and error class
287
304
  export { RouterTimeoutError } from "./router/timeout.js";
288
305
  export type {