bosia 0.8.3 → 0.8.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/README.md +14 -0
- package/package.json +1 -1
- package/src/ambient.d.ts +0 -1
- package/src/core/cache.ts +94 -28
- package/src/core/cookies.ts +18 -0
- package/src/core/dedup.ts +7 -7
- package/src/core/renderer.ts +247 -208
- package/src/core/routeFile.ts +0 -2
- package/src/core/scanner.ts +1 -7
- package/src/core/server.ts +93 -44
- package/src/core/staticManifest.ts +35 -1
- package/src/core/types.ts +0 -6
package/README.md
CHANGED
|
@@ -18,6 +18,20 @@ File-based routing inspired by SvelteKit, built on top of the Bun runtime and El
|
|
|
18
18
|
- **Dev server with HMR** — file watcher + SSE browser reload, no page blink
|
|
19
19
|
- **Tailwind CSS v4** — compiled at build time, shadcn-inspired design tokens out of the box
|
|
20
20
|
- **CLI** — `bosia create`, `bosia dev`, `bosia build`, `bosia add`, `bosia feat`
|
|
21
|
+
- **UI registry** — shadcn-style, copy-paste source you own: **60 components**, **100+ blocks**, **10 pages**, **19 themes**
|
|
22
|
+
|
|
23
|
+
## UI Registry
|
|
24
|
+
|
|
25
|
+
`bosia add` copies source directly into your project — no runtime package to import:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bun x bosia@latest add button card data-table # 60 primitives
|
|
29
|
+
bun x bosia@latest add block heros/product # 100+ composed sections
|
|
30
|
+
bun x bosia@latest add page storefront/home # 10 ready-to-wire pages
|
|
31
|
+
bun x bosia@latest add theme editorial # 19 token themes
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
See the [registry docs](https://bosia.dev) for the full catalog.
|
|
21
35
|
|
|
22
36
|
## Quick Start
|
|
23
37
|
|
package/package.json
CHANGED
package/src/ambient.d.ts
CHANGED
package/src/core/cache.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
//
|
|
6
6
|
// See docs/guides/response-cache.md.
|
|
7
7
|
|
|
8
|
+
import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
8
9
|
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
10
|
+
import type { CookieJar } from "./cookies.ts";
|
|
9
11
|
import { dedupKey } from "./dedup.ts";
|
|
10
12
|
|
|
11
13
|
// ─── Config ──────────────────────────────────────────────
|
|
@@ -28,22 +30,31 @@ function parseMaxEntries(raw: string | undefined): number {
|
|
|
28
30
|
return n;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
function parseMaxBodyBytes(raw: string | undefined): number {
|
|
34
|
+
const DEFAULT = 2_097_152; // 2MB; 0 = unlimited
|
|
35
|
+
if (!raw) return DEFAULT;
|
|
36
|
+
const n = parseInt(raw, 10);
|
|
37
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT;
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Server-only module (imported by core/renderer.ts, core/server.ts,
|
|
42
|
+
// lib/server.ts — never the client barrel; client `invalidate` lives in
|
|
43
|
+
// core/client/navigation.ts). The `process` guards below are kept as
|
|
44
|
+
// defense in case a future refactor pulls this into a browser bundle.
|
|
35
45
|
const env: Record<string, string | undefined> =
|
|
36
46
|
typeof process !== "undefined" && process.env ? process.env : {};
|
|
37
47
|
const isServer = typeof process !== "undefined";
|
|
38
48
|
|
|
39
49
|
export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
|
|
40
50
|
export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
|
|
51
|
+
export const CACHE_MAX_BODY_BYTES = parseMaxBodyBytes(env.CACHE_MAX_BODY_BYTES);
|
|
41
52
|
export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
|
|
42
53
|
|
|
43
54
|
if (isServer) {
|
|
44
55
|
if (CACHE_ENABLED) {
|
|
45
56
|
console.log(
|
|
46
|
-
`💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, identity keys [${CACHE_KEYS.join(", ")}]`,
|
|
57
|
+
`💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, max body ${CACHE_MAX_BODY_BYTES === 0 ? "unlimited" : `${CACHE_MAX_BODY_BYTES} bytes`}, identity keys [${CACHE_KEYS.join(", ")}]`,
|
|
47
58
|
);
|
|
48
59
|
} else {
|
|
49
60
|
console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
|
|
@@ -111,31 +122,28 @@ const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
|
|
|
111
122
|
|
|
112
123
|
// ─── Key building ────────────────────────────────────────
|
|
113
124
|
|
|
114
|
-
/**
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
for (let i = 0; i < s.length; i++) {
|
|
118
|
-
h ^= s.charCodeAt(i);
|
|
119
|
-
h = Math.imul(h, 0x01000193);
|
|
120
|
-
}
|
|
121
|
-
return (h >>> 0).toString(36);
|
|
125
|
+
/** SHA-256 truncated to 64 bits — identity buckets must not collide across users. */
|
|
126
|
+
function identityDigest(s: string): string {
|
|
127
|
+
return new Bun.CryptoHasher("sha256").update(s).digest("hex").slice(0, 16);
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
export function computeIdentityHash(req: Request, cookies:
|
|
130
|
+
export function computeIdentityHash(req: Request, cookies: Pick<CookieJar, "peek">): string {
|
|
125
131
|
const headers = req.headers;
|
|
126
132
|
const parts: string[] = [];
|
|
127
133
|
for (const name of CACHE_KEYS) {
|
|
128
|
-
|
|
134
|
+
// peek, not get — building the identity must not count as a cookie read
|
|
135
|
+
// (get flips `accessed`, which forces Cache-Control: private downstream).
|
|
136
|
+
const cv = cookies.peek(name);
|
|
129
137
|
if (cv) parts.push(`c:${name}=${cv}`);
|
|
130
138
|
const hv = headers.get(name);
|
|
131
139
|
if (hv) parts.push(`h:${name}=${hv}`);
|
|
132
140
|
}
|
|
133
141
|
if (parts.length === 0) return "0";
|
|
134
142
|
parts.sort();
|
|
135
|
-
return
|
|
143
|
+
return identityDigest(parts.join("&"));
|
|
136
144
|
}
|
|
137
145
|
|
|
138
|
-
export function computeCacheKey(url: URL, req: Request, cookies:
|
|
146
|
+
export function computeCacheKey(url: URL, req: Request, cookies: Pick<CookieJar, "peek">): string {
|
|
139
147
|
return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
|
|
140
148
|
}
|
|
141
149
|
|
|
@@ -175,8 +183,37 @@ export function cacheGet(key: string): CacheEntry | undefined {
|
|
|
175
183
|
return htmlCache.get(key);
|
|
176
184
|
}
|
|
177
185
|
|
|
178
|
-
|
|
186
|
+
// ─── Uncovered-cookie warning ────────────────────────────
|
|
187
|
+
// The identity hash only sees cookies named in CACHE_KEYS. If a loader read a
|
|
188
|
+
// cookie outside that list and the response got cached anyway, the response
|
|
189
|
+
// may be personalised on something the cache key can't distinguish — users
|
|
190
|
+
// could be served each other's pages. Warn loudly (dev AND prod), once per
|
|
191
|
+
// cookie name per process.
|
|
192
|
+
|
|
193
|
+
const warnedUncoveredCookies = new Set<string>();
|
|
194
|
+
|
|
195
|
+
export function warnUncoveredCookies(cookies: Cookies): void {
|
|
196
|
+
const readNames = (cookies as { readNames?: ReadonlySet<string> }).readNames;
|
|
197
|
+
if (!readNames) return;
|
|
198
|
+
for (const name of readNames) {
|
|
199
|
+
if (CACHE_KEYS.includes(name) || warnedUncoveredCookies.has(name)) continue;
|
|
200
|
+
warnedUncoveredCookies.add(name);
|
|
201
|
+
console.warn(
|
|
202
|
+
`\n🚨 [bosia] SECURITY WARNING — possible cross-user cache leak 🚨\n` +
|
|
203
|
+
` A cached response's loader read the cookie "${name}", which is NOT in CACHE_KEYS.\n` +
|
|
204
|
+
` The cache key cannot tell users apart by this cookie: if the page content\n` +
|
|
205
|
+
` depends on it, one user's page can be served to another.\n` +
|
|
206
|
+
` Fix (pick one):\n` +
|
|
207
|
+
` - Add it to the identity key list: CACHE_KEYS=${[...CACHE_KEYS, name].join(",")}\n` +
|
|
208
|
+
` - Or opt the route out of caching: export const cache = false\n`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function cacheSet(key: string, entry: CacheEntry, cookies?: Cookies): void {
|
|
179
214
|
if (!CACHE_ENABLED) return;
|
|
215
|
+
if (CACHE_MAX_BODY_BYTES > 0 && entry.raw.length > CACHE_MAX_BODY_BYTES) return;
|
|
216
|
+
if (cookies) warnUncoveredCookies(cookies);
|
|
180
217
|
// Drop any existing entry's index pointers first
|
|
181
218
|
cacheDeleteKey(key);
|
|
182
219
|
const evicted = htmlCache.set(key, entry);
|
|
@@ -247,16 +284,16 @@ export function buildCompressedVariants(body: Bytes): {
|
|
|
247
284
|
} catch {
|
|
248
285
|
gzip = null;
|
|
249
286
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
287
|
+
try {
|
|
288
|
+
// Quality 5: ~20x faster than the default 11, still beats gzip on size.
|
|
289
|
+
// This runs sync in a microtask — 11 would block the event loop ~17ms/500KB.
|
|
290
|
+
brotli = new Uint8Array(
|
|
291
|
+
brotliCompressSync(body, {
|
|
292
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 },
|
|
293
|
+
}),
|
|
294
|
+
) as Bytes;
|
|
295
|
+
} catch {
|
|
296
|
+
brotli = null;
|
|
260
297
|
}
|
|
261
298
|
return { gzip, brotli };
|
|
262
299
|
}
|
|
@@ -274,6 +311,35 @@ export function concatChunks(chunks: Uint8Array[]): Bytes {
|
|
|
274
311
|
return out;
|
|
275
312
|
}
|
|
276
313
|
|
|
314
|
+
// ─── Miss coalescing ─────────────────────────────────────
|
|
315
|
+
// Stampede protection for cache misses. The first miss on a key becomes the
|
|
316
|
+
// leader (gets `release`); concurrent misses become waiters (get `wait`).
|
|
317
|
+
// Waiters re-check cacheGet after the wait resolves — hit: serve it; miss
|
|
318
|
+
// (leader skipped the write): build independently, no re-leadering. Distinct
|
|
319
|
+
// from dedup(), which shares the *result*; this shares only the *wait*.
|
|
320
|
+
//
|
|
321
|
+
// INVARIANT: the leader must call release() exactly once, after its cacheSet
|
|
322
|
+
// attempt completed or was skipped. A missed release() hangs waiters for the
|
|
323
|
+
// process lifetime — callers guard with try/finally.
|
|
324
|
+
|
|
325
|
+
const missGates = new Map<string, { promise: Promise<void>; release: () => void }>();
|
|
326
|
+
|
|
327
|
+
export function coalesceMiss(
|
|
328
|
+
key: string,
|
|
329
|
+
): { release: () => void; wait?: undefined } | { wait: Promise<void>; release?: undefined } {
|
|
330
|
+
const existing = missGates.get(key);
|
|
331
|
+
if (existing) return { wait: existing.promise };
|
|
332
|
+
let resolve!: () => void;
|
|
333
|
+
const promise = new Promise<void>((r) => (resolve = r));
|
|
334
|
+
const release = () => {
|
|
335
|
+
// Idempotent, and never deletes a successor leader's gate.
|
|
336
|
+
if (missGates.get(key)?.promise === promise) missGates.delete(key);
|
|
337
|
+
resolve();
|
|
338
|
+
};
|
|
339
|
+
missGates.set(key, { promise, release });
|
|
340
|
+
return { release };
|
|
341
|
+
}
|
|
342
|
+
|
|
277
343
|
// ─── Serve a cache hit ───────────────────────────────────
|
|
278
344
|
|
|
279
345
|
export function serveCached(entry: CacheEntry, req: Request): Response {
|
package/src/core/cookies.ts
CHANGED
|
@@ -58,6 +58,7 @@ export class CookieJar implements Cookies {
|
|
|
58
58
|
private _outgoing: string[] = [];
|
|
59
59
|
private _defaults: CookieOptions;
|
|
60
60
|
private _accessed = false;
|
|
61
|
+
private _readNames = new Set<string>();
|
|
61
62
|
private _isHttps: boolean;
|
|
62
63
|
|
|
63
64
|
constructor(cookieHeader: string, isHttps = false) {
|
|
@@ -70,11 +71,23 @@ export class CookieJar implements Cookies {
|
|
|
70
71
|
|
|
71
72
|
get(name: string): string | undefined {
|
|
72
73
|
this._accessed = true;
|
|
74
|
+
if (name in this._incoming) this._readNames.add(name);
|
|
75
|
+
return this._incoming[name];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read an incoming cookie WITHOUT flipping `accessed` or recording the name
|
|
80
|
+
* in `readNames`. Framework-internal (identity hashing must not count as a
|
|
81
|
+
* cookie read); deliberately absent from the public `Cookies` interface so
|
|
82
|
+
* loaders can't bypass dependency tracking.
|
|
83
|
+
*/
|
|
84
|
+
peek(name: string): string | undefined {
|
|
73
85
|
return this._incoming[name];
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
getAll(): Record<string, string> {
|
|
77
89
|
this._accessed = true;
|
|
90
|
+
for (const name of Object.keys(this._incoming)) this._readNames.add(name);
|
|
78
91
|
return { ...this._incoming };
|
|
79
92
|
}
|
|
80
93
|
|
|
@@ -82,6 +95,11 @@ export class CookieJar implements Cookies {
|
|
|
82
95
|
return this._accessed;
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
/** Names of incoming cookies that were actually read (present in the request). */
|
|
99
|
+
get readNames(): ReadonlySet<string> {
|
|
100
|
+
return this._readNames;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
86
104
|
if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
|
|
87
105
|
const opts = { ...this._defaults, ...options };
|
package/src/core/dedup.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// ─── Request Deduplication ──────────────────────────────
|
|
2
|
-
// Concurrent in-flight coalescing
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// Concurrent in-flight coalescing. When N parallel requests hit the same URL,
|
|
3
|
+
// run the loader once and share the resolved value across all N waiters.
|
|
4
|
+
// Settled responses are NOT cached — once the promise resolves, the entry is
|
|
5
|
+
// dropped, so the next request runs the loader again.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// The caller's key includes the CACHE_KEYS identity hash (see cache.ts), so
|
|
8
|
+
// different users never share a loader result. Custom session cookie names
|
|
9
|
+
// must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
|
|
10
10
|
|
|
11
11
|
const inflight = new Map<string, Promise<unknown>>();
|
|
12
12
|
|
package/src/core/renderer.ts
CHANGED
|
@@ -7,14 +7,17 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
|
7
7
|
import { CSP_ENABLED } from "./csp.ts";
|
|
8
8
|
import {
|
|
9
9
|
CACHE_ENABLED,
|
|
10
|
+
CACHE_MAX_BODY_BYTES,
|
|
10
11
|
buildCompressedVariants,
|
|
11
12
|
cacheGet,
|
|
12
13
|
cacheSet,
|
|
14
|
+
coalesceMiss,
|
|
13
15
|
collectTags,
|
|
14
16
|
computeCacheKey,
|
|
15
17
|
concatChunks,
|
|
16
18
|
serveCached,
|
|
17
19
|
} from "./cache.ts";
|
|
20
|
+
import type { CookieJar } from "./cookies.ts";
|
|
18
21
|
import { HttpError, Redirect } from "./errors.ts";
|
|
19
22
|
import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
|
|
20
23
|
import App from "./client/App.svelte";
|
|
@@ -532,27 +535,96 @@ export async function renderSSRStream(
|
|
|
532
535
|
const cacheable =
|
|
533
536
|
CACHE_ENABLED && !CSP_ENABLED && pageMod.cache !== false && req.method === "GET";
|
|
534
537
|
let cacheKey: string | null = null;
|
|
538
|
+
let releaseMiss: (() => void) | null = null;
|
|
535
539
|
if (cacheable) {
|
|
536
|
-
cacheKey = computeCacheKey(url, req, cookies);
|
|
540
|
+
cacheKey = computeCacheKey(url, req, cookies as CookieJar);
|
|
537
541
|
if (!cacheBypass) {
|
|
538
542
|
const hit = cacheGet(cacheKey);
|
|
539
543
|
if (hit) return serveCached(hit, req);
|
|
544
|
+
// Miss coalescing: the first miss leads the build; concurrent misses
|
|
545
|
+
// wait, then re-check the cache. A waiter that still misses (leader
|
|
546
|
+
// skipped the write) builds independently — no re-leadering.
|
|
547
|
+
const gate = coalesceMiss(cacheKey);
|
|
548
|
+
if (gate.wait) {
|
|
549
|
+
await gate.wait;
|
|
550
|
+
const rehit = cacheGet(cacheKey);
|
|
551
|
+
if (rehit) return serveCached(rehit, req);
|
|
552
|
+
} else {
|
|
553
|
+
releaseMiss = gate.release;
|
|
554
|
+
}
|
|
540
555
|
}
|
|
541
556
|
}
|
|
542
557
|
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
|
|
558
|
+
// INVARIANT: releaseMiss must fire exactly once — a missed release() hangs
|
|
559
|
+
// coalesced waiters for the process lifetime. Every early return and throw
|
|
560
|
+
// below must stay inside this try; the cache-write path hands the release
|
|
561
|
+
// off to its cacheSet microtask by nulling releaseMiss first.
|
|
546
562
|
try {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
563
|
+
// ── Pre-stream phase: resolve metadata before committing to a 200 ──
|
|
564
|
+
// Errors here return a proper error response with correct status code.
|
|
565
|
+
let metadata: Metadata | null = null;
|
|
566
|
+
try {
|
|
567
|
+
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
if (err instanceof Redirect) {
|
|
570
|
+
return Response.redirect(err.location, err.status);
|
|
571
|
+
}
|
|
572
|
+
if (err instanceof HttpError) {
|
|
573
|
+
return renderErrorPage(
|
|
574
|
+
err.status,
|
|
575
|
+
err.message,
|
|
576
|
+
url,
|
|
577
|
+
req,
|
|
578
|
+
route,
|
|
579
|
+
undefined,
|
|
580
|
+
undefined,
|
|
581
|
+
undefined,
|
|
582
|
+
nonce,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (isDev) console.error("Metadata load error:", err);
|
|
586
|
+
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
587
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
588
|
+
// Continue with null metadata — don't break the page for a metadata failure
|
|
551
589
|
}
|
|
552
|
-
|
|
590
|
+
|
|
591
|
+
// ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
|
|
592
|
+
// This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
|
|
593
|
+
const metadataData = metadata?.data ?? null;
|
|
594
|
+
let data: Awaited<ReturnType<typeof loadRouteData>>;
|
|
595
|
+
let layoutMods: any[];
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
[data, layoutMods] = await Promise.all([
|
|
599
|
+
loadRouteData(url, locals, req, cookies, metadataData, match),
|
|
600
|
+
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
601
|
+
]);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
if (err instanceof Redirect) return Response.redirect(err.location, err.status);
|
|
604
|
+
if (err instanceof HttpError) {
|
|
605
|
+
const e = err as HttpError & {
|
|
606
|
+
errorDepth?: number;
|
|
607
|
+
errorOrigin?: ErrorOrigin;
|
|
608
|
+
partialLayoutData?: Record<string, any>[];
|
|
609
|
+
};
|
|
610
|
+
return renderErrorPage(
|
|
611
|
+
err.status,
|
|
612
|
+
err.message,
|
|
613
|
+
url,
|
|
614
|
+
req,
|
|
615
|
+
route,
|
|
616
|
+
e.errorDepth,
|
|
617
|
+
e.errorOrigin,
|
|
618
|
+
e.partialLayoutData,
|
|
619
|
+
nonce,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
if (isDev) console.error("SSR load error:", err);
|
|
623
|
+
else console.error("SSR load error:", (err as Error).message ?? err);
|
|
624
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
553
625
|
return renderErrorPage(
|
|
554
|
-
|
|
555
|
-
|
|
626
|
+
500,
|
|
627
|
+
"Internal Server Error",
|
|
556
628
|
url,
|
|
557
629
|
req,
|
|
558
630
|
route,
|
|
@@ -562,222 +634,189 @@ export async function renderSSRStream(
|
|
|
562
634
|
nonce,
|
|
563
635
|
);
|
|
564
636
|
}
|
|
565
|
-
if (isDev) console.error("Metadata load error:", err);
|
|
566
|
-
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
567
|
-
if (isDev) reportDevErrorFromCatch(err);
|
|
568
|
-
// Continue with null metadata — don't break the page for a metadata failure
|
|
569
|
-
}
|
|
570
637
|
|
|
571
|
-
|
|
572
|
-
// This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
|
|
573
|
-
const metadataData = metadata?.data ?? null;
|
|
574
|
-
let data: Awaited<ReturnType<typeof loadRouteData>>;
|
|
575
|
-
let layoutMods: any[];
|
|
576
|
-
|
|
577
|
-
try {
|
|
578
|
-
[data, layoutMods] = await Promise.all([
|
|
579
|
-
loadRouteData(url, locals, req, cookies, metadataData, match),
|
|
580
|
-
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
581
|
-
]);
|
|
582
|
-
} catch (err) {
|
|
583
|
-
if (err instanceof Redirect) return Response.redirect(err.location, err.status);
|
|
584
|
-
if (err instanceof HttpError) {
|
|
585
|
-
const e = err as HttpError & {
|
|
586
|
-
errorDepth?: number;
|
|
587
|
-
errorOrigin?: ErrorOrigin;
|
|
588
|
-
partialLayoutData?: Record<string, any>[];
|
|
589
|
-
};
|
|
638
|
+
if (!data)
|
|
590
639
|
return renderErrorPage(
|
|
591
|
-
|
|
592
|
-
|
|
640
|
+
404,
|
|
641
|
+
"Not Found",
|
|
593
642
|
url,
|
|
594
643
|
req,
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
644
|
+
undefined,
|
|
645
|
+
undefined,
|
|
646
|
+
undefined,
|
|
647
|
+
undefined,
|
|
599
648
|
nonce,
|
|
600
649
|
);
|
|
601
|
-
}
|
|
602
|
-
if (isDev) console.error("SSR load error:", err);
|
|
603
|
-
else console.error("SSR load error:", (err as Error).message ?? err);
|
|
604
|
-
if (isDev) reportDevErrorFromCatch(err);
|
|
605
|
-
return renderErrorPage(
|
|
606
|
-
500,
|
|
607
|
-
"Internal Server Error",
|
|
608
|
-
url,
|
|
609
|
-
req,
|
|
610
|
-
route,
|
|
611
|
-
undefined,
|
|
612
|
-
undefined,
|
|
613
|
-
undefined,
|
|
614
|
-
nonce,
|
|
615
|
-
);
|
|
616
|
-
}
|
|
617
650
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
"Not Found",
|
|
651
|
+
const enc = new TextEncoder();
|
|
652
|
+
const renderCtx: RenderContext = {
|
|
653
|
+
request: req,
|
|
622
654
|
url,
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
);
|
|
630
|
-
|
|
631
|
-
const enc = new TextEncoder();
|
|
632
|
-
const renderCtx: RenderContext = {
|
|
633
|
-
request: req,
|
|
634
|
-
url,
|
|
635
|
-
route: { pattern: route.pattern },
|
|
636
|
-
metadata,
|
|
637
|
-
};
|
|
638
|
-
const [headExtras, bodyEndExtras] = await Promise.all([
|
|
639
|
-
pluginRenderFragments("head", renderCtx),
|
|
640
|
-
pluginRenderFragments("bodyEnd", renderCtx),
|
|
641
|
-
]);
|
|
655
|
+
route: { pattern: route.pattern },
|
|
656
|
+
metadata,
|
|
657
|
+
};
|
|
658
|
+
const [headExtras, bodyEndExtras] = await Promise.all([
|
|
659
|
+
pluginRenderFragments("head", renderCtx),
|
|
660
|
+
pluginRenderFragments("bodyEnd", renderCtx),
|
|
661
|
+
]);
|
|
642
662
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
663
|
+
// SSR always runs every loader, so coerce types from the optional sparse shape.
|
|
664
|
+
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
665
|
+
const pageDataFull = data.pageData ?? {};
|
|
646
666
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
667
|
+
// ssr=false → no render() needed; ship shell + hydration as a single response.
|
|
668
|
+
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
669
|
+
if (!data.ssr) {
|
|
670
|
+
if (!data.csr && isDev) {
|
|
671
|
+
console.warn(
|
|
672
|
+
`⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
const html =
|
|
676
|
+
buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
|
|
677
|
+
buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
|
|
678
|
+
buildHtmlTail(
|
|
679
|
+
"",
|
|
680
|
+
"",
|
|
681
|
+
pageDataFull,
|
|
682
|
+
layoutDataFull,
|
|
683
|
+
true,
|
|
684
|
+
null,
|
|
685
|
+
false,
|
|
686
|
+
bodyEndExtras,
|
|
687
|
+
nonce,
|
|
688
|
+
data.pageDeps,
|
|
689
|
+
data.layoutDeps,
|
|
690
|
+
appHtmlSegments,
|
|
691
|
+
);
|
|
692
|
+
return new Response(html, {
|
|
693
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
694
|
+
});
|
|
654
695
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
696
|
+
|
|
697
|
+
// Render-first: run render() before committing to a 200. Failure → proper error page
|
|
698
|
+
// with correct status code, instead of a bare <p> mixed into an already-flushed shell.
|
|
699
|
+
let body: string, head: string;
|
|
700
|
+
try {
|
|
701
|
+
({ body, head } = render(App, {
|
|
702
|
+
props: {
|
|
703
|
+
ssrMode: true,
|
|
704
|
+
ssrPageComponent: pageMod.default,
|
|
705
|
+
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
706
|
+
ssrPageData: pageDataFull,
|
|
707
|
+
ssrLayoutData: layoutDataFull,
|
|
708
|
+
},
|
|
709
|
+
}));
|
|
710
|
+
} catch (err) {
|
|
711
|
+
if (isDev) console.error("SSR render error:", err);
|
|
712
|
+
else console.error("SSR render error:", (err as Error).message ?? err);
|
|
713
|
+
if (isDev) reportDevErrorFromCatch(err);
|
|
714
|
+
// Render-phase errors fall through to deepest boundary like a page error.
|
|
715
|
+
return renderErrorPage(
|
|
716
|
+
500,
|
|
717
|
+
"Internal Server Error",
|
|
718
|
+
url,
|
|
719
|
+
req,
|
|
720
|
+
route,
|
|
721
|
+
route.layoutModules.length,
|
|
722
|
+
"page",
|
|
662
723
|
layoutDataFull,
|
|
663
|
-
true,
|
|
664
|
-
null,
|
|
665
|
-
false,
|
|
666
|
-
bodyEndExtras,
|
|
667
724
|
nonce,
|
|
668
|
-
data.pageDeps,
|
|
669
|
-
data.layoutDeps,
|
|
670
|
-
appHtmlSegments,
|
|
671
725
|
);
|
|
672
|
-
|
|
673
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Render-first: run render() before committing to a 200. Failure → proper error page
|
|
678
|
-
// with correct status code, instead of a bare <p> mixed into an already-flushed shell.
|
|
679
|
-
let body: string, head: string;
|
|
680
|
-
try {
|
|
681
|
-
({ body, head } = render(App, {
|
|
682
|
-
props: {
|
|
683
|
-
ssrMode: true,
|
|
684
|
-
ssrPageComponent: pageMod.default,
|
|
685
|
-
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
686
|
-
ssrPageData: pageDataFull,
|
|
687
|
-
ssrLayoutData: layoutDataFull,
|
|
688
|
-
},
|
|
689
|
-
}));
|
|
690
|
-
} catch (err) {
|
|
691
|
-
if (isDev) console.error("SSR render error:", err);
|
|
692
|
-
else console.error("SSR render error:", (err as Error).message ?? err);
|
|
693
|
-
if (isDev) reportDevErrorFromCatch(err);
|
|
694
|
-
// Render-phase errors fall through to deepest boundary like a page error.
|
|
695
|
-
return renderErrorPage(
|
|
696
|
-
500,
|
|
697
|
-
"Internal Server Error",
|
|
698
|
-
url,
|
|
699
|
-
req,
|
|
700
|
-
route,
|
|
701
|
-
route.layoutModules.length,
|
|
702
|
-
"page",
|
|
703
|
-
layoutDataFull,
|
|
704
|
-
nonce,
|
|
705
|
-
);
|
|
706
|
-
}
|
|
726
|
+
}
|
|
707
727
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
728
|
+
// Pre-compute all chunks; pull-based stream gives Bun native backpressure.
|
|
729
|
+
const chunks: Uint8Array[] = [
|
|
730
|
+
enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
|
|
731
|
+
enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
|
|
732
|
+
enc.encode(
|
|
733
|
+
buildHtmlTail(
|
|
734
|
+
body,
|
|
735
|
+
head,
|
|
736
|
+
pageDataFull,
|
|
737
|
+
layoutDataFull,
|
|
738
|
+
data.csr,
|
|
739
|
+
null,
|
|
740
|
+
true,
|
|
741
|
+
bodyEndExtras,
|
|
742
|
+
nonce,
|
|
743
|
+
data.pageDeps,
|
|
744
|
+
data.layoutDeps,
|
|
745
|
+
appHtmlSegments,
|
|
746
|
+
),
|
|
726
747
|
),
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
controller.enqueue(chunks[i++]);
|
|
767
|
-
if (i >= chunks.length) {
|
|
768
|
-
controller.close();
|
|
769
|
-
req.signal.removeEventListener("abort", onAbort);
|
|
748
|
+
];
|
|
749
|
+
|
|
750
|
+
// ── Response cache: write after chunks built, before stream creation ──
|
|
751
|
+
// Skip if the handler set cookies — cached response can't reproduce
|
|
752
|
+
// per-request Set-Cookie headers. Compression runs in a microtask so
|
|
753
|
+
// the response goes out first.
|
|
754
|
+
if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
|
|
755
|
+
const fullBody = concatChunks(chunks);
|
|
756
|
+
// Oversized bodies skip early so they never pay compression; cacheSet
|
|
757
|
+
// re-checks as the authoritative guard.
|
|
758
|
+
if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
|
|
759
|
+
const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
|
|
760
|
+
const keyForWrite = cacheKey;
|
|
761
|
+
// Hand the gate release to the write microtask: waiters resume only
|
|
762
|
+
// after cacheSet ran, so their re-check hits.
|
|
763
|
+
const rel = releaseMiss;
|
|
764
|
+
releaseMiss = null;
|
|
765
|
+
queueMicrotask(() => {
|
|
766
|
+
try {
|
|
767
|
+
const { gzip, brotli } = buildCompressedVariants(fullBody);
|
|
768
|
+
cacheSet(
|
|
769
|
+
keyForWrite,
|
|
770
|
+
{
|
|
771
|
+
raw: fullBody,
|
|
772
|
+
gzip,
|
|
773
|
+
brotli,
|
|
774
|
+
contentType: "text/html; charset=utf-8",
|
|
775
|
+
status: 200,
|
|
776
|
+
extraHeaders: {},
|
|
777
|
+
tags,
|
|
778
|
+
},
|
|
779
|
+
cookies,
|
|
780
|
+
);
|
|
781
|
+
} finally {
|
|
782
|
+
rel?.();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
770
785
|
}
|
|
771
|
-
}
|
|
772
|
-
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let i = 0;
|
|
789
|
+
let cancelled = false;
|
|
790
|
+
const onAbort = () => {
|
|
773
791
|
cancelled = true;
|
|
774
|
-
|
|
775
|
-
}
|
|
776
|
-
|
|
792
|
+
};
|
|
793
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
794
|
+
|
|
795
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
796
|
+
pull(controller) {
|
|
797
|
+
if (cancelled || i >= chunks.length) {
|
|
798
|
+
controller.close();
|
|
799
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
controller.enqueue(chunks[i++]);
|
|
803
|
+
if (i >= chunks.length) {
|
|
804
|
+
controller.close();
|
|
805
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
cancel() {
|
|
809
|
+
cancelled = true;
|
|
810
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
811
|
+
},
|
|
812
|
+
});
|
|
777
813
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
814
|
+
return new Response(stream, {
|
|
815
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
816
|
+
});
|
|
817
|
+
} finally {
|
|
818
|
+
releaseMiss?.();
|
|
819
|
+
}
|
|
781
820
|
}
|
|
782
821
|
|
|
783
822
|
// ─── Form Action Page Renderer ───────────────────────────
|
package/src/core/routeFile.ts
CHANGED
|
@@ -94,7 +94,6 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
94
94
|
lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
|
|
95
95
|
lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
|
|
96
96
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
97
|
-
lines.push(' scope: "public" | "private";');
|
|
98
97
|
lines.push("}> = [");
|
|
99
98
|
for (const r of pages) {
|
|
100
99
|
const layoutImports = r.layouts
|
|
@@ -122,7 +121,6 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
122
121
|
lines.push(` layoutServers: [${layoutServerImports}],`);
|
|
123
122
|
lines.push(` errorPages: [${errorPageImports}],`);
|
|
124
123
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
125
|
-
lines.push(` scope: ${JSON.stringify(r.scope)},`);
|
|
126
124
|
lines.push(" },");
|
|
127
125
|
}
|
|
128
126
|
lines.push("];\n");
|
package/src/core/scanner.ts
CHANGED
|
@@ -46,7 +46,6 @@ export function scanRoutes(): RouteManifest {
|
|
|
46
46
|
layoutServerChain: { path: string; depth: number }[],
|
|
47
47
|
errorPageChain: { path: string; depth: number }[],
|
|
48
48
|
inheritedTrailingSlash: TrailingSlash,
|
|
49
|
-
inheritedScope: "public" | "private",
|
|
50
49
|
) {
|
|
51
50
|
const fullDir = join(ROUTES_DIR, dir);
|
|
52
51
|
if (!existsSync(fullDir)) return;
|
|
@@ -110,7 +109,6 @@ export function scanRoutes(): RouteManifest {
|
|
|
110
109
|
layoutServers: [...currentLayoutServers],
|
|
111
110
|
errorPages: [...currentErrorPages],
|
|
112
111
|
trailingSlash: effectiveTs,
|
|
113
|
-
scope: inheritedScope,
|
|
114
112
|
});
|
|
115
113
|
}
|
|
116
114
|
|
|
@@ -122,9 +120,6 @@ export function scanRoutes(): RouteManifest {
|
|
|
122
120
|
const dirName = entry.name;
|
|
123
121
|
// Route groups like (public), (auth) are invisible in URLs
|
|
124
122
|
const isGroup = /^\(.*\)$/.test(dirName);
|
|
125
|
-
// `(private)` anywhere in the chain marks descendants as per-user (dedup off)
|
|
126
|
-
const childScope: "public" | "private" =
|
|
127
|
-
inheritedScope === "private" || dirName === "(private)" ? "private" : "public";
|
|
128
123
|
|
|
129
124
|
walk(
|
|
130
125
|
dir ? join(dir, dirName) : dirName,
|
|
@@ -133,12 +128,11 @@ export function scanRoutes(): RouteManifest {
|
|
|
133
128
|
currentLayoutServers,
|
|
134
129
|
currentErrorPages,
|
|
135
130
|
currentTrailingSlash,
|
|
136
|
-
childScope,
|
|
137
131
|
);
|
|
138
132
|
}
|
|
139
133
|
}
|
|
140
134
|
|
|
141
|
-
walk("", [], [], [], [], "never"
|
|
135
|
+
walk("", [], [], [], [], "never");
|
|
142
136
|
|
|
143
137
|
// Warn when a catch-all exists but no exact route covers its prefix.
|
|
144
138
|
// e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
|
package/src/core/server.ts
CHANGED
|
@@ -24,15 +24,19 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
|
|
|
24
24
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
25
25
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
26
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
|
-
import { buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
28
|
-
import { dedup
|
|
27
|
+
import { buildPrerenderManifest, buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
28
|
+
import { dedup } from "./dedup.ts";
|
|
29
29
|
import {
|
|
30
30
|
CACHE_ENABLED,
|
|
31
|
+
CACHE_KEYS,
|
|
32
|
+
CACHE_MAX_BODY_BYTES,
|
|
31
33
|
buildCompressedVariants,
|
|
32
34
|
cacheGet,
|
|
33
35
|
cacheSet,
|
|
36
|
+
coalesceMiss,
|
|
34
37
|
computeCacheKey,
|
|
35
38
|
serveCached,
|
|
39
|
+
warnUncoveredCookies,
|
|
36
40
|
} from "./cache.ts";
|
|
37
41
|
import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
38
42
|
import {
|
|
@@ -187,6 +191,7 @@ function parseActionName(url: URL): string {
|
|
|
187
191
|
// Dev keeps the per-request fallthrough so files dropped into `public/` mid-session
|
|
188
192
|
// are served without a restart (dev's watcher doesn't fire on `public/`).
|
|
189
193
|
const staticManifest = isDev ? null : buildStaticManifest(OUT_DIR);
|
|
194
|
+
const prerenderManifest = isDev ? null : buildPrerenderManifest(OUT_DIR);
|
|
190
195
|
|
|
191
196
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
192
197
|
const { request, url, locals, cookies } = event;
|
|
@@ -284,15 +289,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
284
289
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
285
290
|
};
|
|
286
291
|
|
|
287
|
-
// Dedup
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
292
|
+
// Dedup concurrent identical requests. The key includes the CACHE_KEYS
|
|
293
|
+
// identity hash, so different users never share a loader result — same
|
|
294
|
+
// isolation contract as the response cache. The mask is part of the key
|
|
295
|
+
// so concurrent requests for the same URL with different invalidation
|
|
296
|
+
// patterns don't collapse onto each other. See dedup.ts.
|
|
297
|
+
const dedupK =
|
|
298
|
+
computeCacheKey(routeUrl, request, cookies as CookieJar) +
|
|
299
|
+
(invalidatedBits ? `|m=${invalidatedBits}` : "");
|
|
300
|
+
const result = await dedup(dedupK, runLoad);
|
|
301
|
+
// Identity only covers CACHE_KEYS — warn if a loader read a session
|
|
302
|
+
// cookie outside that list (dedup could then mix users' results).
|
|
303
|
+
warnUncoveredCookies(cookies);
|
|
296
304
|
|
|
297
305
|
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
298
306
|
const cc = cookiesWereAccessed ? "private, no-cache" : "public, max-age=0, must-revalidate";
|
|
@@ -359,6 +367,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
359
367
|
// `.webp` URLs that would otherwise be intercepted by isStaticPath).
|
|
360
368
|
const apiMatch = await resolveApiMatch(apiRoutes, path);
|
|
361
369
|
if (apiMatch) {
|
|
370
|
+
// INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
|
|
371
|
+
// release() hangs coalesced waiters for the process lifetime. The cache
|
|
372
|
+
// write path hands it off to its microtask by nulling it first.
|
|
373
|
+
let releaseApiMiss: (() => void) | null = null;
|
|
362
374
|
try {
|
|
363
375
|
const mod = await apiMatch.route.module();
|
|
364
376
|
const handler = mod[method];
|
|
@@ -383,10 +395,20 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
383
395
|
CACHE_ENABLED && !CSP_ENABLED && (mod as any).cache !== false && method === "GET";
|
|
384
396
|
let apiCacheKey: string | null = null;
|
|
385
397
|
if (apiCacheable) {
|
|
386
|
-
apiCacheKey = computeCacheKey(url, request, cookies);
|
|
398
|
+
apiCacheKey = computeCacheKey(url, request, cookies as CookieJar);
|
|
387
399
|
if (!url.searchParams.has("_invalidated")) {
|
|
388
400
|
const hit = cacheGet(apiCacheKey);
|
|
389
401
|
if (hit) return serveCached(hit, request);
|
|
402
|
+
// Miss coalescing: first miss runs the handler; concurrent misses
|
|
403
|
+
// wait, re-check the cache, and on a still-miss run independently.
|
|
404
|
+
const gate = coalesceMiss(apiCacheKey);
|
|
405
|
+
if (gate.wait) {
|
|
406
|
+
await gate.wait;
|
|
407
|
+
const rehit = cacheGet(apiCacheKey);
|
|
408
|
+
if (rehit) return serveCached(rehit, request);
|
|
409
|
+
} else {
|
|
410
|
+
releaseApiMiss = gate.release;
|
|
411
|
+
}
|
|
390
412
|
}
|
|
391
413
|
}
|
|
392
414
|
|
|
@@ -433,23 +455,36 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
433
455
|
const extraHeaders = captureCacheableHeaders(response.headers);
|
|
434
456
|
const contentType = responseContentType || "application/octet-stream";
|
|
435
457
|
const keyForWrite = apiCacheKey;
|
|
458
|
+
// Hand the gate release to the write microtask: waiters resume only
|
|
459
|
+
// after the cacheSet attempt, so their re-check hits.
|
|
460
|
+
const rel = releaseApiMiss;
|
|
461
|
+
releaseApiMiss = null;
|
|
436
462
|
queueMicrotask(async () => {
|
|
437
463
|
try {
|
|
438
464
|
const buf = new Uint8Array(await cloned.arrayBuffer());
|
|
465
|
+
// Oversized bodies skip early so they never pay compression;
|
|
466
|
+
// cacheSet re-checks as the authoritative guard.
|
|
467
|
+
if (CACHE_MAX_BODY_BYTES > 0 && buf.length > CACHE_MAX_BODY_BYTES) return;
|
|
439
468
|
const { gzip, brotli } = buildCompressedVariants(buf);
|
|
440
469
|
// API endpoints have no LoaderDeps in v0.6 — invalidation is
|
|
441
470
|
// URL/prefix only. See ROADMAP for deferred tag support.
|
|
442
|
-
cacheSet(
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
471
|
+
cacheSet(
|
|
472
|
+
keyForWrite,
|
|
473
|
+
{
|
|
474
|
+
raw: buf,
|
|
475
|
+
gzip,
|
|
476
|
+
brotli,
|
|
477
|
+
contentType,
|
|
478
|
+
status: 200,
|
|
479
|
+
extraHeaders,
|
|
480
|
+
tags: [],
|
|
481
|
+
},
|
|
482
|
+
cookies,
|
|
483
|
+
);
|
|
451
484
|
} catch {
|
|
452
485
|
/* drop silently — cache population is best-effort */
|
|
486
|
+
} finally {
|
|
487
|
+
rel?.();
|
|
453
488
|
}
|
|
454
489
|
});
|
|
455
490
|
}
|
|
@@ -480,6 +515,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
480
515
|
});
|
|
481
516
|
}
|
|
482
517
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
518
|
+
} finally {
|
|
519
|
+
releaseApiMiss?.();
|
|
483
520
|
}
|
|
484
521
|
}
|
|
485
522
|
|
|
@@ -497,11 +534,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
497
534
|
return new Response("Not Found", { status: 404 });
|
|
498
535
|
}
|
|
499
536
|
// Dev: keep the per-request fallthrough so files dropped into `public/`
|
|
500
|
-
// mid-session are served without a restart.
|
|
501
|
-
|
|
537
|
+
// mid-session are served without a restart. Decode once — filenames on
|
|
538
|
+
// disk are raw; safePath still runs after so traversal stays blocked.
|
|
539
|
+
let decodedPath: string;
|
|
540
|
+
try {
|
|
541
|
+
decodedPath = decodeURIComponent(path);
|
|
542
|
+
} catch {
|
|
543
|
+
return new Response("Not Found", { status: 404 });
|
|
544
|
+
}
|
|
545
|
+
if (decodedPath.startsWith("/dist/client/")) {
|
|
502
546
|
const resolved = safePath(
|
|
503
547
|
`${OUT_DIR}/client`,
|
|
504
|
-
|
|
548
|
+
decodedPath.split("?")[0].slice("/dist/client".length),
|
|
505
549
|
);
|
|
506
550
|
if (resolved) {
|
|
507
551
|
const file = Bun.file(resolved);
|
|
@@ -511,17 +555,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
511
555
|
}
|
|
512
556
|
return new Response("Not Found", { status: 404 });
|
|
513
557
|
}
|
|
514
|
-
const pubPath = safePath("./public",
|
|
558
|
+
const pubPath = safePath("./public", decodedPath);
|
|
515
559
|
if (pubPath) {
|
|
516
560
|
const pub = Bun.file(pubPath);
|
|
517
561
|
if (await pub.exists()) return new Response(pub);
|
|
518
562
|
}
|
|
519
|
-
const distPath = safePath(OUT_DIR,
|
|
563
|
+
const distPath = safePath(OUT_DIR, decodedPath);
|
|
520
564
|
if (distPath) {
|
|
521
565
|
const dist = Bun.file(distPath);
|
|
522
566
|
if (await dist.exists()) return new Response(dist);
|
|
523
567
|
}
|
|
524
|
-
const staticPath = safePath(`${OUT_DIR}/static`,
|
|
568
|
+
const staticPath = safePath(`${OUT_DIR}/static`, decodedPath);
|
|
525
569
|
if (staticPath) {
|
|
526
570
|
const staticFile = Bun.file(staticPath);
|
|
527
571
|
if (await staticFile.exists()) return new Response(staticFile);
|
|
@@ -535,22 +579,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
535
579
|
// in dev would mask errors (the badge stays empty, the SSE reload script
|
|
536
580
|
// isn't injected, and the page can't auto-recover when the source is fixed).
|
|
537
581
|
// Live SSR every request in dev so /about behaves like every other route.
|
|
538
|
-
if (
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
"Cache-Control": "public, max-age=3600",
|
|
551
|
-
},
|
|
552
|
-
});
|
|
553
|
-
}
|
|
582
|
+
if (prerenderManifest) {
|
|
583
|
+
// Keys come from a boot-time walk of `dist/prerendered/`, not from the
|
|
584
|
+
// URL, so no safePath needed — a non-matching path is just a miss.
|
|
585
|
+
const key = path === "/" ? "/" : path.replace(/\/$/, "");
|
|
586
|
+
const abs = prerenderManifest.get(key);
|
|
587
|
+
if (abs) {
|
|
588
|
+
return new Response(Bun.file(abs), {
|
|
589
|
+
headers: {
|
|
590
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
591
|
+
"Cache-Control": "public, max-age=3600",
|
|
592
|
+
},
|
|
593
|
+
});
|
|
554
594
|
}
|
|
555
595
|
}
|
|
556
596
|
|
|
@@ -1008,7 +1048,6 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1008
1048
|
layoutServers: [],
|
|
1009
1049
|
errorPages: [],
|
|
1010
1050
|
trailingSlash: r.trailingSlash,
|
|
1011
|
-
scope: r.scope,
|
|
1012
1051
|
})),
|
|
1013
1052
|
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
|
|
1014
1053
|
errorPage: null,
|
|
@@ -1095,6 +1134,16 @@ for (const plugin of plugins) {
|
|
|
1095
1134
|
app.listen(PORT, () => {
|
|
1096
1135
|
// In dev mode the proxy owns the user-facing port — don't print the internal port
|
|
1097
1136
|
if (!isDev) console.log(`⬡ Bosia server running at http://localhost:${PORT}`);
|
|
1137
|
+
// Last line of startup on purpose — the cache identity contract is the one
|
|
1138
|
+
// config mistake that leaks one user's page to another, so it stays visible.
|
|
1139
|
+
if (CACHE_ENABLED) {
|
|
1140
|
+
console.log(
|
|
1141
|
+
`\n🔑 Response cache tells users apart ONLY by these cookies/headers: [${CACHE_KEYS.join(", ")}]\n` +
|
|
1142
|
+
` Using a different session cookie or auth header? Add its name to CACHE_KEYS,\n` +
|
|
1143
|
+
` or one user's personalised page can be served to another. Routes personalised\n` +
|
|
1144
|
+
` by anything else should set \`export const cache = false\`.\n`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1098
1147
|
});
|
|
1099
1148
|
|
|
1100
1149
|
async function shutdown() {
|
|
@@ -100,10 +100,44 @@ export function buildStaticManifest(outDir: string): StaticManifest {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
|
|
103
|
-
const
|
|
103
|
+
const raw = urlPath.split("?")[0];
|
|
104
|
+
// Manifest keys are raw filenames; URLs arrive percent-encoded.
|
|
105
|
+
let key: string;
|
|
106
|
+
try {
|
|
107
|
+
key = decodeURIComponent(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return null; // malformed encoding → 404
|
|
110
|
+
}
|
|
104
111
|
return manifest.get(key) ?? null;
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Boot-time map of URL path → absolute file path for `dist/prerendered/`.
|
|
116
|
+
* `index.html` → `/`, `foo/index.html` → `/foo`, `foo.html` → `/foo`.
|
|
117
|
+
* On collision the `…/index.html` variant wins (mirrors the old runtime
|
|
118
|
+
* candidate order). Replaces per-request `Bun.file().exists()` probes.
|
|
119
|
+
*/
|
|
120
|
+
export function buildPrerenderManifest(outDir: string): Map<string, string> {
|
|
121
|
+
const manifest = new Map<string, string>();
|
|
122
|
+
const root = join(resolvePath(outDir), "prerendered");
|
|
123
|
+
for (const { abs, rel } of walk(root)) {
|
|
124
|
+
if (!rel.endsWith(".html")) continue;
|
|
125
|
+
let key: string;
|
|
126
|
+
let isIndex = false;
|
|
127
|
+
if (rel === "index.html") {
|
|
128
|
+
key = "/";
|
|
129
|
+
isIndex = true;
|
|
130
|
+
} else if (rel.endsWith("/index.html")) {
|
|
131
|
+
key = `/${rel.slice(0, -"/index.html".length)}`;
|
|
132
|
+
isIndex = true;
|
|
133
|
+
} else {
|
|
134
|
+
key = `/${rel.slice(0, -".html".length)}`;
|
|
135
|
+
}
|
|
136
|
+
if (isIndex || !manifest.has(key)) manifest.set(key, abs);
|
|
137
|
+
}
|
|
138
|
+
return manifest;
|
|
139
|
+
}
|
|
140
|
+
|
|
107
141
|
// Re-export for tests that want to confirm a file-on-disk exists at the entry.
|
|
108
142
|
export function entryFileExists(entry: StaticEntry): boolean {
|
|
109
143
|
try {
|
package/src/core/types.ts
CHANGED
|
@@ -26,12 +26,6 @@ export interface PageRoute {
|
|
|
26
26
|
errorPages: { path: string; depth: number }[];
|
|
27
27
|
/** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
|
|
28
28
|
trailingSlash: TrailingSlash;
|
|
29
|
-
/**
|
|
30
|
-
* Dedup scope. `"public"` (default) → loader runs once for concurrent identical
|
|
31
|
-
* URLs. `"private"` → loader runs per-request (use for per-user routes).
|
|
32
|
-
* Set by placing the route under a `(private)` group folder anywhere in the chain.
|
|
33
|
-
*/
|
|
34
|
-
scope: "public" | "private";
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
/** An API route discovered from the file system */
|