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

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 +12 -3
  5. package/skills/prerender/SKILL.md +30 -11
  6. package/skills/router-setup/SKILL.md +11 -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 +109 -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 +230 -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 +198 -0
  30. package/src/router.ts +9 -0
  31. package/src/rsc/handler.ts +132 -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 +13 -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.127",
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.127",
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,16 +73,25 @@ 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
+
82
90
  const router = createRouter({
83
91
  document: Document,
84
92
  urls: urlpatterns,
85
- telemetry: createOTelSink(trace.getTracer("my-app")),
93
+ tracing: createOTelTracing(tracer), // request/loader/render/… phase spans
94
+ telemetry: createOTelSink(tracer), // handler errors, cache decisions, …
86
95
  });
87
96
  ```
88
97
 
@@ -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,22 @@ 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
+
476
483
  const router = createRouter({
477
484
  document: Document,
478
485
  urls: urlpatterns,
479
- telemetry: createOTelSink(trace.getTracer("my-app")),
486
+ tracing: createOTelTracing(tracer),
487
+ telemetry: createOTelSink(tracer),
480
488
  });
481
489
  ```
482
490
 
@@ -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,109 @@
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: 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.
36
+ */
37
+
38
+ import { _getRequestContext } from "../server/request-context.js";
39
+ import {
40
+ type RouterTracingConfig,
41
+ type SpanRunner,
42
+ type TracePhaseToggles,
43
+ NOOP_TRACE_SPAN,
44
+ } from "../router/tracing.js";
45
+
46
+ /**
47
+ * Minimal local view of Cloudflare's `Span`. Declared here (not imported from
48
+ * `@cloudflare/workers-types`) to avoid a hard type dependency.
49
+ */
50
+ interface CloudflareSpan {
51
+ readonly isTraced: boolean;
52
+ setAttribute(key: string, value?: boolean | number | string): void;
53
+ }
54
+
55
+ /** Minimal local view of Cloudflare's `Tracing` (only enterSpan is used). */
56
+ interface CloudflareTracing {
57
+ enterSpan<T>(name: string, callback: (span: CloudflareSpan) => T): T;
58
+ }
59
+
60
+ /** Options for createCloudflareTracing. */
61
+ export interface CloudflareTracingOptions {
62
+ /** Master switch. Defaults to true. */
63
+ enabled?: boolean;
64
+ /** Per-phase span toggles. Omitted phases default to enabled. */
65
+ spans?: TracePhaseToggles;
66
+ }
67
+
68
+ /**
69
+ * Resolve the per-request Cloudflare tracer from the active execution context.
70
+ * Returns undefined off-Cloudflare or when tracing is not enabled for the
71
+ * worker, in which case the caller runs the work without a span.
72
+ */
73
+ function getRequestTracer(): CloudflareTracing | undefined {
74
+ const executionContext = _getRequestContext()?.executionContext as
75
+ | { tracing?: CloudflareTracing }
76
+ | undefined;
77
+ const tracing = executionContext?.tracing;
78
+ return tracing && typeof tracing.enterSpan === "function"
79
+ ? tracing
80
+ : undefined;
81
+ }
82
+
83
+ const cloudflareSpanRunner: SpanRunner = (name, fn) => {
84
+ const tracer = getRequestTracer();
85
+ if (!tracer) return fn(NOOP_TRACE_SPAN);
86
+ // enterSpan runs the callback now and ends the span when its return value
87
+ // (or returned promise) settles; spans nest by JS async context. fn's param
88
+ // is the narrower TraceSpan, so the wider CloudflareSpan satisfies it directly.
89
+ return tracer.enterSpan(name, fn);
90
+ };
91
+
92
+ /**
93
+ * Create the tracing config for a Cloudflare router. Pass the result to
94
+ * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
95
+ * action, loaders, render, and ssr phases; pass `spans` to turn individual
96
+ * phases off.
97
+ *
98
+ * @see createOTelTracing (`@rangojs/router`) for the same slot on any platform
99
+ * with an OpenTelemetry SDK.
100
+ */
101
+ export function createCloudflareTracing(
102
+ options: CloudflareTracingOptions = {},
103
+ ): RouterTracingConfig {
104
+ return {
105
+ runner: cloudflareSpanRunner,
106
+ enabled: options.enabled ?? true,
107
+ spans: options.spans,
108
+ };
109
+ }
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 {
package/src/index.ts CHANGED
@@ -344,7 +344,12 @@ export {
344
344
  // bundle analysis output and slow build-time module resolution. Consumers
345
345
  // who need the values in non-RSC contexts can import from
346
346
  // `@rangojs/router/server`.
347
- export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
347
+ export type {
348
+ OTelTracer,
349
+ OTelActiveSpanTracer,
350
+ OTelSpan,
351
+ OTelTracingOptions,
352
+ } from "./router/telemetry-otel.js";
348
353
  // The full TelemetryEvent union PLUS its member types, so a consumer writing a
349
354
  // TelemetrySink can annotate a per-`type` handler (or construct an event literal
350
355
  // in a test) instead of only narrowing the opaque union.
@@ -366,6 +371,16 @@ export type {
366
371
  OriginCheckRejectedEvent,
367
372
  } from "./router/telemetry.js";
368
373
 
374
+ // Span tracing config types a consumer annotates. SpanRunner/TraceSpan are the
375
+ // internal runner contract (consumers go through createOTelTracing /
376
+ // createCloudflareTracing, which return a ready RouterTracingConfig) and are not
377
+ // exported.
378
+ export type {
379
+ RouterTracingConfig,
380
+ TracePhase,
381
+ TracePhaseToggles,
382
+ } from "./router/tracing.js";
383
+
369
384
  // Timeout types and error class
370
385
  export { RouterTimeoutError } from "./router/timeout.js";
371
386
  export type {