bosia 1.1.4 → 1.1.6

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
@@ -433,15 +606,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
433
606
  let releaseApiMiss: (() => void) | null = null;
434
607
  try {
435
608
  const mod = await apiMatch.route.module();
436
- const handler = mod[method];
609
+ // HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
610
+ // strips the body on the way out, for every route kind.
611
+ const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
437
612
 
438
613
  if (!handler) {
439
- const allowed = Object.keys(mod)
440
- .filter((k) => /^[A-Z]+$/.test(k))
441
- .join(", ");
614
+ const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
615
+ if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
442
616
  return Response.json(
443
617
  { error: `Method ${method} not allowed` },
444
- { status: 405, headers: { Allow: allowed } },
618
+ { status: 405, headers: { Allow: allowed.join(", ") } },
445
619
  );
446
620
  }
447
621
 
@@ -450,15 +624,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
450
624
  // ── Response cache for +server.ts GET handlers ──
451
625
  // CSP is skipped because cached responses would ship with a stale
452
626
  // nonce (see renderer.ts for the same gate). The cache key includes
453
- // URL + identity so per-user responses stay isolated.
454
- const apiCacheable =
455
- CACHE_ENABLED && !CSP_ENABLED && (mod as any).cache !== false && method === "GET";
627
+ // URL + identity so per-user responses stay isolated. HEAD reads the GET
628
+ // entry but never coalesces or writes: it isn't a leader that fills it.
629
+ // An explicit HEAD export skips the cache so it always runs.
630
+ const apiCacheRead =
631
+ CACHE_ENABLED &&
632
+ !CSP_ENABLED &&
633
+ (mod as any).cache !== false &&
634
+ (method === "GET" || (method === "HEAD" && !mod.HEAD));
635
+ const apiCacheable = apiCacheRead && method === "GET";
456
636
  let apiCacheKey: string | null = null;
457
- if (apiCacheable) {
637
+ if (apiCacheRead) {
458
638
  apiCacheKey = computeCacheKey(url, request, cookies as CookieJar);
459
639
  if (!url.searchParams.has("_invalidated")) {
460
640
  const hit = cacheGet(apiCacheKey);
461
641
  if (hit) return serveCached(hit, request);
642
+ }
643
+ if (apiCacheable && !url.searchParams.has("_invalidated")) {
462
644
  // Miss coalescing: first miss runs the handler; concurrent misses
463
645
  // wait, re-check the cache, and on a still-miss run independently.
464
646
  const gate = coalesceMiss(apiCacheKey);
@@ -475,6 +657,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
475
657
  const handlerResult = await handler({
476
658
  request,
477
659
  params: apiMatch.params,
660
+ route: { id: apiMatch.route.id },
478
661
  url,
479
662
  locals,
480
663
  cookies,
@@ -585,51 +768,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
585
768
  }
586
769
 
587
770
  // Static files — fallthrough after API routes so explicit handlers win.
771
+ // handleRequest already served hits before the hooks; this answers a URL a
772
+ // hook rewrote, and the 404 for a miss (without looking a second time).
588
773
  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 });
774
+ const parked = routeMatches.get(request);
775
+ const missed = parked?.staticMiss && parked.path === path;
776
+ return (
777
+ (missed ? null : await serveStaticFile(path, request)) ??
778
+ new Response("Not Found", { status: 404 })
779
+ );
633
780
  }
634
781
 
635
782
  // Prerendered pages — serve static HTML built at build time.
@@ -644,15 +791,19 @@ async function resolve(event: RequestEvent): Promise<Response> {
644
791
  const key = path === "/" ? "/" : path.replace(/\/$/, "");
645
792
  const hit = prerenderManifest.get(key);
646
793
  if (hit) {
794
+ // Stylesheets only: whether this page hydrates isn't known here, and a
795
+ // hint for a script it never runs is a wasted download.
796
+ const link = preloadLinkHeader(undefined, false);
647
797
  return serveStatic(hit, request, {
648
798
  "Content-Type": "text/html; charset=utf-8",
649
799
  "Cache-Control": "public, max-age=3600",
800
+ ...(link ? { Link: link } : {}),
650
801
  });
651
802
  }
652
803
  }
653
804
 
654
- // Resolve the page route once; reuse for trailing-slash, form-action, and SSR phases.
655
- const pageMatch = findMatch(serverRoutes, path);
805
+ // Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
806
+ const pageMatch = routes.page;
656
807
 
657
808
  // Trailing-slash canonicalization — 308 preserves method (form POSTs included)
658
809
  if (pageMatch) {
@@ -677,6 +828,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
677
828
  try {
678
829
  const mod = await pageMatch.route.pageServer();
679
830
  if (mod.actions && typeof mod.actions === "object") {
831
+ // Layout loaders gate route groups (`(private)/+layout.server.ts`
832
+ // redirecting anonymous visitors), so they run before any action:
833
+ // a POST must not reach code the same visitor could not GET.
834
+ // Their data is discarded; a redirect or error() they throw is
835
+ // handled by the catch below exactly like one from the action.
836
+ await loadRouteData(url, locals, request, cookies, null, pageMatch, {
837
+ page: false,
838
+ layouts: [],
839
+ });
680
840
  const actionName = parseActionName(url);
681
841
  const action = mod.actions[actionName];
682
842
  if (!action) {
@@ -704,6 +864,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
704
864
  }
705
865
 
706
866
  event.params = pageMatch.params;
867
+ event.route = { id: pageMatch.route.id };
707
868
  let result: any;
708
869
  try {
709
870
  result = await action(event);
@@ -971,11 +1132,38 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
971
1132
  url: dataReq ? dataReq.routeUrl : url,
972
1133
  locals: { nonce },
973
1134
  params: {},
1135
+ route: { id: null },
974
1136
  cookies: cookieJar,
975
1137
  isDataRequest: dataReq !== null,
976
1138
  platform: getPlatform(),
977
1139
  };
978
- let response =
1140
+
1141
+ // Static files are answered before the hooks: a session-lookup hook would
1142
+ // otherwise cost a DB query per chunk. A miss falls through to the hooks
1143
+ // and resolve(), which 404s. Prerendered pages are pages and stay behind
1144
+ // the hooks. Order mirrors resolve(): framework prefixes first (prod only,
1145
+ // unshadowable), then a user `+server.ts` wins over a same-path file.
1146
+ const path = event.url.pathname;
1147
+ let response: Response | null = null;
1148
+ let staticTried = false;
1149
+ if (!dataReq && staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
1150
+ response = await serveStaticFile(path, request);
1151
+ staticTried = true;
1152
+ }
1153
+ if (!response) {
1154
+ const routes = await matchRoutes(path, dataReq !== null);
1155
+ routeMatches.set(request, routes);
1156
+ const matched = routes.api ?? routes.page;
1157
+ if (matched) {
1158
+ event.params = matched.params;
1159
+ event.route = { id: matched.route.id };
1160
+ }
1161
+ if (!dataReq && !routes.api && isStaticPath(path)) {
1162
+ if (!staticTried) response = await serveStaticFile(path, request);
1163
+ routes.staticMiss = !response;
1164
+ }
1165
+ }
1166
+ response ??=
979
1167
  userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
980
1168
 
981
1169
  // A hook that short-circuits a data request with a redirect is answering
@@ -1000,7 +1188,24 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1000
1188
  }
1001
1189
  }
1002
1190
 
1003
- const headers = new Headers(response.headers);
1191
+ // Set on the response itself when its headers are mutable, which every
1192
+ // Response the framework builds has. Copying them and rebuilding the
1193
+ // Response cost a Headers copy plus a body re-wrap on every request.
1194
+ // Responses with immutable headers (`fetch()`, `Response.redirect()`)
1195
+ // still get the copy.
1196
+ // On Workers (compressionOn is false only there) an already-encoded body
1197
+ // is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
1198
+ let mutable: Headers | null = null;
1199
+ if (compressionOn || !response.headers.has("content-encoding")) {
1200
+ try {
1201
+ response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
1202
+ mutable = response.headers;
1203
+ } catch {
1204
+ // immutable — copied below
1205
+ }
1206
+ }
1207
+ const inPlace = mutable !== null;
1208
+ const headers = mutable ?? new Headers(response.headers);
1004
1209
  // A handle can mark a response (e.g. a proxied embeddable preview) to opt
1005
1210
  // out of the frame guard. Strip the internal marker so it never ships, and
1006
1211
  // skip only X-Frame-Options for that response — other security headers stay.
@@ -1025,6 +1230,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1025
1230
  }
1026
1231
  // Apply any Set-Cookie headers accumulated during the request
1027
1232
  for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
1233
+ if (inPlace) return response;
1028
1234
  return new Response(response.body, {
1029
1235
  // A Content-Encoding here means the body is already compressed (cache
1030
1236
  // hit, compress()); rebuilding drops that flag and Workers would
@@ -1209,6 +1415,7 @@ function loadBuiltManifest(): RouteManifest {
1209
1415
  return {
1210
1416
  pages: serverRoutes.map((r: any) => ({
1211
1417
  pattern: r.pattern,
1418
+ id: r.id,
1212
1419
  page: "",
1213
1420
  layouts: [],
1214
1421
  pageServer: r.pageServer ? "" : null,
@@ -1219,7 +1426,7 @@ function loadBuiltManifest(): RouteManifest {
1219
1426
  cache: r.cache ?? null,
1220
1427
  prerender: false,
1221
1428
  })),
1222
- apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
1429
+ apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, id: r.id, server: "" })),
1223
1430
  errorPage: null,
1224
1431
  };
1225
1432
  }
@@ -1229,8 +1436,10 @@ export type CreateAppOptions = {
1229
1436
  handle?: Handle | null;
1230
1437
  };
1231
1438
 
1232
- const frameworkHandler = ({ request }: { request: Request }) =>
1233
- handleRequest(request, new URL(request.url));
1439
+ // `url` is BosiaApp's own parse of `request.url`; handleRequest rewrites it in
1440
+ // place (BASE_PATH, X-Forwarded-*), which is safe because routing already ran.
1441
+ const frameworkHandler = ({ request, url }: { request: Request; url: URL }) =>
1442
+ handleRequest(request, url);
1234
1443
 
1235
1444
  /** Build the backend app: plugins, the framework's catch-all routes, error handling. */
1236
1445
  export async function createApp(options: CreateAppOptions = {}): Promise<BosiaApp> {