@rivetjs/router 0.1.0-alpha.0 → 0.1.0-alpha.1

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 (49) hide show
  1. package/adapters/files.d.ts +13 -0
  2. package/adapters/files.js +58 -6
  3. package/adapters/node.d.ts +22 -5
  4. package/adapters/node.js +113 -67
  5. package/adapters/static.js +1 -1
  6. package/adapters/target-build.d.ts +5 -2
  7. package/adapters/vercel.d.ts +4 -2
  8. package/adapters/vercel.js +1 -1
  9. package/build/codegen.d.ts +13 -0
  10. package/build/codegen.js +152 -35
  11. package/build/dev-errors.d.ts +6 -2
  12. package/build/dev-errors.js +15 -4
  13. package/build/lazy-rows.d.ts +19 -0
  14. package/build/lazy-rows.js +59 -0
  15. package/build/migrate.d.ts +20 -4
  16. package/build/migrate.js +300 -61
  17. package/build/reach.d.ts +60 -0
  18. package/build/reach.js +302 -0
  19. package/build/routes.d.ts +9 -4
  20. package/build/routes.js +30 -13
  21. package/build/vite.d.ts +13 -0
  22. package/build/vite.js +165 -7
  23. package/cli/rivet.js +3 -2
  24. package/client/dev.js +17 -0
  25. package/client/index.d.ts +4 -0
  26. package/client/index.js +34 -0
  27. package/client/route-context.d.ts +1 -1
  28. package/index.d.ts +22 -25
  29. package/index.js +2 -1
  30. package/package.json +4 -4
  31. package/server/app.d.ts +7 -1
  32. package/server/app.js +38 -5
  33. package/server/deferred.d.ts +12 -0
  34. package/server/deferred.js +60 -0
  35. package/server/handler.d.ts +56 -12
  36. package/server/handler.js +46 -8
  37. package/server/index.d.ts +1 -1
  38. package/server/module.d.ts +7 -7
  39. package/server/module.js +52 -34
  40. package/server/render.d.ts +2 -0
  41. package/server/render.js +3 -0
  42. package/server/route-cache.d.ts +74 -4
  43. package/server/route-cache.js +77 -6
  44. package/server/shell-build.d.ts +3 -0
  45. package/server/shell-build.js +2 -0
  46. package/server/shell-cache.d.ts +8 -0
  47. package/server/shell-cache.js +836 -0
  48. package/shared/server-exports.d.ts +78 -0
  49. package/shared/server-exports.js +77 -0
