bosia 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,17 @@ import type { Cookies, LoaderDeps } from "./hooks.ts";
7
7
  import { CSP_ENABLED } from "./csp.ts";
8
8
  import {
9
9
  CACHE_ENABLED,
10
+ CACHE_MAX_BODY_BYTES,
10
11
  buildCompressedVariants,
11
12
  cacheGet,
12
13
  cacheSet,
14
+ coalesceMiss,
13
15
  collectTags,
14
16
  computeCacheKey,
15
17
  concatChunks,
16
18
  serveCached,
17
19
  } from "./cache.ts";
20
+ import type { CookieJar } from "./cookies.ts";
18
21
  import { HttpError, Redirect } from "./errors.ts";
19
22
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
20
23
  import App from "./client/App.svelte";
@@ -532,27 +535,96 @@ export async function renderSSRStream(
532
535
  const cacheable =
533
536
  CACHE_ENABLED && !CSP_ENABLED && pageMod.cache !== false && req.method === "GET";
534
537
  let cacheKey: string | null = null;
538
+ let releaseMiss: (() => void) | null = null;
535
539
  if (cacheable) {
536
- cacheKey = computeCacheKey(url, req, cookies);
540
+ cacheKey = computeCacheKey(url, req, cookies as CookieJar);
537
541
  if (!cacheBypass) {
538
542
  const hit = cacheGet(cacheKey);
539
543
  if (hit) return serveCached(hit, req);
544
+ // Miss coalescing: the first miss leads the build; concurrent misses
545
+ // wait, then re-check the cache. A waiter that still misses (leader
546
+ // skipped the write) builds independently — no re-leadering.
547
+ const gate = coalesceMiss(cacheKey);
548
+ if (gate.wait) {
549
+ await gate.wait;
550
+ const rehit = cacheGet(cacheKey);
551
+ if (rehit) return serveCached(rehit, req);
552
+ } else {
553
+ releaseMiss = gate.release;
554
+ }
540
555
  }
541
556
  }
542
557
 
543
- // ── Pre-stream phase: resolve metadata before committing to a 200 ──
544
- // Errors here return a proper error response with correct status code.
545
- let metadata: Metadata | null = null;
558
+ // INVARIANT: releaseMiss must fire exactly once a missed release() hangs
559
+ // coalesced waiters for the process lifetime. Every early return and throw
560
+ // below must stay inside this try; the cache-write path hands the release
561
+ // off to its cacheSet microtask by nulling releaseMiss first.
546
562
  try {
547
- metadata = await loadMetadata(route, params, url, locals, cookies, req);
548
- } catch (err) {
549
- if (err instanceof Redirect) {
550
- return Response.redirect(err.location, err.status);
563
+ // ── Pre-stream phase: resolve metadata before committing to a 200 ──
564
+ // Errors here return a proper error response with correct status code.
565
+ let metadata: Metadata | null = null;
566
+ try {
567
+ metadata = await loadMetadata(route, params, url, locals, cookies, req);
568
+ } catch (err) {
569
+ if (err instanceof Redirect) {
570
+ return Response.redirect(err.location, err.status);
571
+ }
572
+ if (err instanceof HttpError) {
573
+ return renderErrorPage(
574
+ err.status,
575
+ err.message,
576
+ url,
577
+ req,
578
+ route,
579
+ undefined,
580
+ undefined,
581
+ undefined,
582
+ nonce,
583
+ );
584
+ }
585
+ if (isDev) console.error("Metadata load error:", err);
586
+ else console.error("Metadata load error:", (err as Error).message ?? err);
587
+ if (isDev) reportDevErrorFromCatch(err);
588
+ // Continue with null metadata — don't break the page for a metadata failure
551
589
  }
552
- if (err instanceof HttpError) {
590
+
591
+ // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
592
+ // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
593
+ const metadataData = metadata?.data ?? null;
594
+ let data: Awaited<ReturnType<typeof loadRouteData>>;
595
+ let layoutMods: any[];
596
+
597
+ try {
598
+ [data, layoutMods] = await Promise.all([
599
+ loadRouteData(url, locals, req, cookies, metadataData, match),
600
+ Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
601
+ ]);
602
+ } catch (err) {
603
+ if (err instanceof Redirect) return Response.redirect(err.location, err.status);
604
+ if (err instanceof HttpError) {
605
+ const e = err as HttpError & {
606
+ errorDepth?: number;
607
+ errorOrigin?: ErrorOrigin;
608
+ partialLayoutData?: Record<string, any>[];
609
+ };
610
+ return renderErrorPage(
611
+ err.status,
612
+ err.message,
613
+ url,
614
+ req,
615
+ route,
616
+ e.errorDepth,
617
+ e.errorOrigin,
618
+ e.partialLayoutData,
619
+ nonce,
620
+ );
621
+ }
622
+ if (isDev) console.error("SSR load error:", err);
623
+ else console.error("SSR load error:", (err as Error).message ?? err);
624
+ if (isDev) reportDevErrorFromCatch(err);
553
625
  return renderErrorPage(
554
- err.status,
555
- err.message,
626
+ 500,
627
+ "Internal Server Error",
556
628
  url,
557
629
  req,
558
630
  route,
@@ -562,222 +634,189 @@ export async function renderSSRStream(
562
634
  nonce,
563
635
  );
564
636
  }
565
- if (isDev) console.error("Metadata load error:", err);
566
- else console.error("Metadata load error:", (err as Error).message ?? err);
567
- if (isDev) reportDevErrorFromCatch(err);
568
- // Continue with null metadata — don't break the page for a metadata failure
569
- }
570
637
 
571
- // ── Pre-stream phase: run load() + module imports in parallel before committing to a 200 ──
572
- // This ensures HttpError/Redirect from load() can return a proper response before any bytes are sent.
573
- const metadataData = metadata?.data ?? null;
574
- let data: Awaited<ReturnType<typeof loadRouteData>>;
575
- let layoutMods: any[];
576
-
577
- try {
578
- [data, layoutMods] = await Promise.all([
579
- loadRouteData(url, locals, req, cookies, metadataData, match),
580
- Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
581
- ]);
582
- } catch (err) {
583
- if (err instanceof Redirect) return Response.redirect(err.location, err.status);
584
- if (err instanceof HttpError) {
585
- const e = err as HttpError & {
586
- errorDepth?: number;
587
- errorOrigin?: ErrorOrigin;
588
- partialLayoutData?: Record<string, any>[];
589
- };
638
+ if (!data)
590
639
  return renderErrorPage(
591
- err.status,
592
- err.message,
640
+ 404,
641
+ "Not Found",
593
642
  url,
594
643
  req,
595
- route,
596
- e.errorDepth,
597
- e.errorOrigin,
598
- e.partialLayoutData,
644
+ undefined,
645
+ undefined,
646
+ undefined,
647
+ undefined,
599
648
  nonce,
600
649
  );
601
- }
602
- if (isDev) console.error("SSR load error:", err);
603
- else console.error("SSR load error:", (err as Error).message ?? err);
604
- if (isDev) reportDevErrorFromCatch(err);
605
- return renderErrorPage(
606
- 500,
607
- "Internal Server Error",
608
- url,
609
- req,
610
- route,
611
- undefined,
612
- undefined,
613
- undefined,
614
- nonce,
615
- );
616
- }
617
650
 
618
- if (!data)
619
- return renderErrorPage(
620
- 404,
621
- "Not Found",
651
+ const enc = new TextEncoder();
652
+ const renderCtx: RenderContext = {
653
+ request: req,
622
654
  url,
623
- req,
624
- undefined,
625
- undefined,
626
- undefined,
627
- undefined,
628
- nonce,
629
- );
630
-
631
- const enc = new TextEncoder();
632
- const renderCtx: RenderContext = {
633
- request: req,
634
- url,
635
- route: { pattern: route.pattern },
636
- metadata,
637
- };
638
- const [headExtras, bodyEndExtras] = await Promise.all([
639
- pluginRenderFragments("head", renderCtx),
640
- pluginRenderFragments("bodyEnd", renderCtx),
641
- ]);
655
+ route: { pattern: route.pattern },
656
+ metadata,
657
+ };
658
+ const [headExtras, bodyEndExtras] = await Promise.all([
659
+ pluginRenderFragments("head", renderCtx),
660
+ pluginRenderFragments("bodyEnd", renderCtx),
661
+ ]);
642
662
 
643
- // SSR always runs every loader, so coerce types from the optional sparse shape.
644
- const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
645
- const pageDataFull = data.pageData ?? {};
663
+ // SSR always runs every loader, so coerce types from the optional sparse shape.
664
+ const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
665
+ const pageDataFull = data.pageData ?? {};
646
666
 
647
- // ssr=false → no render() needed; ship shell + hydration as a single response.
648
- // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
649
- if (!data.ssr) {
650
- if (!data.csr && isDev) {
651
- console.warn(
652
- `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
653
- );
667
+ // ssr=false → no render() needed; ship shell + hydration as a single response.
668
+ // ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
669
+ if (!data.ssr) {
670
+ if (!data.csr && isDev) {
671
+ console.warn(
672
+ `⚠️ ${url.pathname}: ssr=false && csr=false renders nothing — forcing csr=true`,
673
+ );
674
+ }
675
+ const html =
676
+ buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
677
+ buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
678
+ buildHtmlTail(
679
+ "",
680
+ "",
681
+ pageDataFull,
682
+ layoutDataFull,
683
+ true,
684
+ null,
685
+ false,
686
+ bodyEndExtras,
687
+ nonce,
688
+ data.pageDeps,
689
+ data.layoutDeps,
690
+ appHtmlSegments,
691
+ );
692
+ return new Response(html, {
693
+ headers: { "Content-Type": "text/html; charset=utf-8" },
694
+ });
654
695
  }
655
- const html =
656
- buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments) +
657
- buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
658
- buildHtmlTail(
659
- "",
660
- "",
661
- pageDataFull,
696
+
697
+ // Render-first: run render() before committing to a 200. Failure → proper error page
698
+ // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
699
+ let body: string, head: string;
700
+ try {
701
+ ({ body, head } = render(App, {
702
+ props: {
703
+ ssrMode: true,
704
+ ssrPageComponent: pageMod.default,
705
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
706
+ ssrPageData: pageDataFull,
707
+ ssrLayoutData: layoutDataFull,
708
+ },
709
+ }));
710
+ } catch (err) {
711
+ if (isDev) console.error("SSR render error:", err);
712
+ else console.error("SSR render error:", (err as Error).message ?? err);
713
+ if (isDev) reportDevErrorFromCatch(err);
714
+ // Render-phase errors fall through to deepest boundary like a page error.
715
+ return renderErrorPage(
716
+ 500,
717
+ "Internal Server Error",
718
+ url,
719
+ req,
720
+ route,
721
+ route.layoutModules.length,
722
+ "page",
662
723
  layoutDataFull,
663
- true,
664
- null,
665
- false,
666
- bodyEndExtras,
667
724
  nonce,
668
- data.pageDeps,
669
- data.layoutDeps,
670
- appHtmlSegments,
671
725
  );
672
- return new Response(html, {
673
- headers: { "Content-Type": "text/html; charset=utf-8" },
674
- });
675
- }
676
-
677
- // Render-first: run render() before committing to a 200. Failure → proper error page
678
- // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
679
- let body: string, head: string;
680
- try {
681
- ({ body, head } = render(App, {
682
- props: {
683
- ssrMode: true,
684
- ssrPageComponent: pageMod.default,
685
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
686
- ssrPageData: pageDataFull,
687
- ssrLayoutData: layoutDataFull,
688
- },
689
- }));
690
- } catch (err) {
691
- if (isDev) console.error("SSR render error:", err);
692
- else console.error("SSR render error:", (err as Error).message ?? err);
693
- if (isDev) reportDevErrorFromCatch(err);
694
- // Render-phase errors fall through to deepest boundary like a page error.
695
- return renderErrorPage(
696
- 500,
697
- "Internal Server Error",
698
- url,
699
- req,
700
- route,
701
- route.layoutModules.length,
702
- "page",
703
- layoutDataFull,
704
- nonce,
705
- );
706
- }
726
+ }
707
727
 
708
- // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
709
- const chunks: Uint8Array[] = [
710
- enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
711
- enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
712
- enc.encode(
713
- buildHtmlTail(
714
- body,
715
- head,
716
- pageDataFull,
717
- layoutDataFull,
718
- data.csr,
719
- null,
720
- true,
721
- bodyEndExtras,
722
- nonce,
723
- data.pageDeps,
724
- data.layoutDeps,
725
- appHtmlSegments,
728
+ // Pre-compute all chunks; pull-based stream gives Bun native backpressure.
729
+ const chunks: Uint8Array[] = [
730
+ enc.encode(buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments)),
731
+ enc.encode(buildMetadataChunk(metadata, headExtras, appHtmlSegments)),
732
+ enc.encode(
733
+ buildHtmlTail(
734
+ body,
735
+ head,
736
+ pageDataFull,
737
+ layoutDataFull,
738
+ data.csr,
739
+ null,
740
+ true,
741
+ bodyEndExtras,
742
+ nonce,
743
+ data.pageDeps,
744
+ data.layoutDeps,
745
+ appHtmlSegments,
746
+ ),
726
747
  ),
727
- ),
728
- ];
729
-
730
- // ── Response cache: write after chunks built, before stream creation ──
731
- // Skip if the handler set cookies cached response can't reproduce
732
- // per-request Set-Cookie headers. Compression runs in a microtask so
733
- // the response goes out first.
734
- if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
735
- const fullBody = concatChunks(chunks);
736
- const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
737
- const keyForWrite = cacheKey;
738
- queueMicrotask(() => {
739
- const { gzip, brotli } = buildCompressedVariants(fullBody);
740
- cacheSet(keyForWrite, {
741
- raw: fullBody,
742
- gzip,
743
- brotli,
744
- contentType: "text/html; charset=utf-8",
745
- status: 200,
746
- extraHeaders: {},
747
- tags,
748
- });
749
- });
750
- }
751
-
752
- let i = 0;
753
- let cancelled = false;
754
- const onAbort = () => {
755
- cancelled = true;
756
- };
757
- req.signal.addEventListener("abort", onAbort, { once: true });
758
-
759
- const stream = new ReadableStream<Uint8Array>({
760
- pull(controller) {
761
- if (cancelled || i >= chunks.length) {
762
- controller.close();
763
- req.signal.removeEventListener("abort", onAbort);
764
- return;
765
- }
766
- controller.enqueue(chunks[i++]);
767
- if (i >= chunks.length) {
768
- controller.close();
769
- req.signal.removeEventListener("abort", onAbort);
748
+ ];
749
+
750
+ // ── Response cache: write after chunks built, before stream creation ──
751
+ // Skip if the handler set cookies cached response can't reproduce
752
+ // per-request Set-Cookie headers. Compression runs in a microtask so
753
+ // the response goes out first.
754
+ if (cacheable && cacheKey && (cookies as any).outgoing?.length === 0) {
755
+ const fullBody = concatChunks(chunks);
756
+ // Oversized bodies skip early so they never pay compression; cacheSet
757
+ // re-checks as the authoritative guard.
758
+ if (CACHE_MAX_BODY_BYTES === 0 || fullBody.length <= CACHE_MAX_BODY_BYTES) {
759
+ const tags = collectTags(data.layoutDeps ?? null, data.pageDeps ?? null);
760
+ const keyForWrite = cacheKey;
761
+ // Hand the gate release to the write microtask: waiters resume only
762
+ // after cacheSet ran, so their re-check hits.
763
+ const rel = releaseMiss;
764
+ releaseMiss = null;
765
+ queueMicrotask(() => {
766
+ try {
767
+ const { gzip, brotli } = buildCompressedVariants(fullBody);
768
+ cacheSet(
769
+ keyForWrite,
770
+ {
771
+ raw: fullBody,
772
+ gzip,
773
+ brotli,
774
+ contentType: "text/html; charset=utf-8",
775
+ status: 200,
776
+ extraHeaders: {},
777
+ tags,
778
+ },
779
+ cookies,
780
+ );
781
+ } finally {
782
+ rel?.();
783
+ }
784
+ });
770
785
  }
771
- },
772
- cancel() {
786
+ }
787
+
788
+ let i = 0;
789
+ let cancelled = false;
790
+ const onAbort = () => {
773
791
  cancelled = true;
774
- req.signal.removeEventListener("abort", onAbort);
775
- },
776
- });
792
+ };
793
+ req.signal.addEventListener("abort", onAbort, { once: true });
794
+
795
+ const stream = new ReadableStream<Uint8Array>({
796
+ pull(controller) {
797
+ if (cancelled || i >= chunks.length) {
798
+ controller.close();
799
+ req.signal.removeEventListener("abort", onAbort);
800
+ return;
801
+ }
802
+ controller.enqueue(chunks[i++]);
803
+ if (i >= chunks.length) {
804
+ controller.close();
805
+ req.signal.removeEventListener("abort", onAbort);
806
+ }
807
+ },
808
+ cancel() {
809
+ cancelled = true;
810
+ req.signal.removeEventListener("abort", onAbort);
811
+ },
812
+ });
777
813
 
778
- return new Response(stream, {
779
- headers: { "Content-Type": "text/html; charset=utf-8" },
780
- });
814
+ return new Response(stream, {
815
+ headers: { "Content-Type": "text/html; charset=utf-8" },
816
+ });
817
+ } finally {
818
+ releaseMiss?.();
819
+ }
781
820
  }
782
821
 
783
822
  // ─── Form Action Page Renderer ───────────────────────────
@@ -94,7 +94,6 @@ export function generateRoutesFile(manifest: RouteManifest): void {
94
94
  lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
95
95
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
96
96
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
97
- lines.push(' scope: "public" | "private";');
98
97
  lines.push("}> = [");
99
98
  for (const r of pages) {
100
99
  const layoutImports = r.layouts
@@ -122,7 +121,6 @@ export function generateRoutesFile(manifest: RouteManifest): void {
122
121
  lines.push(` layoutServers: [${layoutServerImports}],`);
123
122
  lines.push(` errorPages: [${errorPageImports}],`);
124
123
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
125
- lines.push(` scope: ${JSON.stringify(r.scope)},`);
126
124
  lines.push(" },");
127
125
  }
128
126
  lines.push("];\n");
@@ -46,7 +46,6 @@ export function scanRoutes(): RouteManifest {
46
46
  layoutServerChain: { path: string; depth: number }[],
47
47
  errorPageChain: { path: string; depth: number }[],
48
48
  inheritedTrailingSlash: TrailingSlash,
49
- inheritedScope: "public" | "private",
50
49
  ) {
51
50
  const fullDir = join(ROUTES_DIR, dir);
52
51
  if (!existsSync(fullDir)) return;
@@ -110,7 +109,6 @@ export function scanRoutes(): RouteManifest {
110
109
  layoutServers: [...currentLayoutServers],
111
110
  errorPages: [...currentErrorPages],
112
111
  trailingSlash: effectiveTs,
113
- scope: inheritedScope,
114
112
  });
115
113
  }
116
114
 
@@ -122,9 +120,6 @@ export function scanRoutes(): RouteManifest {
122
120
  const dirName = entry.name;
123
121
  // Route groups like (public), (auth) are invisible in URLs
124
122
  const isGroup = /^\(.*\)$/.test(dirName);
125
- // `(private)` anywhere in the chain marks descendants as per-user (dedup off)
126
- const childScope: "public" | "private" =
127
- inheritedScope === "private" || dirName === "(private)" ? "private" : "public";
128
123
 
129
124
  walk(
130
125
  dir ? join(dir, dirName) : dirName,
@@ -133,12 +128,11 @@ export function scanRoutes(): RouteManifest {
133
128
  currentLayoutServers,
134
129
  currentErrorPages,
135
130
  currentTrailingSlash,
136
- childScope,
137
131
  );
138
132
  }
139
133
  }
140
134
 
141
- walk("", [], [], [], [], "never", "public");
135
+ walk("", [], [], [], [], "never");
142
136
 
143
137
  // Warn when a catch-all exists but no exact route covers its prefix.
144
138
  // e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).