@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
@@ -11,6 +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
15
  import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
15
16
  import type { RscPayload } from "./types.js";
16
17
  import type { MatchResult } from "../types.js";
@@ -21,7 +22,34 @@ import {
21
22
  } from "./helpers.js";
22
23
  import type { HandlerContext } from "./handler-context.js";
23
24
 
24
- export async function handleRscRendering<TEnv>(
25
+ export function handleRscRendering<TEnv>(
26
+ ctx: HandlerContext<TEnv>,
27
+ request: Request,
28
+ env: TEnv,
29
+ url: URL,
30
+ isPartial: boolean,
31
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
32
+ nonce: string | undefined,
33
+ ): Promise<Response> {
34
+ // Instrument the whole render phase once through the unified API: it records
35
+ // the "render:total" perf metric AND opens the "rango.render" span from the
36
+ // same boundary (match -> serialize -> SSR), so the two surfaces agree.
37
+ // Loaders kicked off during matching nest under the span; the SSR HTML pass
38
+ // below opens "rango.ssr" the same way.
39
+ return observePhase(PHASES.render, () =>
40
+ handleRscRenderingInner(
41
+ ctx,
42
+ request,
43
+ env,
44
+ url,
45
+ isPartial,
46
+ handleStore,
47
+ nonce,
48
+ ),
49
+ );
50
+ }
51
+
52
+ async function handleRscRenderingInner<TEnv>(
25
53
  ctx: HandlerContext<TEnv>,
26
54
  request: Request,
27
55
  env: TEnv,
@@ -158,7 +186,6 @@ export async function handleRscRendering<TEnv>(
158
186
  }
159
187
 
160
188
  const metricsStore = reqCtx._metricsStore;
161
- const renderStart = performance.now();
162
189
 
163
190
  // Serialize to RSC stream
164
191
  const rscSerializeStart = performance.now();
@@ -177,8 +204,7 @@ export async function handleRscRendering<TEnv>(
177
204
  );
178
205
 
179
206
  if (isRscRequest(request, url, isPartial)) {
180
- const renderDur = performance.now() - renderStart;
181
- appendMetric(metricsStore, "render:total", renderStart, renderDur);
207
+ // render:total is recorded by the observePhase wrapper around this function.
182
208
  const rscHeaders: Record<string, string> = {
183
209
  "content-type": "text/x-component;charset=utf-8",
184
210
  vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
@@ -220,16 +246,14 @@ export async function handleRscRendering<TEnv>(
220
246
  metricsStore,
221
247
  );
222
248
 
223
- const ssrRenderStart = performance.now();
224
- const htmlStream = await ssrModule.renderHTML(rscStream, {
225
- nonce,
226
- streamMode,
227
- });
228
- const ssrRenderDur = performance.now() - ssrRenderStart;
229
- appendMetric(metricsStore, "ssr-render-html", ssrRenderStart, ssrRenderDur);
230
-
231
- const renderDur = performance.now() - renderStart;
232
- appendMetric(metricsStore, "render:total", renderStart, renderDur);
249
+ // ssr-render-html metric + rango.ssr span from one boundary. render:total is
250
+ // recorded by the observePhase wrapper around this function.
251
+ const htmlStream = await observePhase(PHASES.ssr, () =>
252
+ ssrModule.renderHTML(rscStream, {
253
+ nonce,
254
+ streamMode,
255
+ }),
256
+ );
233
257
 
234
258
  return createResponseWithMergedHeaders(htmlStream, {
235
259
  headers: { "content-type": "text/html;charset=utf-8" },
@@ -20,6 +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
24
  import type { RscPayload } from "./types.js";
24
25
  import {
25
26
  hasBodyContent,
@@ -256,7 +257,32 @@ export async function executeServerAction<TEnv>(
256
257
  * provide. Redirects are the only non-partial outcome and are handled via
257
258
  * X-RSC-Redirect headers before Flight deserialization.
258
259
  */
259
- export async function revalidateAfterAction<TEnv>(
260
+ export function revalidateAfterAction<TEnv>(
261
+ ctx: HandlerContext<TEnv>,
262
+ request: Request,
263
+ env: TEnv,
264
+ url: URL,
265
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
266
+ continuation: ActionContinuation,
267
+ ): Promise<Response> {
268
+ // Instrument the action-revalidation render through the unified phase API,
269
+ // exactly like a normal navigation render (handleRscRendering). It records
270
+ // "render:total" AND opens "rango.render" from one boundary covering
271
+ // matchPartial -> serialize, so the revalidation loaders' rango.loader spans
272
+ // nest under a rango.render parent instead of dangling at the request root.
273
+ return observePhase(PHASES.render, () =>
274
+ revalidateAfterActionInner(
275
+ ctx,
276
+ request,
277
+ env,
278
+ url,
279
+ handleStore,
280
+ continuation,
281
+ ),
282
+ );
283
+ }
284
+
285
+ async function revalidateAfterActionInner<TEnv>(
260
286
  ctx: HandlerContext<TEnv>,
261
287
  request: Request,
262
288
  env: TEnv,
@@ -335,13 +361,8 @@ export async function revalidateAfterAction<TEnv>(
335
361
  });
336
362
  const rscSerializeDur = performance.now() - renderStart;
337
363
  // This measures synchronous stream creation, not end-to-end stream consumption.
364
+ // render:total is recorded by the observePhase wrapper in revalidateAfterAction.
338
365
  appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
339
- appendMetric(
340
- metricsStore,
341
- "render:total",
342
- renderStart,
343
- performance.now() - renderStart,
344
- );
345
366
 
346
367
  return createResponseWithMergedHeaders(rscStream, {
347
368
  status: actionStatus,
@@ -504,7 +504,10 @@ export async function renderSegments(
504
504
  // slot name is user-controlled (`@${string}`) and may contain an uppercase "D"
505
505
  // (e.g. "@Detail"). Strip from the first `D<index>.` separator so the slot name
506
506
  // is preserved; splitting on a bare "D" mis-cut "@Detail" to "@" and silently
507
- // dropped the loader's data.
507
+ // dropped the loader's data. The first-`D<index>.` strip is only correct because
508
+ // slot names cannot contain "." -- assertValidSlotName (route-definition/
509
+ // dsl-helpers.ts) rejects a "." at definition time, so a name like "@D3.foo"
510
+ // (which WOULD mis-cut here) can never reach this function.
508
511
  function loaderParentId(loaderSegmentId: string): string {
509
512
  return loaderSegmentId.replace(/D\d+\..*$/, "");
510
513
  }
@@ -41,11 +41,13 @@ import {
41
41
  } from "./handle-store.js";
42
42
  import { isHandle } from "../handle.js";
43
43
  import { withDefer } from "../defer.js";
44
- import { track, type MetricsStore } from "./context.js";
44
+ import { type MetricsStore } from "./context.js";
45
+ import { observePhase, PHASES } from "../router/instrument.js";
45
46
  import { getFetchableLoader } from "./fetchable-loader-store.js";
46
47
  import type { SegmentCacheStore } from "../cache/types.js";
47
48
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
48
49
  import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
50
+ import type { ResolvedTracing } from "../router/tracing.js";
49
51
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
50
52
  import { THEME_COOKIE } from "../theme/constants.js";
51
53
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
@@ -363,6 +365,9 @@ export interface RequestContext<
363
365
  /** @internal Request-scoped performance metrics store */
364
366
  _metricsStore?: MetricsStore;
365
367
 
368
+ /** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
369
+ _tracing?: ResolvedTracing;
370
+
366
371
  /** @internal Router basename for this request (used by redirect()) */
367
372
  _basename?: string;
368
373
 
@@ -1114,10 +1119,13 @@ export function createUseFunction<TEnv>(
1114
1119
  },
1115
1120
  };
1116
1121
 
1117
- const doneLoader = track(`loader:${loader.$$id}`, 2);
1118
- const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
1119
- doneLoader();
1120
- });
1122
+ // Meter through the same unified phase API as the loader-resolution funnel
1123
+ // (observePhase), so a loader resolved via this base request-context ctx.use
1124
+ // co-emits the "loader:<id>" perf metric AND the "rango.loader" span — no
1125
+ // drift between the two ctx.use implementations.
1126
+ const promise = observePhase(PHASES.loader(loader.$$id), () =>
1127
+ Promise.resolve(loaderFn(loaderCtx)),
1128
+ );
1121
1129
 
1122
1130
  loaderPromises.set(loader.$$id, promise);
1123
1131
 
@@ -13,6 +13,7 @@ import {
13
13
  runWithConcurrency,
14
14
  groupByConcurrency,
15
15
  notifyOnError,
16
+ resolvePrerenderError,
16
17
  stageBuildAssetModule,
17
18
  } from "../utils/prerender-utils.js";
18
19
  import type { DiscoveryState } from "./state.js";
@@ -231,6 +232,10 @@ export async function expandPrerenderRoutes(
231
232
  const manifestEntries: Record<string, string> = {};
232
233
  let doneCount = 0;
233
234
  let skipCount = 0;
235
+ // #587: a render error reaches here (matchForPrerender now re-throws it rather
236
+ // than baking the error boundary). Default "fail" fails the build; "warn" logs
237
+ // and skips baking the URL.
238
+ const prerenderOnError = state.opts?.prerenderOnError ?? "fail";
234
239
  const startTotal = performance.now();
235
240
 
236
241
  // Group entries by concurrency for batched rendering.
@@ -297,35 +302,22 @@ export async function expandPrerenderRoutes(
297
302
  doneCount++;
298
303
  break;
299
304
  } catch (err: any) {
300
- if (err.name === "Skip") {
301
- const elapsed = (performance.now() - startUrl).toFixed(0);
302
- console.log(
303
- `[rango] SKIP ${entry.urlPath.padEnd(40)} (${elapsed}ms) - ${err.message}`,
304
- );
305
- skipCount++;
306
- notifyOnError(
307
- registry,
308
- err,
309
- "prerender",
310
- entry.routeName,
311
- entry.urlPath,
312
- true,
313
- );
314
- break;
315
- }
316
- // Regular error: log, notify, and fail the build
305
+ // Skip, or a render error under prerender.onError "warn": skip the
306
+ // URL (never bake the error page, #587). A render error under the
307
+ // default "fail" re-throws below to fail the build.
317
308
  const elapsed = (performance.now() - startUrl).toFixed(0);
318
- console.error(
319
- `[rango] FAIL ${entry.urlPath.padEnd(40)} (${elapsed}ms) - ${err.message}`,
320
- );
321
- notifyOnError(
309
+ resolvePrerenderError(
322
310
  registry,
323
311
  err,
312
+ prerenderOnError,
313
+ entry.urlPath.padEnd(40),
314
+ elapsed,
324
315
  "prerender",
325
316
  entry.routeName,
326
317
  entry.urlPath,
327
318
  );
328
- throw err;
319
+ skipCount++;
320
+ break;
329
321
  }
330
322
  }
331
323
  },
@@ -378,6 +370,7 @@ export async function renderStaticHandlers(
378
370
  let staticDone = 0;
379
371
  let staticSkip = 0;
380
372
  let totalStaticCount = 0;
373
+ const prerenderOnError = state.opts?.prerenderOnError ?? "fail";
381
374
 
382
375
  // Count handlers for the log header
383
376
  for (const [, exportNames] of state.resolvedStaticModules) {
@@ -434,23 +427,19 @@ export async function renderStaticHandlers(
434
427
  break;
435
428
  }
436
429
  } catch (err: any) {
437
- if (err.name === "Skip") {
438
- const elapsed = (performance.now() - startHandler).toFixed(0);
439
- console.log(
440
- `[rango] SKIP ${name.padEnd(40)} (${elapsed}ms) - ${err.message}`,
441
- );
442
- staticSkip++;
443
- notifyOnError(registry, err, "static", undefined, undefined, true);
444
- handled = true;
445
- break;
446
- }
447
- // Regular error: log, notify, and fail the build
430
+ // Same Skip/warn/fail policy as the prerender loop (shared helper).
448
431
  const elapsed = (performance.now() - startHandler).toFixed(0);
449
- console.error(
450
- `[rango] FAIL ${name.padEnd(40)} (${elapsed}ms) - ${err.message}`,
432
+ resolvePrerenderError(
433
+ registry,
434
+ err,
435
+ prerenderOnError,
436
+ name.padEnd(40),
437
+ elapsed,
438
+ "static",
451
439
  );
452
- notifyOnError(registry, err, "static");
453
- throw err;
440
+ staticSkip++;
441
+ handled = true;
442
+ break;
454
443
  }
455
444
  }
456
445
  if (!handled) {
@@ -25,6 +25,12 @@ export interface PluginOptions {
25
25
  * Compiled into `DiscoveryState.scanFilter` once `projectRoot` is known.
26
26
  */
27
27
  discovery?: { include?: string[]; exclude?: string[] };
28
+ /**
29
+ * What to do when a Prerender/Static render throws at build time, from
30
+ * rango({ prerender: { onError } }). "fail" (default) fails the build; "warn"
31
+ * logs and skips baking the URL. See {@link import("../plugin-types.js").RangoOptions}.
32
+ */
33
+ prerenderOnError?: "fail" | "warn";
28
34
  /**
29
35
  * Shared context the built-in clientChunks strategy reads. Discovery populates
30
36
  * it (registered fallback hashes + single-router name) before the client build
@@ -139,6 +139,31 @@ interface RangoBaseOptions {
139
139
  include?: string[];
140
140
  exclude?: string[];
141
141
  };
142
+
143
+ /**
144
+ * What to do when a `Prerender` route's or `Static` handler's render throws at
145
+ * build time. Otherwise the route error boundary catches it and the rendered
146
+ * error page is baked as the artifact, then served as an HTTP 200 — a silent,
147
+ * user-visible breakage (issue #587). Independent of this setting, a render may
148
+ * `throw new Skip()` (from `@rangojs/router`) to skip a single URL/handler.
149
+ *
150
+ * - `"fail"` (**default**): fail the build, naming the URL/handler and the
151
+ * original render error.
152
+ * - `"warn"`: log a warning and skip baking the artifact — it is never served as a
153
+ * baked 200 error page. `"warn"` is a build-unblock, NOT a runtime contract for
154
+ * the skipped entry: the route falls through to normal resolution, which may
155
+ * render live (its handler is still bundled — e.g. when nothing else baked) or
156
+ * 404 (once prerender handler eviction has run for other baked entries), so the
157
+ * outcome depends on the rest of the build, and a skipped `Static()` handler's
158
+ * evicted code can surface as an error. For DEFINED runtime behavior use
159
+ * `Passthrough()` (a live fallback) or `throw new Skip()` (an intentional skip);
160
+ * otherwise prefer the default `"fail"`.
161
+ *
162
+ * @default "fail"
163
+ */
164
+ prerender?: {
165
+ onError?: "fail" | "warn";
166
+ };
142
167
  }
143
168
 
144
169
  /**
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { normalizePath, findMatchingParen } from "../expose-id-utils.js";
6
6
  import { getImportedFnNames } from "./export-analysis.js";
7
+ import { codeMatchIndices } from "../../../build/route-types/source-scan.js";
7
8
  import { createRangoDebugger, createCounter, NS } from "../../debug.js";
8
9
 
9
10
  const debug = createRangoDebugger(NS.transform);
@@ -28,7 +29,16 @@ export function transformRouter(
28
29
  const routeNamesImport = `./${basename}.named-routes.gen.js`;
29
30
  const routeNamesVar = `__rsc_rn`;
30
31
 
32
+ // Only inject at call sites in REAL code, not inside comments or string
33
+ // literals — e.g. a `createRouter({ ... })` snippet in a JSDoc example must
34
+ // not get a `$$id`/`.named-routes.gen` import injected. The sibling analysis
35
+ // path (export-analysis.ts) already uses this same comment/string-aware scan;
36
+ // the raw `pat.exec(code)` loop below would otherwise match in comments too.
37
+ const codeOffsets = new Set(codeMatchIndices(code, pat));
38
+ pat.lastIndex = 0;
39
+
31
40
  while ((match = pat.exec(code)) !== null) {
41
+ if (!codeOffsets.has(match.index)) continue;
32
42
  const callStart = match.index;
33
43
  const parenPos = match.index + match[0].length - 1;
34
44
 
package/src/vite/rango.ts CHANGED
@@ -428,6 +428,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
428
428
  enableBuildPrerender: prerenderEnabled,
429
429
  buildEnv: options?.buildEnv,
430
430
  preset,
431
+ prerenderOnError: options?.prerender?.onError,
431
432
  discovery: options?.discovery,
432
433
  clientChunkCtx,
433
434
  }),
@@ -912,9 +912,15 @@ export function createRouterDiscoveryPlugin(
912
912
  logResult(200, `match ${result.routeName}`);
913
913
  return;
914
914
  } catch (err: any) {
915
- console.warn(
916
- `[rango] Dev prerender failed for ${pathname}: ${err.message}`,
917
- );
915
+ // matchForPrerender now re-throws render failures instead of baking
916
+ // an error page (issue #587). In dev there is no frozen artifact, so
917
+ // we fall through (404 -> live handler). A `throw new Skip()` is the
918
+ // expected "skip this URL" signal, not a failure, so it stays quiet.
919
+ if (err?.name !== "Skip") {
920
+ console.warn(
921
+ `[rango] Dev prerender error for ${pathname} (serving live instead): ${err.message}`,
922
+ );
923
+ }
918
924
  }
919
925
  }
920
926
 
@@ -137,6 +137,42 @@ export function notifyOnError(
137
137
  }
138
138
  }
139
139
 
140
+ /**
141
+ * Resolve a thrown build-time render error into the prerender build's policy and
142
+ * log a per-entry line. A `Skip` (or any render error under `prerender.onError:
143
+ * "warn"`) logs and returns so the caller skips the entry; a render error under
144
+ * the default "fail" logs FAIL, notifies `onError`, and re-throws to fail the
145
+ * build. Shared by `expandPrerenderRoutes` (prerender) and `renderStaticHandlers`
146
+ * (static) so the Skip/warn/fail policy lives in one place. `label` is the padded
147
+ * URL / handler name for the log line; `elapsed` is the per-entry duration string.
148
+ */
149
+ export function resolvePrerenderError(
150
+ registry: Map<string, any>,
151
+ error: any,
152
+ onError: "fail" | "warn",
153
+ label: string,
154
+ elapsed: string,
155
+ phase: "prerender" | "static",
156
+ routeKey?: string,
157
+ pathname?: string,
158
+ ): void {
159
+ const isSkip = error?.name === "Skip";
160
+ if (isSkip || onError === "warn") {
161
+ if (isSkip) {
162
+ console.log(`[rango] SKIP ${label} (${elapsed}ms) - ${error.message}`);
163
+ } else {
164
+ console.warn(
165
+ `[rango] WARN ${label} (${elapsed}ms) - render error, not pre-rendered (prerender.onError: "warn"): ${error.message}`,
166
+ );
167
+ }
168
+ notifyOnError(registry, error, phase, routeKey, pathname, true);
169
+ return;
170
+ }
171
+ console.error(`[rango] FAIL ${label} (${elapsed}ms) - ${error.message}`);
172
+ notifyOnError(registry, error, phase, routeKey, pathname);
173
+ throw error;
174
+ }
175
+
140
176
  function getStagedAssetDir(projectRoot: string): string {
141
177
  return resolve(projectRoot, "node_modules/.rangojs-router-build/rsc-assets");
142
178
  }