bosia 0.8.3 → 0.8.5
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/build.ts +12 -3
- package/src/core/cache.ts +106 -34
- package/src/core/cookies.ts +18 -0
- package/src/core/dedup.ts +7 -7
- package/src/core/dev.ts +52 -4
- package/src/core/html.ts +13 -5
- package/src/core/plugin.ts +2 -2
- package/src/core/prerender.ts +4 -3
- 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 +96 -44
- package/src/core/staticManifest.ts +35 -1
- package/src/core/twHash.ts +18 -0
- package/src/core/types.ts +0 -6
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,19 @@ 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
|
+
` Note: the runtime auto-warns only on uncovered *cookie* reads — it CANNOT\n` +
|
|
1146
|
+
` detect header-based personalisation, so custom auth headers (X-Api-Key,\n` +
|
|
1147
|
+
` X-Auth-Token, …) must be added to CACHE_KEYS by hand.\n`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1098
1150
|
});
|
|
1099
1151
|
|
|
1100
1152
|
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 {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync, renameSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
|
|
4
|
+
/** Temp filename Tailwind CLI writes to before the content-hash rename. */
|
|
5
|
+
export const TW_TEMP_BASENAME = ".bosia-tw.build.css";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Content-hash the compiled Tailwind CSS and rename it to its final
|
|
9
|
+
* `bosia-tw-<hash>.css` name (matches staticManifest's HASHED_BASENAME rule,
|
|
10
|
+
* so it gets immutable caching). Returns the final basename.
|
|
11
|
+
*/
|
|
12
|
+
export function finalizeTailwindCss(tempPath: string): string {
|
|
13
|
+
const bytes = readFileSync(tempPath);
|
|
14
|
+
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
|
|
15
|
+
const name = `bosia-tw-${hash}.css`;
|
|
16
|
+
renameSync(tempPath, join(dirname(tempPath), name));
|
|
17
|
+
return name;
|
|
18
|
+
}
|
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 */
|