bosia 1.2.2 → 1.2.4
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/package.json +1 -1
- package/src/core/build.ts +10 -2
- package/src/core/client/App.svelte +363 -273
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +7 -2
- package/src/core/client/navigation.ts +46 -27
- package/src/core/client/router.svelte.ts +24 -5
- package/src/core/html.ts +0 -27
- package/src/core/matcher.ts +52 -85
- package/src/core/prerender.ts +151 -38
- package/src/core/routeFile.ts +21 -17
- package/src/core/routeTypes.ts +16 -11
- package/src/core/scanner.ts +72 -13
- package/src/core/server.ts +6 -6
- package/src/core/staticPath.ts +35 -0
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +1 -1
package/src/core/prerender.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { RuntimeTarget } from "./types/plugin.ts";
|
|
|
7
7
|
import { BOSIA_NODE_PATH, OUT_DIR } from "./paths.ts";
|
|
8
8
|
import { currentBase } from "./appBase.ts";
|
|
9
9
|
import { prerenderSkipReason, wantsPrerender } from "./scanner.ts";
|
|
10
|
+
import { isDynamic, segmentName, segmentRank } from "./matcher.ts";
|
|
11
|
+
import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
|
|
10
12
|
|
|
11
13
|
/** Acquire an OS-assigned ephemeral port. Tiny TOCTOU race window; acceptable for build-time use. */
|
|
12
14
|
export function getEphemeralPort(): Promise<number> {
|
|
@@ -54,8 +56,10 @@ const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
|
|
|
54
56
|
|
|
55
57
|
// ─── Prerendering ─────────────────────────────────────────
|
|
56
58
|
|
|
57
|
-
interface PrerenderTarget {
|
|
59
|
+
export interface PrerenderTarget {
|
|
58
60
|
path: string;
|
|
61
|
+
/** Route pattern the path came from (`/[slug]`); equals `path` for a static route. */
|
|
62
|
+
route: string;
|
|
59
63
|
kind: "page" | "api";
|
|
60
64
|
/** Page targets only; APIs always write a single `.json` file regardless of slash mode. */
|
|
61
65
|
trailingSlash: TrailingSlash;
|
|
@@ -68,23 +72,43 @@ interface PrerenderTarget {
|
|
|
68
72
|
* concrete values from an `entries()` record.
|
|
69
73
|
*/
|
|
70
74
|
export function substituteParams(pattern: string, entry: Record<string, string>): string {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
// Per segment, so an inserted value is never re-read as a placeholder.
|
|
76
|
+
return pattern
|
|
77
|
+
.split("/")
|
|
78
|
+
.map((seg, i, segs) => {
|
|
79
|
+
const rank = segmentRank(seg);
|
|
80
|
+
if (!rank) return seg;
|
|
81
|
+
const name = segmentName(seg);
|
|
82
|
+
if (entry[name] == null || !Object.hasOwn(entry, name))
|
|
83
|
+
throw new Error(`entries() gave no value for ${seg}`);
|
|
84
|
+
// A catch-all may end in one "/" ("guide/intro/"); the out path drops it.
|
|
85
|
+
const value = rank === 2 ? String(entry[name]).replace(/\/$/, "") : String(entry[name]);
|
|
86
|
+
const bad = (why: string): never => {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`entries() value ${JSON.stringify(String(entry[name]))} for ${seg} is unsafe — ${why}`,
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
// Strict on purpose (decided 2026-10-14): ASCII unreserved URL characters only, so the
|
|
92
|
+
// fetched URL, the file written and a static host's lookup all agree, with nothing to
|
|
93
|
+
// percent-encode or decode. `50%-off`, `café`, `what%3F` fail by design: slugify them.
|
|
94
|
+
// No part may end in `.`: the URL parser folds `.`/`..`, and Windows drops trailing
|
|
95
|
+
// dots (`v1.` → `v1`), so the page would land on another route's file. No part may
|
|
96
|
+
// start with `.` either: static hosts hide dotfiles (`.env`).
|
|
97
|
+
const parts = rank === 2 ? value.split("/") : [value];
|
|
98
|
+
if (!parts.every((part) => /^(?!\.)[A-Za-z0-9._~-]*[A-Za-z0-9_~-]$/.test(part)))
|
|
99
|
+
bad(
|
|
100
|
+
`use only ASCII letters, digits, - _ . ~${rank === 2 ? " and /" : ""}, with no empty parts or leading/trailing \`.\``,
|
|
101
|
+
);
|
|
102
|
+
// Windows device names: writing `nul.html` hits the device, not a file.
|
|
103
|
+
if (parts.some((part) => /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(part)))
|
|
104
|
+
bad("Windows device names (con, nul, com1, …) are not allowed");
|
|
105
|
+
// Only the URL's last part names a file: `index` is written as `<parent>/index.html`,
|
|
106
|
+
// which serves the parent URL; `post.html` as `post.html.html`, never served for it.
|
|
107
|
+
if (i === segs.length - 1 && /^index$|\.(html|json)$/i.test(parts.at(-1)!))
|
|
108
|
+
bad("the last part can't be `index` or end with .html or .json");
|
|
109
|
+
return value;
|
|
110
|
+
})
|
|
111
|
+
.join("/");
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
/**
|
|
@@ -112,6 +136,11 @@ export function prerenderDataPath(routePath: string): string {
|
|
|
112
136
|
return routePath === "/" ? "/index.json" : `${routePath.replace(/\/$/, "")}.json`;
|
|
113
137
|
}
|
|
114
138
|
|
|
139
|
+
/** Where a page's data payload is written. */
|
|
140
|
+
export function prerenderDataOutPath(routePath: string): string {
|
|
141
|
+
return `${OUT_DIR}/prerendered/__bosia/data${prerenderDataPath(routePath)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
115
144
|
/**
|
|
116
145
|
* Output filename for a prerendered API route. Always emits a single `.json`
|
|
117
146
|
* file at the route's path (no trailing-slash variants — static hosts serve
|
|
@@ -121,6 +150,42 @@ export function prerenderApiOutPath(routePath: string): string {
|
|
|
121
150
|
return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
|
|
122
151
|
}
|
|
123
152
|
|
|
153
|
+
/** A dynamic prerender route whose entries() can't be used. build.ts prints `message` and exits. */
|
|
154
|
+
export class PrerenderEntriesError extends Error {
|
|
155
|
+
override name = "PrerenderEntriesError";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Throws when a dynamic route's path would save the wrong thing. Pure, so the invariant
|
|
160
|
+
* tests (test/prerender-invariants.test.ts) can sweep every value × slash mode × kind.
|
|
161
|
+
*/
|
|
162
|
+
export function checkPrerenderPaths(
|
|
163
|
+
paths: string[],
|
|
164
|
+
kind: "page" | "api",
|
|
165
|
+
ts: TrailingSlash,
|
|
166
|
+
): void {
|
|
167
|
+
for (const p of paths) {
|
|
168
|
+
// `dist/` and `__bosia/` are the framework's folders in static/: `dist/client` is copied
|
|
169
|
+
// after the pages, so on macOS/Windows a page folder `DIST/` would swallow the bundle
|
|
170
|
+
// (served as `DIST/client/…`, a 404 on case-sensitive hosts). A page `/dist` also fails:
|
|
171
|
+
// `/dist/` is answered as a static file, not 308'd to it.
|
|
172
|
+
// Lowercased for case-insensitive disks; a page's `/` form is tested too, so `/dist` fails.
|
|
173
|
+
const lower = p.toLowerCase();
|
|
174
|
+
if (isFrameworkPath(kind === "page" ? `${lower}/` : lower))
|
|
175
|
+
throw new Error(`entries() gives ${p}; dist and __bosia are the framework's folders`);
|
|
176
|
+
// The server answers `/_health`, and for pages any static-file ending like `.png`, itself,
|
|
177
|
+
// so the saved file would hold its reply, not the route. Checked on the slash-less form:
|
|
178
|
+
// `/logo.png` 404s before the 308 to `/logo.png/`, and the `/` form adds only the prefixes
|
|
179
|
+
// rejected above (the invariant test checks both forms).
|
|
180
|
+
if (p === HEALTH_PATH || (kind === "page" && isStaticPath(p)))
|
|
181
|
+
throw new Error(`entries() gives ${p}, a URL the server answers itself`);
|
|
182
|
+
// generateStaticSite copies prerendered/ over static/, where the 404 probe saves
|
|
183
|
+
// 404.html. A static src/routes/404 page may replace it on purpose; a slug may not.
|
|
184
|
+
if (kind === "page" && ts === "never" && lower === "/404")
|
|
185
|
+
throw new Error("an entries() value would overwrite 404.html, the not-found page");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
124
189
|
/** Dynamic route → import module, call entries(), expand to concrete targets. */
|
|
125
190
|
async function expandDynamicRoute(
|
|
126
191
|
pattern: string,
|
|
@@ -130,23 +195,22 @@ async function expandDynamicRoute(
|
|
|
130
195
|
): Promise<PrerenderTarget[]> {
|
|
131
196
|
try {
|
|
132
197
|
const mod = await import(join(process.cwd(), filePath));
|
|
133
|
-
if (typeof mod.entries !== "function")
|
|
134
|
-
|
|
135
|
-
return [];
|
|
136
|
-
}
|
|
198
|
+
if (typeof mod.entries !== "function")
|
|
199
|
+
throw new Error("prerender = true on a dynamic route needs an entries() export");
|
|
137
200
|
const entryList: Record<string, string>[] = await mod.entries();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}));
|
|
201
|
+
// A repeated value (a tag on two posts) is written once. Only within this route:
|
|
202
|
+
// the same URL from two routes is a clash, reported by findPrerenderClash.
|
|
203
|
+
const paths = [...new Set(entryList.map((entry) => substituteParams(pattern, entry)))];
|
|
204
|
+
checkPrerenderPaths(paths, kind, ts);
|
|
205
|
+
return paths.map((path) => ({ path, route: pattern, kind, trailingSlash: ts }));
|
|
143
206
|
} catch (err) {
|
|
144
|
-
|
|
145
|
-
|
|
207
|
+
// Fail the build: skipping the route would silently drop every page it lists.
|
|
208
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
209
|
+
throw new PrerenderEntriesError(`Prerender failed for ${pattern}: ${why}`, { cause: err });
|
|
146
210
|
}
|
|
147
211
|
}
|
|
148
212
|
|
|
149
|
-
async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
|
|
213
|
+
export async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
|
|
150
214
|
const pageTasks = manifest.pages.map(async (route): Promise<PrerenderTarget[]> => {
|
|
151
215
|
if (!route.pageServer) return [];
|
|
152
216
|
const filePath = join("src", "routes", route.pageServer);
|
|
@@ -158,24 +222,73 @@ async function detectPrerenderRoutes(manifest: RouteManifest): Promise<Prerender
|
|
|
158
222
|
return [];
|
|
159
223
|
}
|
|
160
224
|
const ts = route.trailingSlash;
|
|
161
|
-
if (route.pattern
|
|
162
|
-
return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
|
|
225
|
+
if (isDynamic(route.pattern)) return expandDynamicRoute(route.pattern, filePath, "page", ts);
|
|
226
|
+
return [{ path: route.pattern, route: route.pattern, kind: "page", trailingSlash: ts }];
|
|
163
227
|
});
|
|
164
228
|
|
|
165
229
|
const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
|
|
166
230
|
const filePath = join("src", "routes", route.server);
|
|
167
231
|
const content = await Bun.file(filePath).text();
|
|
168
232
|
if (!wantsPrerender(content)) return [];
|
|
169
|
-
if (route.pattern
|
|
233
|
+
if (isDynamic(route.pattern))
|
|
170
234
|
return expandDynamicRoute(route.pattern, filePath, "api", "never");
|
|
171
|
-
return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
|
|
235
|
+
return [{ path: route.pattern, route: route.pattern, kind: "api", trailingSlash: "never" }];
|
|
172
236
|
});
|
|
173
237
|
|
|
174
|
-
// Unbounded Promise.
|
|
238
|
+
// Unbounded Promise.allSettled over all routes; each task holds only small text reads
|
|
175
239
|
// and route metadata, so RAM stays trivial. Chunk it if route counts ever hit
|
|
176
240
|
// tens of thousands (open file descriptors would be the first limit).
|
|
177
|
-
const results = await Promise.
|
|
178
|
-
|
|
241
|
+
const results = await Promise.allSettled([...pageTasks, ...apiTasks]);
|
|
242
|
+
// List every broken entries() route in one error, not just the first.
|
|
243
|
+
const failed = results.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
|
|
244
|
+
const other = failed.find((e) => !(e instanceof PrerenderEntriesError));
|
|
245
|
+
if (other) throw other;
|
|
246
|
+
// One `❌` per route; a multi-line message (a ZodError) stays under its own mark.
|
|
247
|
+
if (failed.length)
|
|
248
|
+
throw new PrerenderEntriesError(failed.map((e) => `❌ ${e.message}`).join("\n"), {
|
|
249
|
+
cause: failed,
|
|
250
|
+
});
|
|
251
|
+
const targets = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
|
252
|
+
const clash = findPrerenderClash(targets);
|
|
253
|
+
if (clash.length) throw new PrerenderEntriesError(clash.join("\n"));
|
|
254
|
+
return targets;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Files two targets would both write. macOS/Windows ignore case, so `About` and `about`
|
|
259
|
+
* (or folders `A/` and `a/`) merge; a file and a folder can't share a path either.
|
|
260
|
+
* Every folder level counts, and so do data payloads.
|
|
261
|
+
*/
|
|
262
|
+
export function findPrerenderClash(targets: PrerenderTarget[]): string[] {
|
|
263
|
+
const owners = new Map<string, { path: string; who: string; file: boolean }>();
|
|
264
|
+
const clashes: string[] = [];
|
|
265
|
+
for (const t of targets) {
|
|
266
|
+
// Name the route a slug came from, so the message says which file to fix.
|
|
267
|
+
const who = t.route === t.path ? t.path : `${t.path} (from ${t.route})`;
|
|
268
|
+
let clash = "";
|
|
269
|
+
const files =
|
|
270
|
+
t.kind === "api"
|
|
271
|
+
? [prerenderApiOutPath(t.path)]
|
|
272
|
+
: [prerenderOutPath(t.path, t.trailingSlash), prerenderDataOutPath(t.path)];
|
|
273
|
+
// One ❌ per URL, but every file is still recorded so a later target can't slip past it.
|
|
274
|
+
for (const out of files) {
|
|
275
|
+
const segs = out.split("/");
|
|
276
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
277
|
+
const path = segs.slice(0, i).join("/");
|
|
278
|
+
const file = i === segs.length;
|
|
279
|
+
const key = path.toLowerCase();
|
|
280
|
+
const prev = owners.get(key);
|
|
281
|
+
if (!prev) owners.set(key, { path, who, file });
|
|
282
|
+
// Only two folders with the exact same name may share a path.
|
|
283
|
+
else if (!clash && (prev.path !== path || file || prev.file)) {
|
|
284
|
+
const what = file && prev.file ? "file" : "path";
|
|
285
|
+
clash = `❌ Prerender: ${prev.who} and ${who} write the same ${what} (${path})`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (clash) clashes.push(clash);
|
|
290
|
+
}
|
|
291
|
+
return clashes;
|
|
179
292
|
}
|
|
180
293
|
|
|
181
294
|
export async function prerenderStaticRoutes(
|
|
@@ -302,7 +415,7 @@ export async function prerenderStaticRoutes(
|
|
|
302
415
|
});
|
|
303
416
|
if (dataRes.ok) {
|
|
304
417
|
const dataJson = await dataRes.text();
|
|
305
|
-
const dataOutPath =
|
|
418
|
+
const dataOutPath = prerenderDataOutPath(routePath);
|
|
306
419
|
mkdirSync(dataOutPath.substring(0, dataOutPath.lastIndexOf("/")), {
|
|
307
420
|
recursive: true,
|
|
308
421
|
});
|
package/src/core/routeFile.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync } from "fs";
|
|
|
2
2
|
import type { RouteManifest } from "./types.ts";
|
|
3
3
|
import { currentBase } from "./appBase.ts";
|
|
4
4
|
import { toPosix } from "./paths.ts";
|
|
5
|
+
import { segmentRank } from "./matcher.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The pattern the *client* router matches against. It sees real browser URLs, so
|
|
@@ -28,23 +29,26 @@ function clientPattern(pattern: string): string {
|
|
|
28
29
|
// serverRoutes — used by SSR renderer (+ pageServer + layoutServers)
|
|
29
30
|
// apiRoutes — used by API handler
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
/** Most specific first: compared segment by segment, the first differing kind
|
|
33
|
+
* decides (so `/label/[id]` beats `/[a]/[b]`, `/blog/[...r]` beats `/[a]/about`).
|
|
34
|
+
* Ties fall back to more segments first, then alphabetical. findMatch takes the
|
|
35
|
+
* first match, so this order is the routing precedence. */
|
|
36
|
+
export function sortRoutes<T extends { pattern: string }>(routes: T[]): T[] {
|
|
37
|
+
return routes
|
|
38
|
+
.map((route) => ({ route, ranks: route.pattern.split("/").filter(Boolean).map(segmentRank) }))
|
|
39
|
+
.sort((a, b) => {
|
|
40
|
+
const ra = a.ranks;
|
|
41
|
+
const rb = b.ranks;
|
|
42
|
+
for (let i = 0; i < Math.min(ra.length, rb.length); i++) {
|
|
43
|
+
if (ra[i] !== rb[i]) return ra[i]! - rb[i]!;
|
|
44
|
+
}
|
|
45
|
+
if (ra.length !== rb.length) return rb.length - ra.length;
|
|
46
|
+
// plain code-unit compare: same order on every machine, unlike localeCompare
|
|
47
|
+
const pa = a.route.pattern;
|
|
48
|
+
const pb = b.route.pattern;
|
|
49
|
+
return pa < pb ? -1 : pa > pb ? 1 : 0;
|
|
50
|
+
})
|
|
51
|
+
.map((x) => x.route);
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** `defaultTitle` is what a page with no title of its own is called: app.html's
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import type { RouteManifest } from "./types.ts";
|
|
4
|
+
import { segmentName, segmentRank } from "./matcher.ts";
|
|
4
5
|
import { toPosix } from "./paths.ts";
|
|
6
|
+
import { PARAM_NAME } from "./scanner.ts";
|
|
5
7
|
|
|
6
8
|
// ─── Route Types Generator ────────────────────────────────
|
|
7
9
|
// Generates .bosia/types/src/routes/**/$types.d.ts for each
|
|
@@ -23,13 +25,7 @@ function paramsForDir(dir: string): string[] {
|
|
|
23
25
|
.filter((s) => !/^\(.*\)$/.test(s));
|
|
24
26
|
const out: string[] = [];
|
|
25
27
|
for (const seg of segs) {
|
|
26
|
-
|
|
27
|
-
if (catchAll) {
|
|
28
|
-
out.push(catchAll[1]!);
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
const dyn = seg.match(/^\[(\w+)\]$/);
|
|
32
|
-
if (dyn) out.push(dyn[1]!);
|
|
28
|
+
if (segmentRank(seg)) out.push(segmentName(seg));
|
|
33
29
|
}
|
|
34
30
|
return out;
|
|
35
31
|
}
|
|
@@ -75,8 +71,11 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
75
71
|
const srcBase = `${up}src/routes/${segments.length ? segments.join("/") + "/" : ""}`;
|
|
76
72
|
|
|
77
73
|
const params = paramsForDir(dir);
|
|
74
|
+
// Quoted fallback: a cached manifest (BOSIA_SKIP_ROUTE_SCAN) may predate the name check.
|
|
78
75
|
const paramsType =
|
|
79
|
-
params.length === 0
|
|
76
|
+
params.length === 0
|
|
77
|
+
? `{}`
|
|
78
|
+
: `{ ${params.map((p) => `${PARAM_NAME.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
|
|
80
79
|
|
|
81
80
|
const lines: string[] = ["// AUTO-GENERATED by bosia — do not edit\n"];
|
|
82
81
|
lines.push(`import type { LoadEvent, MetadataEvent, RequestEvent, Metadata } from 'bosia';`);
|
|
@@ -101,7 +100,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
101
100
|
|
|
102
101
|
if (info.pageServer) {
|
|
103
102
|
lines.push(``);
|
|
104
|
-
lines.push(
|
|
103
|
+
lines.push(
|
|
104
|
+
`import type { load as _pageLoad } from ${JSON.stringify(srcBase + "+page.server.ts")};`,
|
|
105
|
+
);
|
|
105
106
|
lines.push(`export type PageServerLoad = (event: _LoadEvent) => any;`);
|
|
106
107
|
lines.push(
|
|
107
108
|
`export type PageMetadataLoad = (event: _MetadataEvent) => Metadata | Promise<Metadata>;`,
|
|
@@ -131,7 +132,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
131
132
|
// ActionData — union of all action return types, unwrapping ActionFailure
|
|
132
133
|
if (info.pageServer) {
|
|
133
134
|
lines.push(``);
|
|
134
|
-
lines.push(
|
|
135
|
+
lines.push(
|
|
136
|
+
`import type { actions as _actions } from ${JSON.stringify(srcBase + "+page.server.ts")};`,
|
|
137
|
+
);
|
|
135
138
|
lines.push(
|
|
136
139
|
`type _ActionReturn<T> = T extends (...args: any[]) => infer R ? Awaited<R> : never;`,
|
|
137
140
|
);
|
|
@@ -144,7 +147,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
if (info.layoutServer) {
|
|
147
|
-
lines.push(
|
|
150
|
+
lines.push(
|
|
151
|
+
`\nimport type { load as _layoutLoad } from ${JSON.stringify(srcBase + "+layout.server.ts")};`,
|
|
152
|
+
);
|
|
148
153
|
lines.push(`export type LayoutServerLoad = (event: _LoadEvent) => any;`);
|
|
149
154
|
lines.push(`export type LayoutData = Awaited<ReturnType<typeof _layoutLoad>>;`);
|
|
150
155
|
lines.push(`export type LayoutProps = { data: LayoutData; params: Params; children: any };`);
|
package/src/core/scanner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdirSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import type { PageRoute, ApiRoute, RouteManifest, TrailingSlash } from "./types.ts";
|
|
4
|
+
import { segmentName, segmentRank } from "./matcher.ts";
|
|
4
5
|
|
|
5
6
|
// ─── Route Scanner ───────────────────────────────────────
|
|
6
7
|
// Walks src/routes/ and produces a RouteManifest.
|
|
@@ -192,19 +193,22 @@ export function scanRoutes(): RouteManifest {
|
|
|
192
193
|
|
|
193
194
|
walk("", [], [], [], [], "never", null);
|
|
194
195
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
// Invalid segments first, so they get their own message, not a conflict or warning.
|
|
197
|
+
checkParams(pages, (p) => p.page);
|
|
198
|
+
checkParams(apis, (a) => a.server);
|
|
199
|
+
|
|
200
|
+
// Warn when a trailing catch-all exists but no page covers its prefix.
|
|
201
|
+
// e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte),
|
|
202
|
+
// "/[lang]/[...rest]" needs "/[lang]" for "/en".
|
|
203
|
+
const pageKeys = new Set(pages.map((p) => routeKey(p.pattern)));
|
|
200
204
|
for (const p of pages) {
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
const exactEquivalent =
|
|
204
|
-
if (!
|
|
205
|
+
const cut = p.pattern.lastIndexOf("/");
|
|
206
|
+
if (segmentRank(p.pattern.slice(cut + 1)) === 2) {
|
|
207
|
+
const exactEquivalent = p.pattern.slice(0, cut) || "/";
|
|
208
|
+
if (!pageKeys.has(routeKey(exactEquivalent))) {
|
|
205
209
|
console.warn(
|
|
206
|
-
`⚠️
|
|
207
|
-
` Add a +page.svelte at
|
|
210
|
+
`⚠️ The catch-all "${p.pattern}" doesn't match "${exactEquivalent}" itself.\n` +
|
|
211
|
+
` Add a +page.svelte at "${exactEquivalent}" unless another route already serves it.`,
|
|
208
212
|
);
|
|
209
213
|
}
|
|
210
214
|
}
|
|
@@ -228,7 +232,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
228
232
|
function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
|
|
229
233
|
const seen = new Map<string, T>();
|
|
230
234
|
for (const r of routes) {
|
|
231
|
-
const key = r.pattern
|
|
235
|
+
const key = routeKey(r.pattern);
|
|
232
236
|
const first = seen.get(key);
|
|
233
237
|
if (!first) {
|
|
234
238
|
seen.set(key, r);
|
|
@@ -244,7 +248,62 @@ function preventConflicts<T extends { pattern: string }>(routes: T[], fileOf: (r
|
|
|
244
248
|
}
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
|
|
251
|
+
const KINDS = ["", "[]", "[...]"];
|
|
252
|
+
|
|
253
|
+
/** The URL shape of a pattern, param names dropped: `/[lang]/[...rest]` → `/[]/[...]`. */
|
|
254
|
+
function routeKey(pattern: string): string {
|
|
255
|
+
return pattern.replace(/[^/]+/g, (seg) => {
|
|
256
|
+
const rank = segmentRank(seg);
|
|
257
|
+
return rank ? KINDS[rank]! : seg;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Fail on params the matcher can't serve: brackets that don't wrap a whole
|
|
263
|
+
* segment, a `[...rest]` that isn't the last segment, `__proto__`, or one name
|
|
264
|
+
* bound twice. Mid-path catch-alls (`docs/[...path]/edit`) are left out on
|
|
265
|
+
* purpose: each needs an unanchored `(.+)` (several backtrack for seconds on a
|
|
266
|
+
* long URL) and span-aware ranking.
|
|
267
|
+
* Add both together if that route shape is ever needed.
|
|
268
|
+
*/
|
|
269
|
+
function checkParams<T extends { pattern: string }>(routes: T[], fileOf: (r: T) => string) {
|
|
270
|
+
for (const r of routes) {
|
|
271
|
+
const segs = r.pattern.split("/").filter(Boolean);
|
|
272
|
+
const names = new Set<string>();
|
|
273
|
+
const fail = (problem: string) => {
|
|
274
|
+
throw new RouteConflictError(
|
|
275
|
+
`The "${r.pattern}" route is invalid: ${problem}.\n src/routes/${fileOf(r)}`,
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
for (const [i, seg] of segs.entries()) {
|
|
279
|
+
const rank = segmentRank(seg);
|
|
280
|
+
if (!rank) {
|
|
281
|
+
// `[...]`, `[.x]` would match only their literal text.
|
|
282
|
+
if (/^\[[^[\]/]*\]$/.test(seg)) fail(BAD_NAME(seg));
|
|
283
|
+
// `[id].json`, `post-[slug]`, `[[lang]]`.
|
|
284
|
+
if (/[[\]]/.test(seg)) fail(`${seg} must be a whole segment like [slug] or [...rest]`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const name = segmentName(seg);
|
|
288
|
+
if (rank === 2 && i !== segs.length - 1) fail(`${seg} must be the last part of the route`);
|
|
289
|
+
if (!PARAM_NAME.test(name)) fail(BAD_NAME(seg));
|
|
290
|
+
// params is a plain object: assigning "__proto__" sets the prototype, not a key.
|
|
291
|
+
if (name === "__proto__") fail(`"__proto__" can't be a param name`);
|
|
292
|
+
if (names.has(name)) fail(`the param "${name}" is used twice`);
|
|
293
|
+
names.add(name);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Strict on purpose (decided 2026-10-14): an ASCII TypeScript identifier, so `params.name`
|
|
299
|
+
// always works: `[slug]`, `[x123]`, `[user_id]`, `[_id]`, `[$id]`. `[post-id]`, `[file.name]`,
|
|
300
|
+
// `[slüg]` fail by design, with one message (no per-typo hints).
|
|
301
|
+
export const PARAM_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
302
|
+
const BAD_NAME = (seg: string) =>
|
|
303
|
+
`${seg}: a param name uses ASCII letters, digits, _ and $, and can't start with a digit (like [slug], [user_id] or [...rest])`;
|
|
304
|
+
|
|
305
|
+
/** A route tree the build refuses (two files at one URL, or an invalid param).
|
|
306
|
+
* Callers print `message` and exit. */
|
|
248
307
|
export class RouteConflictError extends Error {
|
|
249
308
|
override name = "RouteConflictError";
|
|
250
309
|
}
|
package/src/core/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { NO_FRAME_GUARD_HEADER, type Handle, type RequestEvent } from "./hooks.t
|
|
|
15
15
|
import { HttpError, Redirect, ActionFailure, isHttpError, isRedirect } from "./errors.ts";
|
|
16
16
|
import { CookieJar } from "./cookies.ts";
|
|
17
17
|
import { safePath } from "./safePath.ts";
|
|
18
|
+
import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
|
|
18
19
|
import { checkCsrf } from "./csrf.ts";
|
|
19
20
|
import type { CsrfConfig } from "./csrf.ts";
|
|
20
21
|
import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
compress,
|
|
26
27
|
compressBytes,
|
|
27
28
|
encodeForRequest,
|
|
28
|
-
isStaticPath,
|
|
29
29
|
distManifest,
|
|
30
30
|
PRECOMPRESSED,
|
|
31
31
|
type Encoded,
|
|
@@ -372,7 +372,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
372
372
|
const method = request.method.toUpperCase();
|
|
373
373
|
|
|
374
374
|
// Health check endpoint — for load balancers and orchestrators
|
|
375
|
-
if (path ===
|
|
375
|
+
if (path === HEALTH_PATH) {
|
|
376
376
|
if (shuttingDown) {
|
|
377
377
|
return Response.json({ status: "shutting_down" }, { status: 503 });
|
|
378
378
|
}
|
|
@@ -593,7 +593,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
593
593
|
// by a user `+server.ts`, so serve them straight from the manifest before the
|
|
594
594
|
// API scan. A miss falls through to the normal path (which 404s). Keeps the
|
|
595
595
|
// api-before-static ordering intact for every user-facing path.
|
|
596
|
-
if (staticManifest && (path
|
|
596
|
+
if (staticManifest && isFrameworkPath(path)) {
|
|
597
597
|
const hit = lookupStatic(staticManifest, path);
|
|
598
598
|
if (hit) return serveStatic(hit, request);
|
|
599
599
|
}
|
|
@@ -1144,7 +1144,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1144
1144
|
}
|
|
1145
1145
|
|
|
1146
1146
|
// Reject new non-health requests during shutdown
|
|
1147
|
-
if (shuttingDown && url.pathname !==
|
|
1147
|
+
if (shuttingDown && url.pathname !== HEALTH_PATH) {
|
|
1148
1148
|
return new Response("Service Unavailable", {
|
|
1149
1149
|
status: 503,
|
|
1150
1150
|
headers: { "Retry-After": "5" },
|
|
@@ -1154,7 +1154,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1154
1154
|
// Shed load above MAX_INFLIGHT. Checked before any work so the 503 is
|
|
1155
1155
|
// cheap. /_health stays available so the orchestrator can still tell the
|
|
1156
1156
|
// process is alive (and decide whether to restart or scale out).
|
|
1157
|
-
if (inFlight >= MAX_INFLIGHT && url.pathname !==
|
|
1157
|
+
if (inFlight >= MAX_INFLIGHT && url.pathname !== HEALTH_PATH) {
|
|
1158
1158
|
return new Response("Service Unavailable", {
|
|
1159
1159
|
status: 503,
|
|
1160
1160
|
headers: { "Retry-After": "1" },
|
|
@@ -1215,7 +1215,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1215
1215
|
const path = event.url.pathname;
|
|
1216
1216
|
let response: Response | null = null;
|
|
1217
1217
|
let staticTried = false;
|
|
1218
|
-
if (!dataReq && staticManifest && (path
|
|
1218
|
+
if (!dataReq && staticManifest && isFrameworkPath(path)) {
|
|
1219
1219
|
response = await serveStaticFile(path, request);
|
|
1220
1220
|
staticTried = true;
|
|
1221
1221
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// URLs the server answers as static files, never as pages. No imports and no top-level
|
|
2
|
+
// work: the build loads this before loadEnv(), so it must not touch env or the base path.
|
|
3
|
+
|
|
4
|
+
const STATIC_EXTS = new Set([
|
|
5
|
+
".ico",
|
|
6
|
+
".png",
|
|
7
|
+
".jpg",
|
|
8
|
+
".jpeg",
|
|
9
|
+
".gif",
|
|
10
|
+
".webp",
|
|
11
|
+
".svg",
|
|
12
|
+
".css",
|
|
13
|
+
".js",
|
|
14
|
+
".woff",
|
|
15
|
+
".woff2",
|
|
16
|
+
".ttf",
|
|
17
|
+
".xml",
|
|
18
|
+
".txt",
|
|
19
|
+
".json",
|
|
20
|
+
".webmanifest",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// The server answers these itself; prerender rejects entries() values that reach them.
|
|
24
|
+
export const HEALTH_PATH = "/_health";
|
|
25
|
+
|
|
26
|
+
// The framework's own folders: built assets and data/runtime endpoints.
|
|
27
|
+
export function isFrameworkPath(path: string): boolean {
|
|
28
|
+
return path.startsWith("/dist/") || path.startsWith("/__bosia/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isStaticPath(path: string): boolean {
|
|
32
|
+
if (isFrameworkPath(path)) return true;
|
|
33
|
+
const dot = path.lastIndexOf(".");
|
|
34
|
+
return dot !== -1 && STATIC_EXTS.has(path.slice(dot));
|
|
35
|
+
}
|
|
@@ -29,7 +29,7 @@ This page lives at routes/(public)/blog/[slug]/+page.svelte but is served at /bl
|
|
|
29
29
|
|
|
30
30
|
The matched value is available as params.slug inside +page.server.ts load() and inside the page component via the params prop.
|
|
31
31
|
|
|
32
|
-
The route matcher
|
|
32
|
+
The route matcher compares routes part by part: a fixed name beats a param, which beats a catch-all.`,
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
|