@rangojs/router 0.0.0-experimental.150 → 0.0.0-experimental.151

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.
@@ -131,6 +131,30 @@ function ensureCloudflareProtocolLoaderRegistered(): void {
131
131
  // Temp Server Factory
132
132
  // ============================================================================
133
133
 
134
+ /**
135
+ * Outcome of getOrCreateTempServer. `env` is the temp RSC environment on
136
+ * success, else null with `error` carrying WHY the create/import failed — or
137
+ * null when createServer resolved cleanly but attached no runner (terminal, but
138
+ * not an exception). The dev /__rsc_shell endpoint runs `error` through the
139
+ * SAME reoptimization classifier the import sites use: only a transient Vite
140
+ * re-optimization is signalled NOT-READY (client re-polls); every terminal
141
+ * failure fails fast so the read-through MISSes immediately instead of
142
+ * re-polling a permanently-broken realm for the full readiness deadline
143
+ * (issue #719 P2).
144
+ */
145
+ type TempServerResult = { env: any; error: unknown };
146
+
147
+ /**
148
+ * Test-only one-shot boot-race injection (issue #719 P3). When
149
+ * RANGO_E2E_INJECT_SHELL_NOTREADY=1, the dev /__rsc_shell endpoint emits a
150
+ * single reoptimization-class NOT-READY per pathname before serving, so an e2e
151
+ * can drive the REAL endpoint + real client re-poll deterministically — a
152
+ * natural cold race settles too fast on quick machines to guard the regression.
153
+ * Keyed per pathname so the read-through's re-poll finds it already fired and
154
+ * gets the HIT. Never engaged without the env flag.
155
+ */
156
+ const injectedShellNotReadyPaths = new Set<string>();
157
+
134
158
  /**
135
159
  * Create a minimal Vite server for router discovery.
136
160
  *
@@ -533,7 +557,7 @@ export function createRouterDiscoveryPlugin(
533
557
  }
534
558
  }
535
559
 
536
- async function getOrCreateTempServer(): Promise<any | null> {
560
+ async function getOrCreateTempServer(): Promise<TempServerResult> {
537
561
  // Reuse path: if a temp server is already alive, prefer reusing
538
562
  // it over orphaning the existing instance and spinning up a new
539
563
  // one. This handles two cases:
@@ -554,7 +578,7 @@ export function createRouterDiscoveryPlugin(
554
578
  debugDiscovery?.(
555
579
  "getOrCreateTempServer: cached temp runner reused",
556
580
  );
557
- return existingEnv;
581
+ return { env: existingEnv, error: null };
558
582
  }
559
583
  // Server alive but registry missing — likely after a prior
560
584
  // refresh's invalidate + import threw. Try to re-import.
@@ -563,7 +587,7 @@ export function createRouterDiscoveryPlugin(
563
587
  );
564
588
  try {
565
589
  await importEntryAndRegistry(existingEnv);
566
- return existingEnv;
590
+ return { env: existingEnv, error: null };
567
591
  } catch (err: any) {
568
592
  debugDiscovery?.(
569
593
  "getOrCreateTempServer: reuse import failed (%s) — closing orphan and creating fresh",
@@ -591,6 +615,11 @@ export function createRouterDiscoveryPlugin(
591
615
  "getOrCreateTempServer: creating new temp server, entry=%s",
592
616
  s.resolvedEntryPath ?? "(unset)",
593
617
  );
618
+ // Surface the create/import cause to the caller (issue #719 P2): a
619
+ // transient re-optimization is re-pollable, a terminal fault is not. A
620
+ // clean createServer that yields no runner leaves this null — terminal,
621
+ // but not an exception.
622
+ let createError: unknown = null;
594
623
  try {
595
624
  prerenderTempServer = await createTempRscServer(s, {
596
625
  cacheDir: "node_modules/.vite_prerender",
@@ -603,12 +632,13 @@ export function createRouterDiscoveryPlugin(
603
632
  const tempRscEnv = (prerenderTempServer.environments as any)?.rsc;
604
633
  if (tempRscEnv?.runner) {
605
634
  await importEntryAndRegistry(tempRscEnv);
606
- return tempRscEnv;
635
+ return { env: tempRscEnv, error: null };
607
636
  }
608
637
  debugDiscovery?.(
609
638
  "getOrCreateTempServer: tempRscEnv.runner unavailable",
610
639
  );
611
640
  } catch (err: any) {
641
+ createError = err;
612
642
  debugDiscovery?.(
613
643
  "getOrCreateTempServer: FAILED message=%s",
614
644
  err.message,
@@ -623,7 +653,7 @@ export function createRouterDiscoveryPlugin(
623
653
  await prerenderTempServer?.close().catch(() => {});
624
654
  prerenderTempServer = null;
625
655
  prerenderNodeRegistry = null;
626
- return null;
656
+ return { env: null, error: createError };
627
657
  }
628
658
 
629
659
  // Clear the package-level singleton registries that survive a Vite
@@ -676,7 +706,7 @@ export function createRouterDiscoveryPlugin(
676
706
  // versions / preset configurations may differ in which graph carries
677
707
  // the module-runner cache).
678
708
  async function refreshTempRscEnv(): Promise<any | null> {
679
- let tempRscEnv = await getOrCreateTempServer();
709
+ const tempRscEnv = (await getOrCreateTempServer()).env;
680
710
  if (!tempRscEnv) return null;
681
711
 
682
712
  // Module-runner cache is on the per-environment graph in Vite 6+;
@@ -702,7 +732,7 @@ export function createRouterDiscoveryPlugin(
702
732
  prerenderTempServer = null;
703
733
  prerenderNodeRegistry = null;
704
734
  }
705
- return await getOrCreateTempServer();
735
+ return (await getOrCreateTempServer()).env;
706
736
  }
707
737
 
708
738
  debugDiscovery?.(
@@ -775,11 +805,11 @@ export function createRouterDiscoveryPlugin(
775
805
  acquireBuildEnv(s, viteCommand, viteMode),
776
806
  );
777
807
 
778
- tempRscEnv = await timed(
779
- debugDiscovery,
780
- "getOrCreateTempServer",
781
- () => getOrCreateTempServer(),
782
- );
808
+ tempRscEnv = (
809
+ await timed(debugDiscovery, "getOrCreateTempServer", () =>
810
+ getOrCreateTempServer(),
811
+ )
812
+ ).env;
783
813
  if (tempRscEnv) {
784
814
  optimizerHashBefore =
785
815
  tempRscEnv.depsOptimizer?.metadata?.browserHash;
@@ -1006,7 +1036,7 @@ export function createRouterDiscoveryPlugin(
1006
1036
  // instances. Before #654 the cached registry was only refreshed on
1007
1037
  // route-file edits, so handler-only edits served stale prerender
1008
1038
  // content on this path. Warm-cache re-imports are module-cache hits.
1009
- const tempRscEnv = await getOrCreateTempServer();
1039
+ const tempRscEnv = (await getOrCreateTempServer()).env;
1010
1040
  if (tempRscEnv) {
1011
1041
  try {
1012
1042
  await importEntryAndRegistry(tempRscEnv);
@@ -1143,6 +1173,63 @@ export function createRouterDiscoveryPlugin(
1143
1173
  res.end("Missing pathname/routeName/version/ttl");
1144
1174
  return;
1145
1175
  }
1176
+
1177
+ // Boot-race readiness signal (issue #719): the capture realm is stood
1178
+ // up lazily on the first hit (temp server, registry import, and a Vite
1179
+ // dep re-optimization the first shell-capture import can trigger). All
1180
+ // are TRANSIENT — a retry seconds later succeeds. Tagging them 503 +
1181
+ // x-rango-shell-dev: NOT-READY lets the read-through re-poll ONLY these
1182
+ // (a bounded await of readiness) instead of mapping the boot window to
1183
+ // a hard first-request MISS. Genuine negatives (404) stay untagged so a
1184
+ // non-baked route never stalls the foreground.
1185
+ const sendNotReady = (detail: string): void => {
1186
+ res.statusCode = 503;
1187
+ res.setHeader("x-rango-shell-dev", "NOT-READY");
1188
+ res.end(detail);
1189
+ };
1190
+ // A Vite dependency re-optimization surfaces as ERR_OUTDATED_OPTIMIZED_DEP
1191
+ // (import throws once, then re-imports clean) — retryable, unlike a real
1192
+ // module fault (syntax error, missing export), which stays a hard 500.
1193
+ // The message-regex fallback is NOT gratuitous: err.code is stripped when
1194
+ // the error serializes across the module-runner/workerd RPC boundary (the
1195
+ // message survives), so under the Cloudflare preset .code alone misses it.
1196
+ const isReoptimizing = (err: any): boolean =>
1197
+ err?.code === "ERR_OUTDATED_OPTIMIZED_DEP" ||
1198
+ /Outdated Optimize Dep|optimized dependency|new dependencies optimized/i.test(
1199
+ String(err?.message ?? ""),
1200
+ );
1201
+ // Fold the reoptimize-guard pasted at all three import/capture catch
1202
+ // sites: emit NOT-READY + report handled for a transient re-optimization,
1203
+ // else leave the caller to send its own terminal (500 for an entry
1204
+ // import, 404 for a mid-capture failure that keeps runtime capture).
1205
+ const handledAsReoptimizing = (err: any): boolean => {
1206
+ if (!isReoptimizing(err)) return false;
1207
+ sendNotReady(`Shell capture re-optimizing: ${err.message}`);
1208
+ return true;
1209
+ };
1210
+ // Both entry-import sites (main-server rsc env, temp Node server) fail
1211
+ // identically: reoptimize → NOT-READY, else → hard 500.
1212
+ const handleShellImportError = (err: any): void => {
1213
+ if (handledAsReoptimizing(err)) return;
1214
+ res.statusCode = 500;
1215
+ res.end(`Shell capture module refresh failed: ${err.message}`);
1216
+ };
1217
+ // Deterministic boot-race injection for e2e (issue #719 P3): fire ONE
1218
+ // reopt-class NOT-READY per pathname through the REAL classifier so the
1219
+ // read-through's re-poll path is exercised end-to-end (not just the
1220
+ // unit-mocked fetch), then serve normally on the re-poll. Env-gated —
1221
+ // inert in every non-test run.
1222
+ if (
1223
+ process.env.RANGO_E2E_INJECT_SHELL_NOTREADY === "1" &&
1224
+ !injectedShellNotReadyPaths.has(pathname)
1225
+ ) {
1226
+ injectedShellNotReadyPaths.add(pathname);
1227
+ const injected = Object.assign(
1228
+ new Error("Outdated Optimize Dep (injected boot-race)"),
1229
+ { code: "ERR_OUTDATED_OPTIMIZED_DEP" },
1230
+ );
1231
+ if (handledAsReoptimizing(injected)) return;
1232
+ }
1146
1233
  const ttl = Number(ttlRaw);
1147
1234
  const swrRaw = url.searchParams.get("swr");
1148
1235
  const swr = swrRaw === null ? undefined : Number(swrRaw);
@@ -1169,12 +1256,16 @@ export function createRouterDiscoveryPlugin(
1169
1256
  let rscRealm: any = null;
1170
1257
  let ssrRealm: any = null;
1171
1258
  let ssrEntryId: string;
1259
+ // Why the temp-server path returned no runner, if it did: a transient
1260
+ // re-optimization is re-pollable (NOT-READY), a terminal create/import
1261
+ // fault is not. Carried from getOrCreateTempServer to the readiness
1262
+ // check below so only reopt re-polls (issue #719 P2).
1263
+ let tempServerError: unknown = null;
1172
1264
  if (rscEnvMain?.runner && s.resolvedEntryPath) {
1173
1265
  try {
1174
1266
  await rscEnvMain.runner.import(s.resolvedEntryPath);
1175
1267
  } catch (err: any) {
1176
- res.statusCode = 500;
1177
- res.end(`Shell capture module refresh failed: ${err.message}`);
1268
+ handleShellImportError(err);
1178
1269
  return;
1179
1270
  }
1180
1271
  rscRealm = rscEnvMain;
@@ -1183,35 +1274,49 @@ export function createRouterDiscoveryPlugin(
1183
1274
  (server.environments as any)?.ssr?.config?.build?.rollupOptions
1184
1275
  ?.input?.index ?? VIRTUAL_IDS.ssr;
1185
1276
  } else {
1186
- const tempRscEnv = await getOrCreateTempServer();
1187
- if (tempRscEnv) {
1277
+ const tempResult = await getOrCreateTempServer();
1278
+ if (tempResult.env) {
1188
1279
  try {
1189
- await importEntryAndRegistry(tempRscEnv);
1280
+ await importEntryAndRegistry(tempResult.env);
1190
1281
  } catch (err: any) {
1191
- res.statusCode = 500;
1192
- res.end(`Shell capture module refresh failed: ${err.message}`);
1282
+ handleShellImportError(err);
1193
1283
  return;
1194
1284
  }
1285
+ } else {
1286
+ tempServerError = tempResult.error;
1195
1287
  }
1196
- rscRealm = tempRscEnv;
1288
+ rscRealm = tempResult.env;
1197
1289
  ssrRealm = (prerenderTempServer?.environments as any)?.ssr;
1198
1290
  ssrEntryId = "virtual:entry-ssr";
1199
1291
  }
1200
1292
  if (!rscRealm?.runner || !ssrRealm?.runner) {
1293
+ // Reoptimization is the ONLY transient class: re-poll it (NOT-READY).
1294
+ // A terminal temp-server create/import fault, or an SSR runner absent
1295
+ // after a clean createServer, fails fast with a plain 503 (no
1296
+ // NOT-READY header) so the read-through MISSes on its first attempt
1297
+ // instead of re-polling a permanently-broken realm for the full
1298
+ // readiness deadline (issue #719 P2).
1299
+ if (tempServerError && handledAsReoptimizing(tempServerError)) return;
1201
1300
  res.statusCode = 503;
1202
1301
  res.end("Shell capture runners not available");
1203
1302
  return;
1204
1303
  }
1205
1304
  let registry: Map<string, any> | null = null;
1305
+ let registryError: unknown = null;
1206
1306
  try {
1207
1307
  const serverMod = await rscRealm.runner.import(
1208
1308
  "@rangojs/router/server",
1209
1309
  );
1210
1310
  registry = serverMod.RouterRegistry ?? null;
1211
- } catch {
1311
+ } catch (err: any) {
1312
+ registryError = err;
1212
1313
  registry = null;
1213
1314
  }
1214
1315
  if (!registry || registry.size === 0) {
1316
+ // Same rule as the runner check: a re-optimization mid-import is
1317
+ // re-pollable (NOT-READY); a terminal import fault or a genuinely
1318
+ // empty registry (no routers registered) fails fast (issue #719 P2).
1319
+ if (registryError && handledAsReoptimizing(registryError)) return;
1215
1320
  res.statusCode = 503;
1216
1321
  res.end("Shell capture registry not available");
1217
1322
  return;
@@ -1300,6 +1405,10 @@ export function createRouterDiscoveryPlugin(
1300
1405
  res.end(body);
1301
1406
  return;
1302
1407
  } catch (err: any) {
1408
+ // A dep re-optimization mid-capture is a boot-race, not a capture
1409
+ // failure: signal NOT-READY so the read-through re-polls instead of
1410
+ // conceding a first-request MISS (issue #719).
1411
+ if (handledAsReoptimizing(err)) return;
1303
1412
  console.warn(
1304
1413
  `[rango] Dev shell capture error for ${pathname} (route keeps runtime capture): ${err.message}`,
1305
1414
  );