bosia 1.1.4 → 1.1.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.
@@ -6,7 +6,7 @@ import { apiRoutes, serverRoutes } from "bosia:routes";
6
6
  import { loadPlugins } from "./config.ts";
7
7
  import { readArtifact } from "./artifacts.ts";
8
8
  import { getPlatform, warmingUp } from "./platform.ts";
9
- import type { RouteManifest } from "./types.ts";
9
+ import type { RouteManifest, RouteMatch } from "./types.ts";
10
10
 
11
11
  // Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
12
12
  compileRoutes(apiRoutes);
@@ -20,7 +20,18 @@ import type { CsrfConfig } from "./csrf.ts";
20
20
  import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
21
21
  import type { CorsConfig } from "./cors.ts";
22
22
  import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
23
- import { isDev, compress, isStaticPath, distManifest, PRECOMPRESSED } from "./html.ts";
23
+ import {
24
+ isDev,
25
+ compress,
26
+ compressBytes,
27
+ encodeForRequest,
28
+ isStaticPath,
29
+ distManifest,
30
+ PRECOMPRESSED,
31
+ type Encoded,
32
+ preloadLinkHeader,
33
+ compressionOn,
34
+ } from "./html.ts";
24
35
  import { dev500WithPlugins } from "./dev-500.ts";
25
36
  import { OUT_DIR } from "./paths.ts";
26
37
  import { stripBase, withBase } from "./basePath.ts";
@@ -28,6 +39,7 @@ import { currentBase } from "./appBase.ts";
28
39
  import {
29
40
  buildPrerenderManifest,
30
41
  buildStaticManifest,
42
+ findOutDirFile,
31
43
  lookupStatic,
32
44
  serveStatic,
33
45
  } from "./staticManifest.ts";
@@ -39,6 +51,7 @@ import {
39
51
  cacheGet,
40
52
  cacheSet,
41
53
  coalesceMiss,
54
+ collectTags,
42
55
  computeCacheKey,
43
56
  deferCacheWrite,
44
57
  serveCached,
@@ -163,7 +176,7 @@ function isValidRoutePath(path: string, origin: string): boolean {
163
176
  }
164
177
  }
165
178
 
166
- type DataRequest = { routeUrl: URL; invalidatedBits: string | null };
179
+ type DataRequest = { routeUrl: URL; invalidatedBits: string | null; fresh: boolean };
167
180
 
168
181
  /**
169
182
  * Decode `/__bosia/data/<route>.json` into the page URL it stands for.
@@ -188,14 +201,21 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
188
201
 
189
202
  const routeUrl = new URL(routePathStr, url.origin);
190
203
  let invalidatedBits: string | null = null;
204
+ let fresh = false;
191
205
  for (const [key, val] of url.searchParams.entries()) {
192
206
  if (key === "_invalidated") {
193
207
  invalidatedBits = val;
194
208
  continue;
195
209
  }
210
+ // Set by the client router when the fetch follows an invalidate():
211
+ // skip the cached copy, as `?_invalidated` does for page HTML.
212
+ if (key === "_fresh") {
213
+ fresh = true;
214
+ continue;
215
+ }
196
216
  routeUrl.searchParams.append(key, val);
197
217
  }
198
- return { routeUrl, invalidatedBits };
218
+ return { routeUrl, invalidatedBits, fresh };
199
219
  }
200
220
 
201
221
  /**
@@ -211,9 +231,62 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
211
231
  */
212
232
  const dataRequests = new WeakMap<Request, DataRequest>();
213
233
 
