bosia 0.5.13 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/core/cache.ts +367 -0
- package/src/core/dev.ts +6 -0
- package/src/core/plugins/inspector/index.ts +3 -3
- package/src/core/renderer.ts +55 -3
- package/src/core/routeTypes.ts +4 -0
- package/src/core/server.ts +137 -1
- package/src/lib/index.ts +3 -0
- package/src/lib/server.ts +12 -0
- package/templates/default/.env.example +20 -1
- package/templates/demo/.env.example +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"exports": {
|
|
34
34
|
".": "./src/lib/index.ts",
|
|
35
35
|
"./client": "./src/lib/client.ts",
|
|
36
|
+
"./server": "./src/lib/server.ts",
|
|
36
37
|
"./plugins/server-timing": "./src/core/plugins/server-timing.ts",
|
|
37
38
|
"./plugins/inspector": "./src/core/plugins/inspector/index.ts"
|
|
38
39
|
},
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// ─── Server-side Response Cache ──────────────────────────
|
|
2
|
+
// Skips load() + render() + compression on cache hit. Keyed by URL + identity
|
|
3
|
+
// (cookies/headers from CACHE_KEYS). Invalidated by tag (LoaderDeps.keys),
|
|
4
|
+
// fetch URL (LoaderDeps.urls), or exact/prefix path.
|
|
5
|
+
//
|
|
6
|
+
// See docs/guides/response-cache.md.
|
|
7
|
+
|
|
8
|
+
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
9
|
+
import { dedupKey } from "./dedup.ts";
|
|
10
|
+
|
|
11
|
+
// ─── Config ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function parseCacheKeys(raw: string | undefined): string[] {
|
|
14
|
+
const value = raw?.trim();
|
|
15
|
+
if (value === undefined || value === "") {
|
|
16
|
+
return ["session", "sid", "auth", "token", "jwt", "Authorization"];
|
|
17
|
+
}
|
|
18
|
+
return value
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((s) => s.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseMaxEntries(raw: string | undefined): number {
|
|
25
|
+
if (!raw) return 500;
|
|
26
|
+
const n = parseInt(raw, 10);
|
|
27
|
+
if (!Number.isFinite(n) || n < 0) return 500;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `invalidate` / `invalidateAll` are re-exported from the public `bosia`
|
|
32
|
+
// barrel, so this module also evaluates in the browser bundle. Guard every
|
|
33
|
+
// `process.env` read — otherwise hydration throws `ReferenceError: Can't
|
|
34
|
+
// find variable: process` (Safari) the moment the barrel is imported.
|
|
35
|
+
const env: Record<string, string | undefined> =
|
|
36
|
+
typeof process !== "undefined" && process.env ? process.env : {};
|
|
37
|
+
const isServer = typeof process !== "undefined";
|
|
38
|
+
|
|
39
|
+
export const CACHE_KEYS: readonly string[] = parseCacheKeys(env.CACHE_KEYS);
|
|
40
|
+
export const CACHE_MAX_ENTRIES = parseMaxEntries(env.CACHE_MAX_ENTRIES);
|
|
41
|
+
export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
|
|
42
|
+
|
|
43
|
+
if (isServer) {
|
|
44
|
+
if (CACHE_ENABLED) {
|
|
45
|
+
console.log(
|
|
46
|
+
`💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, identity keys [${CACHE_KEYS.join(", ")}]`,
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Entry shape ─────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
type Bytes = Uint8Array<ArrayBuffer>;
|
|
56
|
+
|
|
57
|
+
export type CacheEntry = {
|
|
58
|
+
raw: Bytes;
|
|
59
|
+
gzip: Bytes | null;
|
|
60
|
+
brotli: Bytes | null;
|
|
61
|
+
contentType: string;
|
|
62
|
+
status: number;
|
|
63
|
+
extraHeaders: Record<string, string>;
|
|
64
|
+
tags: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ─── Tiny LRU ────────────────────────────────────────────
|
|
68
|
+
// Uses Map's insertion-order iteration. get() promotes by re-inserting.
|
|
69
|
+
|
|
70
|
+
class LRU<K, V> {
|
|
71
|
+
private map = new Map<K, V>();
|
|
72
|
+
constructor(private cap: number) {}
|
|
73
|
+
get(key: K): V | undefined {
|
|
74
|
+
const v = this.map.get(key);
|
|
75
|
+
if (v === undefined) return undefined;
|
|
76
|
+
this.map.delete(key);
|
|
77
|
+
this.map.set(key, v);
|
|
78
|
+
return v;
|
|
79
|
+
}
|
|
80
|
+
set(key: K, value: V): K | undefined {
|
|
81
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
82
|
+
this.map.set(key, value);
|
|
83
|
+
if (this.map.size > this.cap) {
|
|
84
|
+
const oldest = this.map.keys().next().value as K | undefined;
|
|
85
|
+
if (oldest !== undefined) {
|
|
86
|
+
this.map.delete(oldest);
|
|
87
|
+
return oldest;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
delete(key: K): boolean {
|
|
93
|
+
return this.map.delete(key);
|
|
94
|
+
}
|
|
95
|
+
keys(): IterableIterator<K> {
|
|
96
|
+
return this.map.keys();
|
|
97
|
+
}
|
|
98
|
+
clear(): void {
|
|
99
|
+
this.map.clear();
|
|
100
|
+
}
|
|
101
|
+
get size(): number {
|
|
102
|
+
return this.map.size;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Storage ─────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
const htmlCache = new LRU<string, CacheEntry>(CACHE_MAX_ENTRIES || 1);
|
|
109
|
+
const tagIndex = new Map<string, Set<string>>();
|
|
110
|
+
const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
|
|
111
|
+
|
|
112
|
+
// ─── Key building ────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** FNV-1a 32-bit hash. Compact, no dep, collision-tolerant for an identity bucket. */
|
|
115
|
+
function fnv1a(s: string): string {
|
|
116
|
+
let h = 0x811c9dc5;
|
|
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);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function computeIdentityHash(req: Request, cookies: Cookies): string {
|
|
125
|
+
const headers = req.headers;
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
for (const name of CACHE_KEYS) {
|
|
128
|
+
const cv = cookies.get(name);
|
|
129
|
+
if (cv) parts.push(`c:${name}=${cv}`);
|
|
130
|
+
const hv = headers.get(name);
|
|
131
|
+
if (hv) parts.push(`h:${name}=${hv}`);
|
|
132
|
+
}
|
|
133
|
+
if (parts.length === 0) return "0";
|
|
134
|
+
parts.sort();
|
|
135
|
+
return fnv1a(parts.join("&"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function computeCacheKey(url: URL, req: Request, cookies: Cookies): string {
|
|
139
|
+
return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Extract pathname portion of a cacheKey for path-based invalidation. */
|
|
143
|
+
function pathOfKey(key: string): string {
|
|
144
|
+
const qIdx = key.indexOf("?");
|
|
145
|
+
const pIdx = key.indexOf("|");
|
|
146
|
+
const end = qIdx === -1 ? pIdx : Math.min(qIdx, pIdx);
|
|
147
|
+
return end === -1 ? key : key.slice(0, end);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Tag collection ──────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function collectTags(
|
|
153
|
+
layoutDeps: (LoaderDeps | null)[] | null,
|
|
154
|
+
pageDeps: LoaderDeps | null,
|
|
155
|
+
): string[] {
|
|
156
|
+
const tags = new Set<string>();
|
|
157
|
+
if (layoutDeps) {
|
|
158
|
+
for (const deps of layoutDeps) {
|
|
159
|
+
if (!deps) continue;
|
|
160
|
+
for (const k of deps.keys) tags.add(`k:${k}`);
|
|
161
|
+
for (const u of deps.urls) tags.add(`u:${u}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (pageDeps) {
|
|
165
|
+
for (const k of pageDeps.keys) tags.add(`k:${k}`);
|
|
166
|
+
for (const u of pageDeps.urls) tags.add(`u:${u}`);
|
|
167
|
+
}
|
|
168
|
+
return [...tags];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Public-ish: read / write ────────────────────────────
|
|
172
|
+
|
|
173
|
+
export function cacheGet(key: string): CacheEntry | undefined {
|
|
174
|
+
if (!CACHE_ENABLED) return undefined;
|
|
175
|
+
return htmlCache.get(key);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function cacheSet(key: string, entry: CacheEntry): void {
|
|
179
|
+
if (!CACHE_ENABLED) return;
|
|
180
|
+
// Drop any existing entry's index pointers first
|
|
181
|
+
cacheDeleteKey(key);
|
|
182
|
+
const evicted = htmlCache.set(key, entry);
|
|
183
|
+
if (evicted) cacheDeleteIndexOnly(evicted);
|
|
184
|
+
for (const tag of entry.tags) {
|
|
185
|
+
let set = tagIndex.get(tag);
|
|
186
|
+
if (!set) {
|
|
187
|
+
set = new Set();
|
|
188
|
+
tagIndex.set(tag, set);
|
|
189
|
+
}
|
|
190
|
+
set.add(key);
|
|
191
|
+
}
|
|
192
|
+
const path = pathOfKey(key);
|
|
193
|
+
let pset = pathIndex.get(path);
|
|
194
|
+
if (!pset) {
|
|
195
|
+
pset = new Set();
|
|
196
|
+
pathIndex.set(path, pset);
|
|
197
|
+
}
|
|
198
|
+
pset.add(key);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Remove a key from htmlCache AND its index pointers. */
|
|
202
|
+
function cacheDeleteKey(key: string): void {
|
|
203
|
+
const entry = htmlCache.get(key);
|
|
204
|
+
if (entry) {
|
|
205
|
+
for (const tag of entry.tags) {
|
|
206
|
+
const set = tagIndex.get(tag);
|
|
207
|
+
if (set) {
|
|
208
|
+
set.delete(key);
|
|
209
|
+
if (set.size === 0) tagIndex.delete(tag);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const path = pathOfKey(key);
|
|
214
|
+
const pset = pathIndex.get(path);
|
|
215
|
+
if (pset) {
|
|
216
|
+
pset.delete(key);
|
|
217
|
+
if (pset.size === 0) pathIndex.delete(path);
|
|
218
|
+
}
|
|
219
|
+
htmlCache.delete(key);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Cleanup index pointers for a key after LRU evicted it. */
|
|
223
|
+
function cacheDeleteIndexOnly(key: string): void {
|
|
224
|
+
for (const set of tagIndex.values()) set.delete(key);
|
|
225
|
+
for (const [tag, set] of tagIndex) if (set.size === 0) tagIndex.delete(tag);
|
|
226
|
+
const path = pathOfKey(key);
|
|
227
|
+
const pset = pathIndex.get(path);
|
|
228
|
+
if (pset) {
|
|
229
|
+
pset.delete(key);
|
|
230
|
+
if (pset.size === 0) pathIndex.delete(path);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Compression helpers ─────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** Build gzip + brotli copies of body. Sync, runs in microtask. */
|
|
237
|
+
export function buildCompressedVariants(body: Bytes): {
|
|
238
|
+
gzip: Bytes | null;
|
|
239
|
+
brotli: Bytes | null;
|
|
240
|
+
} {
|
|
241
|
+
const COMPRESS_MIN_BYTES = 2048;
|
|
242
|
+
if (body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
|
|
243
|
+
let gzip: Bytes | null = null;
|
|
244
|
+
let brotli: Bytes | null = null;
|
|
245
|
+
try {
|
|
246
|
+
gzip = Bun.gzipSync(body) as Bytes;
|
|
247
|
+
} catch {
|
|
248
|
+
gzip = null;
|
|
249
|
+
}
|
|
250
|
+
// brotliCompressSync exists in Bun >= 1.1.5 but is missing from older
|
|
251
|
+
// @types/bun shipments — cast through any so the call stays loose.
|
|
252
|
+
const brotliFn = (Bun as unknown as { brotliCompressSync?: (b: Bytes) => Uint8Array })
|
|
253
|
+
.brotliCompressSync;
|
|
254
|
+
if (brotliFn) {
|
|
255
|
+
try {
|
|
256
|
+
brotli = brotliFn(body) as Bytes;
|
|
257
|
+
} catch {
|
|
258
|
+
brotli = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { gzip, brotli };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Concatenate multiple Uint8Array chunks into one buffer. */
|
|
265
|
+
export function concatChunks(chunks: Uint8Array[]): Bytes {
|
|
266
|
+
let total = 0;
|
|
267
|
+
for (const c of chunks) total += c.length;
|
|
268
|
+
const out = new Uint8Array(new ArrayBuffer(total));
|
|
269
|
+
let off = 0;
|
|
270
|
+
for (const c of chunks) {
|
|
271
|
+
out.set(c, off);
|
|
272
|
+
off += c.length;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Serve a cache hit ───────────────────────────────────
|
|
278
|
+
|
|
279
|
+
export function serveCached(entry: CacheEntry, req: Request): Response {
|
|
280
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
281
|
+
const headers: Record<string, string> = {
|
|
282
|
+
"Content-Type": entry.contentType,
|
|
283
|
+
Vary: "Accept-Encoding",
|
|
284
|
+
"X-Bosia-Cache": "HIT",
|
|
285
|
+
...entry.extraHeaders,
|
|
286
|
+
};
|
|
287
|
+
if (entry.brotli && accept.includes("br")) {
|
|
288
|
+
headers["Content-Encoding"] = "br";
|
|
289
|
+
return new Response(entry.brotli, { status: entry.status, headers });
|
|
290
|
+
}
|
|
291
|
+
if (entry.gzip && accept.includes("gzip")) {
|
|
292
|
+
headers["Content-Encoding"] = "gzip";
|
|
293
|
+
return new Response(entry.gzip, { status: entry.status, headers });
|
|
294
|
+
}
|
|
295
|
+
return new Response(entry.raw, { status: entry.status, headers });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Invalidation API ────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Evict all cache entries matching `key`.
|
|
302
|
+
*
|
|
303
|
+
* - `invalidate("app:user")` → evict entries tagged with depends("app:user")
|
|
304
|
+
* (matches the loader's tag list).
|
|
305
|
+
* - `invalidate("/api/posts")` → evict entries tagged with a fetch URL whose
|
|
306
|
+
* path equals `/api/posts`, AND entries whose own path equals `/api/posts`.
|
|
307
|
+
*/
|
|
308
|
+
export function invalidate(key: string): number {
|
|
309
|
+
if (!CACHE_ENABLED) return 0;
|
|
310
|
+
let count = 0;
|
|
311
|
+
const tagKey = key.startsWith("/") ? `u:${key}` : `k:${key}`;
|
|
312
|
+
const fromTag = tagIndex.get(tagKey);
|
|
313
|
+
if (fromTag) {
|
|
314
|
+
// Also collect URL tag matches where the loader fetched an absolute URL
|
|
315
|
+
// whose pathname == key. Conservative: also match the bare `k:` tag in
|
|
316
|
+
// case the user uses a key that starts with `/` but isn't a URL.
|
|
317
|
+
for (const k of [...fromTag]) {
|
|
318
|
+
cacheDeleteKey(k);
|
|
319
|
+
count++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (key.startsWith("/")) {
|
|
323
|
+
// Match absolute URL tags too: any `u:<origin><key>` recorded by trackedFetch.
|
|
324
|
+
for (const [tag, set] of tagIndex) {
|
|
325
|
+
if (tag.startsWith("u:") && tag.endsWith(key)) {
|
|
326
|
+
for (const k of [...set]) {
|
|
327
|
+
cacheDeleteKey(k);
|
|
328
|
+
count++;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Match cache entries whose own path equals key
|
|
333
|
+
const pset = pathIndex.get(key);
|
|
334
|
+
if (pset) {
|
|
335
|
+
for (const k of [...pset]) {
|
|
336
|
+
cacheDeleteKey(k);
|
|
337
|
+
count++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return count;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Evict every entry whose path starts with `prefix`.
|
|
346
|
+
* Use for bulk eviction (e.g. `invalidateAll("/products/")`).
|
|
347
|
+
*/
|
|
348
|
+
export function invalidateAll(prefix: string): number {
|
|
349
|
+
if (!CACHE_ENABLED) return 0;
|
|
350
|
+
let count = 0;
|
|
351
|
+
for (const [path, set] of [...pathIndex]) {
|
|
352
|
+
if (path.startsWith(prefix)) {
|
|
353
|
+
for (const k of [...set]) {
|
|
354
|
+
cacheDeleteKey(k);
|
|
355
|
+
count++;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return count;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Test-only: clear everything. */
|
|
363
|
+
export function cacheClear(): void {
|
|
364
|
+
htmlCache.clear();
|
|
365
|
+
tagIndex.clear();
|
|
366
|
+
pathIndex.clear();
|
|
367
|
+
}
|
package/src/core/dev.ts
CHANGED
|
@@ -247,6 +247,12 @@ const devServer = Bun.serve({
|
|
|
247
247
|
const forwardedHeaders = new Headers(req.headers);
|
|
248
248
|
forwardedHeaders.set("x-forwarded-host", reqUrl.host);
|
|
249
249
|
forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
|
|
250
|
+
// Force inner app to respond uncompressed. Bun's `fetch()` auto-decodes
|
|
251
|
+
// gzip/br bodies but leaves the original `Content-Encoding` header on
|
|
252
|
+
// the Response, so passing it through made Safari throw -1015 ("cannot
|
|
253
|
+
// decode raw data") on every HTML navigation. Identity on the dev wire
|
|
254
|
+
// is fine — it's localhost.
|
|
255
|
+
forwardedHeaders.set("accept-encoding", "identity");
|
|
250
256
|
|
|
251
257
|
// HMR-driven reloads can land on the proxy before the freshly-respawned
|
|
252
258
|
// inner has bound APP_PORT. Retry for a few seconds on idempotent HTML
|
|
@@ -20,7 +20,7 @@ export interface InspectorOptions {
|
|
|
20
20
|
interface ServerError {
|
|
21
21
|
id: string;
|
|
22
22
|
ts: number;
|
|
23
|
-
source: "
|
|
23
|
+
source: "server" | "uncaught" | "rejection";
|
|
24
24
|
message: string;
|
|
25
25
|
stack?: string;
|
|
26
26
|
file?: string;
|
|
@@ -147,7 +147,7 @@ function installProcessListeners() {
|
|
|
147
147
|
function installGlobalReporter() {
|
|
148
148
|
globalThis.__BOSIA_REPORT_ERROR__ = (e) => {
|
|
149
149
|
pushServerError({
|
|
150
|
-
source: e.source ?? "
|
|
150
|
+
source: e.source ?? "server",
|
|
151
151
|
message: e.message,
|
|
152
152
|
stack: e.stack,
|
|
153
153
|
});
|
|
@@ -282,7 +282,7 @@ export function inspector(options: InspectorOptions = {}): BosiaPlugin | false {
|
|
|
282
282
|
chained = chained.onError(({ error }) => {
|
|
283
283
|
const e = error as Error;
|
|
284
284
|
pushServerError({
|
|
285
|
-
source: "
|
|
285
|
+
source: "server",
|
|
286
286
|
message: e?.message ?? String(error),
|
|
287
287
|
stack: e?.stack,
|
|
288
288
|
});
|
package/src/core/renderer.ts
CHANGED
|
@@ -5,6 +5,16 @@ import { serverRoutes, errorPage } from "bosia:routes";
|
|
|
5
5
|
import type { RouteMatch } from "./types.ts";
|
|
6
6
|
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
7
7
|
import { CSP_ENABLED } from "./csp.ts";
|
|
8
|
+
import {
|
|
9
|
+
CACHE_ENABLED,
|
|
10
|
+
buildCompressedVariants,
|
|
11
|
+
cacheGet,
|
|
12
|
+
cacheSet,
|
|
13
|
+
collectTags,
|
|
14
|
+
computeCacheKey,
|
|
15
|
+
concatChunks,
|
|
16
|
+
serveCached,
|
|
17
|
+
} from "./cache.ts";
|
|
8
18
|
import { HttpError, Redirect } from "./errors.ts";
|
|
9
19
|
import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
|
|
10
20
|
import App from "./client/App.svelte";
|
|
@@ -503,6 +513,28 @@ export async function renderSSRStream(
|
|
|
503
513
|
const { route, params } = match;
|
|
504
514
|
const nonce = CSP_ENABLED && typeof locals.nonce === "string" ? locals.nonce : undefined;
|
|
505
515
|
|
|
516
|
+
// ── Response cache: short-circuit on hit ──
|
|
517
|
+
// Look up cached HTML before doing anything expensive (metadata, load,
|
|
518
|
+
// render, compress). Key includes URL + identity hash (cookies/headers
|
|
519
|
+
// from CACHE_KEYS), so per-user pages stay isolated. Routes opt out via
|
|
520
|
+
// `export const cache = false`. See cache.ts and docs/guides/response-cache.md.
|
|
521
|
+
const pageMod: any = await route.pageModule();
|
|
522
|
+
const cacheBypass = url.searchParams.has("_invalidated");
|
|
523
|
+
// CSP is incompatible with response cache — the per-request nonce is baked
|
|
524
|
+
// into the cached HTML but the CSP header is re-derived each request, so a
|
|
525
|
+
// cached page would ship with a dead nonce and the browser would block its
|
|
526
|
+
// inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
|
|
527
|
+
const cacheable =
|
|
528
|
+
CACHE_ENABLED && !CSP_ENABLED && pageMod.cache !== false && req.method === "GET";
|
|
529
|
+
let cacheKey: string | null = null;
|
|
530
|
+
if (cacheable) {
|
|
531
|
+
cacheKey = computeCacheKey(url, req, cookies);
|
|
532
|
+
if (!cacheBypass) {
|
|
533
|
+
const hit = cacheGet(cacheKey);
|
|
534
|
+
if (hit) return serveCached(hit, req);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
506
538
|
// ── Pre-stream phase: resolve metadata before committing to a 200 ──
|
|
507
539
|
// Errors here return a proper error response with correct status code.
|
|
508
540
|
let metadata: Metadata | null = null;
|
|
@@ -535,13 +567,11 @@ export async function renderSSRStream(
|
|
|
535
567
|
// This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
|
|
536
568
|
const metadataData = metadata?.data ?? null;
|
|
537
569
|
let data: Awaited<ReturnType<typeof loadRouteData>>;
|
|
538
|
-
let pageMod: any;
|
|
539
570
|
let layoutMods: any[];
|
|
540
571
|
|
|
541
572
|
try {
|
|
542
|
-
[data,
|
|
573
|
+
[data, layoutMods] = await Promise.all([
|
|
543
574
|
loadRouteData(url, locals, req, cookies, metadataData, match),
|
|
544
|
-
route.pageModule(),
|
|
545
575
|
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
546
576
|
]);
|
|
547
577
|
} catch (err) {
|
|
@@ -692,6 +722,28 @@ export async function renderSSRStream(
|
|
|
692
722
|
),
|
|
693
723
|
];
|
|
694
724
|
|
|
725
|
+
// ── Response cache: write after chunks built, before stream creation ──
|
|
726
|
+
// Skip if the handler set cookies — cached response can't reproduce
|
|
727
|
+
// per-request Set-Cookie headers. Compression runs in a microtask so
|
|
728
|
+
// the response goes out first.
|
|
729
|
+
if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
|
|
730
|
+
const fullBody = concatChunks(chunks);
|
|
731
|
+
const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
|
|
732
|
+
const keyForWrite = cacheKey;
|
|
733
|
+
queueMicrotask(() => {
|
|
734
|
+
const { gzip, brotli } = buildCompressedVariants(fullBody);
|
|
735
|
+
cacheSet(keyForWrite, {
|
|
736
|
+
raw: fullBody,
|
|
737
|
+
gzip,
|
|
738
|
+
brotli,
|
|
739
|
+
contentType: "text/html; charset=utf-8",
|
|
740
|
+
status: 200,
|
|
741
|
+
extraHeaders: {},
|
|
742
|
+
tags,
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
695
747
|
let i = 0;
|
|
696
748
|
let cancelled = false;
|
|
697
749
|
const onAbort = () => {
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -84,6 +84,10 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
84
84
|
lines.push(``);
|
|
85
85
|
lines.push(`export type Params = ${paramsType};`);
|
|
86
86
|
lines.push(``);
|
|
87
|
+
lines.push(`/** Set \`export const cache: CacheOption = false;\` in +page(.server).ts`);
|
|
88
|
+
lines.push(` * or +server.ts to opt the route out of the server response cache. */`);
|
|
89
|
+
lines.push(`export type CacheOption = false;`);
|
|
90
|
+
lines.push(``);
|
|
87
91
|
lines.push(`type _LoadEvent = Omit<LoadEvent, 'params'> & { params: Params };`);
|
|
88
92
|
lines.push(`type _MetadataEvent = Omit<MetadataEvent, 'params'> & { params: Params };`);
|
|
89
93
|
lines.push(`type _RequestEvent = Omit<RequestEvent, 'params'> & { params: Params };`);
|
package/src/core/server.ts
CHANGED
|
@@ -25,6 +25,14 @@ import { isDev, compress, isStaticPath } from "./html.ts";
|
|
|
25
25
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
26
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
27
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
28
|
+
import {
|
|
29
|
+
CACHE_ENABLED,
|
|
30
|
+
buildCompressedVariants,
|
|
31
|
+
cacheGet,
|
|
32
|
+
cacheSet,
|
|
33
|
+
computeCacheKey,
|
|
34
|
+
serveCached,
|
|
35
|
+
} from "./cache.ts";
|
|
28
36
|
import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
29
37
|
import {
|
|
30
38
|
loadRouteData,
|
|
@@ -56,6 +64,27 @@ if (existsSync(hooksPath)) {
|
|
|
56
64
|
|
|
57
65
|
// ─── Env Helpers ─────────────────────────────────────────
|
|
58
66
|
|
|
67
|
+
// Headers that must not be baked into a cache entry. Content-Length is
|
|
68
|
+
// recomputed by Bun, content-encoding/transfer-encoding depend on the chosen
|
|
69
|
+
// variant, and security/CORS/Set-Cookie headers are applied by handleRequest.
|
|
70
|
+
const NON_CACHEABLE_HEADERS = new Set([
|
|
71
|
+
"content-length",
|
|
72
|
+
"content-encoding",
|
|
73
|
+
"transfer-encoding",
|
|
74
|
+
"content-type",
|
|
75
|
+
"set-cookie",
|
|
76
|
+
"vary",
|
|
77
|
+
"x-bosia-cache",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
function captureCacheableHeaders(headers: Headers): Record<string, string> {
|
|
81
|
+
const out: Record<string, string> = {};
|
|
82
|
+
for (const [k, v] of headers) {
|
|
83
|
+
if (!NON_CACHEABLE_HEADERS.has(k.toLowerCase())) out[k] = v;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
function splitCsvEnv(key: string): string[] | undefined {
|
|
60
89
|
return (
|
|
61
90
|
process.env[key]
|
|
@@ -379,7 +408,63 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
379
408
|
}
|
|
380
409
|
|
|
381
410
|
event.params = apiMatch.params;
|
|
382
|
-
|
|
411
|
+
|
|
412
|
+
// ── Response cache for +server.ts GET handlers ──
|
|
413
|
+
// CSP is skipped because cached responses would ship with a stale
|
|
414
|
+
// nonce (see renderer.ts for the same gate). The cache key includes
|
|
415
|
+
// URL + identity so per-user responses stay isolated.
|
|
416
|
+
const apiCacheable =
|
|
417
|
+
CACHE_ENABLED && !CSP_ENABLED && (mod as any).cache !== false && method === "GET";
|
|
418
|
+
let apiCacheKey: string | null = null;
|
|
419
|
+
if (apiCacheable) {
|
|
420
|
+
apiCacheKey = computeCacheKey(url, request, cookies);
|
|
421
|
+
if (!url.searchParams.has("_invalidated")) {
|
|
422
|
+
const hit = cacheGet(apiCacheKey);
|
|
423
|
+
if (hit) return serveCached(hit, request);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const response = await handler({
|
|
428
|
+
request,
|
|
429
|
+
params: apiMatch.params,
|
|
430
|
+
url,
|
|
431
|
+
locals,
|
|
432
|
+
cookies,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (
|
|
436
|
+
apiCacheable &&
|
|
437
|
+
apiCacheKey &&
|
|
438
|
+
response.status === 200 &&
|
|
439
|
+
(cookies as CookieJar).outgoing.length === 0
|
|
440
|
+
) {
|
|
441
|
+
const cloned = response.clone();
|
|
442
|
+
const extraHeaders = captureCacheableHeaders(response.headers);
|
|
443
|
+
const contentType =
|
|
444
|
+
response.headers.get("content-type") ?? "application/octet-stream";
|
|
445
|
+
const keyForWrite = apiCacheKey;
|
|
446
|
+
queueMicrotask(async () => {
|
|
447
|
+
try {
|
|
448
|
+
const buf = new Uint8Array(await cloned.arrayBuffer());
|
|
449
|
+
const { gzip, brotli } = buildCompressedVariants(buf);
|
|
450
|
+
// API endpoints have no LoaderDeps in v0.6 — invalidation is
|
|
451
|
+
// URL/prefix only. See ROADMAP for deferred tag support.
|
|
452
|
+
cacheSet(keyForWrite, {
|
|
453
|
+
raw: buf,
|
|
454
|
+
gzip,
|
|
455
|
+
brotli,
|
|
456
|
+
contentType,
|
|
457
|
+
status: 200,
|
|
458
|
+
extraHeaders,
|
|
459
|
+
tags: [],
|
|
460
|
+
});
|
|
461
|
+
} catch {
|
|
462
|
+
/* drop silently — cache population is best-effort */
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return response;
|
|
383
468
|
} catch (err) {
|
|
384
469
|
if (isDev) console.error("API route error:", err);
|
|
385
470
|
else console.error("API route error:", (err as Error).message ?? err);
|
|
@@ -630,6 +715,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
630
715
|
});
|
|
631
716
|
}
|
|
632
717
|
|
|
718
|
+
// Shed load above MAX_INFLIGHT. Checked before any work so the 503 is
|
|
719
|
+
// cheap. /_health stays available so the orchestrator can still tell the
|
|
720
|
+
// process is alive (and decide whether to restart or scale out).
|
|
721
|
+
if (inFlight >= MAX_INFLIGHT && url.pathname !== "/_health") {
|
|
722
|
+
return new Response("Service Unavailable", {
|
|
723
|
+
status: 503,
|
|
724
|
+
headers: { "Retry-After": "1" },
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
633
728
|
inFlight++;
|
|
634
729
|
try {
|
|
635
730
|
// Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
|
|
@@ -762,6 +857,29 @@ const IDLE_TIMEOUT = parseIdleTimeout(process.env.IDLE_TIMEOUT);
|
|
|
762
857
|
|
|
763
858
|
console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
|
|
764
859
|
|
|
860
|
+
// ─── Concurrency Ceiling ──────────────────────────────────
|
|
861
|
+
// Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
|
|
862
|
+
// Default is unlimited so existing apps see no behavior change. When set,
|
|
863
|
+
// requests above the cap get a fast 503 + Retry-After before any work is
|
|
864
|
+
// done — protects single-replica container deploys from OOM under spike
|
|
865
|
+
// traffic. /_health is exempt so orchestrator liveness probes still work
|
|
866
|
+
// while the app sheds load.
|
|
867
|
+
|
|
868
|
+
function parseMaxInflight(value?: string): number {
|
|
869
|
+
if (!value) return Infinity;
|
|
870
|
+
const trimmed = value.trim();
|
|
871
|
+
if (trimmed === "" || trimmed.toLowerCase() === "infinity") return Infinity;
|
|
872
|
+
const n = parseInt(trimmed, 10);
|
|
873
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`Invalid MAX_INFLIGHT: "${value}"`);
|
|
874
|
+
return n;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const MAX_INFLIGHT = parseMaxInflight(process.env.MAX_INFLIGHT);
|
|
878
|
+
|
|
879
|
+
if (Number.isFinite(MAX_INFLIGHT)) {
|
|
880
|
+
console.log(`🚦 Max in-flight requests: ${MAX_INFLIGHT}`);
|
|
881
|
+
}
|
|
882
|
+
|
|
765
883
|
// ─── Graceful Shutdown State ──────────────────────────────
|
|
766
884
|
|
|
767
885
|
let shuttingDown = false;
|
|
@@ -909,4 +1027,22 @@ async function shutdown() {
|
|
|
909
1027
|
process.on("SIGTERM", shutdown);
|
|
910
1028
|
process.on("SIGINT", shutdown);
|
|
911
1029
|
|
|
1030
|
+
// Prod-only fatal handlers. The dev inspector plugin installs its own
|
|
1031
|
+
// uncaughtException/unhandledRejection listeners that route errors into the
|
|
1032
|
+
// overlay and let the dev runner's crash-backoff restart the process. In prod
|
|
1033
|
+
// there's no inspector — without these handlers an unhandled rejection from a
|
|
1034
|
+
// background timer or plugin hook orphans the process with no log context.
|
|
1035
|
+
// Log + exit(1) lets the orchestrator (Podman/k8s) restart cleanly.
|
|
1036
|
+
if (!isDev) {
|
|
1037
|
+
process.on("uncaughtException", (err: Error) => {
|
|
1038
|
+
console.error("[FATAL] uncaughtException:", err?.stack ?? err);
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
});
|
|
1041
|
+
process.on("unhandledRejection", (reason: unknown) => {
|
|
1042
|
+
const e = reason as Error | undefined;
|
|
1043
|
+
console.error("[FATAL] unhandledRejection:", e?.stack ?? reason);
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
912
1048
|
export { app };
|
package/src/lib/index.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
export { cn, getServerTime } from "./utils.ts";
|
|
7
7
|
export { sequence } from "../core/hooks.ts";
|
|
8
8
|
export { error, redirect, fail } from "../core/errors.ts";
|
|
9
|
+
// `invalidate` / `invalidateAll` (server response-cache eviction) live in
|
|
10
|
+
// "bosia/server" — they touch server-process state and pulling them into
|
|
11
|
+
// the shared barrel leaks `process.env` reads into client bundles.
|
|
9
12
|
export type { HttpError, Redirect, RedirectOptions, ActionFailure } from "../core/errors.ts";
|
|
10
13
|
export type {
|
|
11
14
|
RequestEvent,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ─── Bosia Server API ─────────────────────────────────────
|
|
2
|
+
// Server-only helpers — import from "bosia/server".
|
|
3
|
+
// Kept separate from "bosia" because these modules touch server-process
|
|
4
|
+
// state (the response-cache Map, `process.env`) and have no meaning in
|
|
5
|
+
// the browser. Importing them through the shared `bosia` barrel would
|
|
6
|
+
// drag `process.env` reads into client bundles via the lib re-export
|
|
7
|
+
// graph (see ROADMAP v0.6.0 entry on the Safari hydration ReferenceError).
|
|
8
|
+
//
|
|
9
|
+
// Usage in user apps (form actions, +server.ts, hooks):
|
|
10
|
+
// import { invalidate, invalidateAll } from "bosia/server";
|
|
11
|
+
|
|
12
|
+
export { invalidate, invalidateAll } from "../core/cache.ts";
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# Import in your code:
|
|
15
15
|
# import { PUBLIC_STATIC_APP_NAME, DB_PASSWORD } from '$env';
|
|
16
16
|
#
|
|
17
|
-
# Framework vars (PORT, NODE_ENV, BODY_SIZE_LIMIT, CSRF_ALLOWED_ORIGINS,
|
|
17
|
+
# Framework vars (PORT, NODE_ENV, BODY_SIZE_LIMIT, MAX_INFLIGHT, CSRF_ALLOWED_ORIGINS,
|
|
18
18
|
# CORS_*, LOAD_TIMEOUT, METADATA_TIMEOUT, PRERENDER_TIMEOUT) are NOT exposed via $env —
|
|
19
19
|
# access them via process.env directly.
|
|
20
20
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -30,6 +30,25 @@ PUBLIC_STATIC_APP_NAME=My Bosia App
|
|
|
30
30
|
# Maximum request body size. Supports K/M/G suffixes or "Infinity". Defaults to 512K.
|
|
31
31
|
# BODY_SIZE_LIMIT=512K
|
|
32
32
|
|
|
33
|
+
# Soft cap on concurrent in-flight requests. Requests above the cap get a fast
|
|
34
|
+
# 503 + Retry-After before any work is done — protects single-replica container
|
|
35
|
+
# deploys from OOM under traffic spikes. Defaults to Infinity (no cap).
|
|
36
|
+
# /_health is exempt so orchestrator liveness probes keep working under load.
|
|
37
|
+
# MAX_INFLIGHT=500
|
|
38
|
+
|
|
39
|
+
# Server response cache (skip-render). Caches HTML SSR pages and +server.ts GET
|
|
40
|
+
# handlers in-memory, keyed by URL + identity (cookies/headers from CACHE_KEYS).
|
|
41
|
+
# Per-user pages stay isolated. Disable per-route with `export const cache = false`.
|
|
42
|
+
# See guides/response-cache for details.
|
|
43
|
+
#
|
|
44
|
+
# Identity keys: cookies AND headers with any of these names contribute to the
|
|
45
|
+
# cache key, so logged-in users never see each other's pages. Default covers
|
|
46
|
+
# common session cookie/header names. Leave blank to use the default.
|
|
47
|
+
# CACHE_KEYS=session,sid,auth,token,jwt,Authorization
|
|
48
|
+
#
|
|
49
|
+
# Max cache entries (LRU). Set to 0 to disable the cache entirely. Defaults to 500.
|
|
50
|
+
# CACHE_MAX_ENTRIES=500
|
|
51
|
+
|
|
33
52
|
# Timeout for load() functions (layout + page) in milliseconds. Defaults to 5000 (5s).
|
|
34
53
|
# Set to 0 or Infinity to disable.
|
|
35
54
|
# LOAD_TIMEOUT=5000
|
|
@@ -4,6 +4,18 @@ PORT=9000
|
|
|
4
4
|
# Maximum request body size. Supports K/M/G suffixes or "Infinity". Defaults to 512K.
|
|
5
5
|
BODY_SIZE_LIMIT=512K
|
|
6
6
|
|
|
7
|
+
# Soft cap on concurrent in-flight requests. Requests above the cap get a fast
|
|
8
|
+
# 503 + Retry-After before any work is done — protects single-replica container
|
|
9
|
+
# deploys from OOM under traffic spikes. Defaults to Infinity (no cap).
|
|
10
|
+
# /_health is exempt so orchestrator liveness probes keep working under load.
|
|
11
|
+
# MAX_INFLIGHT=500
|
|
12
|
+
|
|
13
|
+
# Server response cache (skip-render). Caches HTML SSR pages and +server.ts GET
|
|
14
|
+
# handlers in-memory, keyed by URL + identity (cookies/headers from CACHE_KEYS).
|
|
15
|
+
# Per-user pages stay isolated. Disable per-route with `export const cache = false`.
|
|
16
|
+
# CACHE_KEYS=session,sid,auth,token,jwt,Authorization
|
|
17
|
+
# CACHE_MAX_ENTRIES=500
|
|
18
|
+
|
|
7
19
|
# Comma-separated list of allowed CORS origins for CSRF validation.
|
|
8
20
|
# Leave unset to allow same-origin requests only.
|
|
9
21
|
# Example: https://app.example.com, https://admin.example.com
|