package/build/vite.js CHANGED
@@ -11,7 +11,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
11
11
  import { lstat, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
12
12
  import { dirname, join, relative, resolve, sep } from "node:path";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
- import { rivet, _factsResolve, } from "@rivetjs/compiler/vite";
14
+ import { rivet, _factsResolve, declaredServerOnly, isServerOnly, serverOnlyStub, } from "@rivetjs/compiler/vite";
15
15
  import { updateEditorManifest } from "@rivetjs/compiler/editor";
16
16
  import { scanRoutes, _serverExportsOf } from "./routes.js";
17
17
  import { generate } from "./codegen.js";
@@ -23,14 +23,29 @@ import { compileRules } from "../server/route-cache.js";
23
23
  export { mergeRouteCSS } from "./route-css.js";
24
24
  import { bundleRouteCSS, devRouteCSS, INLINE_CSS, inlineCSS, mergeRouteCSS, ROUTE_CSS } from "./route-css.js";
25
25
  import { devHandler, devReport, listenClientErrors } from "./dev-errors.js";
26
+ import { lazyRowsBuild, lazyRowsHarness } from "./lazy-rows.js";
27
+ import { analyzeReach, serverOnlyOption, RV_REACH } from "./reach.js";
28
+ import { compileRoutes } from "@rivetjs/runtime/router/server";
26
29
  import { adapterOptions, targetBuildConfig, targetEntryPlugin } from "../adapters/target-build.js";
27
30
  /** The router's own base-path module (its `BASE` reads Vite's `BASE_URL`). */
28
31
  const BASE_MODULE = fileURLToPath(new URL("../shared/base.js", import.meta.url));
29
32
  /** The router's trailing-slash module (its `TRAILING_SLASH` constant is rewritten when the option is set). */
30
33
  const SLASH_MODULE = fileURLToPath(new URL("../shared/slash.js", import.meta.url));
31
34
  const SLASH_DEFAULT = '"ignore" as TrailingSlash';
35
+ /** The shell cache loader (`SHELL_BUILD`, rewritten to an `import()` when a route rule has `shell`). */
36
+ const SHELL_MODULE = fileURLToPath(new URL("../server/shell-build.js", import.meta.url));
37
+ /** `SHELL_BUILD`'s declaration in the source (`.ts`, with its type cast) and in the published `.js` (cast stripped). */
38
+ const SHELL_DEFAULT = /export const SHELL_BUILD = undefined(?: as [^;]+)?;/;
39
+ /** The shell cache module the rewritten loader imports (by file path: also what keeps it in the published package). */
40
+ const SHELL_CACHE = fileURLToPath(new URL("../server/shell-cache.js", import.meta.url));
32
41
  const posix = (path) => path.split(sep).join("/");
33
42
  const EMPTY = "\0rivet-router-empty";
43
+ /** Client build: a document route's page module as `.rivet/app.tsx` imports it (no browser code for the page). */
44
+ const DOCUMENT_PAGE = "\0rivet-document-page:";
45
+ /** Browser stand-in of a server-only module imported only from document routes' graphs (`serverOnlyStub`). */
46
+ const STAND_IN = "\0rivet-server-only:";
47
+ /** The file behind a module id (virtual compiler parts `\0rivet-resume-part:/abs/file.tsx?…` included), no query. */
48
+ const fileOf = (id) => (id.startsWith("\0") ? id.slice(id.indexOf(":") + 1) : id).split("?")[0];
34
49
  /** Write generated files whose contents changed; returns whether anything changed. */
35
50
  async function sync(dir, files) {
36
51
  await mkdir(dir, { recursive: true });
@@ -143,6 +158,8 @@ export async function generateRoutes(root, options = {}) {
143
158
  ...(options.preload?.background !== undefined ? { preloadBackground: options.preload.background } : {}),
144
159
  ...(options.routeRules && Object.keys(options.routeRules).length ? { routeRules: options.routeRules } : {}),
145
160
  ...(options.clientRouter !== undefined ? { clientRouter: options.clientRouter } : {}),
161
+ ...(options.documents?.length ? { documents: options.documents } : {}),
162
+ ...(options.derived ? { derived: true } : {}),
146
163
  });
147
164
  const changed = await sync(resolve(root, outDir), generated.files);
148
165
  // No router contexts: the compiler's default resume entry (drop a stale generated one).
@@ -225,6 +242,8 @@ export function rivetRouter(options = {}) {
225
242
  ...(options.preload ? { preload: options.preload } : {}),
226
243
  ...(options.routeRules ? { routeRules: options.routeRules } : {}),
227
244
  ...(options.clientRouter !== undefined ? { clientRouter: options.clientRouter } : {}),
245
+ // Derived resume model (M2 R5): the generated `Root` boundary carries the route payload as data.
246
+ ...(options.resume?.model === "derived" && mode !== "csr" ? { derived: true } : {}),
228
247
  };
229
248
  if (options.routeRules !== undefined) {
230
249
  // Checked at config time (same checks as at server start, except the origin `isr` needs: a server option), and
@@ -257,7 +276,9 @@ export function rivetRouter(options = {}) {
257
276
  Object.keys(inlineOption).every((key) => key === "maxTotalBrotli") &&
258
277
  (inlineOption.maxTotalBrotli === undefined || (Number.isSafeInteger(inlineOption.maxTotalBrotli) && inlineOption.maxTotalBrotli >= 0))))
259
278
  throw new Error(`rivetRouter: inlineStylesheets must be "auto", "always", "never" or { maxTotalBrotli: <bytes> } (got ${JSON.stringify(inlineOption)})`);
279
+ const serverOnly = serverOnlyOption(options.serverOnly);
260
280
  const slash = options.trailingSlash ?? "ignore";
281
+ const shellBuild = Object.values(options.routeRules ?? {}).some((rule) => Boolean(rule?.shell));
261
282
  if (slash !== "ignore" && slash !== "never" && slash !== "always")
262
283
  throw new Error(`rivetRouter: trailingSlash must be "ignore", "never" or "always" (got ${JSON.stringify(slash)})`);
263
284
  // Filled in `config`, which runs before the compiler plugin reads it in `configResolved`.
@@ -271,6 +292,11 @@ export function rivetRouter(options = {}) {
271
292
  dev: options.dev?.resume !== false,
272
293
  // Every app component module a route imports resumes without being listed in `resume.modules`.
273
294
  appModules: options.resume?.appModules ?? true,
295
+ // Lazy rows measurement builds (`RIVET_LAZY_ROWS=on` + the harness flag of the lazy-rows tools): the option on.
296
+ ...(lazyRowsBuild() ? { lazyRows: true } : {}),
297
+ // Derived model (M2 R5): `.rivet/app.tsx`'s default export (`Root`) is an independent root boundary (its props =
298
+ // the route payload as data), like a route module's default export.
299
+ ...(options.resume?.model === "derived" && mode !== "csr" ? { roots: [...(options.resume.roots ?? []), posix(join(outDir, "app.tsx"))] } : {}),
274
300
  };
275
301
  // The generated resume entry (when there is one) provides the router's contexts (navigation state,
276
302
  // search params) to resumed regions.
@@ -286,6 +312,33 @@ export function rivetRouter(options = {}) {
286
312
  };
287
313
  let root = process.cwd();
288
314
  let table = { routes: [] };
315
+ // Server reach (`build/reach.ts`): computed in `configResolved` (before any module compiles), re-run in dev after edits.
316
+ let reach = null, reachDirty = false, reachRun = Promise.resolve(), resolvedConfig = null, resolver = null, patterns = [...serverOnly.modules], building = false;
317
+ // Document routes' page files (absolute → routes-relative), client builds.
318
+ let documentPages = new Map();
319
+ /** Walk the routes' browser graphs; true when the document routes changed (codegen input). */
320
+ const analyze = async () => {
321
+ if (!resolvedConfig || mode === "csr")
322
+ return false;
323
+ resolver ??= resolvedConfig.createResolver();
324
+ const resolveFor = resolver;
325
+ reach = await analyzeReach({
326
+ root,
327
+ routesDir: resolve(root, routesDir),
328
+ table,
329
+ resolve: (spec, importer) => resolveFor(spec, importer, false, false),
330
+ serverOnly: patterns,
331
+ reach: serverOnly.reach,
332
+ routeModules: new Set(routeModules(table, routesDir).map((file) => resolve(root, file))),
333
+ config: resolvedConfig,
334
+ });
335
+ documentPages = new Map([...reach.documents.keys()].map((page) => [resolve(root, routesDir, page), page]));
336
+ const documents = [...reach.documents.keys()].sort();
337
+ if (documents.join("\0") === (codegen.documents ?? []).join("\0"))
338
+ return false;
339
+ codegen.documents = documents;
340
+ return true;
341
+ };
289
342
  const devtools = options.devtools ?? true;
290
343
  const css = options.css ? { css: options.css } : {};
291
344
  // Filled in `config` and on route changes: in-file server exports leave the browser build.
@@ -432,8 +485,32 @@ export function rivetRouter(options = {}) {
432
485
  },
433
486
  };
