bosia 1.1.5 → 1.2.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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/core/appHtml.ts +17 -0
  3. package/src/core/cache.ts +3 -0
  4. package/src/core/client/router.svelte.ts +1 -2
  5. package/src/core/html.ts +208 -29
  6. package/src/core/renderer.ts +145 -93
  7. package/src/core/server.ts +113 -89
  8. package/src/lib/utils.ts +1 -8
  9. package/templates/default/package.json +2 -2
  10. package/templates/demo/package.json +2 -2
  11. package/templates/shop/package.json +2 -2
  12. package/templates/shop/src/routes/(private)/dashboard/files/+page.svelte +9 -0
  13. package/templates/shop/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
  14. package/templates/shop/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
  15. package/templates/shop/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
  16. package/templates/shop/src/routes/(private)/dashboard/products/+page.svelte +9 -0
  17. package/templates/shop/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
  18. package/templates/shop/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
  19. package/templates/shop/src/routes/(private)/dashboard/users/+page.svelte +9 -0
  20. package/templates/store/package.json +2 -2
  21. package/templates/store/src/routes/(private)/dashboard/files/+page.svelte +9 -0
  22. package/templates/store/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
  23. package/templates/store/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
  24. package/templates/store/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
  25. package/templates/store/src/routes/(private)/dashboard/products/+page.svelte +9 -0
  26. package/templates/store/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
  27. package/templates/store/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
  28. package/templates/store/src/routes/(private)/dashboard/users/+page.svelte +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -62,6 +62,23 @@ export function loadAppHtmlTemplate(cwd: string = process.cwd()): AppHtmlSegment
62
62
  };
63
63
  }
64
64
 
65
+ // Comments and <script>/<style> bodies are matched only to be skipped; the
66
+ // first real `</head …>` or, when the optional </head> is left out, `<body`
67
+ // is where the head ends.
68
+ const HEAD_END_RE =
69
+ /<!--[\s\S]*?(?:-->|$)|<(script|style)\b[\s\S]*?(?:<\/\1\s*>|$)|<\/head\s*>|<body[\s>/]/gi;
70
+
71
+ /** Index in headClose (which runs on to %bosia.body%) where the head ends, or -1.
72
+ * Matches case-insensitively on the original string: lowercasing a copy can
73
+ * change its length (`İ`) and shift the index. */
74
+ export function findHeadEnd(headClose: string): number {
75
+ for (const m of headClose.matchAll(HEAD_END_RE)) {
76
+ if (m[0].startsWith("<!--") || m[1]) continue;
77
+ return m.index;
78
+ }
79
+ return -1;
80
+ }
81
+
65
82
  function replaceStaticPlaceholders(template: string): string {
66
83
  // Replace %bosia.assets% with BOSIA_ASSETS_BASE env var
67
84
  const assetsBase = process.env.BOSIA_ASSETS_BASE || "";
package/src/core/cache.ts CHANGED
@@ -404,6 +404,9 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
404
404
  return new Response(null, { status: 304, headers });
405
405
  }
406
406
  }
