@rivetjs/router 0.0.0-stage → 0.1.0-alpha.0

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/adapters/assemble.d.ts +32 -0
  4. package/adapters/assemble.js +32 -0
  5. package/adapters/build.d.ts +24 -0
  6. package/adapters/build.js +43 -0
  7. package/adapters/bun.d.ts +18 -0
  8. package/adapters/bun.js +44 -0
  9. package/adapters/cloudflare.d.ts +5 -0
  10. package/adapters/cloudflare.js +8 -0
  11. package/adapters/cluster.d.ts +7 -0
  12. package/adapters/cluster.js +130 -0
  13. package/adapters/csr.d.ts +12 -0
  14. package/adapters/csr.js +11 -0
  15. package/adapters/deno.d.ts +13 -0
  16. package/adapters/deno.js +35 -0
  17. package/adapters/fetch.d.ts +55 -0
  18. package/adapters/fetch.js +72 -0
  19. package/adapters/files.d.ts +7 -0
  20. package/adapters/files.js +103 -0
  21. package/adapters/idle-memory.d.ts +23 -0
  22. package/adapters/idle-memory.js +91 -0
  23. package/adapters/netlify.d.ts +13 -0
  24. package/adapters/netlify.js +15 -0
  25. package/adapters/node.d.ts +163 -0
  26. package/adapters/node.js +940 -0
  27. package/adapters/pack.d.ts +39 -0
  28. package/adapters/pack.js +360 -0
  29. package/adapters/server-target.d.ts +17 -0
  30. package/adapters/server-target.js +8 -0
  31. package/adapters/static.d.ts +84 -0
  32. package/adapters/static.js +231 -0
  33. package/adapters/target-build.d.ts +92 -0
  34. package/adapters/target-build.js +118 -0
  35. package/adapters/vercel-edge.d.ts +7 -0
  36. package/adapters/vercel-edge.js +9 -0
  37. package/adapters/vercel.d.ts +33 -0
  38. package/adapters/vercel.js +45 -0
  39. package/build/codegen.d.ts +84 -0
  40. package/build/codegen.js +920 -0
  41. package/build/dev-errors.d.ts +19 -0
  42. package/build/dev-errors.js +236 -0
  43. package/build/migrate.d.ts +15 -0
  44. package/build/migrate.js +146 -0
  45. package/build/route-css.d.ts +54 -0
  46. package/build/route-css.js +216 -0
  47. package/build/routes.d.ts +50 -0
  48. package/build/routes.js +202 -0
  49. package/build/sync.d.ts +20 -0
  50. package/build/sync.js +16 -0
  51. package/build/vite.d.ts +195 -0
  52. package/build/vite.js +703 -0
  53. package/cli/rivet.d.ts +2 -0
  54. package/cli/rivet.js +100 -0
  55. package/client/active.d.ts +6 -0
  56. package/client/active.js +22 -0
  57. package/client/announce.d.ts +2 -0
  58. package/client/announce.js +21 -0
  59. package/client/dev.d.ts +41 -0
  60. package/client/dev.js +74 -0
  61. package/client/index.d.ts +152 -0
  62. package/client/index.js +680 -0
  63. package/client/layouts.d.ts +110 -0
  64. package/client/layouts.js +310 -0
  65. package/client/leave.d.ts +40 -0
  66. package/client/leave.js +75 -0
  67. package/client/link.d.ts +48 -0
  68. package/client/link.jsx +65 -0
  69. package/client/live.d.ts +57 -0
  70. package/client/live.js +170 -0
  71. package/client/navigate.d.ts +71 -0
  72. package/client/navigate.js +120 -0
  73. package/client/navigation.d.ts +28 -0
  74. package/client/navigation.js +14 -0
  75. package/client/params.d.ts +16 -0
  76. package/client/params.js +28 -0
  77. package/client/route-context.d.ts +32 -0
  78. package/client/route-context.js +21 -0
  79. package/client/scroll.d.ts +16 -0
  80. package/client/scroll.js +80 -0
  81. package/client/search.d.ts +8 -0
  82. package/client/search.js +17 -0
  83. package/client/tailwind.css +8 -0
  84. package/client/transition-helpers.d.ts +28 -0
  85. package/client/transition-helpers.js +63 -0
  86. package/client/transitions.css +90 -0
  87. package/client/transitions.d.ts +21 -0
  88. package/client/transitions.js +156 -0
  89. package/index.d.ts +138 -0
  90. package/index.js +7 -0
  91. package/package.json +156 -4
  92. package/server/app.d.ts +169 -0
  93. package/server/app.js +302 -0
  94. package/server/body.d.ts +20 -0
  95. package/server/body.js +8 -0
  96. package/server/early-hints.d.ts +34 -0
  97. package/server/early-hints.js +63 -0
  98. package/server/handler.d.ts +196 -0
  99. package/server/handler.js +623 -0
  100. package/server/index.d.ts +9 -0
  101. package/server/index.js +6 -0
  102. package/server/inline-styles.d.ts +21 -0
  103. package/server/inline-styles.js +28 -0
  104. package/server/module.d.ts +14 -0
  105. package/server/module.js +78 -0
  106. package/server/render.d.ts +94 -0
  107. package/server/render.js +489 -0
  108. package/server/route-cache.d.ts +175 -0
  109. package/server/route-cache.js +742 -0
  110. package/server/security.d.ts +18 -0
  111. package/server/security.js +47 -0
  112. package/server/strict-csp.d.ts +7 -0
  113. package/server/strict-csp.js +18 -0
  114. package/server-only.d.ts +1 -0
  115. package/server-only.js +3 -0
  116. package/shared/actions.d.ts +10 -0
  117. package/shared/actions.js +18 -0
  118. package/shared/base.d.ts +8 -0
  119. package/shared/base.js +20 -0
  120. package/shared/cookie.d.ts +7 -0
  121. package/shared/cookie.js +32 -0
  122. package/shared/csp-directives.d.ts +9 -0
  123. package/shared/csp-directives.js +85 -0
  124. package/shared/csp-hash.d.ts +39 -0
  125. package/shared/csp-hash.js +121 -0
  126. package/shared/csp.d.ts +26 -0
  127. package/shared/csp.js +13 -0
  128. package/shared/data.d.ts +175 -0
  129. package/shared/data.js +22 -0
  130. package/shared/errors.d.ts +31 -0
  131. package/shared/errors.js +51 -0
  132. package/shared/head.d.ts +57 -0
  133. package/shared/head.js +242 -0
  134. package/shared/nonce.d.ts +2 -0
  135. package/shared/nonce.js +23 -0
  136. package/shared/prefetch-fallback.min.d.ts +1 -0
  137. package/shared/prefetch-fallback.min.js +2 -0
  138. package/shared/prefetch.d.ts +54 -0
  139. package/shared/prefetch.js +74 -0
  140. package/shared/redirect.d.ts +6 -0
  141. package/shared/redirect.js +25 -0
  142. package/shared/slash.d.ts +8 -0
  143. package/shared/slash.js +24 -0