434
487
  },
488
+ // Server reach: every route's browser graph, before the first module compiles (codegen marks document routes).
489
+ async configResolved(config) {
490
+ resolvedConfig = config;
491
+ resolver = null;
492
+ building = config.command === "build";
493
+ patterns = [...serverOnly.modules, ...declaredServerOnly(config.plugins)];
494
+ if (mode === "csr")
495
+ return;
496
+ if (await analyze())
497
+ await generateRoutes(root, codegen);
498
+ },
499
+ buildStart() {
500
+ if (!building || !reach)
501
+ return;
502
+ const client = this.environment?.config?.consumer !== "server";
503
+ // Warnings once (client build); errors fail either build.
504
+ if (client)
505
+ for (const problem of reach.problems)
506
+ if (problem.severity === "warning")
507
+ this.warn(problem.message);
508
+ const errors = reach.problems.filter((problem) => problem.severity === "error");
509
+ if (errors.length)
510
+ this.error({ message: errors.map((problem) => problem.message).join("\n\n"), plugin: "vite-plugin-rivet-router", pluginCode: RV_REACH });
511
+ },
435
512
  resolveId(id, importer, resolveOptions) {
436
- if (id === EMPTY)
513
+ if (id === EMPTY || id.startsWith(DOCUMENT_PAGE) || id.startsWith(STAND_IN))
437
514
  return id;
438
515
  // `x.server.ts` modules hold secrets and server code: never part of a browser graph.
439
516
  if (!importer || resolveOptions?.ssr || this.environment?.config?.consumer === "server")
@@ -441,21 +518,51 @@ export function rivetRouter(options = {}) {
441
518
  // The compiler's resume grouping resolves every specifier its facts list (type-only imports too): not an import.
442
519
  if (_factsResolve(id, importer))
443
520
  return null;
444
- if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(id.split("?")[0]) || id === "@rivetjs/router/server-only")
521
+ const from = fileOf(importer);
522
+ // Client build: the App's import of a document route's page — the browser never renders it (links load its document).
523
+ if (building && documentPages.size && /^\.\.?\//.test(id) && from === resolve(root, outDir, "app.tsx")) {
524
+ const page = documentPages.get(resolve(dirname(from), id.split("?")[0]));
525
+ if (page)
526
+ return DOCUMENT_PAGE + page;
527
+ }
528
+ if (isServerOnly(patterns, id)) {
529
+ // Reached only from document routes' graphs (their resumed parts): a stand-in that throws on use, never the module.
530
+ if (reach?.documentOnly.has(from))
531
+ return STAND_IN + (reach.targets.get(from + "\0" + id) ?? id);
445
532
  this.error({
446
533
  message: `[rivet:router] ${id} is server-only and cannot be imported by browser code (imported from ${posix(importer)})` +
447
- "\n fix: read it in a server export (`serverLoad`, `serverActions`, `onGet`, …) and pass the data to the page; type-only imports (`import type`) are fine" +
448
- "\n docs: https://rivetjs.dev/guides/security/server-only-code",
534
+ "\n fix: read it in a server export (`load(…)`, `actions(…)`, `onGet(…)`, …) and pass the data to the page; type-only imports (`import type`) are fine" +
535
+ "\n docs: https://rivetjs.severyn.pro/guides/security/server-only-code",
449
536
  id: importer,
450
537
  plugin: "vite-plugin-rivet-router",
451
538
  });
539
+ }
540
+ return null;
541
+ },
542
+ load(id) {
543
+ if (id === EMPTY)
544
+ return "export {};\n";
545
+ if (id.startsWith(DOCUMENT_PAGE)) {
546
+ const page = id.slice(DOCUMENT_PAGE.length);
547
+ const message = `[rivet:router] ${posix(join(routesDir, page))} is a document route (its browser graph reaches a server-only module): its links load the document, the browser never renders it`;
548
+ return `export default function DocumentRoute() {\n throw new Error(${JSON.stringify(message)});\n}\n`;
549
+ }
550
+ if (id.startsWith(STAND_IN)) {
551
+ const key = id.slice(STAND_IN.length);
552
+ const shown = key.startsWith("/") ? posix(relative(root, key)) : key;
553
+ return serverOnlyStub(shown, reach?.chains.get(key) ?? [], reach?.names.get(key) ?? []);
554
+ }
452
555
  return null;
453
556
  },
