@rivetjs/router 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/adapters/assemble.d.ts +32 -0
- package/adapters/assemble.js +32 -0
- package/adapters/build.d.ts +24 -0
- package/adapters/build.js +43 -0
- package/adapters/bun.d.ts +18 -0
- package/adapters/bun.js +44 -0
- package/adapters/cloudflare.d.ts +5 -0
- package/adapters/cloudflare.js +8 -0
- package/adapters/cluster.d.ts +7 -0
- package/adapters/cluster.js +130 -0
- package/adapters/csr.d.ts +12 -0
- package/adapters/csr.js +11 -0
- package/adapters/deno.d.ts +13 -0
- package/adapters/deno.js +35 -0
- package/adapters/fetch.d.ts +55 -0
- package/adapters/fetch.js +72 -0
- package/adapters/files.d.ts +20 -0
- package/adapters/files.js +155 -0
- package/adapters/idle-memory.d.ts +23 -0
- package/adapters/idle-memory.js +91 -0
- package/adapters/netlify.d.ts +13 -0
- package/adapters/netlify.js +15 -0
- package/adapters/node.d.ts +180 -0
- package/adapters/node.js +986 -0
- package/adapters/pack.d.ts +39 -0
- package/adapters/pack.js +360 -0
- package/adapters/server-target.d.ts +17 -0
- package/adapters/server-target.js +8 -0
- package/adapters/static.d.ts +84 -0
- package/adapters/static.js +231 -0
- package/adapters/target-build.d.ts +95 -0
- package/adapters/target-build.js +118 -0
- package/adapters/vercel-edge.d.ts +7 -0
- package/adapters/vercel-edge.js +9 -0
- package/adapters/vercel.d.ts +35 -0
- package/adapters/vercel.js +45 -0
- package/build/codegen.d.ts +97 -0
- package/build/codegen.js +1037 -0
- package/build/dev-errors.d.ts +23 -0
- package/build/dev-errors.js +247 -0
- package/build/lazy-rows.d.ts +19 -0
- package/build/lazy-rows.js +59 -0
- package/build/migrate.d.ts +31 -0
- package/build/migrate.js +385 -0
- package/build/reach.d.ts +60 -0
- package/build/reach.js +302 -0
- package/build/route-css.d.ts +54 -0
- package/build/route-css.js +216 -0
- package/build/routes.d.ts +55 -0
- package/build/routes.js +219 -0
- package/build/sync.d.ts +20 -0
- package/build/sync.js +16 -0
- package/build/vite.d.ts +208 -0
- package/build/vite.js +861 -0
- package/cli/rivet.d.ts +2 -0
- package/cli/rivet.js +101 -0
- package/client/active.d.ts +6 -0
- package/client/active.js +22 -0
- package/client/announce.d.ts +2 -0
- package/client/announce.js +21 -0
- package/client/dev.d.ts +41 -0
- package/client/dev.js +91 -0
- package/client/index.d.ts +156 -0
- package/client/index.js +714 -0
- package/client/layouts.d.ts +110 -0
- package/client/layouts.js +310 -0
- package/client/leave.d.ts +40 -0
- package/client/leave.js +75 -0
- package/client/link.d.ts +48 -0
- package/client/link.jsx +65 -0
- package/client/live.d.ts +57 -0
- package/client/live.js +170 -0
- package/client/navigate.d.ts +71 -0
- package/client/navigate.js +120 -0
- package/client/navigation.d.ts +28 -0
- package/client/navigation.js +14 -0
- package/client/params.d.ts +16 -0
- package/client/params.js +28 -0
- package/client/route-context.d.ts +32 -0
- package/client/route-context.js +21 -0
- package/client/scroll.d.ts +16 -0
- package/client/scroll.js +80 -0
- package/client/search.d.ts +8 -0
- package/client/search.js +17 -0
- package/client/tailwind.css +8 -0
- package/client/transition-helpers.d.ts +28 -0
- package/client/transition-helpers.js +63 -0
- package/client/transitions.css +90 -0
- package/client/transitions.d.ts +21 -0
- package/client/transitions.js +156 -0
- package/index.d.ts +135 -0
- package/index.js +8 -0
- package/package.json +156 -4
- package/server/app.d.ts +175 -0
- package/server/app.js +335 -0
- package/server/body.d.ts +20 -0
- package/server/body.js +8 -0
- package/server/deferred.d.ts +12 -0
- package/server/deferred.js +60 -0
- package/server/early-hints.d.ts +34 -0
- package/server/early-hints.js +63 -0
- package/server/handler.d.ts +240 -0
- package/server/handler.js +661 -0
- package/server/index.d.ts +9 -0
- package/server/index.js +6 -0
- package/server/inline-styles.d.ts +21 -0
- package/server/inline-styles.js +28 -0
- package/server/module.d.ts +14 -0
- package/server/module.js +96 -0
- package/server/render.d.ts +96 -0
- package/server/render.js +492 -0
- package/server/route-cache.d.ts +245 -0
- package/server/route-cache.js +813 -0
- package/server/security.d.ts +18 -0
- package/server/security.js +47 -0
- package/server/shell-build.d.ts +3 -0
- package/server/shell-build.js +2 -0
- package/server/shell-cache.d.ts +8 -0
- package/server/shell-cache.js +836 -0
- package/server/strict-csp.d.ts +7 -0
- package/server/strict-csp.js +18 -0
- package/server-only.d.ts +1 -0
- package/server-only.js +3 -0
- package/shared/actions.d.ts +10 -0
- package/shared/actions.js +18 -0
- package/shared/base.d.ts +8 -0
- package/shared/base.js +20 -0
- package/shared/cookie.d.ts +7 -0
- package/shared/cookie.js +32 -0
- package/shared/csp-directives.d.ts +9 -0
- package/shared/csp-directives.js +85 -0
- package/shared/csp-hash.d.ts +39 -0
- package/shared/csp-hash.js +121 -0
- package/shared/csp.d.ts +26 -0
- package/shared/csp.js +13 -0
- package/shared/data.d.ts +175 -0
- package/shared/data.js +22 -0
- package/shared/errors.d.ts +31 -0
- package/shared/errors.js +51 -0
- package/shared/head.d.ts +57 -0
- package/shared/head.js +242 -0
- package/shared/nonce.d.ts +2 -0
- package/shared/nonce.js +23 -0
- package/shared/prefetch-fallback.min.d.ts +1 -0
- package/shared/prefetch-fallback.min.js +2 -0
- package/shared/prefetch.d.ts +54 -0
- package/shared/prefetch.js +74 -0
- package/shared/redirect.d.ts +6 -0
- package/shared/redirect.js +25 -0
- package/shared/server-exports.d.ts +78 -0
- package/shared/server-exports.js +77 -0
- package/shared/slash.d.ts +8 -0
- package/shared/slash.js +24 -0
package/build/codegen.js
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
// Code generation for file routes. Output is ordinary TSX/TS written to `.rivet/` so the Rivet
|
|
2
|
+
// compiler (incl. resume mode) treats it like hand-written application code.
|
|
3
|
+
import { posix } from "node:path";
|
|
4
|
+
import { normalizeBase } from "../shared/base.js";
|
|
5
|
+
const HEADER = "// Generated by @rivetjs/router from the routes directory. Do not edit.\n";
|
|
6
|
+
// Characters a browser-serialized `URL.pathname` holds percent-encoded: the WHATWG path percent-encode
|
|
7
|
+
// set, non-ASCII, and `%` (decoded text: a literal `%` is written `%25`).
|
|
8
|
+
const ENCODED = /[\0- "#%<>?`{}\x7f-\uffff]/;
|
|
9
|
+
// RegExp syntax characters (escaped the way `u` / `v` mode RegExps — and URLPattern regexp groups — accept).
|
|
10
|
+
const SYNTAX = /[\\^$.*+?()[\]{}|/]/g;
|
|
11
|
+
/** One static path segment as a browser-serialized `URL.pathname` holds it, RegExp-escaped. */
|
|
12
|
+
function literal(segment) {
|
|
13
|
+
let text;
|
|
14
|
+
try {
|
|
15
|
+
text = decodeURIComponent(segment);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
text = segment;
|
|
19
|
+
}
|
|
20
|
+
return [...text]
|
|
21
|
+
.map((char) => (ENCODED.test(char) ? encodeURIComponent(char).replace(/%[\da-f]{2}/gi, (escape) => escape.toUpperCase()) : char))
|
|
22
|
+
.join("")
|
|
23
|
+
.replace(SYNTAX, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
/** Whether some URL path could match both route patterns (static segments differ → no). */
|
|
26
|
+
function overlaps(a, b) {
|
|
27
|
+
const left = a.split("/").filter(Boolean), right = b.split("/").filter(Boolean);
|
|
28
|
+
for (let i = 0;; i++) {
|
|
29
|
+
const x = left[i], y = right[i];
|
|
30
|
+
if (x?.[0] === "*" || y?.[0] === "*")
|
|
31
|
+
return true;
|
|
32
|
+
if (x === undefined || y === undefined)
|
|
33
|
+
return x === y;
|
|
34
|
+
if (x[0] !== ":" && y[0] !== ":" && x !== y)
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const trie = () => ({ end: false, splat: false, kids: new Map() });
|
|
39
|
+
/** Generated dev helper `<head> = (message) => boolean`: true for a benign browser notice (dropped, not forwarded). */
|
|
40
|
+
const DEV_BENIGN = (head) => [
|
|
41
|
+
"// Benign browser notices (Chrome / Firefox + Safari texts of the ResizeObserver loop), never an app error: dropped like",
|
|
42
|
+
"// Vite's own overlay and webpack-dev-server do; logged once at debug level.",
|
|
43
|
+
"let benignLogged = false;",
|
|
44
|
+
`${head} = (message: string): boolean => {`,
|
|
45
|
+
" if (!/^ResizeObserver loop (completed with undelivered notifications|limit exceeded)/.test(message)) return false;",
|
|
46
|
+
' if (!benignLogged) (benignLogged = true), console.debug("[rivet] ignored benign browser notice: " + message);',
|
|
47
|
+
" return true;",
|
|
48
|
+
"};",
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Generated dev helper `<head> = (error: unknown) => { message, stack }` for `rivet:client-error`: an Error keeps
|
|
52
|
+
* its message + stack; an `ErrorEvent` whose `error` is null (ResizeObserver loop, cross-origin script) reports its
|
|
53
|
+
* message + source location; any other value (`throw null`, a non-Error rejection) its type + value, with the stack
|
|
54
|
+
* of the reporting site. Was: `String(value)` only ("Browser error: null").
|
|
55
|
+
*/
|
|
56
|
+
const DEV_DESCRIBE = (head) => [
|
|
57
|
+
`${head} = (error: unknown): { message: string; stack?: string } => {`,
|
|
58
|
+
" if (error instanceof Error) return { message: error.message, stack: error.stack };",
|
|
59
|
+
' const site = new Error("reported here").stack;',
|
|
60
|
+
' if (typeof ErrorEvent !== "undefined" && error instanceof ErrorEvent)',
|
|
61
|
+
' return { message: `${error.message || "Uncaught error"} (${error.filename || "unknown source"}:${error.lineno}:${error.colno}; the error event carries no Error)`, stack: site };',
|
|
62
|
+
" let value: string;",
|
|
63
|
+
" try {",
|
|
64
|
+
' value = typeof error === "string" ? error : JSON.stringify(error) ?? String(error);',
|
|
65
|
+
" } catch {",
|
|
66
|
+
" value = String(error);",
|
|
67
|
+
" }",
|
|
68
|
+
' return { message: `Non-Error value thrown (${error === null ? "null" : typeof error}): ${value}`, stack: site };',
|
|
69
|
+
"};",
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* RegExp source (no anchors, no leading `/`) over `URL.pathname.slice(1)` that matches every path one
|
|
73
|
+
* of `patterns` (route patterns: static segments, `:param`, terminal `*splat`; one optional trailing
|
|
74
|
+
* slash) matches. Shared prefixes are factored (a trie), so the source stays small for large apps.
|
|
75
|
+
* Valid in `u` / `v` mode and as a URLPattern regexp group (`/(source)`); `""`: only `/` matches.
|
|
76
|
+
*/
|
|
77
|
+
export function routePathRegExp(patterns) {
|
|
78
|
+
const root = trie();
|
|
79
|
+
for (const pattern of patterns) {
|
|
80
|
+
let node = root;
|
|
81
|
+
for (const part of pattern === "/" ? [] : pattern.replace(/^\/|\/$/g, "").split("/")) {
|
|
82
|
+
if (node.splat)
|
|
83
|
+
break;
|
|
84
|
+
if (part[0] === "*") {
|
|
85
|
+
node.splat = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (part[0] === ":")
|
|
89
|
+
node = node.param ??= trie();
|
|
90
|
+
else {
|
|
91
|
+
const key = literal(part);
|
|
92
|
+
let next = node.kids.get(key);
|
|
93
|
+
if (!next)
|
|
94
|
+
node.kids.set(key, (next = trie()));
|
|
95
|
+
node = next;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
node.end = true;
|
|
99
|
+
}
|
|
100
|
+
// Alternatives for what follows a node's own segment: nothing (or one `/`), or `/` and a child.
|
|
101
|
+
const children = (node) => [...[...node.kids].map(([key, kid]) => key + rest(kid)), ...(node.param ? ["[^\\/]+" + rest(node.param)] : [])];
|
|
102
|
+
const rest = (node) => {
|
|
103
|
+
if (node.splat)
|
|
104
|
+
return "(?:\\/.*)?";
|
|
105
|
+
const kids = children(node);
|
|
106
|
+
if (!kids.length)
|
|
107
|
+
return "\\/?";
|
|
108
|
+
const below = "\\/" + (kids.length > 1 ? "(?:" + kids.join("|") + ")" : kids[0]);
|
|
109
|
+
return node.end ? "(?:" + below + "|\\/?)" : below;
|
|
110
|
+
};
|
|
111
|
+
if (root.splat)
|
|
112
|
+
return ".*";
|
|
113
|
+
return [...children(root), ...(root.end ? [""] : [])].join("|");
|
|
114
|
+
}
|
|
115
|
+
const str = JSON.stringify;
|
|
116
|
+
/** JSX lines nesting `inner` in `providers` ([comment, open tag] pairs), outermost first, at `indent`. */
|
|
117
|
+
function wrap(providers, inner, indent) {
|
|
118
|
+
const pad = (n) => indent + " ".repeat(n);
|
|
119
|
+
return [
|
|
120
|
+
...providers.flatMap(([comment, open], n) => [pad(n) + comment, pad(n) + open]),
|
|
121
|
+
pad(providers.length) + inner,
|
|
122
|
+
...providers.map((_, n) => pad(providers.length - 1 - n) + "</Provider>"),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Derived resume model (`CodegenOptions.derived`): `app.tsx`'s default export, the page's independent root boundary. Server:
|
|
127
|
+
* the App with the request's router (`ServerData` comes from the server entry). Browser (its adopt run): the browser
|
|
128
|
+
* router, the payload as `Revived`, and the contexts the server entry provides (navigation state, search params) as the
|
|
129
|
+
* browser's; `ready` loads the page modules of the URL first, so the adopt run renders the route synchronously.
|
|
130
|
+
*/
|
|
131
|
+
function rootComponent(transitions, search) {
|
|
132
|
+
const providers = [
|
|
133
|
+
...(transitions
|
|
134
|
+
? [["{/* useNavigation(): the browser's navigation signal. */}", "<Provider token={Navigation} value={navigation}>"]]
|
|
135
|
+
: []),
|
|
136
|
+
...(search ? [["{/* useSearchParams(): the browser's. */}", "<Provider token={SearchParams} value={searchParams}>"]] : []),
|
|
137
|
+
];
|
|
138
|
+
return [
|
|
139
|
+
"",
|
|
140
|
+
"/** Derived resume model: the page root (independent boundary: its props are the route payload as data). */",
|
|
141
|
+
"export default function Root(props: { s: RootData }) {",
|
|
142
|
+
" if (import.meta.env.SSR) return <App router={serverRouter(useContext(ServerData)!.url)} />;",
|
|
143
|
+
" if (!adopting) ((adopting = true), queueMicrotask(() => (adopting = false)));",
|
|
144
|
+
" const s = props.s,",
|
|
145
|
+
" router = browserRouter(),",
|
|
146
|
+
" data = s.d.slice();",
|
|
147
|
+
" for (const i of s.n ?? []) data[i] = undefined;",
|
|
148
|
+
" const server = { data, url: untrack(router.location), ...(s.e ? { error: s.e } : {}), ...(s.a ? { action: s.a } : {}), ...(s.x ? { byURL: s.x } : {}), ...(s.k ? { deps: s.k } : {}) } as ServerRoutePayload;",
|
|
149
|
+
" return (",
|
|
150
|
+
" <Provider token={Revived} value={server}>",
|
|
151
|
+
...wrap(providers, "<App router={router} />", " "),
|
|
152
|
+
" </Provider>",
|
|
153
|
+
" );",
|
|
154
|
+
"}",
|
|
155
|
+
"/** Before the adopt run: the page modules of the shown URL (a lazy page renders synchronously once loaded). */",
|
|
156
|
+
"Root.ready = (): Promise<unknown> | undefined => loadPage(new URL(globalThis.location.href));",
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
/** Generate the app, server entry, route table and route types for a scanned table. */
|
|
160
|
+
export function generate(table, options) {
|
|
161
|
+
const outDir = options.outDir ?? ".rivet";
|
|
162
|
+
const mode = options.mode ?? "server";
|
|
163
|
+
// Router listeners that alone keep a page resumable (`clientRouter`), else `standalone: false`.
|
|
164
|
+
const lone = (options.clientRouter ?? mode !== "static") ? "" : ", standalone: false";
|
|
165
|
+
// Matched URL paths include the base: `/` → `/app`, `/items/:id` → `/app/items/:id`.
|
|
166
|
+
const base = normalizeBase(options.base);
|
|
167
|
+
const at = (pattern) => (base ? (pattern === "/" ? base : base + pattern) : pattern);
|
|
168
|
+
if (mode === "csr") {
|
|
169
|
+
const servers = [
|
|
170
|
+
...new Set(table.routes
|
|
171
|
+
.flatMap((r) => [r, ...r.layouts])
|
|
172
|
+
.flatMap((m) => [m.server, m.inline ? m.page : undefined])
|
|
173
|
+
.filter(Boolean)),
|
|
174
|
+
];
|
|
175
|
+
if (servers.length)
|
|
176
|
+
throw new Error(`CSR apps have no server; remove or move these modules: ${servers.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
const source = str(mode === "csr" ? "none" : mode);
|
|
179
|
+
const kit = options.head === "components";
|
|
180
|
+
const level = (n, jsx) => (kit ? `<Provider token={HeadLevel} value={${n}}>${jsx}</Provider>` : jsx);
|
|
181
|
+
const transitions = options.transitions ? (options.transitions === true ? {} : options.transitions) : null;
|
|
182
|
+
const search = options.searchParams === true;
|
|
183
|
+
const forms = options.forms === true;
|
|
184
|
+
const go = options.navigate === true || forms;
|
|
185
|
+
const params = options.params === true;
|
|
186
|
+
const active = options.activeLinks === true;
|
|
187
|
+
const slash = options.trailingSlash === "never" || options.trailingSlash === "always" ? options.trailingSlash : null;
|
|
188
|
+
const swap = options.announce === false ? "swapRouteQuiet" : "swapRoute";
|
|
189
|
+
const derived = options.derived === true && mode !== "csr";
|
|
190
|
+
// Derived model: the Routers swap through `adoptSwap` (the adopt task's route renders are the page load, not a navigation).
|
|
191
|
+
const swapProp = derived ? "adoptSwap" : swap;
|
|
192
|
+
const routesDir = options.routesDir.replace(/\/$/, "");
|
|
193
|
+
const fromOut = (path) => {
|
|
194
|
+
const relative = posix.relative(outDir, path);
|
|
195
|
+
return relative.startsWith(".") ? relative : "./" + relative;
|
|
196
|
+
};
|
|
197
|
+
const spec = (file) => fromOut(posix.join(routesDir, file));
|
|
198
|
+
// One binding per distinct module; ids are stable for a given table.
|
|
199
|
+
const names = new Map();
|
|
200
|
+
const bind = (file, prefix) => {
|
|
201
|
+
let name = names.get(file);
|
|
202
|
+
if (!name)
|
|
203
|
+
names.set(file, (name = prefix + names.size));
|
|
204
|
+
return name;
|
|
205
|
+
};
|
|
206
|
+
const pages = new Map(), layouts = new Map(), specials = new Map();
|
|
207
|
+
const servers = new Map();
|
|
208
|
+
const uniqueRoutes = [];
|
|
209
|
+
for (const route of table.routes) {
|
|
210
|
+
if (route.page && !pages.has(route.page))
|
|
211
|
+
pages.set(route.page, bind(route.page, "Page"));
|
|
212
|
+
for (const layout of route.layouts)
|
|
213
|
+
if (layout.page && !layouts.has(layout.page))
|
|
214
|
+
layouts.set(layout.page, bind(layout.page, "Layout"));
|
|
215
|
+
for (const file of [route.notFound, route.error])
|
|
216
|
+
if (file && !specials.has(file))
|
|
217
|
+
specials.set(file, bind(file, "Special"));
|
|
218
|
+
for (const file of [route.server, ...route.layouts.map((l) => l.server)])
|
|
219
|
+
if (file && !servers.has(file))
|
|
220
|
+
servers.set(file, "server" + servers.size);
|
|
221
|
+
uniqueRoutes.push(route);
|
|
222
|
+
}
|
|
223
|
+
for (const file of [table.notFound, table.error, ...(table.notFounds ?? []).map((n) => n.page)])
|
|
224
|
+
if (file && !specials.has(file))
|
|
225
|
+
specials.set(file, bind(file, "Special"));
|
|
226
|
+
const pageIndex = new Map([...pages.keys()].map((file, i) => [file, i]));
|
|
227
|
+
const errorViews = new Set([table.error, ...table.routes.map((r) => r.error)].filter(Boolean));
|
|
228
|
+
// --- app.tsx: universal component tree (server render + resumed browser navigation).
|
|
229
|
+
const pageRoutes = uniqueRoutes.filter((route) => route.page);
|
|
230
|
+
// The root `_layout` wraps the routes region and stays mounted; nested layouts persist as levels
|
|
231
|
+
// (`levelView`). Layout props are accessors of App-level signals the route loads update.
|
|
232
|
+
const rootLayout = pageRoutes.length && pageRoutes.every((r) => r.layouts[0]?.page && !r.layouts[0].page.includes("/")) ? pageRoutes[0].layouts[0].page : undefined;
|
|
233
|
+
// Root layout with server exports (sibling or in-file): error renders carry its data only when its loader succeeded.
|
|
234
|
+
const rootLayoutServer = rootLayout ? Boolean(pageRoutes[0].layouts[0].server || pageRoutes[0].layouts[0].inline) : false;
|
|
235
|
+
// The page (layouts are levels around it, see `levelView`).
|
|
236
|
+
// Data-driven: `Page` / `depth` are the matched table row's (see `routeTable`).
|
|
237
|
+
// Live pages (`export const live = true`) with navigate()/forms in use: `data` is an accessor the
|
|
238
|
+
// App refreshes in place on same-URL revalidation (`_serveLive`); hoisted, so the prop stays static.
|
|
239
|
+
const live = go && mode !== "csr" ? pageRoutes.filter((route) => route.live) : [];
|
|
240
|
+
const pageData = live.length ? "data" : "result.data[depth]";
|
|
241
|
+
const pageView = () => {
|
|
242
|
+
const jsx = kit
|
|
243
|
+
? `<Provider token={HeadLevel} value={depth}><Page data={${pageData}} params={match.params} url={match.url} /></Provider>`
|
|
244
|
+
: `<Page data={${pageData}} params={match.params} url={match.url} />`;
|
|
245
|
+
// `useParams()` in the page: this render's params (the page re-renders per navigation).
|
|
246
|
+
return params ? `<Provider token={RouteParamsContext} value={signal(match.params)}>${jsx}</Provider>` : jsx;
|
|
247
|
+
};
|
|
248
|
+
// Layout table: every layout of a page route (id = `/` + folder); persisted ones (with a component,
|
|
249
|
+
// except the root layout outside the routes) render as levels with their own Router region.
|
|
250
|
+
const layoutKey = (module) => posix.dirname(module.page ?? module.server ?? "");
|
|
251
|
+
const layoutIndex = new Map();
|
|
252
|
+
const layoutModulesList = [];
|
|
253
|
+
for (const route of pageRoutes)
|
|
254
|
+
for (const layout of route.layouts)
|
|
255
|
+
if (!layoutIndex.has(layoutKey(layout)))
|
|
256
|
+
layoutIndex.set(layoutKey(layout), layoutModulesList.push(layout) - 1);
|
|
257
|
+
const indexOf = (module) => layoutIndex.get(layoutKey(module));
|
|
258
|
+
const persisted = (route) => route.layouts.flatMap((layout, at) => (layout.page && layout.page !== rootLayout ? [at] : []));
|
|
259
|
+
const useNest = layoutModulesList.length > 0;
|
|
260
|
+
// Data requests name the layouts whose data the client keeps (only layouts with server exports load).
|
|
261
|
+
const keeps = mode === "server" && layoutModulesList.some((layout) => layout.server || layout.inline);
|
|
262
|
+
const levels = Math.max(0, ...pageRoutes.map((route) => persisted(route).length));
|
|
263
|
+
const nested = levels > 0;
|
|
264
|
+
const folderParams = (folder) => folder === "." ? [] : folder.split("/").flatMap((part) => /^\[{1,2}(?:\.\.\.)?([A-Za-z_$][\w$]*)\]{1,2}$/.exec(part)?.slice(1) ?? []);
|
|
265
|
+
const layoutTable = {
|
|
266
|
+
ids: [...layoutIndex.keys()].map((folder) => (folder === "." ? "/" : "/" + folder)),
|
|
267
|
+
params: [...layoutIndex.keys()].map(folderParams),
|
|
268
|
+
kind: layoutModulesList.map((layout) => (layout.page ? 1 : 0) | (layout.server || layout.inline ? 2 : 0)),
|
|
269
|
+
root: rootLayout ? indexOf(pageRoutes[0].layouts[0]) : -1,
|
|
270
|
+
routes: pageRoutes.map((route) => [at(route.pattern), route.layouts.map(indexOf)]),
|
|
271
|
+
};
|
|
272
|
+
const routeIndex = (route) => pageRoutes.indexOf(route);
|
|
273
|
+
const slot = (i) => `nest.l[${i}]!`;
|
|
274
|
+
// Level `k` of `route`: its persisted layout at that depth around the next level's Router, or the page.
|
|
275
|
+
// Entering level `k + 1`: the persisted layout `i` (at depth `at`) around the next level's Router.
|
|
276
|
+
// One shared view per (k, i, at) — `enter<j>` in App — instead of one copy per route.
|
|
277
|
+
const enterKey = (route, k) => {
|
|
278
|
+
const chain = persisted(route);
|
|
279
|
+
if (k === chain.length)
|
|
280
|
+
return null;
|
|
281
|
+
return `${k}:${indexOf(route.layouts[chain[k]])}:${chain[k]}`;
|
|
282
|
+
};
|
|
283
|
+
const enterView = (key) => {
|
|
284
|
+
const [k, i, at] = key.split(":").map(Number);
|
|
285
|
+
const name = layouts.get(layoutModulesList[i].page);
|
|
286
|
+
const inner = `<div data-rivet-outlet="${k + 1}"><Router router={_level(router, nest, ${k + 1}, t)} routes={routes${i}} swap={${swapProp}} hold={() => heldRoutes(${k + 1})} notFound={() => null} /></div>`;
|
|
287
|
+
const view = level(at, `<${name} data={${slot(i)}.d} params={${slot(i)}.p} url={${slot(i)}.u}>${inner}</${name}>`);
|
|
288
|
+
return `_enter(${k}, result, url, (t) => ${params ? `<Provider token={RouteParamsContext} value={${slot(i)}.p}>${view}</Provider>` : view})`;
|
|
289
|
+
};
|
|
290
|
+
const rootNotFound = table.notFound ? specials.get(table.notFound) : undefined;
|
|
291
|
+
// Failed loads render the route's nearest `_404` (404) or `_error` (anything else).
|
|
292
|
+
// One shared view per distinct (`_404`, `_error`) pair — `fail<j>` in App — instead of one copy per route.
|
|
293
|
+
const failureKey = (route) => `${route.notFound ?? ""}|${route.error ?? ""}`;
|
|
294
|
+
const failureView = (key) => {
|
|
295
|
+
const [notFoundFile, errorFile] = key.split("|");
|
|
296
|
+
const notFound = notFoundFile ? specials.get(notFoundFile) : undefined;
|
|
297
|
+
const error = errorFile ? specials.get(errorFile) : undefined;
|
|
298
|
+
const errorView = error
|
|
299
|
+
? `<${error} error={failure} />`
|
|
300
|
+
: `<p role="alert">{isHttpError(failure) && failure.expose ? failure.message : "Something went wrong"}</p>`;
|
|
301
|
+
return notFound ? `isHttpError(failure) && failure.status === 404 ? <${notFound} url={url} /> : ${errorView}` : errorView;
|
|
302
|
+
};
|
|
303
|
+
// Unmatched URLs: the `_404` of the longest matching folder prefix, else the root one.
|
|
304
|
+
let unmatched = rootNotFound ? `<${rootNotFound} url={url} />` : `<p role="status">Page not found.</p>`;
|
|
305
|
+
for (const { prefix, page } of [...(table.notFounds ?? [])].reverse())
|
|
306
|
+
unmatched = `url.pathname === ${str(at(prefix))} || url.pathname.startsWith(${str(at(prefix) + "/")}) ? <${specials.get(page)} url={url} /> : ${unmatched}`;
|
|
307
|
+
// Route tables, one per Router level: `paths` (compiled once per process: module constants) and
|
|
308
|
+
// rows [route index, data depth, page, enter view (-1: the page), failure view]. Each level's
|
|
309
|
+
// `{ paths, route }` defines only the matched route per request; its load / render read the row.
|
|
310
|
+
const failureKeys = [...new Set(pageRoutes.map(failureKey))];
|
|
311
|
+
const enterKeys = [];
|
|
312
|
+
for (const route of pageRoutes)
|
|
313
|
+
for (let k = 0; k < persisted(route).length; k++)
|
|
314
|
+
if (!enterKeys.includes(enterKey(route, k)))
|
|
315
|
+
enterKeys.push(enterKey(route, k));
|
|
316
|
+
const tables = [
|
|
317
|
+
{ name: "", k: 0, routes: pageRoutes },
|
|
318
|
+
...[...new Set(pageRoutes.flatMap((route) => persisted(route).map((at, k) => [indexOf(route.layouts[at]), k + 1])).map((pair) => pair.join(":")))]
|
|
319
|
+
.map((pair) => pair.split(":").map(Number))
|
|
320
|
+
.map(([i, k]) => ({ name: String(i), k, routes: pageRoutes.filter((route) => persisted(route).map((at) => indexOf(route.layouts[at]))[k - 1] === i) })),
|
|
321
|
+
];
|
|
322
|
+
const tableRows = ({ k, routes }) => "[" +
|
|
323
|
+
routes
|
|
324
|
+
.map((route) => {
|
|
325
|
+
const enter = nested ? enterKey(route, k) : null;
|
|
326
|
+
return `[${routeIndex(route)}, ${route.layouts.length}, ${pageIndex.get(route.page)}, ${enter === null ? -1 : enterKeys.indexOf(enter)}, ${failureKeys.indexOf(failureKey(route))}]`;
|
|
327
|
+
})
|
|
328
|
+
.join(", ") +
|
|
329
|
+
"]";
|
|
330
|
+
// Shared views reachable from a level's rows (a single one is called directly).
|
|
331
|
+
const pick = (indices, call, at) => indices.slice(0, -1).reduceRight((rest, j) => `${at} === ${j} ? ${call(j)} : ${rest}`, call(indices.at(-1)));
|
|
332
|
+
const routeTableOf = (table) => {
|
|
333
|
+
const { name, k, routes } = table;
|
|
334
|
+
const fails = [...new Set(routes.map((route) => failureKeys.indexOf(failureKey(route))))].sort((a, b) => a - b);
|
|
335
|
+
const enters = nested
|
|
336
|
+
? [...new Set(routes.map((route) => enterKey(route, k)).filter((key) => key !== null))].map((key) => enterKeys.indexOf(key)).sort((a, b) => a - b)
|
|
337
|
+
: [];
|
|
338
|
+
const pageRows = routes.some((route) => !nested || enterKey(route, k) === null);
|
|
339
|
+
const enter = enters.length ? pick(enters, (j) => `enter${j}(result, match.url)`, "row[3]") : "";
|
|
340
|
+
const page = [
|
|
341
|
+
` const Page = page(row[2]), depth = row[1];`,
|
|
342
|
+
...(live.length ? [` const data = LIVE.includes(row[2]) ? _livePage(live, result.data[depth], match.url, depth) : result.data[depth];`] : []),
|
|
343
|
+
` return ${pageView()};`,
|
|
344
|
+
];
|
|
345
|
+
const view = !enters.length ? page : pageRows ? [` if (row[3] >= 0) return ${enter};`, ...page] : [` return ${enter};`];
|
|
346
|
+
const usesRow = pageRows || fails.length > 1 || enters.length > 1;
|
|
347
|
+
// Server: the same chain on the handler's data, synchronously (`_ssrLevel`; undefined in the browser).
|
|
348
|
+
const land = useNest ? `(result) => _landed(nest, NEST, ${k}, ROUTES${name}[n]![0], result, match)` : "(result) => result";
|
|
349
|
+
const ok = `(data) => loaded(${kit ? `applyRouteHead(data, ROUTES${name}[n]![1])` : "data"})`;
|
|
350
|
+
// Derived model: the browser's adopt run answers each level's first load from the revived payload (`Root`).
|
|
351
|
+
const served = mode === "csr" ? "" : `_ssrLevel(work, ${ok}, failed, ${land}) ?? ${derived ? `revived(work, ${ok}, failed, ${land}) ?? ` : ""}`;
|
|
352
|
+
return [
|
|
353
|
+
` const routes${name} = {`,
|
|
354
|
+
` paths: PATHS${name},`,
|
|
355
|
+
` route: (n: number) =>`,
|
|
356
|
+
` defineRoute(PATHS${name}[n]!, {`,
|
|
357
|
+
` load: (work, match) => ${nested ? `_take<Loaded>(${k}, match.url) ?? ` : ""}${served}Promise.all([routeData(work, match, router, ${source}${keeps ? ", _keepIds(nest, NEST, match.url)" : ""}), loadPage(match.url)${transitions ? ", transitions()" : ""}])` +
|
|
358
|
+
`.then(([data${transitions ? ", page" : ""}]) => ${transitions ? "(_routeModule(page), data)" : "data"})${kit ? `.then((result) => applyRouteHead(result, ROUTES${name}[n]![1]))` : ""}.then(loaded, failed)${useNest ? `.then((result) => _landed(nest, NEST, ${k}, ROUTES${name}[n]![0], result, match))` : ""},`,
|
|
359
|
+
` render: (result, match) => {`,
|
|
360
|
+
...(usesRow ? [` const row = ROUTES${name}[n]!;`] : []),
|
|
361
|
+
` if (result.failure !== undefined) return ${pick(fails, (j) => `fail${j}(result.failure, match.url)`, "row[4]")};`,
|
|
362
|
+
...view,
|
|
363
|
+
` },`,
|
|
364
|
+
` }),`,
|
|
365
|
+
` };`,
|
|
366
|
+
].join("\n");
|
|
367
|
+
};
|
|
368
|
+
// Endpoint routes (no page) before a dynamic page pattern, as [index in the client's match order, pattern]:
|
|
369
|
+
// the page pattern alone would claim their URLs for client navigation (the server answers the endpoint).
|
|
370
|
+
const shadowed = [];
|
|
371
|
+
// Document routes (server reach): their rows in the client's match table (top-level `PATHS` index, before the splices).
|
|
372
|
+
const documentFiles = new Set(options.documents ?? []);
|
|
373
|
+
const documentRows = pageRoutes.flatMap((route, n) => (documentFiles.has(route.page) ? [n] : []));
|
|
374
|
+
table.routes.forEach((route, i) => {
|
|
375
|
+
if (!route.page && table.routes.slice(i + 1).some((later) => later.page && /[:*]/.test(later.pattern) && overlaps(later.pattern, route.pattern)))
|
|
376
|
+
shadowed.push([table.routes.slice(0, i).filter((before) => before.page).length + shadowed.length, at(route.pattern)]);
|
|
377
|
+
});
|
|
378
|
+
// Single static page route, server/static mode, nothing navigating in place (Z1): no client router events.
|
|
379
|
+
const single = mode !== "csr" && pageRoutes.length === 1 && !/[:*]/.test(pageRoutes[0].pattern) && !go && !search && !active && !nested;
|
|
380
|
+
// Live pages in a single-level app: the top Router keys on the live slot (search-only navigations of the shown
|
|
381
|
+
// live page refresh it in place); the first navigation entry point starts the key's location sync (`_armLive`).
|
|
382
|
+
const liveTop = live.length > 0 && !nested;
|
|
383
|
+
const liveArm = liveTop ? `_armLive(router, live${useNest ? ", nest, NEST" : ""})` : "";
|
|
384
|
+
// Navigation entry points (link click, popstate, navigate()) start the route effects first (`arm`); with forms,
|
|
385
|
+
// they install the router brand (`_brand(_go)`) before anything else (warmed data, queued requests).
|
|
386
|
+
const brand = forms ? "_brand(_go)" : "";
|
|
387
|
+
const armed = (call) => nested || active || liveTop || forms ? `(${[brand, nested || active ? "arm()" : "", liveArm].filter(Boolean).join(", ")}, ${call})` : call;
|
|
388
|
+
const app = [
|
|
389
|
+
HEADER,
|
|
390
|
+
`/// <reference types="vite/client" />`,
|
|
391
|
+
...(derived
|
|
392
|
+
? [`import { ${useNest || (pageRoutes.length && params) ? "signal, " : ""}createContext, untrack, useContext, type Context } from "@rivetjs/reactivity";`]
|
|
393
|
+
: useNest
|
|
394
|
+
? [`import { signal, useContext } from "@rivetjs/reactivity";`]
|
|
395
|
+
: pageRoutes.length && params
|
|
396
|
+
? [`import { signal } from "@rivetjs/reactivity";`]
|
|
397
|
+
: []),
|
|
398
|
+
`import { Router, defineRoute, lazyComponent${single && !go ? "" : ", clientEvent"}${kit || (pageRoutes.length && params) || derived ? ", Provider" : ""} } from "@rivetjs/runtime/dom";`,
|
|
399
|
+
...(kit ? [`import { HeadLevel, applyRouteHead } from "@rivetjs/kit";`] : []),
|
|
400
|
+
...(derived
|
|
401
|
+
? [
|
|
402
|
+
`import { createRouter as browserRouter, type BrowserRouter } from "@rivetjs/runtime/router/dom";`,
|
|
403
|
+
`import { createRouter as serverRouter } from "@rivetjs/runtime/router/server";`,
|
|
404
|
+
...(transitions ? [`import { Navigation, navigation } from "@rivetjs/router/navigation";`] : []),
|
|
405
|
+
...(search ? [`import { SearchParams, searchParams } from "@rivetjs/router/search";`] : []),
|
|
406
|
+
]
|
|
407
|
+
: [`import type { BrowserRouter } from "@rivetjs/runtime/router/dom";`]),
|
|
408
|
+
`import { routeData, ${mode === "csr" ? "" : "_ssrLevel, "}${swap}, heldRoutes, preloadPage, ${single ? "" : "interceptClick, notePopstate, "}${go ? (live.length ? "_serveLive, _live, _livePage, _liveIntent, " + (liveTop ? "_liveRouter, _armLive, " : nested ? "_liveLevels, " : "") : "_serve, ") : ""}${pageRoutes.length && params ? "RouteParamsContext, " : ""}${mode === "csr" || single ? "" : "prefetchData, "}${single ? "" : "prefetchModule, "}loadModule${single ? "" : ", routableAnchor"}${useNest || derived ? ", ServerData" : ""}${derived ? ", type ServerRoutePayload" : ""}${useNest ? ", _layouts, _landed" + (keeps ? ", _keepIds" : "") + (nested ? ", _level, _enter, _take, _sync" : "") + ", type LayoutTable" : ""}${transitions ? ", _routeModule" : ""}${active ? ", onNavigate, _markActive" : ""}${nested || active ? ", _arm" : ""}${shadowed.length || documentRows.length ? ", _endpoint" : ""}${forms && !single ? ", _brand, _go" : ""} } from "@rivetjs/router/client";`,
|
|
409
|
+
`import { ${derived ? "HttpError, " : ""}isHttpError } from "@rivetjs/router";`,
|
|
410
|
+
"",
|
|
411
|
+
"// Route modules as this file renders them (strict-safe whatever props they declare): data comes",
|
|
412
|
+
"// from loaders (untyped here), layout props are accessors.",
|
|
413
|
+
"type PageProps = { data: any; params: any; url: URL };",
|
|
414
|
+
"type LayoutProps = { data: () => any; params: () => any; url: () => URL; children?: any };",
|
|
415
|
+
...(specials.size ? ["type NotFoundProps = { url: URL };", "type ErrorProps = { error: any };"] : []),
|
|
416
|
+
...[...layouts].flatMap(([file, name]) => [`import ${name}$ from ${str(spec(file))};`, `const ${name}: (props: LayoutProps) => unknown = ${name}$;`]),
|
|
417
|
+
...[...specials].flatMap(([file, name]) => [
|
|
418
|
+
`import ${name}$ from ${str(spec(file))};`,
|
|
419
|
+
`const ${name}: (props: ${errorViews.has(file) ? "ErrorProps" : "NotFoundProps"}) => unknown = ${name}$;`,
|
|
420
|
+
]),
|
|
421
|
+
// Page chunks load on navigation intent (hover/focus), not all at once when the page resumes.
|
|
422
|
+
// Z5: one import per page file in a table; a page's lazy component is created on its first use.
|
|
423
|
+
// Untyped table: each importer keeps its module's own type (checked against PageProps at `lazyComponent`).
|
|
424
|
+
`const PAGES = [${[...pages.keys()].map((file) => `() => import(${str(spec(file))})`).join(", ")}];`,
|
|
425
|
+
"const views: ((props: PageProps) => unknown)[] = [];",
|
|
426
|
+
"const page = (i: number): ((props: PageProps) => unknown) => (views[i] ??= lazyComponent<PageProps>(PAGES[i]!, undefined, { warm: false }));",
|
|
427
|
+
...(useNest ? [`const NEST: LayoutTable = ${str(layoutTable)};`] : []),
|
|
428
|
+
...(live.length
|
|
429
|
+
? [
|
|
430
|
+
"// Live pages (page indices): `data` is an accessor, revalidated in place.",
|
|
431
|
+
`const LIVE: readonly number[] = ${str([...new Set(live.map((route) => pageIndex.get(route.page)))])};`,
|
|
432
|
+
]
|
|
433
|
+
: []),
|
|
434
|
+
"// Route tables per Router level (see App): paths + [route index, data depth, page, enter view, failure view] rows.",
|
|
435
|
+
"type Row = readonly [route: number, depth: number, page: number, enter: number, failure: number];",
|
|
436
|
+
...tables.flatMap((table) => [
|
|
437
|
+
`const PATHS${table.name}: readonly string[] = ${str(table.routes.map((route) => at(route.pattern)))};`,
|
|
438
|
+
`const ROUTES${table.name}: readonly Row[] = ${tableRows(table)};`,
|
|
439
|
+
]),
|
|
440
|
+
...(shadowed.length || documentRows.length
|
|
441
|
+
? [
|
|
442
|
+
"const modules: (readonly [string, () => unknown])[] = PATHS.map((path, n) => [path, () => preloadPage(page(ROUTES[n]![2]), PAGES[ROUTES[n]![2]]!)]);",
|
|
443
|
+
...(documentRows.length
|
|
444
|
+
? [
|
|
445
|
+
"// Document routes (their browser graph reaches a server-only module): the browser loads their documents.",
|
|
446
|
+
`for (const n of ${str(documentRows)} as const) modules[n] = [PATHS[n]!, _endpoint];`,
|
|
447
|
+
]
|
|
448
|
+
: []),
|
|
449
|
+
...(shadowed.length
|
|
450
|
+
? [
|
|
451
|
+
"// Endpoints the server matches before a dynamic page pattern that also fits them: the browser loads them.",
|
|
452
|
+
`for (const [at, path] of ${str(shadowed)} as const) modules.splice(at, 0, [path, _endpoint]);`,
|
|
453
|
+
]
|
|
454
|
+
: []),
|
|
455
|
+
]
|
|
456
|
+
: [
|
|
457
|
+
"const modules: readonly (readonly [string, () => unknown])[] = PATHS.map((path, n) => [path, () => preloadPage(page(ROUTES[n]![2]), PAGES[ROUTES[n]![2]]!)]);",
|
|
458
|
+
]),
|
|
459
|
+
"/** Import the page module for `url` (dev mounts after it, so lazy pages render without a fallback frame). */",
|
|
460
|
+
"export const loadPage = (url: URL): Promise<unknown> | undefined => loadModule(url, modules);",
|
|
461
|
+
...(transitions
|
|
462
|
+
? [
|
|
463
|
+
"// Route transitions + pending UI: loaded on the first navigation intent (browser only).",
|
|
464
|
+
`const transitions = () => (typeof document === "undefined" ? undefined : import("@rivetjs/router/transitions").then((m) => m.install(${str(transitions)})));`,
|
|
465
|
+
]
|
|
466
|
+
: []),
|
|
467
|
+
"",
|
|
468
|
+
`export type AppRouter = Pick<BrowserRouter, "location"> & Partial<Pick<BrowserRouter, "navigate">>;`,
|
|
469
|
+
"",
|
|
470
|
+
"type Loaded = { data: readonly unknown[]; failure?: undefined } | { data: readonly unknown[]; failure: unknown };",
|
|
471
|
+
"const loaded = (result: { data: readonly unknown[] }): Loaded => result;",
|
|
472
|
+
"/** Dev: send a browser route error to Vite's error overlay (`rivet:client-error`); builds drop it. */",
|
|
473
|
+
"export const _devError = (error: unknown): void => {",
|
|
474
|
+
" if (!import.meta.hot) return;",
|
|
475
|
+
" const report = _devDescribe(error);",
|
|
476
|
+
' if (!_devBenign(report.message)) import.meta.hot.send("rivet:client-error", report);',
|
|
477
|
+
"};",
|
|
478
|
+
...DEV_BENIGN("export const _devBenign"),
|
|
479
|
+
...DEV_DESCRIBE("export const _devDescribe"),
|
|
480
|
+
"// Dev: failures that are not HTTP errors (data decode, page chunk loads) also reach the overlay.",
|
|
481
|
+
"const failed = (cause: unknown): Loaded => {",
|
|
482
|
+
' if (import.meta.hot && typeof document !== "undefined" && !isHttpError(cause) && (cause as Error | undefined)?.name !== "AbortError") _devError(cause);',
|
|
483
|
+
' return { data: [], failure: cause ?? new Error("Route failed") };',
|
|
484
|
+
"};",
|
|
485
|
+
...(derived
|
|
486
|
+
? [
|
|
487
|
+
"",
|
|
488
|
+
"// Derived resume model: `Root`'s props carry the route payload as data (the page's independent root boundary). In the",
|
|
489
|
+
"// browser its adopt run provides it as `Revived`; the Router levels' loads of that adopt (one synchronous task: the",
|
|
490
|
+
"// probe and adopt runs of every unit) answer from it (the server's chain on the same data, as `_ssrLevel`); once the",
|
|
491
|
+
"// task is over it is spent and every load (navigation, revalidation) fetches.",
|
|
492
|
+
"type RootData = { d: unknown[]; n?: readonly number[]; e?: { readonly status: number; readonly message: string }; a?: unknown; x?: readonly number[]; k?: unknown };",
|
|
493
|
+
"const Revived = createContext<ServerRoutePayload | null>(null);",
|
|
494
|
+
"const spent = new WeakMap<object, boolean>();",
|
|
495
|
+
"const revived = <R, T>(work: { get<V>(token: Context<V>): V }, ok: (data: ServerRoutePayload) => R, fail: (cause: unknown) => R, land: (result: R) => T): T | undefined => {",
|
|
496
|
+
" if (import.meta.env.SSR) return undefined;",
|
|
497
|
+
" const server = work.get(Revived);",
|
|
498
|
+
" if (!server || spent.get(server)) return undefined;",
|
|
499
|
+
" if (!spent.has(server)) (spent.set(server, false), queueMicrotask(() => spent.set(server, true)));",
|
|
500
|
+
" let result: R;",
|
|
501
|
+
" try {",
|
|
502
|
+
" result = server.error ? fail(new HttpError(server.error.status, server.error.message)) : ok(server);",
|
|
503
|
+
" } catch (cause) {",
|
|
504
|
+
" result = fail(cause);",
|
|
505
|
+
" }",
|
|
506
|
+
" return land(result);",
|
|
507
|
+
"};",
|
|
508
|
+
"// The adopt task's Router renders (probe + adopt runs of `Root`) are the page load: no scroll / focus / announce.",
|
|
509
|
+
"let adopting = false;",
|
|
510
|
+
`const adoptSwap = (apply: () => void, next: ParentNode): void => (adopting ? apply() : ${swap}(apply, next));`,
|
|
511
|
+
]
|
|
512
|
+
: []),
|
|
513
|
+
"",
|
|
514
|
+
"/** File-routed application: layouts wrap pages; client navigation fetches route data from the server. */",
|
|
515
|
+
"export function App({ router }: { router: AppRouter }) {",
|
|
516
|
+
...(useNest
|
|
517
|
+
? [
|
|
518
|
+
" // Layout props are App-level signals (resume state carries them): the request's data on the",
|
|
519
|
+
" // server, then each navigation's; kept layouts keep theirs. Level keys drive the nested Routers.",
|
|
520
|
+
` const server = useContext(ServerData)${derived ? " ?? useContext(Revived)" : ""};`,
|
|
521
|
+
` const nest = _layouts(server, router, NEST, ${nested ? levels + 1 : 0});`,
|
|
522
|
+
]
|
|
523
|
+
: []),
|
|
524
|
+
...(single
|
|
525
|
+
? [
|
|
526
|
+
" // One static page route and no navigate()/search params: nothing to route to in the browser, so no",
|
|
527
|
+
" // click interception / intent prefetch / popstate listener (links load documents; the runtime stays",
|
|
528
|
+
" // unloaded until an interaction needs it).",
|
|
529
|
+
]
|
|
530
|
+
: [
|
|
531
|
+
// Z1: nothing of the router loads at idle. `preload: "intent"` listeners don't boot a resumed page: the
|
|
532
|
+
// loader holds the first root-relative link click, loads this chunk and replays the click (intent on a
|
|
533
|
+
// link prefetches it); the listeners attach whenever the runtime starts.
|
|
534
|
+
...(live.length
|
|
535
|
+
? [
|
|
536
|
+
" // The shown live page's data signal: same-URL revalidations refresh it in place.",
|
|
537
|
+
` const live = _live(${source}${liveTop ? ", router" : ""});`,
|
|
538
|
+
]
|
|
539
|
+
: []),
|
|
540
|
+
" // Router listeners load on interaction: the resume loader holds a cold link click until they attach.",
|
|
541
|
+
...(nested || active
|
|
542
|
+
? [
|
|
543
|
+
" // Route effects (nested level sync, `<Link active>` re-marking) start with the first navigation, not at load.",
|
|
544
|
+
...(nested && live.length ? [" // Level sync: a search-only change of the shown live page moves no level key (refreshed in place)."] : []),
|
|
545
|
+
` const arm = () => _arm(router${nested ? `, () => _sync(router, nest, NEST${live.length ? ", (from, url) => _liveLevels(router, live, nest, NEST, from, url)" : ""})` : ""}${active ? ", () => onNavigate(() => _markActive)" : ""});`,
|
|
546
|
+
]
|
|
547
|
+
: []),
|
|
548
|
+
// URLs no page route matches (endpoints, files, other apps on the origin) are not intercepted: documents.
|
|
549
|
+
// ROUTER-LAZY: `match` — clicks / intent outside links (a signal handler's button, plain text) never load
|
|
550
|
+
// this chunk; live pages also watch forms (hover / focus / press inside one warms the page data readers).
|
|
551
|
+
` clientEvent(document, "click", (event: MouseEvent) => ${armed("interceptClick(event, router, modules)")}, { preload: "intent", match: "a"${lone} });`,
|
|
552
|
+
// A resumed page runs a link's own handlers from the resume loader's document capture listener: one that stops
|
|
553
|
+
// propagation there (a cdk Link's press) would hide the click from the bubbling listener above, which a client
|
|
554
|
+
// render's delegated dispatch (registered after it) never does. This capture listener (attached after the
|
|
555
|
+
// loader's) takes exactly those clicks: same-page `#hash` focus, client navigation (resume fuzz T31 /links).
|
|
556
|
+
...(mode === "csr"
|
|
557
|
+
? []
|
|
558
|
+
: [
|
|
559
|
+
` clientEvent(document, "click", (event: MouseEvent) => event.cancelBubble && ${armed("interceptClick(event, router, modules)")}, { capture: true, preload: "intent", match: "a"${lone} });`,
|
|
560
|
+
]),
|
|
561
|
+
` const intent = (event: Event) => {`,
|
|
562
|
+
...(forms ? [` _brand(_go);`] : []),
|
|
563
|
+
// Z1: the loader boots the router on link intent; the nested-layout / active-link effects arm then.
|
|
564
|
+
...(nested || active ? [` arm();`] : []),
|
|
565
|
+
// Live pages: focus in a form warms the chunks of the bindings reading the page / layout data.
|
|
566
|
+
...(live.length ? [` _liveIntent(event, live${useNest ? ", nest" : ""});`] : []),
|
|
567
|
+
` const anchor = routableAnchor(event, modules);`,
|
|
568
|
+
...(transitions ? [` if (anchor) transitions();`] : []),
|
|
569
|
+
` if (!anchor || anchor.getAttribute("data-rivet-prefetch") === "none") return;`,
|
|
570
|
+
` const url = new URL(anchor.href);`,
|
|
571
|
+
` prefetchModule(url, modules);`,
|
|
572
|
+
...(mode === "csr" ? [] : [` prefetchData(url, ${source}${keeps ? ", _keepIds(nest, NEST, url)" : ""});`]),
|
|
573
|
+
` };`,
|
|
574
|
+
` clientEvent(document, "pointerover", intent, { passive: true, preload: "intent", match: "${live.length ? "a,form" : "a"}"${lone} });`,
|
|
575
|
+
` clientEvent(document, "focusin", intent, { preload: "intent", match: "${live.length ? "a,form" : "a"}"${lone} });`,
|
|
576
|
+
...(live.length
|
|
577
|
+
? [
|
|
578
|
+
` clientEvent(document, "pointerdown", (event: PointerEvent) => _liveIntent(event, live${useNest ? ", nest" : ""}), { passive: true, preload: "intent", match: "form"${lone} });`,
|
|
579
|
+
]
|
|
580
|
+
: []),
|
|
581
|
+
` clientEvent(window, "popstate", (event: PopStateEvent) => ${armed("notePopstate(event)")}, { preload: "intent"${lone} });`,
|
|
582
|
+
]),
|
|
583
|
+
...(go
|
|
584
|
+
? [
|
|
585
|
+
" // navigate() / invalidate() from anywhere reach this router.",
|
|
586
|
+
// Server apps with layouts: `invalidate(key)` keeps loaders using the layout state (deps, kept data).
|
|
587
|
+
` clientEvent(document, "rivet:go", () => ${armed(live.length ? `_serveLive(router, live${useNest ? ", nest, NEST" : ""})` : `_serve(router${useNest && mode === "server" ? ", nest, NEST" : ""})`)}, { preload: "intent"${lone} });`,
|
|
588
|
+
]
|
|
589
|
+
: []),
|
|
590
|
+
...(rootLayout
|
|
591
|
+
? [
|
|
592
|
+
" // The root layout wraps the routes region and stays mounted.",
|
|
593
|
+
` const layoutData = ${slot(layoutTable.root)}.d, layoutURL = ${slot(layoutTable.root)}.u, noParams = ${slot(layoutTable.root)}.p;`,
|
|
594
|
+
]
|
|
595
|
+
: []),
|
|
596
|
+
" // Shared failure views (nearest `_404` for 404s, else `_error` / a generic alert) and level views.",
|
|
597
|
+
...failureKeys.map((key, j) => ` const fail${j} = (failure: unknown, url: URL) => ${failureView(key)};`),
|
|
598
|
+
// One Router per persisted layout (innermost first), then the top-level routes; a level's enter
|
|
599
|
+
// views (around the next level's Router) come right before the table that uses them.
|
|
600
|
+
...[...tables.slice(1)]
|
|
601
|
+
.sort((a, b) => b.k - a.k)
|
|
602
|
+
.concat(tables[0])
|
|
603
|
+
.flatMap((table, index, all) => [
|
|
604
|
+
...(all.findIndex((other) => other.k === table.k) === index
|
|
605
|
+
? enterKeys.flatMap((key, j) => Number(key.split(":")[0]) === table.k ? [` const enter${j} = (result: Loaded, url: URL) => ${enterView(key)};`] : [])
|
|
606
|
+
: []),
|
|
607
|
+
routeTableOf(table),
|
|
608
|
+
]),
|
|
609
|
+
// The Router sits directly inside an element so the compiler makes it (and `routes`) a resumable
|
|
610
|
+
// region; the root layout wraps it outside that region and stays mounted.
|
|
611
|
+
...(derived ? [" // Derived model: built where it renders (inside the root layout's adopt run), not passed in ready-made."] : []),
|
|
612
|
+
` const view = ${derived ? "() => " : ""}<div data-rivet-routes=""${go ? ' data-rivet-go=""' : ""}><Router router={${nested ? "_level(router, nest, 0, 0)" : liveTop ? "_liveRouter(router, live)" : "router"}} routes={routes} swap={${swapProp}} hold={heldRoutes} notFound={(url) => (${unmatched})} /></div>;`,
|
|
613
|
+
!rootLayout
|
|
614
|
+
? ` return view${derived ? "()" : ""};`
|
|
615
|
+
: rootLayoutServer
|
|
616
|
+
? " // Server error renders carry the root layout's data when its loader succeeded; else no root layout.\n" +
|
|
617
|
+
` return server?.error && !server.data.length ? view${derived ? "()" : ""} : ${level(0, `<${layouts.get(rootLayout)} data={layoutData} params={noParams} url={layoutURL}>{view${derived ? "()" : ""}}</${layouts.get(rootLayout)}>`)};`
|
|
618
|
+
: ` return ${level(0, `<${layouts.get(rootLayout)} data={layoutData} params={noParams} url={layoutURL}>{view${derived ? "()" : ""}}</${layouts.get(rootLayout)}>`)};`,
|
|
619
|
+
"}",
|
|
620
|
+
...(derived ? rootComponent(transitions !== null, search) : []),
|
|
621
|
+
"",
|
|
622
|
+
].join("\n");
|
|
623
|
+
// --- routes.ts: server route table (server modules load lazily, once per process in builds).
|
|
624
|
+
// Every server loader returns a normalized `ServerModule` (exports found by tag, `server/module.ts`).
|
|
625
|
+
let serverUsed = false;
|
|
626
|
+
const serverModule = (file) => {
|
|
627
|
+
if (!file)
|
|
628
|
+
return "undefined";
|
|
629
|
+
serverUsed = true;
|
|
630
|
+
return `once(async () => serverExports(await import(${str(spec(file))}), ${str(posix.join(routesDir, file))}, true))`;
|
|
631
|
+
};
|
|
632
|
+
// Layouts are static imports of the app already; pages stay lazy.
|
|
633
|
+
const layoutModules = new Map([...layouts.keys()].map((file, i) => [file, "L" + i]));
|
|
634
|
+
// In-file server exports (`load(…)`, `onGet(…)`… calls): normalized from the page/layout module by tag,
|
|
635
|
+
// merged with a sibling `*.server.ts` when there is one. Sibling-only routes load the sibling raw.
|
|
636
|
+
let mergeUsed = false;
|
|
637
|
+
const serverOf = (module, layout) => {
|
|
638
|
+
if (!module.inline || !module.page)
|
|
639
|
+
return serverModule(module.server);
|
|
640
|
+
serverUsed = true;
|
|
641
|
+
if (module.server)
|
|
642
|
+
mergeUsed = true;
|
|
643
|
+
const own = layout ? layoutModules.get(module.page) : `await import(${str(spec(module.page))})`;
|
|
644
|
+
const files = str([posix.join(routesDir, module.page), module.server ? posix.join(routesDir, module.server) : ""]);
|
|
645
|
+
return module.server
|
|
646
|
+
? `once(async () => mergeServer(${own}, await import(${str(spec(module.server))}), ${files}))`
|
|
647
|
+
: `once(async () => serverExports(${own}, ${str(posix.join(routesDir, module.page))}))`;
|
|
648
|
+
};
|
|
649
|
+
const routes = [
|
|
650
|
+
HEADER,
|
|
651
|
+
`/// <reference types="vite/client" />`,
|
|
652
|
+
`import type { AppRoute } from "@rivetjs/router/server";`,
|
|
653
|
+
...[...layoutModules].map(([file, name]) => `import * as ${name} from ${str(spec(file))};`),
|
|
654
|
+
"",
|
|
655
|
+
"// Builds: a module loads once and later requests reuse it. Dev: every request imports (Vite serves",
|
|
656
|
+
"// the current version after an edit, so nothing stale is pinned).",
|
|
657
|
+
"const once = <T>(load: () => Promise<T>): (() => Promise<T>) => {",
|
|
658
|
+
" if (import.meta.env.DEV) return load;",
|
|
659
|
+
" let loaded: Promise<T> | undefined;",
|
|
660
|
+
" return () => (loaded ??= load().catch((error: unknown) => ((loaded = undefined), Promise.reject(error))));",
|
|
661
|
+
"};",
|
|
662
|
+
"",
|
|
663
|
+
"export const routes: AppRoute[] = [",
|
|
664
|
+
...table.routes.map((route) => ` { id: ${str(route.id)}, pattern: ${str(at(route.pattern))}, page: ${Boolean(route.page)}, layouts: [${route.layouts
|
|
665
|
+
.map((l) => serverOf(l, true))
|
|
666
|
+
.join(", ")}]${route.page && route.layouts.length
|
|
667
|
+
? `, keys: ${str(route.layouts.map((l) => {
|
|
668
|
+
const folder = posix.dirname(l.page ?? l.server ?? "");
|
|
669
|
+
return folder === "." ? "/" : "/" + folder;
|
|
670
|
+
}))}`
|
|
671
|
+
: ""}${route.server || route.inline ? `, server: ${serverOf(route, false)}` : ""} },`),
|
|
672
|
+
"];",
|
|
673
|
+
"",
|
|
674
|
+
"type Universal = [number, { head?: unknown }];",
|
|
675
|
+
"/** Universal modules per route id with their index into the route's data (for `head` exports). */",
|
|
676
|
+
"export const universal: Record<string, () => Promise<Universal[]>> = {",
|
|
677
|
+
...pageRoutes
|
|
678
|
+
.filter((route, index, all) => all.findIndex((r) => r.id === route.id) === index)
|
|
679
|
+
.map((route) => {
|
|
680
|
+
const items = route.layouts.flatMap((l, i) => (l.page ? [`[${i}, ${layoutModules.get(l.page)}] as Universal`] : []));
|
|
681
|
+
items.push(`import(${str(spec(route.page))}).then((m) => [${route.layouts.length}, m] as Universal)`);
|
|
682
|
+
return ` ${str(route.id)}: once(() => Promise.all([${items.join(", ")}])),`;
|
|
683
|
+
}),
|
|
684
|
+
"};",
|
|
685
|
+
"",
|
|
686
|
+
].join("\n");
|
|
687
|
+
const routesFile = serverUsed
|
|
688
|
+
? routes.replace(`import type { AppRoute } from "@rivetjs/router/server";`, `import { ${mergeUsed ? "mergeServer, " : ""}serverExports, type AppRoute } from "@rivetjs/router/server";`)
|
|
689
|
+
: routes;
|
|
690
|
+
// --- entry.server.tsx: render one request inside the precomputed route data.
|
|
691
|
+
const entry = [
|
|
692
|
+
HEADER,
|
|
693
|
+
...(search ? [`import { signal } from "@rivetjs/reactivity";`] : []),
|
|
694
|
+
`import { Provider } from "@rivetjs/runtime/server";`,
|
|
695
|
+
...(derived ? [`import { _rListen } from "@rivetjs/runtime/server-derived";`] : [`import { createRouter } from "@rivetjs/runtime/router/server";`]),
|
|
696
|
+
`import { ServerData } from "@rivetjs/router/client";`,
|
|
697
|
+
...(transitions ? [`import { Navigation, navigation } from "@rivetjs/router/navigation";`] : []),
|
|
698
|
+
...(search ? [`import { SearchParams } from "@rivetjs/router/search";`] : []),
|
|
699
|
+
`import type { PageInput } from "@rivetjs/router/server";`,
|
|
700
|
+
derived ? `import Root from "./app.tsx";` : `import { App } from "./app.tsx";`,
|
|
701
|
+
`export { routes, universal } from "./routes.ts";`,
|
|
702
|
+
'/** URL base path the route patterns carry (`""`: root); adapters serve files and prerender under it. */',
|
|
703
|
+
`export const base = ${str(base)};`,
|
|
704
|
+
"// The server build bundles the handler with the app, so both share one runtime instance.",
|
|
705
|
+
`export { createApp } from "@rivetjs/router/server";`,
|
|
706
|
+
...(options.document && options.prefetch
|
|
707
|
+
? [
|
|
708
|
+
`import shell from ${str(fromOut(options.document))};`,
|
|
709
|
+
`import { documentPrefetch } from "@rivetjs/router/prefetch";`,
|
|
710
|
+
"/** Document prefetch (`rivetRouter({ prefetch })`): Speculation Rules + intent fallback in every page's head. */",
|
|
711
|
+
`export const document = documentPrefetch(shell, ${str({ ...(typeof options.prefetch === "string" ? { default: options.prefetch } : {}), ...(single ? {} : { routed: true, ...(options.prefetchPaths === false ? {} : { routes: routePathRegExp(table.routes.filter((route) => !route.page || !documentFiles.has(route.page)).map((route) => at(route.pattern))) }) }), ...(options.prefetchEagerness && options.prefetchEagerness !== "moderate" ? { eagerness: options.prefetchEagerness } : {}) })});`,
|
|
712
|
+
]
|
|
713
|
+
: options.document
|
|
714
|
+
? [`export { default as document } from ${str(fromOut(options.document))};`]
|
|
715
|
+
: []),
|
|
716
|
+
...(kit ? ["/** Pages hold the document start until the shell rendered (component head). */", "export const headComponents = true;"] : []),
|
|
717
|
+
...(slash
|
|
718
|
+
? [
|
|
719
|
+
"/** Trailing-slash policy: page requests in the other form answer 308; the static adapter lays files out for it. */",
|
|
720
|
+
`export const trailingSlash = ${str(slash)};`,
|
|
721
|
+
]
|
|
722
|
+
: []),
|
|
723
|
+
...(options.document && options.prefetch
|
|
724
|
+
? [
|
|
725
|
+
"/** Document prefetch on (`rivetRouter({ prefetch })`): islands-only lean pages (bridge D-lean) keep the runtime fetch-ahead. */",
|
|
726
|
+
"export const fetchAhead = true;",
|
|
727
|
+
]
|
|
728
|
+
: []),
|
|
729
|
+
...(options.preloadBackground !== undefined
|
|
730
|
+
? [
|
|
731
|
+
"/** Preload planner background budget (bytes; `rivetRouter({ preload })`): the app's default, adapter options override it. */",
|
|
732
|
+
`export const preloadBackground = ${options.preloadBackground};`,
|
|
733
|
+
]
|
|
734
|
+
: []),
|
|
735
|
+
...(options.routeRules
|
|
736
|
+
? [
|
|
737
|
+
"/** Route rules (`rivetRouter({ routeRules })`): headers + page cache per path pattern; adapter options replace them. */",
|
|
738
|
+
`export const routeRules = ${JSON.stringify(options.routeRules)};`,
|
|
739
|
+
]
|
|
740
|
+
: []),
|
|
741
|
+
"",
|
|
742
|
+
...(derived
|
|
743
|
+
? [
|
|
744
|
+
"/** Derived resume model: link clicks anywhere in the document are `Root`'s (its App's document click listener). */",
|
|
745
|
+
"function Links() {",
|
|
746
|
+
' _rListen("a[href]");',
|
|
747
|
+
" return null;",
|
|
748
|
+
"}",
|
|
749
|
+
"/** Derived resume model: the route payload `Root` carries as data (`undefined` loader results listed in `n`). */",
|
|
750
|
+
"const rootData = (input: PageInput) => {",
|
|
751
|
+
" const d: unknown[] = [],",
|
|
752
|
+
" n: number[] = [];",
|
|
753
|
+
" input.data.forEach((value, i) => (value === undefined ? ((d[i] = null), n.push(i)) : (d[i] = value)));",
|
|
754
|
+
" return { d, ...(n.length ? { n } : {}), ...(input.error ? { e: input.error } : {}), ...(input.action ? { a: input.action } : {}), ...(input.byURL ? { x: input.byURL } : {}), ...(input.deps ? { k: input.deps } : {}) };",
|
|
755
|
+
"};",
|
|
756
|
+
"",
|
|
757
|
+
]
|
|
758
|
+
: []),
|
|
759
|
+
"/** Page factory for one request; the app reads the handler's loader results through ServerData. */",
|
|
760
|
+
"export function page(input: PageInput) {",
|
|
761
|
+
" return () => (",
|
|
762
|
+
` <Provider token={ServerData} value={{ data: input.data, url: input.url, head: input.head, ...(input.error ? { error: input.error } : {}), ...(input.action ? { action: input.action } : {})${useNest ? ", ...(input.byURL ? { byURL: input.byURL } : {}), ...(input.deps ? { deps: input.deps } : {})" : ""}${kit ? ", ...(input.headSink ? { headSink: input.headSink } : {})" : ""} }}>`,
|
|
763
|
+
...wrap([
|
|
764
|
+
...(search
|
|
765
|
+
? [
|
|
766
|
+
[
|
|
767
|
+
"{/* useSearchParams(): the request's; resumed regions revive to the browser's signal. */}",
|
|
768
|
+
"<Provider token={SearchParams} value={signal(input.url.searchParams)}>",
|
|
769
|
+
],
|
|
770
|
+
]
|
|
771
|
+
: []),
|
|
772
|
+
...(transitions
|
|
773
|
+
? [
|
|
774
|
+
[
|
|
775
|
+
"{/* Resumed regions reading useNavigation() revive to the browser's navigation signal. */}",
|
|
776
|
+
"<Provider token={Navigation} value={navigation}>",
|
|
777
|
+
],
|
|
778
|
+
]
|
|
779
|
+
: []),
|
|
780
|
+
], derived ? "<>\n <Links />\n <Root s={rootData(input)} />\n </>" : "<App router={createRouter(input.url)} />", " "),
|
|
781
|
+
" </Provider>",
|
|
782
|
+
" );",
|
|
783
|
+
"}",
|
|
784
|
+
"",
|
|
785
|
+
].join("\n");
|
|
786
|
+
// --- entry.client.tsx (CSR): mount the app with the browser router.
|
|
787
|
+
const client = [
|
|
788
|
+
HEADER,
|
|
789
|
+
`/// <reference types="vite/client" />`,
|
|
790
|
+
`import { mount, type DOMValue } from "@rivetjs/runtime/dom";`,
|
|
791
|
+
`import { catchError } from "@rivetjs/reactivity";`,
|
|
792
|
+
`import { createRouter } from "@rivetjs/runtime/router/dom";`,
|
|
793
|
+
`import { App, _devError } from "./app.tsx";`,
|
|
794
|
+
"",
|
|
795
|
+
`const container = document.getElementById("app");`,
|
|
796
|
+
`if (!container) throw new Error('CSR shell has no element with id "app"');`,
|
|
797
|
+
"// Dev: render errors no ErrorBoundary handles reach Vite's error overlay (then propagate as before).",
|
|
798
|
+
"const guarded = (view: () => DOMValue) =>",
|
|
799
|
+
" import.meta.hot",
|
|
800
|
+
" ? () =>",
|
|
801
|
+
" catchError(view, (error) => {",
|
|
802
|
+
" _devError(error);",
|
|
803
|
+
" throw error;",
|
|
804
|
+
" })!",
|
|
805
|
+
" : view;",
|
|
806
|
+
"let dispose = mount(guarded(() => <App router={createRouter()} />), container);",
|
|
807
|
+
"// Dev: components hot-replace in place (state kept); other app.tsx updates re-mount the app.",
|
|
808
|
+
"if (import.meta.hot)",
|
|
809
|
+
` import.meta.hot.accept("./app.tsx", (next) => {`,
|
|
810
|
+
" if (!next) return;",
|
|
811
|
+
" dispose();",
|
|
812
|
+
' const Next = (next as unknown as typeof import("./app.tsx")).App;',
|
|
813
|
+
" dispose = mount(guarded(() => <Next router={createRouter()} />), container);",
|
|
814
|
+
" });",
|
|
815
|
+
"",
|
|
816
|
+
].join("\n");
|
|
817
|
+
// --- dev-mount.tsx (server/static dev): client-renders the app over the server HTML, from the
|
|
818
|
+
// route data the page embeds (or fetched), then takes hot updates. Client-rendered dev pages load it
|
|
819
|
+
// directly (it mounts at once); resumed ones through entry.dev.tsx, on their first hot update.
|
|
820
|
+
const devMount = [
|
|
821
|
+
HEADER,
|
|
822
|
+
`/// <reference types="vite/client" />`,
|
|
823
|
+
`import { mount, Provider } from "@rivetjs/runtime/dom";`,
|
|
824
|
+
`import { catchError } from "@rivetjs/reactivity";`,
|
|
825
|
+
`import { createRouter } from "@rivetjs/runtime/router/dom";`,
|
|
826
|
+
`import { ServerData, dataURL, decodeData, type ServerRoutePayload } from "@rivetjs/router/client";`,
|
|
827
|
+
`import * as app from "./app.tsx";`,
|
|
828
|
+
"",
|
|
829
|
+
"let container: Element | null = null;",
|
|
830
|
+
"let dispose = () => {};",
|
|
831
|
+
"let current = app;",
|
|
832
|
+
'type Body = { data?: unknown[]; head?: ServerRoutePayload["head"]; error?: ServerRoutePayload["error"]; action?: ServerRoutePayload["action"]; deps?: ServerRoutePayload["deps"]; redirect?: string };',
|
|
833
|
+
"/** Client-render the app into `into` (the page's `[data-rivet-dev]` container), replacing its content. */",
|
|
834
|
+
"export async function start(into: Element, module: typeof app = current): Promise<void> {",
|
|
835
|
+
" container = into;",
|
|
836
|
+
" current = module;",
|
|
837
|
+
" const url = new URL(location.href);",
|
|
838
|
+
" // A client-rendered dev page embeds its route data; resumed pages and hot re-mounts fetch it.",
|
|
839
|
+
" let text: string;",
|
|
840
|
+
` const embedded = document.getElementById("rivet-dev-data");`,
|
|
841
|
+
" if (embedded) {",
|
|
842
|
+
" text = embedded.textContent!;",
|
|
843
|
+
" embedded.remove();",
|
|
844
|
+
` } else text = await (await fetch(dataURL(url), { headers: { accept: "application/json" } })).text();`,
|
|
845
|
+
" const body = (await decodeData(text)) as Body;",
|
|
846
|
+
" if (body.redirect !== undefined) return location.assign(body.redirect);",
|
|
847
|
+
" const server: ServerRoutePayload = { data: body.data ?? [], url, ...(body.head ? { head: body.head } : {}), ...(body.error ? { error: body.error } : {}), ...(body.action ? { action: body.action } : {}), ...(body.deps ? { deps: body.deps } : {}) };",
|
|
848
|
+
" // Lazy pages render synchronously once their module is in.",
|
|
849
|
+
" await module.loadPage(url);",
|
|
850
|
+
" dispose();",
|
|
851
|
+
` into.textContent = "";`,
|
|
852
|
+
" // Render errors no ErrorBoundary handles reach Vite's error overlay (then propagate as before).",
|
|
853
|
+
" const view = () => <Provider token={ServerData} value={server}><module.App router={createRouter()} /></Provider>;",
|
|
854
|
+
" dispose = mount(() => catchError(view, (error) => {",
|
|
855
|
+
" module._devError(error);",
|
|
856
|
+
" throw error;",
|
|
857
|
+
" })!, into);",
|
|
858
|
+
" // Route stylesheets linked by the server (no flash) give way to Vite's injected <style>s.",
|
|
859
|
+
` for (const link of document.querySelectorAll('link[rel="stylesheet"][href$="direct"]')) link.remove();`,
|
|
860
|
+
"}",
|
|
861
|
+
"/** Load the current page's modules without rendering (a resumed page then gets their hot updates). */",
|
|
862
|
+
"export const preload = (): Promise<unknown> | undefined => app.loadPage(new URL(location.href));",
|
|
863
|
+
"// Once mounted: components hot-replace in place (state kept); other updates reaching app.tsx re-mount",
|
|
864
|
+
"// the app. Server-only edits (loaders) refetch the route data and re-mount.",
|
|
865
|
+
'// A client-rendered dev page (`data-rivet-dev=""`) mounts now.',
|
|
866
|
+
`const page = document.querySelector('[data-rivet-dev=""]');`,
|
|
867
|
+
"if (page) await start(page);",
|
|
868
|
+
"if (import.meta.hot) {",
|
|
869
|
+
` import.meta.hot.accept("./app.tsx", (next) => next && container && start(container, next as unknown as typeof app));`,
|
|
870
|
+
` import.meta.hot.on("rivet:data", () => container && start(container));`,
|
|
871
|
+
"}",
|
|
872
|
+
"",
|
|
873
|
+
].join("\n");
|
|
874
|
+
// --- entry.dev.tsx (server/static dev, resumed pages: `data-rivet-dev="resume"`): the page resumes
|
|
875
|
+
// like production; this client-renders it (dev-mount.tsx) on its first hot update.
|
|
876
|
+
const dev = [
|
|
877
|
+
HEADER,
|
|
878
|
+
`/// <reference types="vite/client" />`,
|
|
879
|
+
`const container = document.querySelector('[data-rivet-dev="resume"]');`,
|
|
880
|
+
`const mounter = () => import("./dev-mount.tsx");`,
|
|
881
|
+
"// Dev a11y checks audit the server markup at load (the runtime that also starts them loads later).",
|
|
882
|
+
`void import("@rivetjs/runtime/dev/a11y").then((module) => module.start());`,
|
|
883
|
+
"if (container && import.meta.hot) {",
|
|
884
|
+
" const hot = import.meta.hot;",
|
|
885
|
+
" let resumed = true;",
|
|
886
|
+
...DEV_BENIGN("const benign"),
|
|
887
|
+
...DEV_DESCRIBE("const describe"),
|
|
888
|
+
` const send = (error: unknown, suffix = "") => {`,
|
|
889
|
+
` const report = describe(error);`,
|
|
890
|
+
` if (benign(report.message)) return;`,
|
|
891
|
+
` hot.send("rivet:client-error", { message: report.message + suffix, stack: report.stack });`,
|
|
892
|
+
` };`,
|
|
893
|
+
" // Resume errors (missing chunk, changed HTML, a handler or binding throwing) reach the overlay; the",
|
|
894
|
+
" // page does not reload (the loader's fallback), so the overlay stays.",
|
|
895
|
+
` document.addEventListener("rivet:resume-error", (event) => {`,
|
|
896
|
+
" const detail = (event as CustomEvent).detail as { symbol?: string; error?: unknown } | unknown;",
|
|
897
|
+
` const error = detail && typeof detail === "object" && "error" in detail ? detail.error : detail;`,
|
|
898
|
+
` send(error, detail && typeof detail === "object" && "symbol" in detail ? " (" + String(detail.symbol) + ")" : "");`,
|
|
899
|
+
" event.preventDefault();",
|
|
900
|
+
" });",
|
|
901
|
+
" // Uncaught errors of what the browser creates (a route rendered by the resumed router) too.",
|
|
902
|
+
` const uncaught = (event: ErrorEvent | PromiseRejectionEvent) => resumed && send("error" in event ? (event.error ?? event) : event.reason);`,
|
|
903
|
+
` addEventListener("error", uncaught);`,
|
|
904
|
+
` addEventListener("unhandledrejection", uncaught);`,
|
|
905
|
+
" // The page's modules load at idle (no component runs): edits to them then reach this page.",
|
|
906
|
+
` const idle = (fn: () => void) => ("requestIdleCallback" in window ? requestIdleCallback(fn) : setTimeout(fn, 200));`,
|
|
907
|
+
" idle(() => void mounter().then((module) => module.preload()));",
|
|
908
|
+
" // Resumed regions run symbols, not hot-replaceable components: the first code update stops resuming",
|
|
909
|
+
" // (disposes the resume runtime) and client-renders the app; from then on components hot-replace in place.",
|
|
910
|
+
" const fallback = async () => {",
|
|
911
|
+
" if (!resumed) return;",
|
|
912
|
+
" resumed = false;",
|
|
913
|
+
` document.dispatchEvent(new CustomEvent("rivet:dev-client-render", { detail: { phase: "start" } }));`,
|
|
914
|
+
` const state = document.querySelector('script[type="rivet/state"]');`,
|
|
915
|
+
" const runtime = state ? (JSON.parse(state.textContent!) as { rt?: string | null }).rt : null;",
|
|
916
|
+
" // The loader stops booting the resumed router on link clicks (Z1): the client-rendered app routes them.",
|
|
917
|
+
" delete (document as { $r?: { p(): { n?: number[] } } }).$r?.p().n;",
|
|
918
|
+
" // The loader's instance: an absolute URL (Vite adds `?import` to a relative one, a second instance).",
|
|
919
|
+
" if (runtime) ((await import(/* @vite-ignore */ new URL(runtime, location.href).href)).default as { dispose?(): void }).dispose?.();",
|
|
920
|
+
" await (await mounter()).start(container);",
|
|
921
|
+
` document.dispatchEvent(new CustomEvent("rivet:dev-client-render", { detail: { phase: "done" } }));`,
|
|
922
|
+
" };",
|
|
923
|
+
" // DevTools asks for the same switch when its inspection tabs need live components (`rivet:devtools-inspect`).",
|
|
924
|
+
` document.addEventListener("rivet:devtools-inspect", () => void fallback());`,
|
|
925
|
+
` hot.on("vite:beforeUpdate", (payload) => {`,
|
|
926
|
+
` if (payload.updates.some((update) => update.type === "js-update" && !/\\.css(?:$|\\?)/.test(update.path))) void fallback();`,
|
|
927
|
+
" });",
|
|
928
|
+
" // Server-only edits: a fresh server render (resumed again).",
|
|
929
|
+
` hot.on("rivet:data", () => resumed && location.reload());`,
|
|
930
|
+
"}",
|
|
931
|
+
"export {};",
|
|
932
|
+
"",
|
|
933
|
+
].join("\n");
|
|
934
|
+
// --- entry.resume.ts: resume runtime with the router's identified contexts (navigation state with
|
|
935
|
+
// transitions; search params when read). Neither: the compiler's default resume entry.
|
|
936
|
+
const contexts = [...(transitions ? ["[Navigation, navigation]"] : []), ...(search ? ["[SearchParams, searchParams]"] : [])];
|
|
937
|
+
const resumeEntry = [
|
|
938
|
+
HEADER,
|
|
939
|
+
`import { createResume } from "@rivetjs/runtime/dom-resume";`,
|
|
940
|
+
...(transitions ? [`import { Navigation, navigation } from "@rivetjs/router/navigation";`] : []),
|
|
941
|
+
...(search ? [`import { SearchParams, searchParams } from "@rivetjs/router/search";`] : []),
|
|
942
|
+
"",
|
|
943
|
+
`export default createResume({ contexts: [${contexts.join(", ")}] });`,
|
|
944
|
+
"",
|
|
945
|
+
].join("\n");
|
|
946
|
+
// --- routes.d.ts: typed ids, params and hrefs.
|
|
947
|
+
const ids = [...new Set(table.routes.map((r) => r.id))];
|
|
948
|
+
const paramsOf = (id) => {
|
|
949
|
+
const entries = table.routes.filter((r) => r.id === id);
|
|
950
|
+
const all = new Set(entries.flatMap((r) => r.params));
|
|
951
|
+
const required = [...all].filter((p) => entries.every((r) => r.params.includes(p)));
|
|
952
|
+
return all.size ? "{ " + [...all].map((p) => `${p}${required.includes(p) ? "" : "?"}: string`).join("; ") + " }" : "{}";
|
|
953
|
+
};
|
|
954
|
+
// Page URL shapes: params and splats become `${string}` (`RoutePath`, `<Link to>`).
|
|
955
|
+
const literal = (text) => text.replace(/[\\`]|\$\{/g, (c) => "\\" + c);
|
|
956
|
+
// Static segments as authors write them (patterns are URL-encoded).
|
|
957
|
+
const pathOf = (pattern) => "`" +
|
|
958
|
+
pattern
|
|
959
|
+
.split("/")
|
|
960
|
+
.map((part) => (/^[:*]/.test(part) ? "${string}" : literal(decodeURIComponent(part))))
|
|
961
|
+
.join("/") +
|
|
962
|
+
"`";
|
|
963
|
+
const paths = [...new Set(pageRoutes.map((r) => pathOf(r.pattern)))];
|
|
964
|
+
// Loader data per route id: the route module's `load(…)` export (in-file or sibling), found by tag.
|
|
965
|
+
const typeOf = (module, wrap, sibling) => {
|
|
966
|
+
const parts = [
|
|
967
|
+
...(module.inline && module.page ? [wrap(`typeof import(${str(spec(module.page))})`)] : []),
|
|
968
|
+
...(module.server ? [sibling(`typeof import(${str(spec(module.server))})`)] : []),
|
|
969
|
+
];
|
|
970
|
+
return parts.length ? parts.join(" & ") : undefined;
|
|
971
|
+
};
|
|
972
|
+
const firstOf = (id) => table.routes.find((r) => r.id === id);
|
|
973
|
+
const data = ids.flatMap((id) => {
|
|
974
|
+
const type = typeOf(firstOf(id), (m) => `ModuleData<${m}>`, (m) => `ModuleData<${m}>`);
|
|
975
|
+
return type ? [` ${str(id)}: ${type};`] : [];
|
|
976
|
+
});
|
|
977
|
+
// Form actions per route id (the page's own module: layouts have none).
|
|
978
|
+
const actions = ids.flatMap((id) => {
|
|
979
|
+
const type = typeOf(firstOf(id), (m) => `ModuleActions<${m}>`, (m) => `ModuleActions<${m}>`);
|
|
980
|
+
return type ? [` ${str(id)}: ${type};`] : [];
|
|
981
|
+
});
|
|
982
|
+
// Layouts by id (file path without extension). Params: the root layout stays mounted and gets
|
|
983
|
+
// none; nested layouts get the page's params (required when every page under them has it).
|
|
984
|
+
const layoutIds = new Map();
|
|
985
|
+
for (const route of pageRoutes)
|
|
986
|
+
for (const layout of route.layouts)
|
|
987
|
+
if (layout.page)
|
|
988
|
+
layoutIds.set(layout.page.replace(/\.[jt]sx$/, ""), layout);
|
|
989
|
+
const layoutParams = [...layoutIds].map(([id, module]) => {
|
|
990
|
+
if (module.page === rootLayout)
|
|
991
|
+
return ` ${str(id)}: {};`;
|
|
992
|
+
const entries = pageRoutes.filter((r) => r.layouts.some((l) => l.page === module.page));
|
|
993
|
+
const all = new Set(entries.flatMap((r) => r.params));
|
|
994
|
+
const required = (p) => entries.every((r) => r.params.includes(p));
|
|
995
|
+
return ` ${str(id)}: ${all.size ? "{ " + [...all].map((p) => `${p}${required(p) ? "" : "?"}: string`).join("; ") + " }" : "{}"};`;
|
|
996
|
+
});
|
|
997
|
+
const layoutData = [...layoutIds].flatMap(([id, module]) => {
|
|
998
|
+
const type = typeOf(module, (m) => `ModuleData<${m}>`, (m) => `ModuleData<${m}>`);
|
|
999
|
+
return type ? [` ${str(id)}: ${type};`] : [];
|
|
1000
|
+
});
|
|
1001
|
+
const lines = [...data, ...actions, ...layoutData].join("\n");
|
|
1002
|
+
const helpers = ["ModuleData", "ModuleActions"].filter((name) => lines.includes(name + "<"));
|
|
1003
|
+
const types = [
|
|
1004
|
+
HEADER,
|
|
1005
|
+
...(helpers.length ? [`import type { ${helpers.join(", ")} } from "@rivetjs/router";`] : []),
|
|
1006
|
+
'declare module "@rivetjs/router" {',
|
|
1007
|
+
" interface RouteParams {",
|
|
1008
|
+
...ids.map((id) => ` ${str(id)}: ${paramsOf(id)};`),
|
|
1009
|
+
" }",
|
|
1010
|
+
" interface RouteRegistry {",
|
|
1011
|
+
` path: ${paths.length ? paths.join(" | ") : "never"};`,
|
|
1012
|
+
" }",
|
|
1013
|
+
...(data.length ? [" interface RouteData {", ...data, " }"] : []),
|
|
1014
|
+
...(actions.length ? [" interface RouteActions {", ...actions, " }"] : []),
|
|
1015
|
+
...(layoutParams.length ? [" interface LayoutParams {", ...layoutParams, " }"] : []),
|
|
1016
|
+
...(layoutData.length ? [" interface LayoutData {", ...layoutData, " }"] : []),
|
|
1017
|
+
"}",
|
|
1018
|
+
"export {};",
|
|
1019
|
+
"",
|
|
1020
|
+
].join("\n");
|
|
1021
|
+
// CSR pages mount in the browser: nothing resumes.
|
|
1022
|
+
const resumeModules = mode === "csr" ? [] : [posix.join(outDir, "app.tsx"), ...[...pages.keys(), ...layouts.keys(), ...specials.keys()].map((f) => posix.join(routesDir, f))];
|
|
1023
|
+
return {
|
|
1024
|
+
files: mode === "csr"
|
|
1025
|
+
? { "app.tsx": app, "entry.client.tsx": client, "routes.d.ts": types }
|
|
1026
|
+
: {
|
|
1027
|
+
"app.tsx": app,
|
|
1028
|
+
"routes.ts": routesFile,
|
|
1029
|
+
"entry.server.tsx": entry,
|
|
1030
|
+
"entry.dev.tsx": dev,
|
|
1031
|
+
"dev-mount.tsx": devMount,
|
|
1032
|
+
"routes.d.ts": types,
|
|
1033
|
+
...(contexts.length ? { "entry.resume.ts": resumeEntry } : {}),
|
|
1034
|
+
},
|
|
1035
|
+
resumeModules,
|
|
1036
|
+
};
|
|
1037
|
+
}
|