234
+ type RouteMatches = {
235
+ path: string;
236
+ pageOnly: boolean;
237
+ api: RouteMatch<(typeof apiRoutes)[number]> | null;
238
+ page: RouteMatch<(typeof serverRoutes)[number]> | null;
239
+ /** handleRequest already looked for a static file at `path` and found none. */
240
+ staticMiss?: boolean;
241
+ };
242
+
243
+ /**
244
+ * Match `path` in resolve()'s order: an API route first (it shadows static
245
+ * files), then a page unless the path is a static file. A data request only
246
+ * ever routes to a page.
247
+ */
248
+ async function matchRoutes(path: string, pageOnly: boolean): Promise<RouteMatches> {
249
+ if (pageOnly) return { path, pageOnly, api: null, page: findMatch(serverRoutes, path) };
250
+ const apiMaybe = resolveApiMatch(apiRoutes, path);
251
+ const api = apiMaybe instanceof Promise ? await apiMaybe : apiMaybe;
252
+ const page = api || isStaticPath(path) ? null : findMatch(serverRoutes, path);
253
+ return { path, pageOnly, api, page };
254
+ }
255
+
256
+ /**
257
+ * handleRequest matches before the hooks run (so they see `params`) and parks
258
+ * the result here, keyed like `dataRequests`. resolve() reuses it unless a hook
259
+ * rewrote the URL, in which case it matches again.
260
+ */
261
+ const routeMatches = new WeakMap<Request, RouteMatches>();
262
+
263
+ function routesFor(
264
+ request: Request,
265
+ path: string,
266
+ pageOnly: boolean,
267
+ ): RouteMatches | Promise<RouteMatches> {
268
+ const parked = routeMatches.get(request);
269
+ if (parked && parked.path === path && parked.pageOnly === pageOnly) return parked;
270
+ return matchRoutes(path, pageOnly);
271
+ }
272
+
273
+ const jsonEncoder = new TextEncoder();
274
+
275
+ /** A page route's `export const cache` flag: the build-time read when it has
276
+ * one, otherwise the compiled page module's own export (see renderer.ts). */
277
+ async function pageRouteCacheable(route: (typeof serverRoutes)[number]): Promise<boolean> {
278
+ const flag = (route as { cache?: boolean | null }).cache;
279
+ if (flag === true || flag === false) return flag;
280
+ return (await route.pageModule()).cache !== false;
281
+ }
282
+
214
283
  /**
215
284
  * Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
216
- * depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
285
+ * depth i. Only '0' has a meaning; any other char counts as '1'.
286
+ * - Page '0': skip the page loader.
287
+ * - Layout '0': omit its data from the response. The loader still runs,
288
+ * since it may be a guard.
289
+ * Missing chars count as '1'. Chars past the route's layout count are ignored.
217
290
  */