454
- load: (id) => (id === EMPTY ? "export {};\n" : null),
455
557
  // i18n client build: `BASE` is the app base, not the locale's asset base (`BASE_URL`). Only this
456
558
  // build's copy of shared/base.ts changes, so other apps' bundles stay byte-identical.
457
559
  // `trailingSlash`: the policy constant (client and server bundles); unset, the module stays as is.
458
560
  transform(code, id, transformOptions) {
561
+ if (shellBuild && id.split("?")[0] === SHELL_MODULE) {
562
+ if (!SHELL_DEFAULT.test(code))
563
+ this.error("rivetRouter: server/shell-build.ts no longer declares SHELL_BUILD as expected");
564
+ return { code: code.replace(SHELL_DEFAULT, `export const SHELL_BUILD = () => import(${JSON.stringify(SHELL_CACHE)});`), map: null };
565
+ }
459
566
  if (slash !== "ignore" && id.split("?")[0] === SLASH_MODULE) {
460
567
  if (!code.includes(SLASH_DEFAULT))
461
568
  this.error("rivetRouter: shared/slash.ts no longer declares TRAILING_SLASH as expected");
@@ -552,6 +659,9 @@ export function rivetRouter(options = {}) {
552
659
  table = next;
553
660
  serverExports.modules.splice(0, serverExports.modules.length, ...routeModules(table, routesDir));
554
661
  inFile = serverFiles(table);
662
+ // A new route table (or new document routes): the browser graphs are walked again on the next page request.
663
+ if (changed)
664
+ reachDirty = true;
555
665
  if (changed && !closed)
556
666
  server.ws.send({ type: "full-reload" });
557
667
  return changed;
@@ -563,6 +673,52 @@ export function rivetRouter(options = {}) {
563
673
  const logged = (fn) => (file) => void fn(file).catch((error) => server.config.logger.error("rivet router: " + (error instanceof Error ? (error.stack ?? error.message) : String(error))));
564
674
  server.watcher.on("add", logged(regenerate));
565
675
  server.watcher.on("unlink", logged(regenerate));
676
+ // Any source edit can change what a route's browser graph reaches (re-walked lazily, changed files only).
677
+ const stale = (file) => {
678
+ if (SOURCE.test(file))
679
+ reachDirty = true;
680
+ };
681
+ server.watcher.on("add", stale);
682
+ server.watcher.on("unlink", stale);
683
+ server.watcher.on("change", stale);
684
+ // Dev server reach: a page's RV3011 reports on its load (terminal once per report, overlay on every load of an
685
+ // error); new document routes regenerate the App (full reload).
686
+ const reported = new Set();
687
+ let routeMatch;
688
+ const devReach = async (url) => {
689
+ if (mode === "csr")
690
+ return [];
691
+ if (reachDirty) {
692
+ reachDirty = false;
693
+ const run = reachRun.then(async () => {
694
+ if (!closed && (await analyze()))
695
+ await refresh();
696
+ });
697
+ reachRun = run.catch(() => { });
698
+ await run;
699
+ }
700
+ if (!reach?.problems.length)
701
+ return [];
702
+ if (routeMatch?.table !== table) {
703
+ const at = (pattern) => (codegen.base ? (pattern === "/" ? codegen.base : codegen.base + pattern) : pattern);
704
+ const match = compileRoutes(table.routes.filter((route) => route.page).map((route) => ({ path: at(route.pattern), render: () => route })));
705
+ routeMatch = { table, match: (target) => match(target)?.route.render(undefined, null) };
706
+ }
707
+ const route = routeMatch.match(url);
708
+ if (!route)
709
+ return [];
710
+ const files = new Set([route.page, route.notFound, route.error, ...route.layouts.map((layout) => layout.page)].filter(Boolean));
711
+ const found = reach.problems.filter((problem) => problem.files.some((file) => files.has(file)));
712
+ for (const problem of found)
713
+ if (!reported.has(problem.message)) {
714
+ reported.add(problem.message);
715
+ if (problem.severity === "error")
716
+ server.config.logger.error(problem.message);
717
+ else
718
+ server.config.logger.warn(problem.message);
719
+ }
720
+ return found;
721
+ };
566
722
  // A source starts using a router feature (search params, navigate(), useParams()): generate it
567
723
  // from now on (regenerate + full reload). A dev server never turns one off again.
568
724
  const features = async (file) => {
@@ -660,7 +816,7 @@ export function rivetRouter(options = {}) {
660
816
  ...(i18n ? { i18n: { locales: i18n.locales } } : {}),
661
817
  onError,
662
818
  });
663
- listener = createListener({ handler: devHandler(handler, server, () => table), compress: false }).listener;
819
+ listener = createListener({ handler: devHandler(handler, server, () => table, devReach), compress: false }).listener;
664
820
  listeners.set(entry, listener);
665
821
  }
666
822
  // Vite's base middleware strips the base from `req.url`: route patterns carry it.
@@ -691,6 +847,8 @@ export function rivetRouter(options = {}) {
691
847
  return [
692
848
  router,
693
849
  ...plugins,
850
+ // Measurement only (`RIVET_LAZY_ROWS=swap`, dev / lazy-rows tools): inert otherwise.
851
+ lazyRowsHarness(() => resolve(root, outDir, "entry.server.tsx")),
694
852
  ...(adapter ? [targetEntryPlugin(adapter, { serverEntry: () => resolve(root, outDir, "entry.server.tsx"), mode: () => mode })] : []),
695
853
  ];
696
854
  }
package/cli/rivet.js CHANGED
@@ -4,8 +4,9 @@
4
4
  // Write the app's generated `.rivet/` files (route types, generated app, editor manifest) from its
5
5
  // Vite config without starting Vite. Run before `tsc` / `rivet-check` on a fresh clone.
6
6
  // rivet migrate server-exports [--routes src/routes] [--dry-run]
7
- // Rename old `*.server.ts` export names (load, guard, actions, entries, headers, GET…) to
8
- // serverLoad, serverGuard, serverActions, serverEntries, serverHeaders, onGet… (scope-aware).
7
+ // Rewrite magic route server export names (in-file serverLoad, onGet…; sibling load, GET…) to
8
+ // wrapper calls (`export const loader = load(…)`, `export const GET = onGet(…)`), references renamed
9
+ // scope-aware, the `@rivetjs/router` import added.
9
10
  // rivet mcp
10
11
  // Start the Rivet MCP server (`@rivetjs/mcp`, stdio) installed in the app; same as `npx @rivetjs/mcp`.
11
12
  import { relative } from "node:path";
package/client/dev.js CHANGED
@@ -7,6 +7,23 @@ import { untrack } from "@rivetjs/reactivity";
7
7
  import { devtoolsHook } from "@rivetjs/reactivity/debug";
8
8
  import * as client from "./index.js";
9
9
  export * from "./index.js";
10
+ // A failed client navigation (`_recover`, index.ts) loads its document after Vite's error overlay showed the error: once
11
+ // the overlay closes, or at once when none opens within 1.5 s (overlay off, error not reported).
12
+ client._recover.defer = (load) => {
13
+ const open = () => document.querySelector("vite-error-overlay");
14
+ const until = Date.now() + 1500;
15
+ const wait = () => {
16
+ if (open()) {
17
+ const watch = new MutationObserver(() => open() || (watch.disconnect(), load()));
18
+ watch.observe(document.documentElement, { childList: true, subtree: true });
19
+ }
20
+ else if (Date.now() > until)
21
+ load();
22
+ else
23
+ setTimeout(wait, 100);
24
+ };
25
+ wait();
26
+ };
10
27
  const hook = devtoolsHook();
11
28
  let table = null;
12
29
  let shown = null;
package/client/index.d.ts CHANGED
@@ -77,6 +77,10 @@ export declare const _transitions: {
77
77
  * returns runs once the new page is in the document. Returns an unsubscribe function.
78
78
  */
79
79
  export declare function onNavigate(fn: (nav: NavigationInfo) => void | (() => void)): () => void;
80
+ /** @internal Dev (`dev.ts`): the error overlay shows a failed navigation's error before its document loads. */
81
+ export declare const _recover: {
82
+ defer?: (load: () => void) => void;
83
+ };
80
84
  /** @internal The navigation in flight (read by the transitions module when it installs). */
81
85
  export declare const _current: () => NavigationInfo | null;
82
86
  /** @internal Run the `onNavigate` callbacks' after-swap functions. */
package/client/index.js CHANGED
@@ -204,6 +204,8 @@ export function onNavigate(fn) {
204
204
  return () => void listeners.delete(fn);
205
205
  }
206
206
  function started(nav) {
207
+ if (recovering === undefined)
208
+ ((recovering = false), addEventListener("error", recover), addEventListener("unhandledrejection", recover));
207
209
  // Warmed data for other URLs is no longer wanted: abort it (the target's own stays for its load).
208
210
  dropPrefetch(key(nav.to));
209
211
  keep = 0;
@@ -217,6 +219,34 @@ function started(nav) {
217
219
  }
218
220
  _transitions.start?.(nav);
219
221
  }
222
+ /** @internal Dev (`dev.ts`): the error overlay shows a failed navigation's error before its document loads. */
223
+ export const _recover = {};
224
+ // Listening (`false`, from the first navigation) / loading the document (`true`); `undefined`: not listening yet.
225
+ let recovering;
226
+ // The URL a navigation just committed (`swapRoute`; until the next frame): its page renders after the commit.
227
+ let rendering;
228
+ /**
229
+ * A client navigation whose render or commit throws (an uncaught error after the history push: before the routes
230
+ * region committed the URL, or inside its swap) loads the URL's document instead: URL and page never diverge. `location.replace`: the
231
+ * history entry is already there. A failing document load does not re-enter: once per URL within 10 s (session storage).
232
+ */
233
+ function recover() {
234
+ const href = location.href, at = Date.now();
235
+ if (recovering || (href !== rendering && (!current || href === committed)))
236
+ return;
237
+ try {
238
+ // `<13-digit ms><href>` of the last recovery.
239
+ const last = sessionStorage["rivet:recover"];
240
+ if (last?.slice(13) === href && at - +last.slice(0, 13) < 1e4)
241
+ return;
242
+ sessionStorage["rivet:recover"] = at + href;
243
+ }
244
+ catch {
245
+ // No session storage: recover without the cross-document loop guard.
246
+ }
247
+ recovering = true;
248
+ (_recover.defer ?? ((load) => load()))(() => location.replace(href));
249
+ }
220
250
  /** @internal The navigation in flight (read by the transitions module when it installs). */
221
251
  export const _current = () => current;
222
252
  /** @internal Run the `onNavigate` callbacks' after-swap functions. */
@@ -334,6 +364,10 @@ export function swapRoute(apply, next) {
334
364
  enter((index = history.state?.i ?? index + 1));
335
365
  }
336
366
  committed = href;
367
+ // Until the next frame's first task (the routes region renders the new page after the swap, up to a frame later), an
368
+ // uncaught error is this navigation's (`recover`).
369
+ if (nav)
370
+ ((rendering = href), requestAnimationFrame(() => setTimeout(() => rendering === href && (rendering = undefined))));
337
371
  if (nav && _transitions.swap)
338
372
  return _transitions.swap(apply, next, nav);
339
373
  apply();
@@ -26,7 +26,7 @@ export declare const ServerData: Context<ServerRoutePayload | null>;
26
26
  export declare function actionResult(name?: string): ActionResult | undefined;
27
27
  /**
28
28
  * Typed: `actionResult<"items/[id]">("save")` (route id, generated `RouteActions`) or
29
- * `actionResult<typeof serverActions>()`. Pass the name as the second type argument to narrow the
29
+ * `actionResult<typeof forms>()` (`forms = actions(…)`). Pass the name as the second type argument to narrow the
30
30
  * result to that action (`actionResult<"items/[id]", "save">("save")`).
31
31
  */
32
32
  export declare function actionResult<A extends (keyof RouteActions & string) | Record<string, unknown>, N extends ActionName<A> = ActionName<A>>(name?: N): ActionResultOf<A, N> | undefined;
package/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { JSX } from "@rivetjs/runtime/jsx-runtime";
2
2
  import type { HttpError as HttpErrorType } from "./shared/errors.js";
3
3
  import type { ActionFailure } from "./shared/actions.js";
4
+ import type { Deferred, SERVER_EXPORT } from "./shared/server-exports.js";
5
+ export { load, guard, actions, entries, headers, onRequest, onGet, onPost, onPut, onPatch, onDelete, onOptions } from "./shared/server-exports.js";
6
+ export type { ServerExport, ServerKind, Deferred, LoadOptions } from "./shared/server-exports.js";
4
7
  export { HttpError, redirect, deny, notFound, error, isHttpError, type RedirectOptions } from "./shared/errors.js";
5
8
  export { safeRedirect } from "./shared/redirect.js";
6
9
  export { parseCookies } from "./shared/cookie.js";
@@ -16,7 +19,7 @@ export interface RouteParams {
16
19
  /** Route id → loader data (routes with a loader). Augmented by the generated `.rivet/routes.d.ts`. */
17
20
  export interface RouteData {
18
21
  }
19
- /** Route id → its `serverActions` object type (routes with server code). Augmented by `.rivet/routes.d.ts`. */
22
+ /** Route id → its `actions(…)` object type (routes with server code). Augmented by `.rivet/routes.d.ts`. */
20
23
  export interface RouteActions {
21
24
  }
22
25
  /** Layout id (`_layout`, `items/_layout`) → the params it receives. Augmented by `.rivet/routes.d.ts`. */
@@ -42,42 +45,36 @@ export type RoutePath = RouteRegistry extends {
42
45
  /** A page path with optional query and hash: `<Link to>`. */
43
46
  export type LinkHref = RoutePath | `${RoutePath}?${string}` | `${RoutePath}#${string}`;
44
47
  declare const unchecked: unique symbol;
45
- /** `PageProps<Unchecked<typeof serverLoad>>`: loader data without the serializability check. */
48
+ /** `PageProps<Unchecked<typeof loader>>`: loader data without the serializability check. */
46
49
  export interface Unchecked<T> {
47
50
  readonly [unchecked]: T;
48
51
  }
49
52
  /** Loader data the page receives must survive the resume encoder: no functions, symbols or class instances. */
50
53
  export interface NotSerializable<T> {
51
- "rivet: loader data must be serializable (plain objects, arrays, primitives, Date, URL, URLSearchParams, RegExp, Map, Set, typed arrays, ArrayBuffer, DataView, Error — no functions, symbols or class instances); opt out with PageProps<Unchecked<typeof serverLoad>>": T;
54
+ "rivet: loader data must be serializable (plain objects, arrays, primitives, Date, URL, URLSearchParams, RegExp, Map, Set, typed arrays, ArrayBuffer, DataView, Error — no functions, symbols or class instances); opt out with PageProps<Unchecked<typeof loader>>": T;
52
55
  }
53
56
  type Leaf = string | number | boolean | bigint | null | undefined | Date | URL | URLSearchParams | RegExp | ArrayBuffer | ArrayBufferView;
54
57
  type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7];
55
58
  type IsError<T> = T extends Error ? ("stack" extends keyof T ? true : false) : false;
56
- type Serializable<T, D extends number = 8> = [D] extends [never] ? true : T extends Leaf ? true : T extends symbol | ((...args: never[]) => unknown) ? false : IsError<T> extends true ? true : T extends ReadonlyMap<infer K, infer V> ? Serializable<K, Depth[D]> extends true ? Serializable<V, Depth[D]> : false : T extends ReadonlySet<infer V> ? Serializable<V, Depth[D]> : T extends readonly (infer E)[] ? Serializable<E, Depth[D]> : T extends object ? false extends {
59
+ type Serializable<T, D extends number = 8> = [D] extends [never] ? true : T extends Leaf ? true : T extends Deferred<infer V> ? Serializable<V, D> : T extends symbol | ((...args: never[]) => unknown) ? false : IsError<T> extends true ? true : T extends ReadonlyMap<infer K, infer V> ? Serializable<K, Depth[D]> extends true ? Serializable<V, Depth[D]> : false : T extends ReadonlySet<infer V> ? Serializable<V, Depth[D]> : T extends readonly (infer E)[] ? Serializable<E, Depth[D]> : T extends object ? false extends {
57
60
  [K in keyof T]-?: Serializable<T[K], Depth[D]>;
58
61
  }[keyof T] ? false : true : true;
59
- type Returned<L> = L extends (...args: never[]) => infer R ? Awaited<R> : L;
62
+ type Returned<L> = L extends (...args: never[]) => infer R ? (R extends Deferred<infer V> ? Deferred<V> : Awaited<R>) : L;
60
63
  /**
61
- * Data type of a loader (`DataOf<typeof serverLoad>`), checked to be serializable; plain data types
64
+ * Data type of a loader (`DataOf<typeof loader>`, `loader = load(…)`), checked to be serializable; plain data types
62
65
  * pass through unchanged (`DataOf<Item[]>` is `Item[]`).
63
66
  */
64
67
  export type DataOf<T> = T extends Unchecked<infer L> ? Returned<L> : T extends (...args: never[]) => unknown ? Serializable<Returned<T>> extends true ? Returned<T> : NotSerializable<Returned<T>> : T;
65
- /** Loader data of an in-file route module (`serverLoad`). */
66
- export type InlineData<M> = M extends {
67
- serverLoad: infer L;
68
- } ? DataOf<L> : unknown;
69
- /** Loader data of a sibling `*.server.ts` module (`serverLoad`, or the old `load`). */
70
- export type ModuleData<M> = M extends {
71
- serverLoad: infer L;
72
- } ? DataOf<L> : M extends {
73
- load: infer L;
74
- } ? DataOf<L> : unknown;
75
- /** Form actions of a route module (`serverActions`, or a sibling's old `actions`); `{}` without. */
76
- export type ModuleActions<M> = M extends {
77
- serverActions: infer A;
78
- } ? A : M extends {
79
- actions: infer A;
80
- } ? A : {};
68
+ /** The export of module type `M` tagged as server export kind `K` (by tag, any name); `never` without. */
69
+ type Tagged<M, K> = {
70
+ [N in keyof M]: M[N] extends {
71
+ readonly [SERVER_EXPORT]: K;
72
+ } ? M[N] : never;
73
+ }[keyof M];
74
+ /** Loader data of a route module (`typeof import("./page")`): its `load(…)` export, any name; `unknown` without. */
75
+ export type ModuleData<M> = [Tagged<M, "load">] extends [never] ? unknown : DataOf<Tagged<M, "load">>;
76
+ /** Form actions of a route module: its `actions(…)` export, any name; `{}` without. */
77
+ export type ModuleActions<M> = [Tagged<M, "actions">] extends [never] ? {} : Tagged<M, "actions">;
81
78
  /** A string that is not an id of `RouteParams` / `LayoutParams`: `data` and `params` are unusable. */
82
79
  export interface UnknownRoute<Id> {
83
80
  "rivet: not a route or layout id of this app (see .rivet/routes.d.ts)": Id;
@@ -89,7 +86,7 @@ type LayoutDataOf<D> = D extends LayoutId ? (D extends keyof LayoutData ? Layout
89
86
  type LayoutParamsOf<D, P> = D extends LayoutId ? LayoutParams[D] : IsLiteral<D> extends true ? UnknownRoute<D> : P;
90
87
  /**
91
88
  * Props every page component receives (layouts: `LayoutProps`). `D` is a route id (generated
92
- * types: `PageProps<"items/[id]">` types data, params), the loader (`PageProps<typeof serverLoad>`
89
+ * types: `PageProps<"items/[id]">` types data, params), the loader (`PageProps<typeof loader>`
93
90
  * infers the data) or the data type.
94
91
  */
95
92
  export interface PageProps<D = unknown, P extends Record<string, string> = Record<string, string>> {
@@ -125,11 +122,11 @@ type Outcome<R> = R extends HttpErrorType | Response ? never : R extends ActionF
125
122
  readonly status?: undefined;
126
123
  };
127
124
  type ActionsOf<A> = A extends RouteId ? (A extends keyof RouteActions ? RouteActions[A] : {}) : A;
128
- /** Action names of a route id (`RouteActions`) or an actions object type (`typeof serverActions`). */
125
+ /** Action names of a route id (`RouteActions`) or an actions object type (`typeof forms`, `forms = actions(…)`). */
129
126
  export type ActionName<A> = keyof ActionsOf<A> & string;
130
127
  /**
131
128
  * Typed `actionResult()`: per action name its return value (`redirect()`/`Response` excluded), or
132
- * the data and status of `fail()`. `A` is a route id or `typeof serverActions`.
129
+ * the data and status of `fail()`. `A` is a route id or `typeof forms` (`forms = actions(…)`).
133
130
  */
134
131
  export type ActionResultOf<A, N extends ActionName<A> = ActionName<A>> = {
135
132
  [K in N]: {
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
- // Route-module API: control-flow helpers and types for guards, loaders and actions.
1
+ // Route-module API: server export wrappers, control-flow helpers and types for guards, loaders and actions.
2
+ export { load, guard, actions, entries, headers, onRequest, onGet, onPost, onPut, onPatch, onDelete, onOptions } from "./shared/server-exports.js";
2
3
  export { HttpError, redirect, deny, notFound, error, isHttpError } from "./shared/errors.js";
3
4
  export { safeRedirect } from "./shared/redirect.js";
4
5
  export { parseCookies } from "./shared/cookie.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivetjs/router",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "File routing for Rivet: loaders, actions, guards, a web-standard request handler, Node / static / edge adapters.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -114,9 +114,9 @@
114
114
  }
115
115
  },
116
116
  "dependencies": {
117
- "@rivetjs/runtime": "0.1.0-alpha.0",
118
- "@rivetjs/reactivity": "0.1.0-alpha.0",
119
- "@rivetjs/compiler": "0.1.0-alpha.0"
117
+ "@rivetjs/runtime": "0.1.0-alpha.1",
118
+ "@rivetjs/reactivity": "0.1.0-alpha.1",
119
+ "@rivetjs/compiler": "0.1.0-alpha.1"
120
120
  },
121
121
  "engines": {
122
122
  "node": ">=24"
package/server/app.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { ActionResult, Head, HeadSink, LoaderDeps } from "../shared/data.js
5
5
  import { type CSPOptions } from "../shared/csp.js";
6
6
  import { type InlineStyleManifest } from "./inline-styles.js";
7
7
  import type { TrailingSlash } from "../shared/slash.js";
8
- import { type AppHandler, type AppRoute, type BodyLimit, type ErrorInfo } from "./handler.js";
8
+ import { type AppHandler, type AppRoute, type BodyLimit, type ErrorInfo, type ShellPhase } from "./handler.js";
9
9
  import { type DocumentShell } from "./render.js";
10
10
  import type { EarlyHintsOptions } from "./early-hints.js";
11
11
  import type { RouteCacheOptions, RouteRules } from "./route-cache.js";
@@ -28,6 +28,12 @@ export interface PageInput {
28
28
  readonly byURL?: readonly number[];
29
29
  /** Loaders' `event.depends()` keys (absent when none called it): resumed with the layout state for `invalidate(key)`. */
30
30
  readonly deps?: LoaderDeps;
31
+ /** A `data` slot is a deferred loader's handle (`load(fn, { defer: true })`): the page streams (out of order) even without `stream`. */
32
+ readonly deferred?: true;
33
+ /** A deferred loader's redirect target, read at the document's end (client redirect). */
34
+ readonly moved?: () => string | undefined;
35
+ /** @internal Route rule `shell`: a shell fill / replay (always streams). */
36
+ readonly shell?: ShellPhase;
31
37
  }
32
38
  /** Shape of the generated `.rivet/entry.server.tsx` module. */
33
39
  export interface GeneratedEntry {