407
+ // BosiaApp drops HEAD bodies, and a null body would report Content-Length 0.
408
+ // GET leaves it to the runtime, so a hook that rewrites the body stays valid.
409
+ if (req.method === "HEAD") headers["content-length"] = String(body.byteLength);
407
410
  if (encoding) {
408
411
  headers["content-encoding"] = encoding;
409
412
  return new Response(body, { ...PRECOMPRESSED, status: entry.status, headers });
@@ -90,8 +90,7 @@ export const router = new (class Router {
90
90
  pendingScroll: { x: number; y: number } | null = null;
91
91
  /** Set by App.svelte; returns the mounted page's `export const snapshot` (if any) at capture time. */
92
92
  getPageSnapshot:
93
- | (() => { capture: () => unknown; restore: (v: any) => void } | undefined)
94
- | null = null;
93
+ (() => { capture: () => unknown; restore: (v: any) => void } | undefined) | null = null;
95
94
  /** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
96
95
  pendingSnapshot: unknown = undefined;
97
96
 
package/src/core/html.ts CHANGED
@@ -5,7 +5,7 @@ import { nonceAttr } from "./csp.ts";
5
5
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
6
  import { currentBase } from "./appBase.ts";
7
7
  import type { AppHtmlSegments } from "./appHtml.ts";
8
- import { interpolateSegment } from "./appHtml.ts";
8
+ import { findHeadEnd, interpolateSegment } from "./appHtml.ts";
9
9
  import type { Metadata } from "./hooks.ts";
10
10
 
11
11
  // Workers compresses any body sent with a Content-Encoding header — again, if it
@@ -249,6 +249,8 @@ export function buildHtml(
249
249
  segments?: AppHtmlSegments,
250
250
  metadata?: Metadata | null,
251
251
  pattern?: string,
252
+ headExtras?: string[],
253
+ fallbackTitle = DEFAULT_TITLE,
252
254
  ): string {
253
255
  // An app writes <a href="/masuk">; under a base the browser has to be handed
254
256
  // /sso/masuk or it walks off this app entirely. Only the rendered markup is
@@ -258,11 +260,11 @@ export function buildHtml(
258
260
  head = rebaseHtmlAttrs(B, head);
259
261
 
260
262
  // Metadata goes in before `head`: the first <title> in the document wins, and
261
- // the streaming path already puts metadata() ahead of <svelte:head> content
262
- // (which arrives later via buildHtmlTail). Same order = same winner on both paths.
263
+ // buildMetadataChunk uses the same order on the streaming path, so both paths
264
+ // pick the same winner.
263
265
  const metaTags = rebaseHtmlAttrs(B, metadataTags(metadata ?? null));
264
- const fallbackTitle =
265
- metaTags.includes("<title>") || head.includes("<title>") ? "" : "<title>Bosia App</title>";
266
+ const extras = rebaseHtmlAttrs(B, headExtraTags(headExtras));
267
+ const fallback = titleFallback(segments, metadata, fallbackTitle, extras, head);
266
268
 
267
269
  const n = nonceAttr(nonce);
268
270
  const publicEnv = getPublicDynamicEnv();
@@ -307,7 +309,6 @@ export function buildHtml(
307
309
  lang: safeKey,
308
310
  nonce,
309
311
  });
310
- const headCloseInterpolated = interpolateSegment(segments.headClose, { nonce });
311
312
  const tailInterpolated = interpolateSegment(segments.tail, { nonce });
312
313
  const faviconLine = segments.hasCustomFavicon
313
314
  ? ""
@@ -319,8 +320,8 @@ export function buildHtml(
319
320
  componentCssLinks() +
320
321
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
321
322
  preloads +
322
- ` ${fallbackTitle}${metaTags}${head}` +
323
- headCloseInterpolated +
323
+ ` ${metaTags}${extras}` +
324
+ closeHead(segments, lateHead(head, fallback), nonce) +
324
325
  (body ? "" : `\n${SPINNER}`) +
325
326
  `\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
326
327
  tailInterpolated
@@ -332,10 +333,8 @@ export function buildHtml(
332
333
  <head>
333
334
  <meta charset="UTF-8">
334
335
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
335
- ${fallbackTitle}
336
336
  <link rel="icon" type="image/svg+xml" href="${FAVICON}">
337
- ${metaTags} ${head}
338
- ${twCssLink()}
337
+ ${metaTags}${extras}${lateHead(head, fallback)} ${twCssLink()}
339
338
  ${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
340
339
  ${preloads}</head>
341
340
  <body>
@@ -424,29 +423,213 @@ export function metadataTags(metadata: Metadata | null): string {
424
423
  return out;
425
424
  }
426
425
 
426
+ /** app.html's headClose with `late` inserted just before its `</head>`, after any
427
+ * markup the app put after %bosia.head%, so <svelte:head> stays last in <head>
428
+ * and a page's styles and meta win over app.html's, as they did when it was
429
+ * injected client-side. No `</head>` in the segment → `late` goes first. */
430
+ function closeHead(segments: AppHtmlSegments, late: string, nonce?: string): string {
431
+ const { before, after } = headSplit(segments);
432
+ return (
433
+ (before ? interpolateSegment(before, { nonce }) : "") +
434
+ late +
435
+ interpolateSegment(after, { nonce })
436
+ );
437
+ }
438
+
439
+ type HeadSplit = { before: string; after: string; hasTitle: boolean };
440
+
441
+ // app.html never changes after it is parsed, so split it and check it for a
442
+ // title once per segments object instead of on every render. Kept in memory,
443
+ // not in dist/app-html.json, so it can't go stale against headClose.
444
+ const headSplits = new WeakMap<AppHtmlSegments, HeadSplit>();
445
+
446
+ function headSplit(segments: AppHtmlSegments): HeadSplit {
447
+ let split = headSplits.get(segments);
448
+ if (!split) {
449
+ const { headOpen, headClose } = segments;
450
+ const i = findHeadEnd(headClose);
451
+ const before = i < 0 ? "" : headClose.slice(0, i);
452
+ const after = i < 0 ? headClose : headClose.slice(i);
453
+ split = { before, after, hasTitle: hasTitle(headOpen) || hasTitle(before) };
454
+ headSplits.set(segments, split);
455
+ }
456
+ return split;
457
+ }
458
+
459
+ /** Whether a chunk of head markup already sets the document title. Matches a
460
+ * <title> with attributes too. Ignores one in a comment, an attribute value, a
461
+ * script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
462
+ * (which only labels the icon). */
463
+ export function hasTitle(html: string): boolean {
464
+ // A single forward scan, not a regex: a lazy `<script…</script>` match retries
465
+ // from every unclosed `<script` and goes quadratic on hostile {@html} output.
466
+ // Each tag is skipped whole, quotes included, so nothing inside an attribute
467
+ // looks like a tag. This is a heuristic, not a full HTML tokenizer.
468
+ let unclosed: Set<string> | undefined;
469
+ let i = 0;
470
+ while ((i = html.indexOf("<", i)) !== -1) {
471
+ if (html.startsWith("<!--", i)) {
472
+ i = commentEnd(html, i);
473
+ if (i === -1) return false; // unclosed comment runs to the end
474
+ continue;
475
+ }
476
+ if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
477
+ i++;
478
+ continue;
479
+ }
480
+ const nameEnd = tagNameEnd(html, i + 1);
481
+ const tagEnd = openTagEnd(html, nameEnd);
482
+ if (tagEnd === -1) return false; // the rest is inside an unfinished tag
483
+ const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
484
+ if (name === "title") return true;
485
+ i = tagEnd;
486
+ const close = SKIPPED_TAGS.get(name);
487
+ // Only foreign content like <svg/> honors the self-closing slash.
488
+ const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
489
+ if (!close || selfClosed || unclosed?.has(name)) continue;
490
+ // An opener with no closer is read as a plain tag, and remembered, so
491
+ // later openers of that tag don't search to the end again.
492
+ close.lastIndex = tagEnd;
493
+ const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
494
+ if (gt === -1) (unclosed ??= new Set()).add(name);
495
+ else i = gt + 1;
496
+ }
497
+ return false;
498
+ }
499
+
500
+ /** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
501
+ * since a `[^>]*>` here would rescan to the end for every candidate. */
502
+ const SKIPPED_TAGS = new Map(
503
+ ["script", "style", "noscript", "template", "svg"].map((t) => [
504
+ t,
505
+ new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
506
+ ]),
507
+ );
508
+
509
+ /** Longest tag name hasTitle needs to tell apart; longer names are never sliced. */
510
+ const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
511
+
512
+ /** Index just past the comment opening at `start`, or -1 when it never closes.
513
+ * `<!-->` and `<!--->` are complete empty comments. */
514
+ function commentEnd(html: string, start: number): number {
515
+ if (html.startsWith(">", start + 4)) return start + 5;
516
+ if (html.startsWith("->", start + 4)) return start + 6;
517
+ const end = html.indexOf("-->", start + 4);
518
+ return end === -1 ? -1 : end + 3;
519
+ }
520
+
521
+ /** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
522
+ function tagNameEnd(html: string, start: number): number {
523
+ let j = start;
524
+ while (j < html.length) {
525
+ const c = html.charCodeAt(j);
526
+ if (isHtmlSpace(c) || c === 47 || c === 62) break;
527
+ j++;
528
+ }
529
+ return j;
530
+ }
531
+
532
+ /** Index just past the `>` closing a start tag, skipping quoted attribute
533
+ * values, or -1 when the tag never closes. */
534
+ function openTagEnd(html: string, from: number): number {
535
+ for (let j = from; j < html.length; j++) {
536
+ const c = html.charCodeAt(j);
537
+ if (c === 62) return j + 1;
538
+ if (c !== 61) continue; // `=` starts a value
539
+ do j++;
540
+ while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
541
+ const q = html.charCodeAt(j);
542
+ if (q === 34 || q === 39) {
543
+ j = html.indexOf(html[j]!, j + 1);
544
+ if (j === -1) return -1;
545
+ continue;
546
+ }
547
+ // Unquoted: runs to whitespace or `>`, quotes and `=` included.
548
+ while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
549
+ j--;
550
+ }
551
+ return -1;
552
+ }
553
+
554
+ /** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
555
+ function isHtmlSpace(c: number): boolean {
556
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
557
+ }
558
+
559
+ function isAsciiAlpha(c: number): boolean {
560
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
561
+ }
562
+
563
+ /** Plugin `head` fragments, one per line, empty ones skipped. */
564
+ function headExtraTags(headExtras?: string[]): string {
565
+ let out = "";
566
+ for (const fragment of headExtras ?? []) {
567
+ if (fragment) out += ` ${fragment}\n`;
568
+ }
569
+ return out;
570
+ }
571
+
572
+ /** The title a page gets when nothing else sets one. */
573
+ const DEFAULT_TITLE = "Bosia App";
574
+
575
+ /** `<title>{text}</title>` unless app.html or one of the head parts already
576
+ * has a title. Shared by buildHtml and buildMetadataChunk so both paths make
577
+ * the same call; each passes every part it writes into <head> (metadata,
578
+ * plugin extras, <svelte:head>). */
579
+ function titleFallback(
580
+ segments: AppHtmlSegments | undefined,
581
+ metadata: Metadata | null | undefined,
582
+ text: string,
583
+ ...parts: string[]
584
+ ): string {
585
+ if (metadata?.title || (segments && appHtmlHasTitle(segments))) return "";
586
+ return parts.some(hasTitle) ? "" : `<title>${escapeHtml(text)}</title>`;
587
+ }
588
+
589
+ /** Whether app.html sets its own <title> in its head. */
590
+ export function appHtmlHasTitle(segments: AppHtmlSegments): boolean {
591
+ return headSplit(segments).hasTitle;
592
+ }
593
+
594
+ /** <svelte:head>, then the fallback title, for the very end of <head>. The
595
+ * fallback goes last so that if hasTitle ever misses a real <title>, the real
596
+ * one still comes first and wins; the cost is a spare tag, not a wrong title. */
597
+ function lateHead(head: string, fallbackTitle: string): string {
598
+ return (head ? ` ${head}\n` : "") + (fallbackTitle ? ` ${fallbackTitle}\n` : "");
599
+ }
600
+
601
+ export type MetadataChunkOptions = {
602
+ /** Plugin `head` fragments; they go right after the metadata() tags. */
603
+ headExtras?: string[];
604
+ segments?: AppHtmlSegments;
605
+ /** <svelte:head> output; it goes last, just before app.html's </head>. */
606
+ head?: string;
607
+ nonce?: string;
608
+ };
609
+
427
610
  /** Chunk 2: metadata tags + close </head> + open <body> + spinner */
428
611
  export function buildMetadataChunk(
429
612
  metadata: Metadata | null,
430
- headExtras?: string[],
431
- segments?: AppHtmlSegments,
613
+ { headExtras, segments, head = "", nonce }: MetadataChunkOptions = {},
432
614
  ): string {
433
- let out = "\n";
434
- out += metadata ? metadataTags(metadata) : ` <title>Bosia App</title>\n`;
435
- if (headExtras?.length) {
436
- for (const fragment of headExtras) {
437
- if (fragment) out += ` ${fragment}\n`;
438
- }
439
- }
615
+ // `head` goes in the real <head>, not in a script, so crawlers that don't run
616
+ // JS still see JSON-LD, icons and og tags. Same order as buildHtml: metadata,
617
+ // plugin extras, app.html's own head markup, then <svelte:head>.
618
+ const metaTags = metadataTags(metadata);
619
+ const extras = headExtraTags(headExtras);
620
+ const fallbackTitle = titleFallback(segments, metadata, DEFAULT_TITLE, extras, head);
621
+ let out = "\n" + metaTags + extras;
622
+ const late = lateHead(head, fallbackTitle);
440
623
 
441
624
  if (segments) {
442
- const headCloseInterpolated = interpolateSegment(segments.headClose, {});
443
- out += headCloseInterpolated + `\n${SPINNER}`;
625
+ out += closeHead(segments, late, nonce) + `\n${SPINNER}`;
444
626
  } else {
445
- out += `</head>\n<body>\n${SPINNER}`;
627
+ out += `${late}</head>\n<body>\n${SPINNER}`;
446
628
  }
447
629
 
448
- // All markup, no data islands — safe to rebase wholesale, which is what picks
449
- // up an app's own headExtras (a canonical link, an og:image on a local file).
630
+ // No loader data islands here, so it's safe to rebase wholesale. That picks up
631
+ // an app's own headExtras (a canonical link, an og:image on a local file) and
632
+ // <svelte:head> output, which buildHtml rebases the same way.
450
633
  return rebaseHtmlAttrs(B, out);
451
634
  }
452
635
 
@@ -464,7 +647,6 @@ export function escapeAttr(s: string): string {
464
647
 
465
648
  export function buildHtmlTail(
466
649
  body: string,
467
- head: string,
468
650
  pageData: any,
469
651
  layoutData: any[],
470
652
  csr: boolean,
@@ -478,13 +660,10 @@ export function buildHtmlTail(
478
660
  ): string {
479
661
  // Same rebase as buildHtml — the streamed tail carries the identical markup.
480
662
  body = rebaseHtmlAttrs(B, body);
481
- head = rebaseHtmlAttrs(B, head);
482
663
 
483
664
  const n = nonceAttr(nonce);
484
665
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
485
666
  out += `\n<div id="app">${body}</div>`;
486
- if (head)
487
- out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
488
667
  if (csr) {
489
668
  out += baseScript(nonce);
490
669
  const publicEnv = getPublicDynamicEnv();
@@ -33,6 +33,8 @@ import {
33
33
  compress,
34
34
  compressBytes,
35
35
  encodeForRequest,
36
+ appHtmlHasTitle,
37
+ hasTitle,
36
38
  isDev,
37
39
  withPreloadLink,
38
40
  type Encoded,
@@ -358,6 +360,14 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
358
360
  // Runs layout + page server loaders for a given URL.
359
361
  // Used by both SSR and the /__bosia/data JSON endpoint.
360
362
  //
363
+ // Every loader starts at once (SvelteKit's model). `parent()` is what orders
364
+ // them: it waits for the layers above and returns their merged data, so only a
365
+ // loader that asks for it waits. Results are still settled root → leaf, so the
366
+ // root-most failure decides the response, exactly as when they ran in turn.
367
+ // The cost: page code that runs before `await parent()` runs even when a layout
368
+ // guard redirects — its output never ships, but its side effects happen. Guard
369
+ // in hooks, or `await parent()` first.
370
+ //
361
371
  // `mask` controls which layers' data the client data endpoint sends back:
362
372
  // - undefined → send everything (SSR, first nav)
363
373
  // - layouts[i] === false → the layout still RUNS, but its slot is emitted as
@@ -375,6 +385,8 @@ export type LoaderMask = {
375
385
  layouts: boolean[];
376
386
  };
377
387
 
388
+ type LayerResult = { data: Record<string, any>; deps: LoaderDeps };
389
+
378
390
  export async function loadRouteData(
379
391
  url: URL,
380
392
  locals: Record<string, any>,
@@ -392,25 +404,61 @@ export async function loadRouteData(
392
404
  const origin = url.origin;
393
405
  const layoutData: (Record<string, any> | null)[] = [];
394
406
  const layoutDeps: (LoaderDeps | null)[] = [];
395
- let parentData: Record<string, any> = {};
396
407
  // Shared across layout + page loaders so cross-loader duplicate
397
408
  // setHeaders() calls throw. Keys stored lowercased.
398
409
  const loaderHeaders: Record<string, string> = {};
399
410
  const setHeaders = makeSetHeaders(loaderHeaders);
400
411
 
401
- // Run layout server loaders root → leaf, each gets parent() data
412
+ // Merged data of the first `n` layout layers. A fresh object per call, so
413
+ // loaders cannot mutate what the others see.
414
+ const layers: Promise<LayerResult>[] = [];
415
+ const parentOf = (n: number) => async () => {
416
+ const above = await Promise.all(layers.slice(0, n));
417
+ return Object.assign({}, ...above.map((l) => l.data));
418
+ };
419
+
402
420
  for (const ls of route.layoutServers) {
403
- // Runs regardless of the mask — see the trust note above loadRouteData.
404
- const omit = mask && mask.layouts[ls.depth] === false;
405
- try {
421
+ const parent = parentOf(layers.length);
422
+ const layer = (async (): Promise<LayerResult> => {
406
423
  const mod = await ls.loader();
407
- if (typeof mod.load === "function" && !warmingUp) {
408
- // Snapshot per layer so loaders cannot mutate the shared accumulator,
409
- // preserving the same isolation semantics as the previous merge-on-call code.
410
- const snapshot = { ...parentData };
411
- const parent = async () => snapshot;
424
+ if (typeof mod.load !== "function" || warmingUp) return { data: {}, deps: emptyDeps() };
425
+ const deps = emptyDeps();
426
+ const data =
427
+ (await withTimeout(
428
+ mod.load({
429
+ params: trackedParams(params, deps),
430
+ route: { id: route.id },
431
+ url: trackedUrl(url, deps),
432
+ locals,
433
+ cookies: trackedCookies(cookies, deps),
434
+ parent,
435
+ fetch: trackedFetch(fetch, origin, deps),
436
+ metadata: null,
437
+ depends: makeDepends(deps),
438
+ platform: getPlatform(),
439
+ setHeaders,
440
+ }),
441
+ LOAD_TIMEOUT,
442
+ `layout load (depth=${ls.depth}, ${url.pathname})`,
443
+ )) ?? {};
444
+ return { data, deps };
445
+ })();
446
+ // Settled in order below; a rejection must not be reported as unhandled
447
+ // while an earlier layer is still being awaited.
448
+ layer.catch(() => {});
449
+ layers.push(layer);
450
+ }
451
+
452
+ const skipPage = mask && mask.page === false;
453
+ const pageP = route.pageServer
454
+ ? (async () => {
455
+ const mod = await route.pageServer!();
456
+ const flags = { csr: mod.csr !== false, ssr: mod.ssr !== false };
457
+ if (skipPage) return { ...flags, data: null, deps: null };
458
+ if (typeof mod.load !== "function" || warmingUp)
459
+ return { ...flags, data: {}, deps: emptyDeps() };
412
460
  const deps = emptyDeps();
413
- const result =
461
+ const data =
414
462
  (await withTimeout(
415
463
  mod.load({
416
464
  params: trackedParams(params, deps),
@@ -418,23 +466,35 @@ export async function loadRouteData(
418
466
  url: trackedUrl(url, deps),
419
467
  locals,
420
468
  cookies: trackedCookies(cookies, deps),
421
- parent,
469
+ parent: parentOf(layers.length),
422
470
  fetch: trackedFetch(fetch, origin, deps),
423
- metadata: null,
471
+ metadata: await metadataData,
424
472
  depends: makeDepends(deps),
425
473
  platform: getPlatform(),
426
474
  setHeaders,
427
475
  }),
428
476
  LOAD_TIMEOUT,
429
- `layout load (depth=${ls.depth}, ${url.pathname})`,
477
+ `page load (${url.pathname})`,
430
478
  )) ?? {};
431
- layoutData[ls.depth] = omit ? null : result;
432
- layoutDeps[ls.depth] = omit ? null : deps;
433
- parentData = { ...parentData, ...result };
434
- } else {
435
- layoutData[ls.depth] = omit ? null : {};
436
- layoutDeps[ls.depth] = omit ? null : emptyDeps();
437
- }
479
+ return {
480
+ ...flags,
481
+ data: data as Record<string, any> | null,
482
+ deps: deps as LoaderDeps | null,
483
+ };
484
+ })()
485
+ : null;
486
+ pageP?.catch(() => {});
487
+
488
+ // Settle root → leaf. The first failure wins, carrying the data of the
489
+ // layers above it — the same error a serial run would have produced.
490
+ for (let i = 0; i < route.layoutServers.length; i++) {
491
+ const ls = route.layoutServers[i]!;
492
+ // Runs regardless of the mask — see the trust note above loadRouteData.
493
+ const omit = mask && mask.layouts[ls.depth] === false;
494
+ try {
495
+ const { data, deps } = await layers[i]!;
496
+ layoutData[ls.depth] = omit ? null : data;
497
+ layoutDeps[ls.depth] = omit ? null : deps;
438
498
  } catch (err) {
439
499
  if (isRedirect(err)) throw err;
440
500
  if (isHttpError(err)) {
@@ -460,47 +520,13 @@ export async function loadRouteData(
460
520
  }
461
521
  }
462
522
 
463
- // Run page server loader
464
- let pageData: Record<string, any> | null = null;
465
- let pageDeps: LoaderDeps | null = null;
523
+ let pageData: Record<string, any> | null = {};
524
+ let pageDeps: LoaderDeps | null = emptyDeps();
466
525
  let csr = true;
467
526
  let ssr = true;
468
- const skipPage = mask && mask.page === false;
469
- if (route.pageServer) {
527
+ if (pageP) {
470
528
  try {
471
- const mod = await route.pageServer();
472
- if (mod.csr === false) csr = false;
473
- if (mod.ssr === false) ssr = false;
474
- if (skipPage) {
475
- pageData = null;
476
- pageDeps = null;
477
- } else if (typeof mod.load === "function" && !warmingUp) {
478
- const snapshot = { ...parentData };
479
- const parent = async () => snapshot;
480
- const deps = emptyDeps();
481
- pageData =
482
- (await withTimeout(
483
- mod.load({
484
- params: trackedParams(params, deps),
485
- route: { id: route.id },
486
- url: trackedUrl(url, deps),
487
- locals,
488
- cookies: trackedCookies(cookies, deps),
489
- parent,
490
- fetch: trackedFetch(fetch, origin, deps),
491
- metadata: await metadataData,
492
- depends: makeDepends(deps),
493
- platform: getPlatform(),
494
- setHeaders,
495
- }),
496
- LOAD_TIMEOUT,
497
- `page load (${url.pathname})`,
498
- )) ?? {};
499
- pageDeps = deps;
500
- } else {
501
- pageData = {};
502
- pageDeps = emptyDeps();
503
- }
529
+ ({ data: pageData, deps: pageDeps, csr, ssr } = await pageP);
504
530
  } catch (err) {
505
531
  if (isRedirect(err)) throw err;
506
532
  if (isHttpError(err)) {
@@ -524,9 +550,6 @@ export async function loadRouteData(
524
550
  );
525
551
  throw wrapped;
526
552
  }
527
- } else {
528
- pageData = {};
529
- pageDeps = emptyDeps();
530
553
  }
531
554
 
532
555
  // `params` are always attached to pageData for client-side router consumption.
@@ -615,15 +638,24 @@ export async function renderSSRStream(
615
638
  // into the cached HTML but the CSP header is re-derived each request, so a
616
639
  // cached page would ship with a dead nonce and the browser would block its
617
640
  // inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
618
- const cacheable =
619
- CACHE_ENABLED && !CSP_ENABLED && !warmingUp && routeCacheable && req.method === "GET";
641
+ // HEAD reads the GET entry but never coalesces or writes: it isn't a leader
642
+ // that fills it.
643
+ const cacheRead =
644
+ CACHE_ENABLED &&
645
+ !CSP_ENABLED &&
646
+ !warmingUp &&
647
+ routeCacheable &&
648
+ (req.method === "GET" || req.method === "HEAD");
649
+ const cacheable = cacheRead && req.method === "GET";
620
650
  let cacheKey: string | null = null;
621
651
  let releaseMiss: (() => void) | null = null;
622
- if (cacheable) {
652
+ if (cacheRead) {
623
653
  cacheKey = computeCacheKey(url, req, cookies as CookieJar);
624
654
  if (!cacheBypass) {
625
655
  const hit = cacheGet(cacheKey);
626
656
  if (hit) return serveCached(hit, req);
657
+ }
658
+ if (cacheable && !cacheBypass) {
627
659
  // Miss coalescing: the first miss leads the build; concurrent misses
628
660
  // wait, then re-check the cache. A waiter that still misses (leader
629
661
  // skipped the write) builds independently — no re-leadering.
@@ -646,8 +678,7 @@ export async function renderSSRStream(
646
678
  // ── Pre-stream phase: metadata(), loaders, module imports and plugin
647
679
  // fragments all start together, and all settle before committing to a 200,
648
680
  // so a Redirect/HttpError from any of them still gets a proper response.
649
- // Layout loaders still run in order before the page load(), which also
650
- // waits for metadata.data (see loadRouteData).
681
+ // The page load() alone waits for metadata.data (see loadRouteData).
651
682
  const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
652
683
  (value) => ({ ok: true as const, value }),
653
684
  (err: unknown) => ({ ok: false as const, err }),
@@ -786,9 +817,8 @@ export async function renderSSRStream(
786
817
  }
787
818
  const html =
788
819
  buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments, route.pattern) +
789
- buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
820
+ buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, nonce }) +
790
821
  buildHtmlTail(
791
- "",
792
822
  "",
793
823
  pageDataFull,
794
824
  layoutDataFull,
@@ -857,10 +887,9 @@ export async function renderSSRStream(
857
887
  appHtmlSegments,
858
888
  data.csr ? route.pattern : undefined,
859
889
  ) +
860
- buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
890
+ buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, head, nonce }) +
861
891
  buildHtmlTail(
862
892
  body,
863
- head,
864
893
  pageDataFull,
865
894
  layoutDataFull,
866
895
  data.csr,
@@ -963,11 +992,34 @@ export async function renderPageWithFormData(
963
992
 
964
993
  const { route, params } = match;
965
994
 
966
- // Serial, not parallel: metadata.data feeds the loader below.
995
+ // Same overlap as the GET path: metadata() and the loaders start together,
996
+ // and only the page load() waits for metadata.data. A metadata redirect or
997
+ // error() still answers first.
998
+ const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
999
+ (value) => ({ ok: true as const, value }),
1000
+ (err: unknown) => ({ ok: false as const, err }),
1001
+ );
1002
+ const loadP = Promise.all([
1003
+ loadRouteData(
1004
+ url,
1005
+ locals,
1006
+ req,
1007
+ cookies,
1008
+ metadataP.then((m) => (m.ok ? (m.value?.data ?? null) : null)),
1009
+ match,
1010
+ ),
1011
+ route.pageModule(),
1012
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
1013
+ ]);
1014
+ // Settled by the early returns below without being read.
1015
+ loadP.catch(() => {});
1016
+
967
1017
  let metadata: Metadata | null = null;
968
- try {
969
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
970
- } catch (err) {
1018
+ const meta = await metadataP;
1019
+ if (meta.ok) {
1020
+ metadata = meta.value;
1021
+ } else {
1022
+ const err = meta.err;
971
1023
  if (isRedirect(err))
972
1024
  return new Response(null, { status: err.status, headers: { Location: err.location } });
973
1025
  if (isHttpError(err)) {
@@ -989,12 +1041,7 @@ export async function renderPageWithFormData(
989
1041
  // Continue with null metadata — don't break the page for a metadata failure
990
1042
  }
991
1043
 
992
- // Load components + data in parallel
993
- const [data, pageMod, layoutMods] = await Promise.all([
994
- loadRouteData(url, locals, req, cookies, metadata?.data ?? null, match),
995
- route.pageModule(),
996
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
997
- ]);
1044
+ const [data, pageMod, layoutMods] = await loadP;
998
1045
 
999
1046
  if (!data)
1000
1047
  return renderErrorPage(
@@ -1019,13 +1066,6 @@ export async function renderPageWithFormData(
1019
1066
  pluginRenderFragments("head", renderCtx),
1020
1067
  pluginRenderFragments("bodyEnd", renderCtx),
1021
1068
  ]);
1022
- // buildHtml has no headExtras slot; fold them in ahead of the SSR head, which
1023
- // is where buildMetadataChunk puts them on the streaming path.
1024
- const headWithExtras = (ssrHead: string) => {
1025
- const extras = headExtras.filter(Boolean);
1026
- return extras.length ? `${extras.join("\n ")}\n ${ssrHead}` : ssrHead;
1027
- };
1028
-
1029
1069
  // Form-action re-render always runs every loader (no client mask).
1030
1070
  const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
1031
1071
  const pageDataFull = data.pageData ?? {};
@@ -1038,7 +1078,7 @@ export async function renderPageWithFormData(
1038
1078
  }
1039
1079
  const html = buildHtml(
1040
1080
  "",
1041
- headWithExtras(""),
1081
+ "",
1042
1082
  pageDataFull,
1043
1083
  layoutDataFull,
1044
1084
  true,
@@ -1052,6 +1092,7 @@ export async function renderPageWithFormData(
1052
1092
  appHtmlSegments,
1053
1093
  metadata,
1054
1094
  route.pattern,
1095
+ headExtras,
1055
1096
  );
1056
1097
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
1057
1098
  }
@@ -1073,7 +1114,7 @@ export async function renderPageWithFormData(
1073
1114
 
1074
1115
  const html = buildHtml(
1075
1116
  body,
1076
- headWithExtras(head),
1117
+ head,
1077
1118
  pageDataFull,
1078
1119
  layoutDataFull,
1079
1120
  data.csr,
@@ -1087,6 +1128,7 @@ export async function renderPageWithFormData(
1087
1128
  appHtmlSegments,
1088
1129
  metadata,
1089
1130
  route.pattern,
1131
+ headExtras,
1090
1132
  );
1091
1133
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
1092
1134
  }
@@ -1115,9 +1157,13 @@ export async function renderErrorPage(
1115
1157
  // The route's own metadata() is deliberately NOT run here — the route may be
1116
1158
  // what failed, and a 404 has no route at all. Synthesize a status title only
1117
1159
  // when the error component didn't set one, so <svelte:head><title> in a custom
1118
- // +error.svelte still wins.
1160
+ // +error.svelte still wins. It is the late fallback (after the component's
1161
+ // head, so a real title hasTitle misses still comes first), except when
1162
+ // app.html has its own <title>: that would come first, so the status title
1163
+ // takes the metadata slot ahead of it instead.
1164
+ const statusTitle = `${status} — ${message}`;
1119
1165
  const errMeta = (head: string): Metadata | null =>
1120
- head.includes("<title>") ? null : { title: `${status} — ${message}` };
1166
+ appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
1121
1167
 
1122
1168
  // Inspector overlay and other plugin bodyEnd fragments must be injected
1123
1169
  // on error pages too — otherwise SSE never connects and runtime errors
@@ -1126,7 +1172,7 @@ export async function renderErrorPage(
1126
1172
  request: req,
1127
1173
  url,
1128
1174
  route: route ? { pattern: route.pattern } : { pattern: "" },
1129
- metadata: errMeta(""),
1175
+ metadata: { title: statusTitle },
1130
1176
  };
1131
1177
  const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
1132
1178
 
@@ -1183,6 +1229,9 @@ export async function renderErrorPage(
1183
1229
  bodyEndExtras,
1184
1230
  appHtmlSegments,
1185
1231
  errMeta(head),
1232
+ undefined,
1233
+ undefined,
1234
+ statusTitle,
1186
1235
  );
1187
1236
  return compress(html, "text/html; charset=utf-8", req, status);
1188
1237
  } catch (err) {
@@ -1221,6 +1270,9 @@ export async function renderErrorPage(
1221
1270
  bodyEndExtras,
1222
1271
  appHtmlSegments,
1223
1272
  errMeta(head),
1273
+ undefined,
1274
+ undefined,
1275
+ statusTitle,
1224
1276
  );
1225
1277
  return compress(html, "text/html; charset=utf-8", req, status);
1226
1278
  } catch (err) {
@@ -404,33 +404,39 @@ async function resolve(event: RequestEvent): Promise<Response> {
404
404
  )
405
405
  : undefined;
406
406
  const runLoad = async () => {
407
- const data = await loadRouteData(routeUrl, locals, request, cookies, null, pageMatch, mask);
408
-
409
- let metadata = null;
410
- if (pageMatch) {
411
- try {
412
- const meta = await loadMetadata(
407
+ // metadata() runs alongside the loaders; the page load() gets its
408
+ // `data`, as on SSR. A failure stays non-fatal here.
409
+ const metaP = pageMatch
410
+ ? loadMetadata(
413
411
  pageMatch.route,
414
412
  pageMatch.params,
415
413
  routeUrl,
416
414
  locals,
417
415
  cookies,
418
416
  request,
419
- );
420
- // Explicit whitelist, not a spread: `metadata.data` feeds load() on the
421
- // server and may hold secrets — it must not reach the client.
422
- if (meta)
423
- metadata = {
424
- title: meta.title,
425
- description: meta.description,
426
- meta: meta.meta,
427
- link: meta.link,
428
- lang: meta.lang,
429
- };
430
- } catch {
431
- /* non-fatal */
432
- }
433
- }
417
+ ).catch(() => null)
418
+ : Promise.resolve(null);
419
+ const data = await loadRouteData(
420
+ routeUrl,
421
+ locals,
422
+ request,
423
+ cookies,
424
+ metaP.then((m) => m?.data ?? null),
425
+ pageMatch,
426
+ mask,
427
+ );
428
+ const meta = await metaP;
429
+ // Explicit whitelist, not a spread: `metadata.data` feeds load() on the
430
+ // server and may hold secrets — it must not reach the client.
431
+ const metadata = meta
432
+ ? {
433
+ title: meta.title,
434
+ description: meta.description,
435
+ meta: meta.meta,
436
+ link: meta.link,
437
+ lang: meta.lang,
438
+ }
439
+ : null;
434
440
 
435
441
  return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
436
442
  };
@@ -606,15 +612,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
606
612
  let releaseApiMiss: (() => void) | null = null;
607
613
  try {
608
614
  const mod = await apiMatch.route.module();
609
- const handler = mod[method];
615
+ // HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
616
+ // strips the body on the way out, for every route kind.
617
+ const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
610
618
 
611
619
  if (!handler) {
612
- const allowed = Object.keys(mod)
613
- .filter((k) => /^[A-Z]+$/.test(k))
614
- .join(", ");
620
+ const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
621
+ if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
615
622
  return Response.json(
616
623
  { error: `Method ${method} not allowed` },
617
- { status: 405, headers: { Allow: allowed } },
624
+ { status: 405, headers: { Allow: allowed.join(", ") } },
618
625
  );
619
626
  }
620
627
 
@@ -623,15 +630,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
623
630
  // ── Response cache for +server.ts GET handlers ──
624
631
  // CSP is skipped because cached responses would ship with a stale
625
632
  // nonce (see renderer.ts for the same gate). The cache key includes
626
- // URL + identity so per-user responses stay isolated.
627
- const apiCacheable =
628
- CACHE_ENABLED && !CSP_ENABLED && (mod as any).cache !== false && method === "GET";
633
+ // URL + identity so per-user responses stay isolated. HEAD reads the GET
634
+ // entry but never coalesces or writes: it isn't a leader that fills it.
635
+ // An explicit HEAD export skips the cache so it always runs.
636
+ const apiCacheRead =
637
+ CACHE_ENABLED &&
638
+ !CSP_ENABLED &&
639
+ (mod as any).cache !== false &&
640
+ (method === "GET" || (method === "HEAD" && !mod.HEAD));
641
+ const apiCacheable = apiCacheRead && method === "GET";
629
642
  let apiCacheKey: string | null = null;
630
- if (apiCacheable) {
643
+ if (apiCacheRead) {
631
644
  apiCacheKey = computeCacheKey(url, request, cookies as CookieJar);
632
645
  if (!url.searchParams.has("_invalidated")) {
633
646
  const hit = cacheGet(apiCacheKey);
634
647
  if (hit) return serveCached(hit, request);
648
+ }
649
+ if (apiCacheable && !url.searchParams.has("_invalidated")) {
635
650
  // Miss coalescing: first miss runs the handler; concurrent misses
636
651
  // wait, re-check the cache, and on a still-miss run independently.
637
652
  const gate = coalesceMiss(apiCacheKey);
@@ -1032,6 +1047,69 @@ if (_xfoDisabled) {
1032
1047
  console.log("🪟 X-Frame-Options disabled (DISABLE_X_FRAME_OPTIONS=true)");
1033
1048
  }
1034
1049
 
1050
+ // Security headers, CSP, CORS and the request's Set-Cookie headers, applied to
1051
+ // every response handleRequest hands back — resolved ones and the ones a hook
1052
+ // threw (redirect / error()).
1053
+ function finalizeResponse(
1054
+ response: Response,
1055
+ request: Request,
1056
+ nonce: string,
1057
+ cookieJar: CookieJar,
1058
+ ): Response {
1059
+ // Set on the response itself when its headers are mutable, which every
1060
+ // Response the framework builds has. Copying them and rebuilding the
1061
+ // Response cost a Headers copy plus a body re-wrap on every request.
1062
+ // Responses with immutable headers (`fetch()`, `Response.redirect()`)
1063
+ // still get the copy.
1064
+ // On Workers (compressionOn is false only there) an already-encoded body
1065
+ // is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
1066
+ let mutable: Headers | null = null;
1067
+ if (compressionOn || !response.headers.has("content-encoding")) {
1068
+ try {
1069
+ response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
1070
+ mutable = response.headers;
1071
+ } catch {
1072
+ // immutable — copied below
1073
+ }
1074
+ }
1075
+ const inPlace = mutable !== null;
1076
+ const headers = mutable ?? new Headers(response.headers);
1077
+ // A handle can mark a response (e.g. a proxied embeddable preview) to opt
1078
+ // out of the frame guard. Strip the internal marker so it never ships, and
1079
+ // skip only X-Frame-Options for that response — other security headers stay.
1080
+ const skipFrameGuard = headers.has(NO_FRAME_GUARD_HEADER);
1081
+ headers.delete(NO_FRAME_GUARD_HEADER);
1082
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
1083
+ if (skipFrameGuard && k === "X-Frame-Options") continue;
1084
+ headers.set(k, v);
1085
+ }
1086
+ const cspHeader = buildCspHeader(nonce);
1087
+ if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
1088
+ // Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
1089
+ // CORS is configured — even on responses to non-allowed origins — so
1090
+ // downstream caches (CDNs, browser HTTP cache) key on the Origin header
1091
+ // instead of serving an Access-Control-Allow-Origin response across origins.
1092
+ if (CORS_CONFIG) {
1093
+ applyCorsVary(headers);
1094
+ const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
1095
+ if (corsHeaders) {
1096
+ for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
1097
+ }
1098
+ }
1099
+ // Apply any Set-Cookie headers accumulated during the request
1100
+ for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
1101
+ if (inPlace) return response;
1102
+ return new Response(response.body, {
1103
+ // A Content-Encoding here means the body is already compressed (cache
1104
+ // hit, compress()); rebuilding drops that flag and Workers would
1105
+ // compress it again.
1106
+ ...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
1107
+ status: response.status,
1108
+ statusText: response.statusText,
1109
+ headers,
1110
+ });
1111
+ }
1112
+
1035
1113
  async function handleRequest(request: Request, url: URL): Promise<Response> {
1036
1114
  // Behind a trusted proxy the inbound `Host`/scheme is the proxy's inner hop
1037
1115
  // (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
@@ -1179,58 +1257,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1179
1257
  }
1180
1258
  }
1181
1259
 
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);
1200
- // A handle can mark a response (e.g. a proxied embeddable preview) to opt
1201
- // out of the frame guard. Strip the internal marker so it never ships, and
1202
- // skip only X-Frame-Options for that response — other security headers stay.
1203
- const skipFrameGuard = headers.has(NO_FRAME_GUARD_HEADER);
1204
- headers.delete(NO_FRAME_GUARD_HEADER);
1205
- for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
1206
- if (skipFrameGuard && k === "X-Frame-Options") continue;
1207
- headers.set(k, v);
1208
- }
1209
- const cspHeader = buildCspHeader(nonce);
1210
- if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
1211
- // Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
1212
- // CORS is configured — even on responses to non-allowed origins — so
1213
- // downstream caches (CDNs, browser HTTP cache) key on the Origin header
1214
- // instead of serving an Access-Control-Allow-Origin response across origins.
1215
- if (CORS_CONFIG) {
1216
- applyCorsVary(headers);
1217
- const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
1218
- if (corsHeaders) {
1219
- for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
1220
- }
1221
- }
1222
- // Apply any Set-Cookie headers accumulated during the request
1223
- for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
1224
- if (inPlace) return response;
1225
- return new Response(response.body, {
1226
- // A Content-Encoding here means the body is already compressed (cache
1227
- // hit, compress()); rebuilding drops that flag and Workers would
1228
- // compress it again.
1229
- ...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
1230
- status: response.status,
1231
- statusText: response.statusText,
1232
- headers,
1233
- });
1260
+ return finalizeResponse(response, request, nonce, cookieJar);
1234
1261
  } catch (err) {
1235
1262
  // `throw redirect()` / `throw error()` from a hook lands here — the same
1236
1263
  // escape hatch loaders have always had. Without these branches both fall
@@ -1264,13 +1291,10 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1264
1291
  undefined,
1265
1292
  nonce,
1266
1293
  );
1267
- // A hook that expires the session before throwing must not lose the
1268
- // Set-Cookie that does it — the redirect above is a fresh Response,
1269
- // so the jar is re-applied by hand here.
1270
- if (cookieJar) {
1271
- for (const cookie of cookieJar.outgoing) out.headers.append("Set-Cookie", cookie);
1272
- }
1273
- return out;
1294
+ // The same finishing pass as a resolved response: security headers,
1295
+ // CSP, CORS, and the jar's Set-Cookie — a hook that expires the session
1296
+ // before throwing must not lose the cookie that does it.
1297
+ return cookieJar ? finalizeResponse(out, request, nonce, cookieJar) : out;
1274
1298
  }
1275
1299
  if (isDev) console.error("Unhandled request error:", err);
1276
1300
  else console.error("Unhandled request error:", (err as Error).message ?? err);
package/src/lib/utils.ts CHANGED
@@ -3,14 +3,7 @@ import { twMerge } from "tailwind-merge";
3
3
  type ClassDictionary = Record<string, any>;
4
4
  type ClassArray = ClassValue[];
5
5
  type ClassValue =
6
- | ClassArray
7
- | ClassDictionary
8
- | string
9
- | number
10
- | bigint
11
- | null
12
- | boolean
13
- | undefined;
6
+ ClassArray | ClassDictionary | string | number | bigint | null | boolean | undefined;
14
7
 
15
8
  function clsx(...inputs: ClassValue[]): string {
16
9
  let str = "";
@@ -20,8 +20,8 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
23
- "prettier": "^3.3.0",
24
- "prettier-plugin-svelte": "^3.2.0",
23
+ "prettier": "3.9.9",
24
+ "prettier-plugin-svelte": "3.5.2",
25
25
  "svelte-check": "^4.4.8",
26
26
  "typescript": "^5"
27
27
  }
@@ -20,8 +20,8 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
23
- "prettier": "^3.3.0",
24
- "prettier-plugin-svelte": "^3.2.0",
23
+ "prettier": "3.9.9",
24
+ "prettier-plugin-svelte": "3.5.2",
25
25
  "typescript": "^5"
26
26
  }
27
27
  }
@@ -21,8 +21,8 @@
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/bun": "latest",
24
- "prettier": "^3.3.0",
25
- "prettier-plugin-svelte": "^3.2.0",
24
+ "prettier": "3.9.9",
25
+ "prettier-plugin-svelte": "3.5.2",
26
26
  "typescript": "^5",
27
27
  "drizzle-kit": "^0.31.0"
28
28
  }
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: uploaded files (the file-upload feature). -->
2
+ <svelte:head>
3
+ <title>Files</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Files</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: list orders and open their details (OrderService in src/features/shop). -->
2
+ <svelte:head>
3
+ <title>Orders</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Orders</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: orders waiting to be paid or shipped. -->
2
+ <svelte:head>
3
+ <title>Pending orders</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Pending orders</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: refund requests and refunded orders. -->
2
+ <svelte:head>
3
+ <title>Refunds</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Refunds</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: list, create and edit products (ProductService in src/features/shop). -->
2
+ <svelte:head>
3
+ <title>Products</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Products</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: the signed-in user's name, email and password. -->
2
+ <svelte:head>
3
+ <title>Profile</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Profile</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: store settings. -->
2
+ <svelte:head>
3
+ <title>Settings</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Settings</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: users and their roles (the auth and rbac features). -->
2
+ <svelte:head>
3
+ <title>Users</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Users</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -21,8 +21,8 @@
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/bun": "latest",
24
- "prettier": "^3.3.0",
25
- "prettier-plugin-svelte": "^3.2.0",
24
+ "prettier": "3.9.9",
25
+ "prettier-plugin-svelte": "3.5.2",
26
26
  "typescript": "^5",
27
27
  "drizzle-kit": "^0.31.0"
28
28
  }
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: uploaded files (the file-upload feature). -->
2
+ <svelte:head>
3
+ <title>Files</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Files</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: list orders and open their details (OrderService in src/features/shop). -->
2
+ <svelte:head>
3
+ <title>Orders</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Orders</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: orders waiting to be paid or shipped. -->
2
+ <svelte:head>
3
+ <title>Pending orders</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Pending orders</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: refund requests and refunded orders. -->
2
+ <svelte:head>
3
+ <title>Refunds</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Refunds</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: list, create and edit products (ProductService in src/features/shop). -->
2
+ <svelte:head>
3
+ <title>Products</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Products</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: the signed-in user's name, email and password. -->
2
+ <svelte:head>
3
+ <title>Profile</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Profile</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: store settings. -->
2
+ <svelte:head>
3
+ <title>Settings</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Settings</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>
@@ -0,0 +1,9 @@
1
+ <!-- EDIT THIS FILE: users and their roles (the auth and rbac features). -->
2
+ <svelte:head>
3
+ <title>Users</title>
4
+ </svelte:head>
5
+
6
+ <div class="flex flex-col gap-4">
7
+ <h1 class="text-2xl font-bold tracking-tight">Users</h1>
8
+ <p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
9
+ </div>