bosia 0.5.12 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/cache.ts +357 -0
- package/src/core/plugins/inspector/bun-plugin.ts +33 -11
- package/src/core/plugins/inspector/overlay.ts +43 -6
- 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 +1 -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.0",
|
|
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": [
|
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
export const CACHE_KEYS: readonly string[] = parseCacheKeys(process.env.CACHE_KEYS);
|
|
32
|
+
export const CACHE_MAX_ENTRIES = parseMaxEntries(process.env.CACHE_MAX_ENTRIES);
|
|
33
|
+
export const CACHE_ENABLED = CACHE_MAX_ENTRIES > 0;
|
|
34
|
+
|
|
35
|
+
if (CACHE_ENABLED) {
|
|
36
|
+
console.log(
|
|
37
|
+
`💾 Response cache: max ${CACHE_MAX_ENTRIES} entries, identity keys [${CACHE_KEYS.join(", ")}]`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
console.log("💾 Response cache: disabled (CACHE_MAX_ENTRIES=0)");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Entry shape ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
type Bytes = Uint8Array<ArrayBuffer>;
|
|
46
|
+
|
|
47
|
+
export type CacheEntry = {
|
|
48
|
+
raw: Bytes;
|
|
49
|
+
gzip: Bytes | null;
|
|
50
|
+
brotli: Bytes | null;
|
|
51
|
+
contentType: string;
|
|
52
|
+
status: number;
|
|
53
|
+
extraHeaders: Record<string, string>;
|
|
54
|
+
tags: string[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ─── Tiny LRU ────────────────────────────────────────────
|
|
58
|
+
// Uses Map's insertion-order iteration. get() promotes by re-inserting.
|
|
59
|
+
|
|
60
|
+
class LRU<K, V> {
|
|
61
|
+
private map = new Map<K, V>();
|
|
62
|
+
constructor(private cap: number) {}
|
|
63
|
+
get(key: K): V | undefined {
|
|
64
|
+
const v = this.map.get(key);
|
|
65
|
+
if (v === undefined) return undefined;
|
|
66
|
+
this.map.delete(key);
|
|
67
|
+
this.map.set(key, v);
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
set(key: K, value: V): K | undefined {
|
|
71
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
72
|
+
this.map.set(key, value);
|
|
73
|
+
if (this.map.size > this.cap) {
|
|
74
|
+
const oldest = this.map.keys().next().value as K | undefined;
|
|
75
|
+
if (oldest !== undefined) {
|
|
76
|
+
this.map.delete(oldest);
|
|
77
|
+
return oldest;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
delete(key: K): boolean {
|
|
83
|
+
return this.map.delete(key);
|
|
84
|
+
}
|
|
85
|
+
keys(): IterableIterator<K> {
|
|
86
|
+
return this.map.keys();
|
|
87
|
+
}
|
|
88
|
+
clear(): void {
|
|
89
|
+
this.map.clear();
|
|
90
|
+
}
|
|
91
|
+
get size(): number {
|
|
92
|
+
return this.map.size;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Storage ─────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const htmlCache = new LRU<string, CacheEntry>(CACHE_MAX_ENTRIES || 1);
|
|
99
|
+
const tagIndex = new Map<string, Set<string>>();
|
|
100
|
+
const pathIndex = new Map<string, Set<string>>(); // pathname → cacheKeys
|
|
101
|
+
|
|
102
|
+
// ─── Key building ────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** FNV-1a 32-bit hash. Compact, no dep, collision-tolerant for an identity bucket. */
|
|
105
|
+
function fnv1a(s: string): string {
|
|
106
|
+
let h = 0x811c9dc5;
|
|
107
|
+
for (let i = 0; i < s.length; i++) {
|
|
108
|
+
h ^= s.charCodeAt(i);
|
|
109
|
+
h = Math.imul(h, 0x01000193);
|
|
110
|
+
}
|
|
111
|
+
return (h >>> 0).toString(36);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function computeIdentityHash(req: Request, cookies: Cookies): string {
|
|
115
|
+
const headers = req.headers;
|
|
116
|
+
const parts: string[] = [];
|
|
117
|
+
for (const name of CACHE_KEYS) {
|
|
118
|
+
const cv = cookies.get(name);
|
|
119
|
+
if (cv) parts.push(`c:${name}=${cv}`);
|
|
120
|
+
const hv = headers.get(name);
|
|
121
|
+
if (hv) parts.push(`h:${name}=${hv}`);
|
|
122
|
+
}
|
|
123
|
+
if (parts.length === 0) return "0";
|
|
124
|
+
parts.sort();
|
|
125
|
+
return fnv1a(parts.join("&"));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function computeCacheKey(url: URL, req: Request, cookies: Cookies): string {
|
|
129
|
+
return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Extract pathname portion of a cacheKey for path-based invalidation. */
|
|
133
|
+
function pathOfKey(key: string): string {
|
|
134
|
+
const qIdx = key.indexOf("?");
|
|
135
|
+
const pIdx = key.indexOf("|");
|
|
136
|
+
const end = qIdx === -1 ? pIdx : Math.min(qIdx, pIdx);
|
|
137
|
+
return end === -1 ? key : key.slice(0, end);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Tag collection ──────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export function collectTags(
|
|
143
|
+
layoutDeps: (LoaderDeps | null)[] | null,
|
|
144
|
+
pageDeps: LoaderDeps | null,
|
|
145
|
+
): string[] {
|
|
146
|
+
const tags = new Set<string>();
|
|
147
|
+
if (layoutDeps) {
|
|
148
|
+
for (const deps of layoutDeps) {
|
|
149
|
+
if (!deps) continue;
|
|
150
|
+
for (const k of deps.keys) tags.add(`k:${k}`);
|
|
151
|
+
for (const u of deps.urls) tags.add(`u:${u}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (pageDeps) {
|
|
155
|
+
for (const k of pageDeps.keys) tags.add(`k:${k}`);
|
|
156
|
+
for (const u of pageDeps.urls) tags.add(`u:${u}`);
|
|
157
|
+
}
|
|
158
|
+
return [...tags];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Public-ish: read / write ────────────────────────────
|
|
162
|
+
|
|
163
|
+
export function cacheGet(key: string): CacheEntry | undefined {
|
|
164
|
+
if (!CACHE_ENABLED) return undefined;
|
|
165
|
+
return htmlCache.get(key);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function cacheSet(key: string, entry: CacheEntry): void {
|
|
169
|
+
if (!CACHE_ENABLED) return;
|
|
170
|
+
// Drop any existing entry's index pointers first
|
|
171
|
+
cacheDeleteKey(key);
|
|
172
|
+
const evicted = htmlCache.set(key, entry);
|
|
173
|
+
if (evicted) cacheDeleteIndexOnly(evicted);
|
|
174
|
+
for (const tag of entry.tags) {
|
|
175
|
+
let set = tagIndex.get(tag);
|
|
176
|
+
if (!set) {
|
|
177
|
+
set = new Set();
|
|
178
|
+
tagIndex.set(tag, set);
|
|
179
|
+
}
|
|
180
|
+
set.add(key);
|
|
181
|
+
}
|
|
182
|
+
const path = pathOfKey(key);
|
|
183
|
+
let pset = pathIndex.get(path);
|
|
184
|
+
if (!pset) {
|
|
185
|
+
pset = new Set();
|
|
186
|
+
pathIndex.set(path, pset);
|
|
187
|
+
}
|
|
188
|
+
pset.add(key);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Remove a key from htmlCache AND its index pointers. */
|
|
192
|
+
function cacheDeleteKey(key: string): void {
|
|
193
|
+
const entry = htmlCache.get(key);
|
|
194
|
+
if (entry) {
|
|
195
|
+
for (const tag of entry.tags) {
|
|
196
|
+
const set = tagIndex.get(tag);
|
|
197
|
+
if (set) {
|
|
198
|
+
set.delete(key);
|
|
199
|
+
if (set.size === 0) tagIndex.delete(tag);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const path = pathOfKey(key);
|
|
204
|
+
const pset = pathIndex.get(path);
|
|
205
|
+
if (pset) {
|
|
206
|
+
pset.delete(key);
|
|
207
|
+
if (pset.size === 0) pathIndex.delete(path);
|
|
208
|
+
}
|
|
209
|
+
htmlCache.delete(key);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Cleanup index pointers for a key after LRU evicted it. */
|
|
213
|
+
function cacheDeleteIndexOnly(key: string): void {
|
|
214
|
+
for (const set of tagIndex.values()) set.delete(key);
|
|
215
|
+
for (const [tag, set] of tagIndex) if (set.size === 0) tagIndex.delete(tag);
|
|
216
|
+
const path = pathOfKey(key);
|
|
217
|
+
const pset = pathIndex.get(path);
|
|
218
|
+
if (pset) {
|
|
219
|
+
pset.delete(key);
|
|
220
|
+
if (pset.size === 0) pathIndex.delete(path);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Compression helpers ─────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/** Build gzip + brotli copies of body. Sync, runs in microtask. */
|
|
227
|
+
export function buildCompressedVariants(body: Bytes): {
|
|
228
|
+
gzip: Bytes | null;
|
|
229
|
+
brotli: Bytes | null;
|
|
230
|
+
} {
|
|
231
|
+
const COMPRESS_MIN_BYTES = 2048;
|
|
232
|
+
if (body.length < COMPRESS_MIN_BYTES) return { gzip: null, brotli: null };
|
|
233
|
+
let gzip: Bytes | null = null;
|
|
234
|
+
let brotli: Bytes | null = null;
|
|
235
|
+
try {
|
|
236
|
+
gzip = Bun.gzipSync(body) as Bytes;
|
|
237
|
+
} catch {
|
|
238
|
+
gzip = null;
|
|
239
|
+
}
|
|
240
|
+
// brotliCompressSync exists in Bun >= 1.1.5 but is missing from older
|
|
241
|
+
// @types/bun shipments — cast through any so the call stays loose.
|
|
242
|
+
const brotliFn = (Bun as unknown as { brotliCompressSync?: (b: Bytes) => Uint8Array })
|
|
243
|
+
.brotliCompressSync;
|
|
244
|
+
if (brotliFn) {
|
|
245
|
+
try {
|
|
246
|
+
brotli = brotliFn(body) as Bytes;
|
|
247
|
+
} catch {
|
|
248
|
+
brotli = null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { gzip, brotli };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Concatenate multiple Uint8Array chunks into one buffer. */
|
|
255
|
+
export function concatChunks(chunks: Uint8Array[]): Bytes {
|
|
256
|
+
let total = 0;
|
|
257
|
+
for (const c of chunks) total += c.length;
|
|
258
|
+
const out = new Uint8Array(new ArrayBuffer(total));
|
|
259
|
+
let off = 0;
|
|
260
|
+
for (const c of chunks) {
|
|
261
|
+
out.set(c, off);
|
|
262
|
+
off += c.length;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Serve a cache hit ───────────────────────────────────
|
|
268
|
+
|
|
269
|
+
export function serveCached(entry: CacheEntry, req: Request): Response {
|
|
270
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
271
|
+
const headers: Record<string, string> = {
|
|
272
|
+
"Content-Type": entry.contentType,
|
|
273
|
+
Vary: "Accept-Encoding",
|
|
274
|
+
"X-Bosia-Cache": "HIT",
|
|
275
|
+
...entry.extraHeaders,
|
|
276
|
+
};
|
|
277
|
+
if (entry.brotli && accept.includes("br")) {
|
|
278
|
+
headers["Content-Encoding"] = "br";
|
|
279
|
+
return new Response(entry.brotli, { status: entry.status, headers });
|
|
280
|
+
}
|
|
281
|
+
if (entry.gzip && accept.includes("gzip")) {
|
|
282
|
+
headers["Content-Encoding"] = "gzip";
|
|
283
|
+
return new Response(entry.gzip, { status: entry.status, headers });
|
|
284
|
+
}
|
|
285
|
+
return new Response(entry.raw, { status: entry.status, headers });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Invalidation API ────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Evict all cache entries matching `key`.
|
|
292
|
+
*
|
|
293
|
+
* - `invalidate("app:user")` → evict entries tagged with depends("app:user")
|
|
294
|
+
* (matches the loader's tag list).
|
|
295
|
+
* - `invalidate("/api/posts")` → evict entries tagged with a fetch URL whose
|
|
296
|
+
* path equals `/api/posts`, AND entries whose own path equals `/api/posts`.
|
|
297
|
+
*/
|
|
298
|
+
export function invalidate(key: string): number {
|
|
299
|
+
if (!CACHE_ENABLED) return 0;
|
|
300
|
+
let count = 0;
|
|
301
|
+
const tagKey = key.startsWith("/") ? `u:${key}` : `k:${key}`;
|
|
302
|
+
const fromTag = tagIndex.get(tagKey);
|
|
303
|
+
if (fromTag) {
|
|
304
|
+
// Also collect URL tag matches where the loader fetched an absolute URL
|
|
305
|
+
// whose pathname == key. Conservative: also match the bare `k:` tag in
|
|
306
|
+
// case the user uses a key that starts with `/` but isn't a URL.
|
|
307
|
+
for (const k of [...fromTag]) {
|
|
308
|
+
cacheDeleteKey(k);
|
|
309
|
+
count++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (key.startsWith("/")) {
|
|
313
|
+
// Match absolute URL tags too: any `u:<origin><key>` recorded by trackedFetch.
|
|
314
|
+
for (const [tag, set] of tagIndex) {
|
|
315
|
+
if (tag.startsWith("u:") && tag.endsWith(key)) {
|
|
316
|
+
for (const k of [...set]) {
|
|
317
|
+
cacheDeleteKey(k);
|
|
318
|
+
count++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Match cache entries whose own path equals key
|
|
323
|
+
const pset = pathIndex.get(key);
|
|
324
|
+
if (pset) {
|
|
325
|
+
for (const k of [...pset]) {
|
|
326
|
+
cacheDeleteKey(k);
|
|
327
|
+
count++;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return count;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Evict every entry whose path starts with `prefix`.
|
|
336
|
+
* Use for bulk eviction (e.g. `invalidateAll("/products/")`).
|
|
337
|
+
*/
|
|
338
|
+
export function invalidateAll(prefix: string): number {
|
|
339
|
+
if (!CACHE_ENABLED) return 0;
|
|
340
|
+
let count = 0;
|
|
341
|
+
for (const [path, set] of [...pathIndex]) {
|
|
342
|
+
if (path.startsWith(prefix)) {
|
|
343
|
+
for (const k of [...set]) {
|
|
344
|
+
cacheDeleteKey(k);
|
|
345
|
+
count++;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return count;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Test-only: clear everything. */
|
|
353
|
+
export function cacheClear(): void {
|
|
354
|
+
htmlCache.clear();
|
|
355
|
+
tagIndex.clear();
|
|
356
|
+
pathIndex.clear();
|
|
357
|
+
}
|
|
@@ -66,18 +66,40 @@ function injectLocs(source: string, relPath: string): string {
|
|
|
66
66
|
if (!ast.fragment) return source;
|
|
67
67
|
|
|
68
68
|
const ms = new MagicString(source);
|
|
69
|
+
const safeAttr = relPath.replace(/"/g, """);
|
|
70
|
+
// HTML comment data cannot contain `--`; neutralise defensively. (Almost no
|
|
71
|
+
// real path contains it, but a stray `--foo` directory shouldn't break parsing.)
|
|
72
|
+
const safeComment = relPath.replace(/--/g, "__");
|
|
69
73
|
walk(ast.fragment, (node) => {
|
|
70
|
-
if (node.type
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
if (node.type === "RegularElement") {
|
|
75
|
+
const name = node.name ?? "";
|
|
76
|
+
if (!name) return;
|
|
77
|
+
if (name === "script" || name === "style") return;
|
|
78
|
+
if (/^[A-Z]/.test(name)) return;
|
|
79
|
+
if (name.includes(":")) return;
|
|
80
|
+
if (typeof node.start !== "number") return;
|
|
81
|
+
const insertAt = node.start + 1 + name.length;
|
|
82
|
+
const { line, col } = lineColFromOffset(source, node.start);
|
|
83
|
+
ms.appendLeft(insertAt, ` data-bosia-loc="${safeAttr}:${line}:${col}"`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Bracket component invocations with HTML comments so the runtime can
|
|
87
|
+
// walk DOM siblings to reconstruct the call-site chain (Page → Layout →
|
|
88
|
+
// Button). Without this the per-element `data-bosia-loc` only points at
|
|
89
|
+
// the component's *definition* file, which is misleading when the user
|
|
90
|
+
// (or an AI agent) wants to edit the page that rendered it. Comments
|
|
91
|
+
// survive into the rendered DOM because `preserveComments: dev` is set
|
|
92
|
+
// on the compile call below.
|
|
93
|
+
if (
|
|
94
|
+
node.type === "Component" ||
|
|
95
|
+
node.type === "SvelteComponent" ||
|
|
96
|
+
node.type === "SvelteSelf"
|
|
97
|
+
) {
|
|
98
|
+
if (typeof node.start !== "number" || typeof node.end !== "number") return;
|
|
99
|
+
const { line, col } = lineColFromOffset(source, node.start);
|
|
100
|
+
ms.appendLeft(node.start, `<!--bosia:o=${safeComment}:${line}:${col}-->`);
|
|
101
|
+
ms.appendRight(node.end, `<!--bosia:c-->`);
|
|
102
|
+
}
|
|
81
103
|
});
|
|
82
104
|
return ms.toString();
|
|
83
105
|
}
|
|
@@ -26,7 +26,7 @@ function ensureOutline(){
|
|
|
26
26
|
outline.style.cssText="position:fixed;pointer-events:none;border:2px solid #f73b27;background:rgba(247,59,39,.08);z-index:2147483646;border-radius:2px;transition:all .05s linear;display:none";
|
|
27
27
|
document.body.appendChild(outline);
|
|
28
28
|
tip=document.createElement("div");
|
|
29
|
-
tip.style.cssText="position:fixed;pointer-events:none;background:#111;color:#fff;font:11px/1.4 ui-monospace,monospace;padding:3px 6px;border-radius:3px;z-index:2147483647;display:none;white-space:nowrap";
|
|
29
|
+
tip.style.cssText="position:fixed;pointer-events:none;background:#111;color:#fff;font:11px/1.4 ui-monospace,monospace;padding:3px 6px;border-radius:3px;z-index:2147483647;display:none;white-space:nowrap;max-width:90vw;overflow:hidden;text-overflow:ellipsis";
|
|
30
30
|
document.body.appendChild(tip);
|
|
31
31
|
}
|
|
32
32
|
function hideOutline(){if(outline)outline.style.display="none";if(tip)tip.style.display="none"}
|
|
@@ -43,6 +43,38 @@ function showOutline(el,loc){
|
|
|
43
43
|
function parseLoc(s){var m=/^(.+):(\\d+):(\\d+)$/.exec(s);if(!m)return null;return{file:m[1],line:+m[2],col:+m[3]}}
|
|
44
44
|
function findTarget(e){var n=e.target;while(n&&n.nodeType===1){if(n.hasAttribute&&n.hasAttribute("data-bosia-loc"))return n;n=n.parentNode}return null}
|
|
45
45
|
|
|
46
|
+
// Walk DOM ancestors collecting <Component> call-site markers (<!--bosia:o=…-->
|
|
47
|
+
// / <!--bosia:c-->) into an outermost-first array. At each ancestor we scan
|
|
48
|
+
// previous siblings tracking a depth counter so an earlier sibling component's
|
|
49
|
+
// open marker doesn't get attributed to a later sibling's element.
|
|
50
|
+
function collectStack(el){
|
|
51
|
+
var stack=[],cur=el;
|
|
52
|
+
while(cur){
|
|
53
|
+
var depth=0,sib=cur.previousSibling;
|
|
54
|
+
while(sib){
|
|
55
|
+
if(sib.nodeType===8){
|
|
56
|
+
var v=sib.nodeValue||"";
|
|
57
|
+
if(v==="bosia:c") depth++;
|
|
58
|
+
else if(v.lastIndexOf("bosia:o=",0)===0){
|
|
59
|
+
if(depth>0) depth--;
|
|
60
|
+
else stack.push(v.slice(8));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
sib=sib.previousSibling;
|
|
64
|
+
}
|
|
65
|
+
var p=cur.parentNode;
|
|
66
|
+
if(!p||p.nodeType!==1) break;
|
|
67
|
+
cur=p;
|
|
68
|
+
}
|
|
69
|
+
return stack.reverse();
|
|
70
|
+
}
|
|
71
|
+
function chainString(el){
|
|
72
|
+
var stack=collectStack(el);
|
|
73
|
+
var leaf=el.getAttribute("data-bosia-loc")||"";
|
|
74
|
+
if(!stack.length) return leaf;
|
|
75
|
+
return stack.concat(leaf).join(" → ");
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
function toast(msg,err){
|
|
47
79
|
var t=document.createElement("div");
|
|
48
80
|
t.textContent=msg;
|
|
@@ -62,10 +94,12 @@ function send(payload,onOk,onErr){
|
|
|
62
94
|
function closeForm(){if(form){form.remove();form=null}}
|
|
63
95
|
function openForm(loc,el){
|
|
64
96
|
closeForm();
|
|
97
|
+
var chain=chainString(el);
|
|
65
98
|
var r=el.getBoundingClientRect();
|
|
66
99
|
form=document.createElement("div");
|
|
67
100
|
form.style.cssText="position:fixed;left:"+r.left+"px;top:"+(r.bottom+6)+"px;background:#fff;color:#111;border:1px solid #d4d4d8;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.18);padding:10px;width:340px;z-index:2147483647;font:13px ui-sans-serif,system-ui,sans-serif";
|
|
68
|
-
|
|
101
|
+
var header='<div style="font-size:11px;color:#71717a;margin-bottom:6px;font-family:ui-monospace,monospace;word-break:break-all">'+chain+'</div>';
|
|
102
|
+
form.innerHTML=header+
|
|
69
103
|
'<textarea placeholder="Describe a fix (Enter to send, Esc to cancel, empty = open in editor)" style="width:100%;min-height:64px;border:1px solid #e4e4e7;border-radius:4px;padding:6px;font:13px ui-sans-serif,system-ui,sans-serif;resize:vertical;box-sizing:border-box;outline:none"></textarea>'+
|
|
70
104
|
'<div style="margin-top:8px;display:flex;gap:6px;justify-content:flex-end">'+
|
|
71
105
|
'<button data-cancel style="padding:4px 10px;border:1px solid #e4e4e7;background:#fff;border-radius:4px;cursor:pointer;font-size:12px">Cancel</button>'+
|
|
@@ -75,9 +109,12 @@ function openForm(loc,el){
|
|
|
75
109
|
var ta=form.querySelector("textarea");
|
|
76
110
|
ta.focus();
|
|
77
111
|
function submit(){
|
|
78
|
-
var
|
|
112
|
+
var userComment=ta.value.trim();
|
|
79
113
|
var payload={file:loc.file,line:loc.line,col:loc.col};
|
|
80
|
-
if(
|
|
114
|
+
if(userComment){
|
|
115
|
+
var tree=chain.indexOf(" → ")>=0?("Component tree (outer → leaf): "+chain+"\\n\\n"):"";
|
|
116
|
+
payload.comment=tree+userComment;
|
|
117
|
+
}
|
|
81
118
|
send(payload,function(j){toast(j.mode==="ai"?"sent to AI":"opened "+loc.file+":"+loc.line)});
|
|
82
119
|
closeForm();
|
|
83
120
|
}
|
|
@@ -100,7 +137,7 @@ window.addEventListener("mousemove",function(e){
|
|
|
100
137
|
if(!altDown||form){hideOutline();return}
|
|
101
138
|
var el=findTarget(e);
|
|
102
139
|
if(!el){hideOutline();return}
|
|
103
|
-
showOutline(el,el
|
|
140
|
+
showOutline(el,chainString(el));
|
|
104
141
|
},true);
|
|
105
142
|
|
|
106
143
|
window.addEventListener("click",function(e){
|
|
@@ -137,7 +174,7 @@ if(ERR_ENABLED){
|
|
|
137
174
|
var t=e.target;
|
|
138
175
|
if(!t||!t.closest)return;
|
|
139
176
|
var el=t.closest("[data-bosia-loc]");
|
|
140
|
-
if(el)lastInteraction=el
|
|
177
|
+
if(el)lastInteraction=chainString(el);
|
|
141
178
|
}
|
|
142
179
|
window.addEventListener("mousedown",trackInteraction,true);
|
|
143
180
|
window.addEventListener("keydown",trackInteraction,true);
|
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,7 @@
|
|
|
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
|
+
export { invalidate, invalidateAll } from "../core/cache.ts";
|
|
9
10
|
export type { HttpError, Redirect, RedirectOptions, ActionFailure } from "../core/errors.ts";
|
|
10
11
|
export type {
|
|
11
12
|
RequestEvent,
|
|
@@ -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
|