218
291
  function buildMaskFromBits(
219
292
  bits: string,
@@ -243,6 +316,56 @@ function parseActionName(url: URL): string {
243
316
  const staticManifest = isDev ? null : buildStaticManifest(OUT_DIR);
244
317
  const prerenderManifest = isDev ? null : buildPrerenderManifest(OUT_DIR);
245
318
 
319
+ /** Serve `path` as a static file, or `null` when there is no such file. */
320
+ async function serveStaticFile(path: string, request: Request): Promise<Response | null> {
321
+ // Prod fast path: single Map lookup, no per-request stat calls.
322
+ if (staticManifest) {
323
+ const hit = lookupStatic(staticManifest, path);
324
+ return hit ? serveStatic(hit, request) : null;
325
+ }
326
+ // Dev: keep the per-request fallthrough so files dropped into `public/`
327
+ // mid-session are served without a restart. Decode once — filenames on
328
+ // disk are raw; safePath still runs after so traversal stays blocked.
329
+ let decodedPath: string;
330
+ try {
331
+ decodedPath = decodeURIComponent(path);
332
+ } catch {
333
+ return null;
334
+ }
335
+ if (decodedPath.startsWith("/dist/client/")) {
336
+ const resolved = safePath(
337
+ `${OUT_DIR}/client`,
338
+ decodedPath.split("?")[0].slice("/dist/client".length),
339
+ );
340
+ if (resolved) {
341
+ const file = Bun.file(resolved);
342
+ if (await file.exists()) {
343
+ return new Response(file, { headers: { "Cache-Control": "no-cache" } });
344
+ }
345
+ }
346
+ return null;
347
+ }
348
+ const pubPath = safePath("./public", decodedPath);
349
+ if (pubPath) {
350
+ const pub = Bun.file(pubPath);
351
+ if (await pub.exists()) return new Response(pub);
352
+ }
353
+ // Same refusals as the prod manifest: never hand out the server bundle,
354
+ // the user's bundled hooks/config, or build metadata. Matched against the
355
+ // real directory listing, so no alternative spelling reaches those files.
356
+ const distPath = findOutDirFile(OUT_DIR, decodedPath);
357
+ if (distPath) {
358
+ const dist = Bun.file(distPath);
359
+ if (await dist.exists()) return new Response(dist);
360
+ }
361
+ const staticPath = safePath(`${OUT_DIR}/static`, decodedPath);
362
+ if (staticPath) {
363
+ const staticFile = Bun.file(staticPath);
364
+ if (await staticFile.exists()) return new Response(staticFile);
365
+ }
366
+ return null;
367
+ }
368
+
246
369
  async function resolve(event: RequestEvent): Promise<Response> {
247
370
  const { request, url, locals, cookies } = event;
248
371
  const path = url.pathname;
@@ -262,11 +385,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
262
385
  // the parse handleRequest parked before the hooks ran is what identifies it.
263
386
  const dataReq = dataRequests.get(request);
264
387
  if (dataReq) {
265
- const { routeUrl, invalidatedBits } = dataReq;
388
+ const { routeUrl, invalidatedBits, fresh } = dataReq;
389
+ // INVARIANT: once set, releaseDataMiss must fire exactly once — see the
390
+ // API-route cache below for the same contract.
391
+ let releaseDataMiss: (() => void) | null = null;
266
392
  try {
267
- const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
268
- // Build mask from `?_invalidated=<bits>` where char 0 = page,
269
- // char i+1 = layout depth i, '1' = run, '0' = skip. Absent → run all.
393
+ const pageMatch = (await routesFor(request, routeUrl.pathname, true)).page;
394
+ // Build mask from `?_invalidated=<bits>` — see buildMaskFromBits. A page
395
+ // '0' skips the page loader; a layout '0' still runs the loader (it may
396
+ // be a guard) and only leaves its data out. Absent → run and send all.
270
397
  // Mask is sized to the total layout count (matching client `layoutIds`),
271
398
  // not the count of layout servers, so depths without a server loader
272
399
  // still occupy a bit position and stay aligned with the client.
@@ -276,32 +403,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
276
403
  pageMatch?.route ? ((pageMatch.route as any).layoutModules?.length ?? 0) : 0,
277
404
  )
278
405
  : undefined;
279
- // Client forwards each skipped layout layer's cached data as
280
- // parentSnapshots (depth → data) so downstream loaders see real
281
- // parent() data instead of {}. Perf hint only — never authoritative;
282
- // authz must read locals. Guarded: undefined for GET / malformed body.
283
- let parentSnapshots: Record<number, Record<string, any>> | undefined;
284
- if (method !== "GET") {
285
- try {
286
- const body = await request.json();
287
- if (body && typeof body === "object" && body.parentSnapshots) {
288
- parentSnapshots = body.parentSnapshots as Record<number, Record<string, any>>;
289
- }
290
- } catch {
291
- parentSnapshots = undefined;
292
- }
293
- }
294
406
  const runLoad = async () => {
295
- const data = await loadRouteData(
296
- routeUrl,
297
- locals,
298
- request,
299
- cookies,
300
- null,
301
- pageMatch,
302
- mask,
303
- parentSnapshots,
304
- );
407
+ const data = await loadRouteData(routeUrl, locals, request, cookies, null, pageMatch, mask);
305
408
 
306
409
  let metadata = null;
307
410
  if (pageMatch) {
@@ -332,6 +435,34 @@ async function resolve(event: RequestEvent): Promise<Response> {
332
435
  return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
333
436
  };
334
437
 
438
+ // ── Response cache for client-navigation data ──
439
+ // Same contract as page HTML: keyed by URL + identity, plus the mask
440
+ // (different masks return different slots), tag/path invalidation, and
441
+ // no CSP. Only GETs use it (the client router only sends GETs); a
442
+ // `_fresh` fetch after invalidate() skips the read only.
443
+ const cacheKey =
444
+ computeCacheKey(routeUrl, request, cookies as CookieJar) +
445
+ `|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
446
+ const dataCacheable =
447
+ CACHE_ENABLED &&
448
+ !CSP_ENABLED &&
449
+ !warmingUp &&
450
+ method === "GET" &&
451
+ pageMatch !== null &&
452
+ (await pageRouteCacheable(pageMatch.route));
453
+ if (dataCacheable && !fresh) {
454
+ const hit = cacheGet(cacheKey);
455
+ if (hit) return serveCached(hit, request);
456
+ const gate = coalesceMiss(cacheKey);
457
+ if (gate.wait) {
458
+ await gate.wait;
459
+ const rehit = cacheGet(cacheKey);
460
+ if (rehit) return serveCached(rehit, request);
461
+ } else {
462
+ releaseDataMiss = gate.release;
463
+ }
464
+ }
465
+
335
466
  // Dedup concurrent identical requests. The key includes the CACHE_KEYS
336
467
  // identity hash, so different users never share a loader result — same
337
468
  // isolation contract as the response cache. The mask is part of the key
@@ -363,13 +494,53 @@ async function resolve(event: RequestEvent): Promise<Response> {
363
494
  // Privacy beats intent: cookie-derived responses stay private even if
364
495
  // a loader set its own cache-control.
365
496
  if (cookiesWereAccessed) extra["cache-control"] = cc;
366
- return compress(
367
- JSON.stringify({ ...payload, metadata: result.metadata }),
368
- "application/json",
369
- request,
370
- 200,
371
- extra,
372
- );
497
+ const body = jsonEncoder.encode(JSON.stringify({ ...payload, metadata: result.metadata }));
498
+
499
+ // A loader that said not to store its response is taken at its word.
500
+ const loaderCc = (loaderHeaders["cache-control"] ?? "").toLowerCase();
501
+ const loaderOptOut = /no-store|no-cache|private/.test(loaderCc);
502
+ let sent: Encoded | null | undefined;
503
+ if (
504
+ dataCacheable &&
505
+ !loaderOptOut &&
506
+ (cookies as CookieJar).outgoing.length === 0 &&
507
+ (CACHE_MAX_BODY_BYTES === 0 || body.length <= CACHE_MAX_BODY_BYTES)
508
+ ) {
509
+ const tags = collectTags(result.data.layoutDeps ?? null, result.data.pageDeps ?? null);
510
+ const rel = releaseDataMiss;
511
+ releaseDataMiss = null;
512
+ // Encoded once at cache quality and shared by response and entry.
513
+ const encoded = encodeForRequest(body, request, "cache");
514
+ sent = encoded;
515
+ deferCacheWrite(() => {
516
+ try {
517
+ const { gzip, brotli } = buildCompressedVariants(
518
+ body,
519
+ encoded?.enc === "br"
520
+ ? { brotli: encoded.encoded }
521
+ : encoded
522
+ ? { gzip: encoded.encoded }
523
+ : {},
524
+ );
525
+ cacheSet(
526
+ cacheKey,
527
+ {
528
+ raw: body,
529
+ gzip,
530
+ brotli,
531
+ contentType: "application/json",
532
+ status: 200,
533
+ extraHeaders: extra,
534
+ tags,
535
+ },
536
+ cookies,
537
+ );
538
+ } finally {
539
+ rel?.();
540
+ }
541
+ });
542
+ }
543
+ return compressBytes(body, "application/json", request, 200, extra, sent);
373
544
  } catch (err) {
374
545
  if (isRedirect(err)) {
375
546
  return compress(
@@ -407,6 +578,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
407
578
  });
408
579
  }
409
580
  return Response.json({ error: "Internal Server Error" }, { status: 500 });
581
+ } finally {
582
+ releaseDataMiss?.();
410
583
  }
411
584
  }
412
585
 
@@ -423,8 +596,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
423
596
  // Matched BEFORE static fallthrough so explicit handlers shadow extension-
424
597
  // based static detection (e.g. `/uploads/[...path]/+server.ts` can serve
425
598
  // `.webp` URLs that would otherwise be intercepted by isStaticPath).
426
- const apiMaybe = resolveApiMatch(apiRoutes, path);
427
- const apiMatch = apiMaybe instanceof Promise ? await apiMaybe : apiMaybe;
599
+ const routes = await routesFor(request, path, false);
600
+ const apiMatch = routes.api;
428
601
  if (apiMatch) {
429
602
  if (warmingUp) return new Response(null, { status: 404 });
430
603
  // INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
@@ -475,6 +648,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
475
648
  const handlerResult = await handler({
476
649
  request,
477
650
  params: apiMatch.params,
651
+ route: { id: apiMatch.route.id },
478
652
  url,
479
653
  locals,
480
654
  cookies,
@@ -585,51 +759,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
585
759
  }
586
760
 
587
761
  // Static files — fallthrough after API routes so explicit handlers win.
762
+ // handleRequest already served hits before the hooks; this answers a URL a
763
+ // hook rewrote, and the 404 for a miss (without looking a second time).
588
764
  if (isStaticPath(path)) {
589
- // Prod fast path: single Map lookup, no per-request stat calls.
590
- if (staticManifest) {
591
- const hit = lookupStatic(staticManifest, path);
592
- if (hit) return serveStatic(hit, request);
593
- return new Response("Not Found", { status: 404 });
594
- }
595
- // Dev: keep the per-request fallthrough so files dropped into `public/`
596
- // mid-session are served without a restart. Decode once — filenames on
597
- // disk are raw; safePath still runs after so traversal stays blocked.
598
- let decodedPath: string;
599
- try {
600
- decodedPath = decodeURIComponent(path);
601
- } catch {
602
- return new Response("Not Found", { status: 404 });
603
- }
604
- if (decodedPath.startsWith("/dist/client/")) {
605
- const resolved = safePath(
606
- `${OUT_DIR}/client`,
607
- decodedPath.split("?")[0].slice("/dist/client".length),
608
- );
609
- if (resolved) {
610
- const file = Bun.file(resolved);
611
- if (await file.exists()) {
612
- return new Response(file, { headers: { "Cache-Control": "no-cache" } });
613
- }
614
- }
615
- return new Response("Not Found", { status: 404 });
616
- }
617
- const pubPath = safePath("./public", decodedPath);
618
- if (pubPath) {
619
- const pub = Bun.file(pubPath);
620
- if (await pub.exists()) return new Response(pub);
621
- }
622
- const distPath = safePath(OUT_DIR, decodedPath);
623
- if (distPath) {
624
- const dist = Bun.file(distPath);
625
- if (await dist.exists()) return new Response(dist);
626
- }
627
- const staticPath = safePath(`${OUT_DIR}/static`, decodedPath);
628
- if (staticPath) {
629
- const staticFile = Bun.file(staticPath);
630
- if (await staticFile.exists()) return new Response(staticFile);
631
- }
632
- return new Response("Not Found", { status: 404 });
765
+ const parked = routeMatches.get(request);
766
+ const missed = parked?.staticMiss && parked.path === path;
767
+ return (
768
+ (missed ? null : await serveStaticFile(path, request)) ??
769
+ new Response("Not Found", { status: 404 })
770
+ );
633
771
  }
634
772
 
635
773
  // Prerendered pages — serve static HTML built at build time.
@@ -644,15 +782,19 @@ async function resolve(event: RequestEvent): Promise<Response> {
644
782
  const key = path === "/" ? "/" : path.replace(/\/$/, "");
645
783
  const hit = prerenderManifest.get(key);
646
784
  if (hit) {
785
+ // Stylesheets only: whether this page hydrates isn't known here, and a
786
+ // hint for a script it never runs is a wasted download.
787
+ const link = preloadLinkHeader(undefined, false);
647
788
  return serveStatic(hit, request, {
648
789
  "Content-Type": "text/html; charset=utf-8",
649
790
  "Cache-Control": "public, max-age=3600",
791
+ ...(link ? { Link: link } : {}),
650
792
  });
651
793
  }
652
794
  }
653
795
 
654
- // Resolve the page route once; reuse for trailing-slash, form-action, and SSR phases.
655
- const pageMatch = findMatch(serverRoutes, path);
796
+ // Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
797
+ const pageMatch = routes.page;
656
798
 
657
799
  // Trailing-slash canonicalization — 308 preserves method (form POSTs included)
658
800
  if (pageMatch) {
@@ -677,6 +819,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
677
819
  try {
678
820
  const mod = await pageMatch.route.pageServer();
679
821
  if (mod.actions && typeof mod.actions === "object") {
822
+ // Layout loaders gate route groups (`(private)/+layout.server.ts`
823
+ // redirecting anonymous visitors), so they run before any action:
824
+ // a POST must not reach code the same visitor could not GET.
825
+ // Their data is discarded; a redirect or error() they throw is
826
+ // handled by the catch below exactly like one from the action.
827
+ await loadRouteData(url, locals, request, cookies, null, pageMatch, {
828
+ page: false,
829
+ layouts: [],
830
+ });
680
831
  const actionName = parseActionName(url);
681
832
  const action = mod.actions[actionName];
682
833
  if (!action) {
@@ -704,6 +855,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
704
855
  }
705
856
 
706
857
  event.params = pageMatch.params;
858
+ event.route = { id: pageMatch.route.id };
707
859
  let result: any;
708
860
  try {
709
861
  result = await action(event);
@@ -971,11 +1123,38 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
971
1123
  url: dataReq ? dataReq.routeUrl : url,
972
1124
  locals: { nonce },
973
1125
  params: {},
1126
+ route: { id: null },
974
1127
  cookies: cookieJar,
975
1128
  isDataRequest: dataReq !== null,
976
1129
  platform: getPlatform(),
977
1130
  };
978
- let response =
1131
+
1132
+ // Static files are answered before the hooks: a session-lookup hook would
1133
+ // otherwise cost a DB query per chunk. A miss falls through to the hooks
1134
+ // and resolve(), which 404s. Prerendered pages are pages and stay behind
1135
+ // the hooks. Order mirrors resolve(): framework prefixes first (prod only,
1136
+ // unshadowable), then a user `+server.ts` wins over a same-path file.
1137
+ const path = event.url.pathname;
1138
+ let response: Response | null = null;
1139
+ let staticTried = false;
1140
+ if (!dataReq && staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
1141
+ response = await serveStaticFile(path, request);
1142
+ staticTried = true;
1143
+ }
1144
+ if (!response) {
1145
+ const routes = await matchRoutes(path, dataReq !== null);
1146
+ routeMatches.set(request, routes);
1147
+ const matched = routes.api ?? routes.page;
1148
+ if (matched) {
1149
+ event.params = matched.params;
1150
+ event.route = { id: matched.route.id };
1151
+ }
1152
+ if (!dataReq && !routes.api && isStaticPath(path)) {
1153
+ if (!staticTried) response = await serveStaticFile(path, request);
1154
+ routes.staticMiss = !response;
1155
+ }
1156
+ }
1157
+ response ??=
979
1158
  userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
980
1159
 
981
1160
  // A hook that short-circuits a data request with a redirect is answering
@@ -1000,7 +1179,24 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1000
1179
  }
1001
1180
  }
1002
1181
 
1003
- const headers = new Headers(response.headers);
1182
+ // Set on the response itself when its headers are mutable, which every
1183
+ // Response the framework builds has. Copying them and rebuilding the
1184
+ // Response cost a Headers copy plus a body re-wrap on every request.
1185
+ // Responses with immutable headers (`fetch()`, `Response.redirect()`)
1186
+ // still get the copy.
1187
+ // On Workers (compressionOn is false only there) an already-encoded body
1188
+ // is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
1189
+ let mutable: Headers | null = null;
1190
+ if (compressionOn || !response.headers.has("content-encoding")) {
1191
+ try {
1192
+ response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
1193
+ mutable = response.headers;
1194
+ } catch {
1195
+ // immutable — copied below
1196
+ }
1197
+ }
1198
+ const inPlace = mutable !== null;
1199
+ const headers = mutable ?? new Headers(response.headers);
1004
1200
  // A handle can mark a response (e.g. a proxied embeddable preview) to opt
1005
1201
  // out of the frame guard. Strip the internal marker so it never ships, and
1006
1202
  // skip only X-Frame-Options for that response — other security headers stay.
@@ -1025,6 +1221,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1025
1221
  }
1026
1222
  // Apply any Set-Cookie headers accumulated during the request
1027
1223
  for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
1224
+ if (inPlace) return response;
1028
1225
  return new Response(response.body, {
1029
1226
  // A Content-Encoding here means the body is already compressed (cache
1030
1227
  // hit, compress()); rebuilding drops that flag and Workers would
@@ -1209,6 +1406,7 @@ function loadBuiltManifest(): RouteManifest {
1209
1406
  return {
1210
1407
  pages: serverRoutes.map((r: any) => ({
1211
1408
  pattern: r.pattern,
1409
+ id: r.id,
1212
1410
  page: "",
1213
1411
  layouts: [],
1214
1412
  pageServer: r.pageServer ? "" : null,
@@ -1219,7 +1417,7 @@ function loadBuiltManifest(): RouteManifest {
1219
1417
  cache: r.cache ?? null,
1220
1418
  prerender: false,
1221
1419
  })),
1222
- apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
1420
+ apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, id: r.id, server: "" })),
1223
1421
  errorPage: null,
1224
1422
  };
1225
1423
  }
@@ -1229,8 +1427,10 @@ export type CreateAppOptions = {
1229
1427
  handle?: Handle | null;
1230
1428
  };
1231
1429
 
1232
- const frameworkHandler = ({ request }: { request: Request }) =>
1233
- handleRequest(request, new URL(request.url));
1430
+ // `url` is BosiaApp's own parse of `request.url`; handleRequest rewrites it in
1431
+ // place (BASE_PATH, X-Forwarded-*), which is safe because routing already ran.
1432
+ const frameworkHandler = ({ request, url }: { request: Request; url: URL }) =>
1433
+ handleRequest(request, url);
1234
1434
 
1235
1435
  /** Build the backend app: plugins, the framework's catch-all routes, error handling. */
1236
1436
  export async function createApp(options: CreateAppOptions = {}): Promise<BosiaApp> {
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "fs";
2
2
  import { basename, join, resolve as resolvePath } from "path";
3
3
  import { PRECOMPRESSED, pickEncoding } from "./html.ts";
4
4
  import { AssetCache, assetCache } from "./assetCache.ts";
5
+ import { matchesEtag } from "./etag.ts";
5
6
 
6
7
  /** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
7
8
  export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
@@ -11,10 +12,63 @@ const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
11
12
  const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
12
13
  const DEFAULT_CACHE = "no-cache";
13
14
 
14
- // Files/dirs at OUT_DIR root that the manifest must not surface — they're either
15
- // build metadata or re-merges already covered by the per-root walks.
16
- const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server"]);
17
- const OUT_DIR_SKIP_FILES = new Set(["manifest.json", "route-manifest.json"]);
15
+ // Files/dirs at OUT_DIR root that must never be served — server code, build
16
+ // metadata, or re-merges already covered by the per-root walks. `server/` and
17
+ // `worker/` are server bundles; `hooks.server.js` and `bosia.config.js` are the
18
+ // user's own server code bundled for the runtime (with their dev `.map`s).
19
+ const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server", "worker"]);
20
+ const OUT_DIR_SKIP_FILES = new Set([
21
+ "manifest.json",
22
+ "route-manifest.json",
23
+ "app-html.json",
24
+ "svelte-maps.json",
25
+ "hooks.server.js",
26
+ "hooks.server.js.map",
27
+ "bosia.config.js",
28
+ "bosia.config.js.map",
29
+ ]);
30
+
31
+ /**
32
+ * Whether `rel` (a path relative to OUT_DIR, `/`-separated, no leading slash)
33
+ * is build output the server must not hand to a browser. Shared by the prod
34
+ * manifest and the dev per-request fallthrough so both refuse the same files.
35
+ */
36
+ export function isPrivateOutPath(rel: string): boolean {
37
+ const slash = rel.indexOf("/");
38
+ return slash === -1 ? OUT_DIR_SKIP_FILES.has(rel) : OUT_DIR_SKIP_DIRS.has(rel.slice(0, slash));
39
+ }
40
+
41
+ /**
42
+ * Dev-mode lookup of a user file at the OUT_DIR root (the prod manifest covers
43
+ * this in production). `urlPath` is the decoded request path. Each segment must
44
+ * equal, exactly, a name `readdir` returns, and the joined real names must not
45
+ * be private. Matching the filesystem's own names instead of comparing request
46
+ * strings means no other spelling of a private file can get through: a
47
+ * different case, a trailing dot or space (Windows), an NTFS `::$DATA` stream,
48
+ * an 8.3 short name, or a Unicode case-folding twin like `ſerver` (macOS).
49
+ * Returns the absolute path, or null.
50
+ */
51
+ export function findOutDirFile(outDir: string, urlPath: string): string | null {
52
+ const segments = urlPath.split("/").filter(Boolean);
53
+ if (segments.length === 0) return null;
54
+ if (isPrivateOutPath(segments.join("/"))) return null;
55
+ let dir = resolvePath(outDir);
56
+ for (let i = 0; i < segments.length; i++) {
57
+ let names: string[];
58
+ try {
59
+ names = readdirSync(dir);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (!names.includes(segments[i]!)) return null;
64
+ dir = join(dir, segments[i]!);
65
+ }
66
+ try {
67
+ return statSync(dir).isFile() ? dir : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
18
72
 
19
73
  const RESERVED_PREFIX = "/__bosia/";
20
74
 
@@ -94,13 +148,13 @@ export function buildStaticManifest(outDir: string): StaticManifest {
94
148
  }
95
149
  for (const ent of rootEntries) {
96
150
  if (ent.isDirectory()) {
97
- if (OUT_DIR_SKIP_DIRS.has(ent.name)) continue;
151
+ if (isPrivateOutPath(`${ent.name}/`)) continue;
98
152
  const sub = join(outAbs, ent.name);
99
153
  for (const { abs, rel } of walk(sub, ent.name)) {
100
154
  addOnce(manifest, `/${rel}`, { absPath: abs });
101
155
  }
102
156
  } else if (ent.isFile()) {
103
- if (OUT_DIR_SKIP_FILES.has(ent.name)) continue;
157
+ if (isPrivateOutPath(ent.name)) continue;
104
158
  addOnce(manifest, `/${ent.name}`, { absPath: join(outAbs, ent.name) });
105
159
  }
106
160
  }
@@ -215,15 +269,6 @@ function etagFor(path: string, encoding: string | undefined): string | null {
215
269
  return tag;
216
270
  }
217
271
 
218
- function matchesEtag(header: string | null, etag: string): boolean {
219
- if (!header) return false;
220
- for (const raw of header.split(",")) {
221
- const t = raw.trim();
222
- if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
223
- }
224
- return false;
225
- }
226
-
227
272
  export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
228
273
  const raw = urlPath.split("?")[0];
229
274
  // Manifest keys are raw filenames; URLs arrive percent-encoded.