@@ -0,0 +1,216 @@
1
+ // Per-route stylesheets: the CSS a route's layouts and page can render (their chunks' static
2
+ // import closure), outer → inner, so SSR links exactly the route's CSS in the head.
3
+ import { createHash } from "node:crypto";
4
+ import { brotliCompressSync, constants } from "node:zlib";
5
+ import { resolve } from "node:path";
6
+ /** Client build asset: route id → stylesheet URLs (written only when some route has CSS). */
7
+ export const ROUTE_CSS = ".vite/rivet-route-css.json";
8
+ /** An app entry (router `styles`, CSR app), not a compiler resume chunk. */
9
+ const isStylesEntry = (chunk) => chunk.isEntry && !chunk.facadeModuleId?.includes("rivet-resume-chunk:");
10
+ const routeFiles = (table, routesDir, id) => {
11
+ const route = table.routes.find((r) => r.id === id);
12
+ if (!route?.page)
13
+ return [];
14
+ return [...route.layouts.flatMap((l) => (l.page ? [l.page] : [])), route.page].map((file) => resolve(routesDir, file));
15
+ };
16
+ /** Stylesheet URLs per route id from a client bundle (empty when no route module imports CSS). */
17
+ export function bundleRouteCSS(bundle, table, routesDir, base) {
18
+ const chunks = Object.values(bundle).filter((o) => o.type === "chunk");
19
+ const byFile = new Map(chunks.map((c) => [c.fileName, c]));
20
+ const owner = new Map();
21
+ for (const chunk of chunks)
22
+ for (const id of chunk.moduleIds)
23
+ owner.set(id.split("?")[0], chunk);
24
+ // Entry CSS (router `styles`) is linked on every page already. Resume chunks are entries too (compiler inputs,
25
+ // `\0rivet-resume-chunk:`) but their CSS is a component's: it stays per route.
26
+ const global = new Set(chunks.filter(isStylesEntry).flatMap((c) => [...(c.viteMetadata?.importedCss ?? [])]));
27
+ const out = {};
28
+ for (const id of new Set(table.routes.map((r) => r.id))) {
29
+ const css = [];
30
+ const seen = new Set();
31
+ // Post-order: a chunk's static imports' CSS precedes its own.
32
+ const visit = (chunk) => {
33
+ if (seen.has(chunk.fileName))
34
+ return;
35
+ seen.add(chunk.fileName);
36
+ for (const file of chunk.imports) {
37
+ const imported = byFile.get(file);
38
+ if (imported)
39
+ visit(imported);
40
+ }
41
+ for (const file of chunk.viteMetadata?.importedCss ?? [])
42
+ if (!global.has(file) && !css.includes(base + file))
43
+ css.push(base + file);
44
+ };
45
+ for (const file of routeFiles(table, routesDir, id)) {
46
+ const chunk = owner.get(file);
47
+ if (chunk)
48
+ visit(chunk);
49
+ }
50
+ if (css.length)
51
+ out[id] = css;
52
+ }
53
+ return out;
54
+ }
55
+ /**
56
+ * One stylesheet per route: a route whose modules import several stylesheets (per-component CSS, `@rivetjs/ui`)
57
+ * links them as one merged asset (`assets/route-<hash>.css`, the files joined in link order; routes with the same
58
+ * list share it), so a page has one render-blocking route sheet next to the global styles. Lists of one file stay.
59
+ * Returns the merged assets to emit and the route map pointing at them. `prefix` names the asset (docs-kit example
60
+ * frames: `frame`).
61
+ */
62
+ export function mergeRouteCSS(bundle, css, base, prefix = "route") {
63
+ const assets = new Map();
64
+ const routes = {};
65
+ for (const [id, list] of Object.entries(css)) {
66
+ const sources = list.map((href) => bundle[href.slice(base.length)]);
67
+ if (list.length < 2 || sources.some((asset) => asset?.type !== "asset")) {
68
+ routes[id] = list;
69
+ continue;
70
+ }
71
+ const source = dedupeTopLevel(sources.map((asset) => unmarked(String(asset.source))).join("\n"));
72
+ const fileName = `assets/${prefix}-${createHash("sha256").update(source).digest("base64url").slice(0, 8)}.css`;
73
+ assets.set(fileName, source + "\n");
74
+ routes[id] = [base + fileName];
75
+ }
76
+ return { routes, assets: [...assets].map(([fileName, source]) => ({ fileName, source })) };
77
+ }
78
+ // Vite's CSS chunk marker (`/*$vite$:1*/`): still in the bundle's CSS assets during `generateBundle` (Vite drops it when
79
+ // it writes its own assets, not from sources copied elsewhere).
80
+ const unmarked = (css) => css.replace(/\/\*\$vite\$:\d+\*\/\n?/g, "").trim();
81
+ /** Client build asset: the stylesheets the handler may write inline (`InlineStyleManifest`; written only when some are). */
82
+ export const INLINE_CSS = ".vite/rivet-inline-css.json";
83
+ /** `auto`: all of a page's sheets inline when their brotli sizes add up to at most this. */
84
+ export const MAX_TOTAL_BROTLI = 6144;
85
+ // A `url()` / `@import` the document would resolve against its own URL instead of the sheet's: `./` / `../` / bare
86
+ // paths (Vite writes base-absolute URLs unless `base` is relative). Site-absolute, scheme, protocol-relative, `data:`
87
+ // and fragment-only references resolve the same from both.
88
+ const RELATIVE_REFERENCE = /(?:url\(\s*|@import\s+)(["']?)(?![a-z][\w+.-]*:|\/|#|["')\s])/i;
89
+ /**
90
+ * The inline stylesheet manifest (`.vite/rivet-inline-css.json`, `server/inline-styles.ts` decides per page), or
91
+ * `undefined` when no sheet qualifies (`"never"`, nothing listed). Listed: every client CSS asset and merged route sheet
92
+ * (keys = the URLs the handler links, `base` + file name) without relative `url()` / `@import` references (they resolve
93
+ * against the sheet's URL) that could ever go inline — `"always"`: all; `"auto"`: brotli size ≤ `maxTotalBrotli` (the
94
+ * per-page total) or raw size ≤ `limit` (Vite `build.assetsInlineLimit`, the per-sheet fallback). Brotli at quality 11.
95
+ */
96
+ export function inlineCSS(bundle, extra, base, mode, limit) {
97
+ if (mode === "never")
98
+ return undefined;
99
+ const always = mode === "always";
100
+ const maxTotalBrotli = typeof mode === "object" ? (mode.maxTotalBrotli ?? MAX_TOTAL_BROTLI) : MAX_TOTAL_BROTLI;
101
+ const sheets = {};
102
+ const all = [
103
+ ...Object.values(bundle).flatMap((o) => (o.type === "asset" && o.fileName.endsWith(".css") ? [{ fileName: o.fileName, source: String(o.source) }] : [])),
104
+ ...extra,
105
+ ];
106
+ for (const { fileName, source } of all) {
107
+ const css = unmarked(source);
108
+ if (!css || RELATIVE_REFERENCE.test(css))
109
+ continue;
110
+ const bytes = Buffer.byteLength(css);
111
+ const br = brotliCompressSync(css, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: bytes } }).length;
112
+ if (always || br <= maxTotalBrotli || bytes <= limit)
113
+ sheets[base + fileName] = { css, bytes, br };
114
+ }
115
+ return Object.keys(sheets).length ? { mode: always ? "always" : "auto", maxTotalBrotli, limit, sheets } : undefined;
116
+ }
117
+ /**
118
+ * Drops repeated top-level `@property` / `@keyframes` blocks and `@layer a, b;` statements that are byte-identical to an
119
+ * earlier one (each per-component stylesheet carries the ones it uses; a repeat changes nothing). Strings and comments
120
+ * are skipped; anything else is kept as is.
121
+ */
122
+ export function dedupeTopLevel(css) {
123
+ const seen = new Set();
124
+ let out = "";
125
+ let depth = 0;
126
+ for (let i = 0; i < css.length;) {
127
+ const ch = css[i];
128
+ if (ch === '"' || ch === "'") {
129
+ let j = i + 1;
130
+ while (j < css.length && css[j] !== ch)
131
+ j += css[j] === "\\" ? 2 : 1;
132
+ out += css.slice(i, j + 1);
133
+ i = j + 1;
134
+ }
135
+ else if (ch === "/" && css[i + 1] === "*") {
136
+ const j = css.indexOf("*/", i + 2);
137
+ const end = j < 0 ? css.length : j + 2;
138
+ out += css.slice(i, end);
139
+ i = end;
140
+ }
141
+ else if (depth === 0 && ch === "@" && /^@(property|keyframes|-webkit-keyframes|layer)\b/.test(css.slice(i, i + 20))) {
142
+ const end = blockEnd(css, i);
143
+ const block = css.slice(i, end);
144
+ const isStatement = block.endsWith(";");
145
+ if (!(/^@layer/.test(block) && !isStatement) && seen.has(block)) {
146
+ i = end;
147
+ continue;
148
+ }
149
+ if (!/^@layer/.test(block) || isStatement)
150
+ seen.add(block);
151
+ if (/^@layer/.test(block) && !isStatement) {
152
+ // A layer block: keep its head, scan its body (nested rules are not deduplicated).
153
+ out += ch;
154
+ i++;
155
+ continue;
156
+ }
157
+ out += block;
158
+ i = end;
159
+ }
160
+ else {
161
+ if (ch === "{")
162
+ depth++;
163
+ else if (ch === "}")
164
+ depth--;
165
+ out += ch;
166
+ i++;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ /** End (exclusive) of the at-rule starting at `at`: after its `;` (statement) or its balanced `{…}` block. */
172
+ function blockEnd(css, at) {
173
+ let depth = 0;
174
+ for (let i = at; i < css.length; i++) {
175
+ const ch = css[i];
176
+ if (ch === '"' || ch === "'") {
177
+ for (i++; i < css.length && css[i] !== ch; i += css[i] === "\\" ? 2 : 1)
178
+ ;
179
+ }
180
+ else if (ch === ";" && depth === 0)
181
+ return i + 1;
182
+ else if (ch === "{")
183
+ depth++;
184
+ else if (ch === "}" && --depth === 0)
185
+ return i + 1;
186
+ }
187
+ return css.length;
188
+ }
189
+ const CSS_REQUEST = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
190
+ const NOT_LINKED = /[?&](?:inline|url|raw)\b/;
191
+ /**
192
+ * Dev: stylesheets in the SSR module graph of a route's modules (loaded first), as `?direct` links
193
+ * (no flash before the client graph injects its `<style>`s; `entry.dev.tsx` then removes them).
194
+ */
195
+ export async function devRouteCSS(server, table, routesDir, id, exclude) {
196
+ const environment = server.environments.ssr;
197
+ const css = [];
198
+ const seen = new Set();
199
+ const visit = (node) => {
200
+ if (!node?.id || seen.has(node.id))
201
+ return;
202
+ seen.add(node.id);
203
+ for (const imported of node.importedModules)
204
+ visit(imported);
205
+ if (CSS_REQUEST.test(node.url) && !NOT_LINKED.test(node.url) && !exclude.has(node.url.split("?")[0])) {
206
+ const href = node.url + (node.url.includes("?") ? "&" : "?") + "direct";
207
+ if (!css.includes(href))
208
+ css.push(href);
209
+ }
210
+ };
211
+ for (const file of routeFiles(table, routesDir, id)) {
212
+ await server.ssrLoadModule(file);
213
+ visit(environment.moduleGraph.getModuleById(file));
214
+ }
215
+ return css;
216
+ }
@@ -0,0 +1,50 @@
1
+ /** A layout or special page found along a route's folder chain. */
2
+ export interface RouteModule {
3
+ /** Universal module (component), relative to the routes directory, posix separators. */
4
+ readonly page?: string;
5
+ /**
6
+ * Server-only module: a sibling `*.server.ts` (guard, load, entries, actions, headers, request
7
+ * handlers), or an endpoint-only plain `x.ts` exporting request handlers.
8
+ */
9
+ readonly server?: string;
10
+ /** The page module itself declares server exports (`serverLoad`, `onGet`, `prerender`…). */
11
+ readonly inline?: boolean;
12
+ /** Page module with `export const live = true`: its `data` prop is an accessor revalidated in place. */
13
+ readonly live?: boolean;
14
+ }
15
+ /** One matchable route. Optional segments expand into several entries sharing an `id`. */
16
+ export interface RouteEntry extends RouteModule {
17
+ /** Stable id: the file path without extension, e.g. `blog/[slug]`. */
18
+ readonly id: string;
19
+ /** Router pattern for `compileRoutes`, e.g. `/blog/:slug`, `/docs/*rest`. */
20
+ readonly pattern: string;
21
+ /** Parameter names in pattern order. */
22
+ readonly params: readonly string[];
23
+ /** Layouts from the outermost folder inward. */
24
+ readonly layouts: readonly RouteModule[];
25
+ /** Nearest `_404` / `_error` page modules. */
26
+ readonly notFound?: string;
27
+ readonly error?: string;
28
+ }
29
+ export interface RouteTable {
30
+ /** Ordered: the first matching entry wins. */
31
+ readonly routes: readonly RouteEntry[];
32
+ /** Root-level `_404` / `_error` for URLs that match no route. */
33
+ readonly notFound?: string;
34
+ readonly error?: string;
35
+ /** `_404` pages under static folders for unmatched URLs, longest URL prefix first (root excluded). */
36
+ readonly notFounds?: readonly {
37
+ readonly prefix: string;
38
+ readonly page: string;
39
+ }[];
40
+ }
41
+ /**
42
+ * Build the route table from file paths relative to the routes directory (posix separators).
43
+ * `exports`: server export names per module file (`scanRoutes` reads them; pages/layouts with any are
44
+ * `inline`, plain `.ts` files with request handlers are endpoint routes).
45
+ */
46
+ export declare function routeTable(files: readonly string[], exports?: ReadonlyMap<string, readonly string[]>): RouteTable;
47
+ /** Scan a routes directory and build its table (reads modules for their server exports). */
48
+ export declare function scanRoutes(dir: string): Promise<RouteTable>;
49
+ /** Server export names of route modules (pages, layouts, plain `.ts` files), by file; live pages also list `live`. */
50
+ export declare function _serverExportsOf(dir: string, files: readonly string[]): Promise<Map<string, string[]>>;
@@ -0,0 +1,202 @@
1
+ // File-based route table: maps `src/routes/**` files to router patterns, layouts and special pages.
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ const PAGE = /\.(tsx|jsx)$/;
5
+ const SERVER = /\.server\.(ts|js)$/;
6
+ /** Plain `.ts`/`.js` route files: endpoint-only routes when they export request handlers. */
7
+ const PLAIN = /\.(ts|js)$/;
8
+ const HANDLER = /^on(Request|Get|Post|Put|Patch|Delete|Options)$/;
9
+ const IGNORED = /(^|\/)[.]|\.(test|spec)\.[^/]+$|\.d\.ts$/;
10
+ const SPECIAL = new Set(["_layout", "_404", "_error"]);
11
+ /** Parse one path segment of a route file name; null for a path-less `(group)`. */
12
+ function segment(part, file) {
13
+ if (/^\([^()/]+\)$/.test(part))
14
+ return null;
15
+ let match = /^\[\[([A-Za-z_$][\w$]*)\]\]$/.exec(part);
16
+ if (match)
17
+ return { kind: "optional", name: match[1] };
18
+ match = /^\[\.\.\.([A-Za-z_$][\w$]*)\]$/.exec(part);
19
+ if (match)
20
+ return { kind: "splat", name: match[1] };
21
+ match = /^\[([A-Za-z_$][\w$]*)\]$/.exec(part);
22
+ if (match)
23
+ return { kind: "param", name: match[1] };
24
+ if (/[[\]()*:?#\\]/.test(part))
25
+ throw new Error(`Invalid route segment "${part}" in ${file}`);
26
+ return { kind: "static", text: part };
27
+ }
28
+ // Rank per segment: static before param before splat; a route that ends sorts before a splat at
29
+ // the same depth (both match the shorter URL), never against static/param (different lengths).
30
+ const rank = (s) => (s === undefined ? 1.5 : s.kind === "static" ? 0 : s.kind === "splat" ? 2 : 1);
31
+ function compare(a, b) {
32
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
33
+ const d = rank(a[i]) - rank(b[i]);
34
+ if (d)
35
+ return d;
36
+ const x = a[i], y = b[i];
37
+ if (x?.kind === "static" && y?.kind === "static" && x.text !== y.text)
38
+ return x.text < y.text ? -1 : 1;
39
+ }
40
+ return 0;
41
+ }
42
+ const shape = (segments) => "/" + segments.map((s) => (s.kind === "static" ? s.text : s.kind === "splat" ? "*" : ":")).join("/");
43
+ const pattern = (segments) => "/" + segments.map((s) => (s.kind === "static" ? encodeURIComponent(s.text) : (s.kind === "splat" ? "*" : ":") + s.name)).join("/");
44
+ /**
45
+ * Build the route table from file paths relative to the routes directory (posix separators).
46
+ * `exports`: server export names per module file (`scanRoutes` reads them; pages/layouts with any are
47
+ * `inline`, plain `.ts` files with request handlers are endpoint routes).
48
+ */
49
+ export function routeTable(files, exports = new Map()) {
50
+ const set = new Set(files.filter((file) => !IGNORED.test(file)));
51
+ const servers = new Map(); // module path without extension → server file
52
+ for (const file of set)
53
+ if (SERVER.test(file))
54
+ servers.set(file.replace(SERVER, ""), file);
55
+ for (const file of set) {
56
+ if (SERVER.test(file) || !PLAIN.test(file) || !exports.get(file)?.some((name) => HANDLER.test(name)))
57
+ continue;
58
+ const base = file.replace(PLAIN, "");
59
+ const other = servers.get(base);
60
+ if (other)
61
+ throw new Error(`${file} and ${other} are both server modules of ${base}; keep one`);
62
+ servers.set(base, file);
63
+ }
64
+ // `live` (a page flag `_serverExportsOf` reports with them) is not a server export.
65
+ const isInline = (page) => (page ? (exports.get(page)?.some((name) => name !== "live") ?? false) : false);
66
+ const isLive = (page) => exports.get(page)?.includes("live") ?? false;
67
+ const special = (folder, name) => {
68
+ const base = (folder ? folder + "/" : "") + name;
69
+ const page = [...set].find((file) => PAGE.test(file) && file.replace(PAGE, "") === base);
70
+ const server = servers.get(base);
71
+ return page || server ? { ...(page ? { page } : {}), ...(server ? { server } : {}), ...(isInline(page) ? { inline: true } : {}) } : undefined;
72
+ };
73
+ const nearest = (folders, name) => {
74
+ for (let i = folders.length - 1; i >= 0; i--) {
75
+ const found = special(folders[i], name)?.page;
76
+ if (found)
77
+ return found;
78
+ }
79
+ return undefined;
80
+ };
81
+ const modules = new Map();
82
+ for (const file of set) {
83
+ const page = PAGE.test(file), server = SERVER.test(file) || (PLAIN.test(file) && servers.get(file.replace(PLAIN, "")) === file);
84
+ if (!page && !server)
85
+ continue;
86
+ const base = file.replace(server ? (SERVER.test(file) ? SERVER : PLAIN) : PAGE, "");
87
+ const name = base.slice(base.lastIndexOf("/") + 1);
88
+ if (name.startsWith("_")) {
89
+ if (!SPECIAL.has(name))
90
+ continue;
91
+ if (server && name !== "_layout")
92
+ throw new Error(`${file}: only _layout may have a server module`);
93
+ continue;
94
+ }
95
+ if (base.split("/").some((part) => part.startsWith("_")))
96
+ continue;
97
+ const entry = modules.get(base) ?? {};
98
+ modules.set(base, server ? { ...entry, server: file } : { ...entry, page: file, ...(isInline(file) ? { inline: true } : {}), ...(isLive(file) ? { live: true } : {}) });
99
+ }
100
+ const drafts = [];
101
+ const seen = new Map();
102
+ for (const [base, module] of [...modules].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
103
+ const parts = base.split("/");
104
+ const folders = [""];
105
+ for (let i = 0; i < parts.length - 1; i++)
106
+ folders.push(parts.slice(0, i + 1).join("/"));
107
+ const parsed = [];
108
+ for (const [i, part] of parts.entries()) {
109
+ if (i === parts.length - 1 && part === "index")
110
+ break;
111
+ const s = segment(part, base);
112
+ if (s)
113
+ parsed.push(s);
114
+ }
115
+ const splat = parsed.findIndex((s) => s.kind === "splat");
116
+ if (splat !== -1 && splat !== parsed.length - 1)
117
+ throw new Error(`${base}: [...${parsed[splat].name}] must be the last segment`);
118
+ const names = parsed.flatMap((s) => (s.kind === "static" ? [] : [s.name]));
119
+ if (new Set(names).size !== names.length)
120
+ throw new Error(`${base}: duplicate parameter name`);
121
+ // Each optional segment doubles the variants: with it (longer) and without.
122
+ let variants = [[]];
123
+ for (const s of parsed)
124
+ variants = s.kind === "optional" ? variants.flatMap((v) => [[...v, { kind: "param", name: s.name }], v]) : variants.map((v) => [...v, s]);
125
+ for (const segments of variants) {
126
+ const key = shape(segments);
127
+ const previous = seen.get(key);
128
+ if (previous !== undefined && previous !== base)
129
+ throw new Error(`Route conflict: ${previous} and ${base} both match ${key}`);
130
+ if (previous === base)
131
+ continue;
132
+ seen.set(key, base);
133
+ drafts.push({ id: base, segments, module, folders });
134
+ }
135
+ }
136
+ drafts.sort((a, b) => compare(a.segments, b.segments));
137
+ const routes = drafts.map(({ id, segments, module, folders }) => {
138
+ const layouts = folders.map((folder) => special(folder, "_layout")).filter((m) => m !== undefined);
139
+ const notFound = nearest(folders, "_404"), error = nearest(folders, "_error");
140
+ return {
141
+ id,
142
+ pattern: pattern(segments),
143
+ params: segments.flatMap((s) => (s.kind === "static" ? [] : [s.name])),
144
+ ...module,
145
+ layouts,
146
+ ...(notFound ? { notFound } : {}),
147
+ ...(error ? { error } : {}),
148
+ };
149
+ });
150
+ const notFound = special("", "_404")?.page, error = special("", "_error")?.page;
151
+ // Unmatched URLs: nearest `_404` by folder, for folders without parameters (groups add no segment).
152
+ const notFounds = [];
153
+ for (const file of set) {
154
+ if (!PAGE.test(file) || !file.endsWith("/_404" + file.slice(file.lastIndexOf("."))))
155
+ continue;
156
+ const parts = file.split("/").slice(0, -1);
157
+ if (parts.some((part) => part.startsWith("[")))
158
+ continue;
159
+ const prefix = "/" +
160
+ parts
161
+ .filter((part) => !/^\(.+\)$/.test(part))
162
+ .map(encodeURIComponent)
163
+ .join("/");
164
+ if (prefix !== "/")
165
+ notFounds.push({ prefix, page: file });
166
+ }
167
+ notFounds.sort((a, b) => b.prefix.length - a.prefix.length || (a.prefix < b.prefix ? -1 : 1));
168
+ return { routes, ...(notFound ? { notFound } : {}), ...(error ? { error } : {}), ...(notFounds.length ? { notFounds } : {}) };
169
+ }
170
+ /** Scan a routes directory and build its table (reads modules for their server exports). */
171
+ export async function scanRoutes(dir) {
172
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
173
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => relative(dir, join(entry.parentPath, entry.name)).split(sep).join("/"));
174
+ return routeTable(files, await _serverExportsOf(dir, files));
175
+ }
176
+ // `export const prerender` alone also makes a page carry server exports (a plain flag).
177
+ const PRERENDER = /^\s*export\s+(?:const|let|var)\s+prerender\b/m;
178
+ // `export const live = true`: a live page (reported as `live`, not a server export; universal, kept in the browser).
179
+ const LIVE = /^\s*export\s+const\s+live\s*=\s*true\b/m;
180
+ /** Server export names of route modules (pages, layouts, plain `.ts` files), by file; live pages also list `live`. */
181
+ export async function _serverExportsOf(dir, files) {
182
+ const { moduleFacts } = await import("@rivetjs/compiler");
183
+ const out = new Map();
184
+ for (const file of files) {
185
+ if (IGNORED.test(file) || SERVER.test(file) || !(PAGE.test(file) || PLAIN.test(file)))
186
+ continue;
187
+ const source = await readFile(join(dir, file), "utf8");
188
+ const live = PAGE.test(file) && LIVE.test(source);
189
+ // Cheap prefilter: no candidate export name, no parse.
190
+ const candidates = /\b(server[A-Z]\w*|on(Request|Get|Post|Put|Patch|Delete|Options)|prerender)\b/.test(source);
191
+ if (!candidates && !live)
192
+ continue;
193
+ const names = candidates ? [...(moduleFacts(source, file, { serverExports: true }).serverExports ?? [])] : [];
194
+ if (PAGE.test(file) && PRERENDER.test(source))
195
+ names.push("prerender");
196
+ if (live)
197
+ names.push("live");
198
+ if (names.length)
199
+ out.set(file, names);
200
+ }
201
+ return out;
202
+ }
@@ -0,0 +1,20 @@
1
+ export interface SyncOptions {
2
+ /** App root (default the current directory). */
3
+ root?: string;
4
+ /** Vite config file (default: Vite's lookup in `root`). */
5
+ configFile?: string;
6
+ /** Vite mode (default `production`). */
7
+ mode?: string;
8
+ }
9
+ export interface SyncResult {
10
+ root: string;
11
+ /** Vite config file used (undefined: none found, nothing generated). */
12
+ configFile: string | undefined;
13
+ /** The written editor manifest, or null when no Rivet plugin ran. */
14
+ manifest: {
15
+ resumeModules?: string[];
16
+ routesDir?: string;
17
+ routes?: unknown[];
18
+ } | null;
19
+ }
20
+ export declare function sync({ root, configFile, mode }?: SyncOptions): Promise<SyncResult>;
package/build/sync.js ADDED
@@ -0,0 +1,16 @@
1
+ // `rivet sync`: write an app's generated `.rivet/` files (route types, generated app, editor
2
+ // manifest) without starting Vite. Resolves the app's own Vite config, so every plugin option
3
+ // (routes dir, resume modules, base, i18n…) applies exactly as in `vite` / `vite build`: the router
4
+ // plugin's `config` hook regenerates `.rivet/`, the compiler plugin's `configResolved` hook writes
5
+ // `.rivet/editor.json`. Run it before `tsc` / `rivet-check` on a fresh clone (`.rivet/` is gitignored).
6
+ import { readFile } from "node:fs/promises";
7
+ import { resolve } from "node:path";
8
+ export async function sync({ root = process.cwd(), configFile, mode = "production" } = {}) {
9
+ const { resolveConfig } = await import("vite");
10
+ const appRoot = resolve(root);
11
+ const config = await resolveConfig({ root: appRoot, mode, logLevel: "warn", ...(configFile ? { configFile: resolve(appRoot, configFile) } : {}) }, "build", mode);
12
+ const manifest = await readFile(resolve(config.root, ".rivet/editor.json"), "utf8")
13
+ .then((text) => JSON.parse(text))
14
+ .catch(() => null);
15
+ return { root: config.root, configFile: config.configFile, manifest };
16
+ }