bosia 1.2.5 → 1.2.7

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 (44) hide show
  1. package/README.md +5 -6
  2. package/package.json +2 -1
  3. package/src/core/backend.ts +87 -3
  4. package/src/core/basePath.ts +30 -0
  5. package/src/core/cache.ts +378 -30
  6. package/src/core/client/App.svelte +19 -11
  7. package/src/core/client/hydrate.ts +17 -9
  8. package/src/core/client/navigation.ts +1 -1
  9. package/src/core/client/prefetch.ts +66 -41
  10. package/src/core/client/router.svelte.ts +41 -22
  11. package/src/core/cors.ts +17 -1
  12. package/src/core/csrf.ts +32 -19
  13. package/src/core/dedup.ts +51 -7
  14. package/src/core/dev.ts +11 -1
  15. package/src/core/devWsProxy.ts +144 -0
  16. package/src/core/env.ts +7 -0
  17. package/src/core/errors.ts +23 -6
  18. package/src/core/hooks.ts +1 -1
  19. package/src/core/html.ts +13 -1
  20. package/src/core/plugins/access-log.ts +128 -0
  21. package/src/core/prerender.ts +143 -6
  22. package/src/core/proxy.ts +48 -0
  23. package/src/core/renderer.ts +70 -29
  24. package/src/core/routeTypes.ts +16 -1
  25. package/src/core/server.bun.ts +6 -1
  26. package/src/core/server.ts +198 -77
  27. package/src/core/server.workers.ts +3 -2
  28. package/src/core/types/plugin.ts +2 -1
  29. package/src/core/websocket.ts +232 -0
  30. package/src/core/workersGuard.ts +18 -3
  31. package/src/lib/index.ts +1 -0
  32. package/templates/default/.env.example +20 -5
  33. package/templates/default/tsconfig.json +1 -1
  34. package/templates/demo/.env.example +20 -5
  35. package/templates/demo/src/hooks.server.ts +3 -12
  36. package/templates/demo/tsconfig.json +1 -1
  37. package/templates/shop/src/app.d.ts +0 -1
  38. package/templates/shop/src/hooks.server.ts +3 -11
  39. package/templates/shop/src/routes/+layout.server.ts +0 -1
  40. package/templates/shop/tsconfig.json +1 -1
  41. package/templates/store/src/app.d.ts +0 -1
  42. package/templates/store/src/hooks.server.ts +3 -11
  43. package/templates/store/src/routes/+layout.server.ts +0 -1
  44. package/templates/store/tsconfig.json +1 -1
@@ -55,6 +55,12 @@ export function generateRouteTypes(manifest: RouteManifest): void {
55
55
  }
56
56
  }
57
57
 
58
+ // +server.ts dirs get Params and WebSocketHandler too.
59
+ for (const api of manifest.apis) {
60
+ const apiDir = routeDirOf(api.server);
61
+ if (!dirs.has(apiDir)) dirs.set(apiDir, {});
62
+ }
63
+
58
64
  if (manifest.errorPage) {
59
65
  if (!dirs.has(".")) dirs.set(".", {});
60
66
  dirs.get(".")!.hasErrorPage = true;
@@ -78,7 +84,9 @@ export function generateRouteTypes(manifest: RouteManifest): void {
78
84
  : `{ ${params.map((p) => `${PARAM_NAME.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
79
85
 
80
86
  const lines: string[] = ["// AUTO-GENERATED by bosia — do not edit\n"];
81
- lines.push(`import type { LoadEvent, MetadataEvent, RequestEvent, Metadata } from 'bosia';`);
87
+ lines.push(
88
+ `import type { LoadEvent, MetadataEvent, RequestEvent, Metadata, WebSocketHandler as _WebSocketHandler } from 'bosia';`,
89
+ );
82
90
  lines.push(``);
83
91
  lines.push(`export type Params = ${paramsType};`);
84
92
  lines.push(``);
@@ -97,6 +105,13 @@ export function generateRouteTypes(manifest: RouteManifest): void {
97
105
  lines.push(`type _LoadEvent = Omit<LoadEvent, 'params'> & { params: Params };`);
98
106
  lines.push(`type _MetadataEvent = Omit<MetadataEvent, 'params'> & { params: Params };`);
99
107
  lines.push(`type _RequestEvent = Omit<RequestEvent, 'params'> & { params: Params };`);
108
+ lines.push(``);
109
+ lines.push(
110
+ `/** \`export const websocket: WebSocketHandler<Data>\` in +server.ts (Bun only). */`,
111
+ );
112
+ lines.push(
113
+ `export type WebSocketHandler<T extends object = {}> = _WebSocketHandler<T, _RequestEvent>;`,
114
+ );
100
115
 
101
116
  if (info.pageServer) {
102
117
  lines.push(``);
@@ -107,8 +107,13 @@ async function shutdown() {
107
107
  console.log("✅ All requests drained");
108
108
  }
109
109
 
110
- app.stop(true).then(() => process.exit(0));
111
110
  setTimeout(() => process.exit(1), 2_000);
111
+ // inFlight reaches 0 just before Bun writes the last responses, so stop
112
+ // gracefully first, briefly: an open stream (SSE) never finishes by itself,
113
+ // and a socket that never acks its 1001 shouldn't hold the exit either.
114
+ if (left === 0) await Promise.race([app.stop(), Bun.sleep(500)]);
115
+ await app.stop(true);
116
+ process.exit(0);
112
117
  }
113
118
 
114
119
  process.on("SIGTERM", shutdown);
@@ -16,7 +16,8 @@ import { HttpError, Redirect, ActionFailure, isHttpError, isRedirect } from "./e
16
16
  import { CookieJar } from "./cookies.ts";
17
17
  import { safePath } from "./safePath.ts";
18
18
  import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
19
- import { checkCsrf } from "./csrf.ts";
19
+ import { checkCsrf, checkUpgradeOrigin } from "./csrf.ts";
20
+ import { applyForwarded } from "./proxy.ts";
20
21
  import type { CsrfConfig } from "./csrf.ts";
21
22
  import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
22
23
  import type { CorsConfig } from "./cors.ts";
@@ -46,26 +47,43 @@ import {
46
47
  import { dedup } from "./dedup.ts";
47
48
  import {
48
49
  CACHE_ENABLED,
49
- CACHE_MAX_BODY_BYTES,
50
+ CACHE_WAIT_TIMEOUT,
51
+ bodyFitsCache,
50
52
  buildCompressedVariants,
51
53
  cacheGet,
52
54
  cacheSet,
53
- coalesceMiss,
55
+ claimMiss,
54
56
  collectTags,
57
+ depsReadIgnoredParams,
58
+ stripIgnoredParams,
55
59
  computeCacheKey,
56
60
  deferCacheWrite,
61
+ isNoStoreCacheControl,
62
+ readBodyForCache,
57
63
  serveCached,
64
+ varyBlocksCache,
58
65
  warnUncoveredCookies,
59
66
  } from "./cache.ts";
60
67
  import { reportDevErrorFromCatch } from "./devErrorReport.ts";
61
68
  import {
62
69
  loadRouteData,
70
+ emptyDeps,
63
71
  loadMetadata,
72
+ metadataFailed,
64
73
  renderSSRStream,
65
74
  renderErrorPage,
66
75
  renderPageWithFormData,
67
76
  } from "./renderer.ts";
68
77
  import { getServerTime } from "../lib/utils.ts";
78
+ import {
79
+ closeAllSockets,
80
+ isUpgradeRequest,
81
+ isUpgraded,
82
+ closeUpgraded,
83
+ upgradeRequest,
84
+ type WebSocketHandler,
85
+ type WebSocketOptions,
86
+ } from "./websocket.ts";
69
87
 
70
88
  // ─── User Hooks ──────────────────────────────────────────
71
89
  // Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
@@ -78,13 +96,14 @@ let userHandle: Handle | null = null;
78
96
  // Headers that must not be baked into a cache entry. Content-Length is
79
97
  // recomputed by Bun, content-encoding/transfer-encoding depend on the chosen
80
98
  // variant, and security/CORS/Set-Cookie headers are applied by handleRequest.
99
+ // A handler's own Vary is kept: serveCached adds Accept-Encoding to it, so a
100
+ // hit varies on the same headers as the miss did.
81
101
  const NON_CACHEABLE_HEADERS = new Set([
82
102
  "content-length",
83
103
  "content-encoding",
84
104
  "transfer-encoding",
85
105
  "content-type",
86
106
  "set-cookie",
87
- "vary",
88
107
  "x-bosia-cache",
89
108
  ]);
90
109
 
@@ -366,6 +385,11 @@ async function serveStaticFile(path: string, request: Request): Promise<Response
366
385
  return null;
367
386
  }
368
387
 
388
+ // Not Response.redirect(): it rejects relative URLs on Workers.
389
+ function redirectResponse(r: Redirect): Response {
390
+ return new Response(null, { status: r.status, headers: { Location: r.location } });
391
+ }
392
+
369
393
  async function resolve(event: RequestEvent): Promise<Response> {
370
394
  const { request, url, locals, cookies } = event;
371
395
  const path = url.pathname;
@@ -406,6 +430,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
406
430
  const runLoad = async () => {
407
431
  // metadata() runs alongside the loaders; the page load() gets its
408
432
  // `data`, as on SSR. A failure stays non-fatal here.
433
+ const metaDeps = emptyDeps();
409
434
  const metaP = pageMatch
410
435
  ? loadMetadata(
411
436
  pageMatch.route,
@@ -414,6 +439,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
414
439
  locals,
415
440
  cookies,
416
441
  request,
442
+ metaDeps,
417
443
  ).catch(() => null)
418
444
  : Promise.resolve(null);
419
445
  const data = await loadRouteData(
@@ -438,7 +464,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
438
464
  }
439
465
  : null;
440
466
 
441
- return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
467
+ return {
468
+ data,
469
+ metadata,
470
+ metaDeps,
471
+ cookiesAccessed: (cookies as CookieJar).accessed,
472
+ };
442
473
  };
443
474
 
444
475
  // ── Response cache for client-navigation data ──
@@ -446,9 +477,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
446
477
  // (different masks return different slots), tag/path invalidation, and
447
478
  // no CSP. Only GETs use it (the client router only sends GETs); a
448
479
  // `_fresh` fetch after invalidate() skips the read only.
449
- const cacheKey =
450
- computeCacheKey(routeUrl, request, cookies as CookieJar) +
451
- `|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
480
+ // Set when this request replaced a stuck leader: its dedup() call,
481
+ // started right after that gate, is replaced rather than joined.
482
+ let replaceDedupBefore = -Infinity;
483
+ const keySuffix = `|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
484
+ const cacheKey = computeCacheKey(routeUrl, request, cookies as CookieJar) + keySuffix;
452
485
  const dataCacheable =
453
486
  CACHE_ENABLED &&
454
487
  !CSP_ENABLED &&
@@ -457,15 +490,27 @@ async function resolve(event: RequestEvent): Promise<Response> {
457
490
  pageMatch !== null &&
458
491
  (await pageRouteCacheable(pageMatch.route));
459
492
  if (dataCacheable && !fresh) {
493
+ // Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry
494
+ // first, then this URL's own, as before — see cache.ts.
495
+ const plainUrl = stripIgnoredParams(routeUrl);
496
+ if (plainUrl !== routeUrl) {
497
+ const plain = cacheGet(
498
+ computeCacheKey(plainUrl, request, cookies as CookieJar) + keySuffix,
499
+ );
500
+ if (plain && !plain.queryAware) return serveCached(plain, request);
501
+ }
460
502
  const hit = cacheGet(cacheKey);
461
503
  if (hit) return serveCached(hit, request);
462
- const gate = coalesceMiss(cacheKey);
463
- if (gate.wait) {
464
- await gate.wait;
465
- const rehit = cacheGet(cacheKey);
466
- if (rehit) return serveCached(rehit, request);
467
- } else {
468
- releaseDataMiss = gate.release;
504
+ const claim = await claimMiss(cacheKey, request.signal);
505
+ if (claim.hit) return serveCached(claim.hit, request);
506
+ if (claim.aborted) return new Response(null, { status: 499 });
507
+ if (claim.lead) {
508
+ releaseDataMiss = claim.lead.release;
509
+ // It replaced a stuck leader: start a new loader run, which
510
+ // later requests join, instead of joining the stuck one (a
511
+ // call that began within 1s of the stuck gate, not a later
512
+ // healthy one).
513
+ if (claim.lead.stale) replaceDedupBefore = claim.lead.staleStart + 1000;
469
514
  }
470
515
  }
471
516
 
@@ -477,7 +522,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
477
522
  const dedupK =
478
523
  computeCacheKey(routeUrl, request, cookies as CookieJar) +
479
524
  (invalidatedBits ? `|m=${invalidatedBits}` : "");
480
- const result = await dedup(dedupK, runLoad);
525
+ const result = await dedup(dedupK, runLoad, CACHE_WAIT_TIMEOUT, replaceDedupBefore);
481
526
  // Identity only covers CACHE_KEYS — warn if a loader read a session
482
527
  // cookie outside that list (dedup could then mix users' results).
483
528
  warnUncoveredCookies(cookies);
@@ -503,16 +548,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
503
548
  const body = jsonEncoder.encode(JSON.stringify({ ...payload, metadata: result.metadata }));
504
549
 
505
550
  // A loader that said not to store its response is taken at its word.
506
- const loaderCc = (loaderHeaders["cache-control"] ?? "").toLowerCase();
507
- const loaderOptOut = /no-store|no-cache|private/.test(loaderCc);
551
+ const loaderOptOut =
552
+ isNoStoreCacheControl(loaderHeaders["cache-control"]) ||
553
+ varyBlocksCache(loaderHeaders["vary"]) ||
554
+ // Failed or timed out: don't keep a copy without its title/meta.
555
+ metadataFailed(result.metaDeps);
508
556
  let sent: Encoded | null | undefined;
509
557
  if (
510
558
  dataCacheable &&
511
559
  !loaderOptOut &&
512
560
  (cookies as CookieJar).outgoing.length === 0 &&
513
- (CACHE_MAX_BODY_BYTES === 0 || body.length <= CACHE_MAX_BODY_BYTES)
561
+ bodyFitsCache(body.length)
514
562
  ) {
515
563
  const tags = collectTags(result.data.layoutDeps ?? null, result.data.pageDeps ?? null);
564
+ const queryAware = depsReadIgnoredParams(
565
+ [...(result.data.layoutDeps ?? []), result.metaDeps],
566
+ result.data.pageDeps ?? null,
567
+ );
516
568
  const rel = releaseDataMiss;
517
569
  releaseDataMiss = null;
518
570
  // Encoded once at cache quality and shared by response and entry.
@@ -538,6 +590,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
538
590
  status: 200,
539
591
  extraHeaders: extra,
540
592
  tags,
593
+ queryAware,
541
594
  },
542
595
  cookies,
543
596
  );
@@ -612,12 +665,42 @@ async function resolve(event: RequestEvent): Promise<Response> {
612
665
  let releaseApiMiss: (() => void) | null = null;
613
666
  try {
614
667
  const mod = await apiMatch.route.module();
668
+
669
+ // WebSocket upgrade — before the response cache, which must never see it.
670
+ const ws = (mod as { websocket?: WebSocketHandler<any, any> }).websocket;
671
+ if (ws && isUpgradeRequest(request)) {
672
+ event.params = apiMatch.params;
673
+ const result = ws.upgrade ? await ws.upgrade(event) : undefined;
674
+ if (result instanceof Response) return result;
675
+ if (isRedirect(result)) {
676
+ return redirectResponse(result);
677
+ }
678
+ if (result !== undefined && (result === null || typeof result !== "object")) {
679
+ throw new Error(
680
+ `websocket.upgrade() in ${apiMatch.route.id} must return an object, a Response or nothing`,
681
+ );
682
+ }
683
+ // Cookies a hook set before resolve() ride on the 101; later ones are lost.
684
+ const headers = new Headers();
685
+ for (const cookie of (cookies as CookieJar).outgoing) headers.append("Set-Cookie", cookie);
686
+ return upgradeRequest(ws, result ?? {}, headers);
687
+ }
688
+
615
689
  // HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
616
690
  // strips the body on the way out, for every route kind.
617
691
  const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
618
692
 
619
693
  if (!handler) {
694
+ // A websocket-only route asked over plain HTTP.
695
+ if (ws && (method === "GET" || method === "HEAD")) {
696
+ return new Response("Upgrade Required", {
697
+ status: 426,
698
+ headers: { Upgrade: "websocket", Connection: "Upgrade" },
699
+ });
700
+ }
620
701
  const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
702
+ // A websocket route takes GET (the upgrade's method); HEAD answers 426.
703
+ if (ws && !allowed.includes("GET")) allowed.push("GET");
621
704
  if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
622
705
  return Response.json(
623
706
  { error: `Method ${method} not allowed` },
@@ -649,14 +732,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
649
732
  if (apiCacheable && !url.searchParams.has("_invalidated")) {
650
733
  // Miss coalescing: first miss runs the handler; concurrent misses
651
734
  // wait, re-check the cache, and on a still-miss run independently.
652
- const gate = coalesceMiss(apiCacheKey);
653
- if (gate.wait) {
654
- await gate.wait;
655
- const rehit = cacheGet(apiCacheKey);
656
- if (rehit) return serveCached(rehit, request);
657
- } else {
658
- releaseApiMiss = gate.release;
659
- }
735
+ const claim = await claimMiss(apiCacheKey, request.signal);
736
+ if (claim.hit) return serveCached(claim.hit, request);
737
+ if (claim.aborted) return new Response(null, { status: 499 });
738
+ if (claim.lead) releaseApiMiss = claim.lead.release;
660
739
  }
661
740
  }
662
741
 
@@ -675,10 +754,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
675
754
 
676
755
  // Redirect returned (not thrown) — convert to a 303 Response.
677
756
  if (isRedirect(handlerResult)) {
678
- return new Response(null, {
679
- status: handlerResult.status,
680
- headers: { Location: handlerResult.location },
681
- });
757
+ return redirectResponse(handlerResult);
682
758
  }
683
759
 
684
760
  const response = handlerResult as Response;
@@ -690,11 +766,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
690
766
 
691
767
  // Respect handler opt-out via Cache-Control. Standard HTTP semantics:
692
768
  // no-store / private / no-cache all signal "don't reuse this response".
693
- const cacheControl = (response.headers.get("cache-control") ?? "").toLowerCase();
769
+ // A Vary the key can't honor, or a body the handler already encoded
770
+ // (the entry would replay it without its Content-Encoding), isn't reused.
694
771
  const noStore =
695
- cacheControl.includes("no-store") ||
696
- cacheControl.includes("private") ||
697
- cacheControl.includes("no-cache");
772
+ isNoStoreCacheControl(response.headers.get("cache-control")) ||
773
+ varyBlocksCache(response.headers.get("vary")) ||
774
+ response.headers.has("content-encoding");
698
775
 
699
776
  if (
700
777
  apiCacheable &&
@@ -702,7 +779,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
702
779
  response.status === 200 &&
703
780
  !isEventStream &&
704
781
  !noStore &&
705
- (cookies as CookieJar).outgoing.length === 0
782
+ (cookies as CookieJar).outgoing.length === 0 &&
783
+ // A declared size that can't fit: skip the copy and the read entirely.
784
+ bodyFitsCache(parseInt(response.headers.get("content-length") ?? "", 10) || 0)
706
785
  ) {
707
786
  const cloned = response.clone();
708
787
  const extraHeaders = captureCacheableHeaders(response.headers);
@@ -714,10 +793,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
714
793
  releaseApiMiss = null;
715
794
  deferCacheWrite(async () => {
716
795
  try {
717
- const buf = new Uint8Array(await cloned.arrayBuffer());
718
- // Oversized bodies skip early so they never pay compression;
719
- // cacheSet re-checks as the authoritative guard.
720
- if (CACHE_MAX_BODY_BYTES > 0 && buf.length > CACHE_MAX_BODY_BYTES) return;
796
+ // Stops reading once the body can't fit, so an oversized body
797
+ // never sits in memory or pays compression; cacheSet re-checks.
798
+ const buf = await readBodyForCache(cloned);
799
+ if (buf === null) return;
721
800
  const { gzip, brotli } = buildCompressedVariants(buf);
722
801
  // API endpoints have no LoaderDeps in v0.6 — invalidation is
723
802
  // URL/prefix only. See ROADMAP for deferred tag support.
@@ -747,10 +826,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
747
826
  // `throw redirect(303, "/")` from a +server.ts handler — turn it into
748
827
  // a real 303 instead of a 500. Mirrors the page-action handler below.
749
828
  if (isRedirect(err)) {
750
- return new Response(null, {
751
- status: err.status,
752
- headers: { Location: err.location },
753
- });
829
+ return redirectResponse(err);
754
830
  }
755
831
  if (isHttpError(err)) {
756
832
  return Response.json({ error: err.message }, { status: err.status });
@@ -1034,9 +1110,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
1034
1110
  // (preview/proxy hubs, design tools, etc.). Other security headers stay on.
1035
1111
  const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
1036
1112
 
1037
- // Trust `x-forwarded-proto` header behind a TLS-terminating proxy when computing
1038
- // per-request HTTPS-ness (drives `Secure` cookie flag). Off by default — the
1039
- // header is spoofable from any client that talks directly to the app.
1113
+ // Behind a reverse proxy, rebuild the request url from X-Forwarded-Host/-Proto
1114
+ // (applyForwarded): event.url, redirects, the CSRF/WebSocket origin checks and
1115
+ // the `Secure` cookie flag all follow it. Off by default — the headers are
1116
+ // spoofable from any client that talks directly to the app.
1040
1117
  const TRUST_PROXY = process.env.TRUST_PROXY === "true";
1041
1118
 
1042
1119
  const SECURITY_HEADERS: Record<string, string> = {
@@ -1095,7 +1172,10 @@ function finalizeResponse(
1095
1172
  applyCorsVary(headers);
1096
1173
  const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
1097
1174
  if (corsHeaders) {
1098
- for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
1175
+ // Vary: Origin is already merged in above; set() would drop Accept-Encoding.
1176
+ for (const [k, v] of Object.entries(corsHeaders)) {
1177
+ if (k.toLowerCase() !== "vary") headers.set(k, v);
1178
+ }
1099
1179
  }
1100
1180
  }
1101
1181
  // Apply any Set-Cookie headers accumulated during the request
@@ -1113,19 +1193,7 @@ function finalizeResponse(
1113
1193
  }
1114
1194
 
1115
1195
  async function handleRequest(request: Request, url: URL): Promise<Response> {
1116
- // Behind a trusted proxy the inbound `Host`/scheme is the proxy's inner hop
1117
- // (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
1118
- // misreports the public origin. Rebuild it from `X-Forwarded-Host`/`-Proto`
1119
- // so `event.url` — and every absolute redirect, canonical URL, and
1120
- // `url.origin` the app derives — points at the public-facing origin instead
1121
- // of localhost. Gated on TRUST_PROXY since these headers are client-spoofable
1122
- // when no proxy strips them.
1123
- if (TRUST_PROXY) {
1124
- const fwdHost = request.headers.get("x-forwarded-host");
1125
- if (fwdHost) url.host = fwdHost;
1126
- const fwdProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
1127
- if (fwdProto) url.protocol = `${fwdProto}:`;
1128
- }
1196
+ applyForwarded(request, url, TRUST_PROXY);
1129
1197
 
1130
1198
  // Mounted under BASE_PATH? Everything downstream — hooks, the router, the
1131
1199
  // /_health and /__bosia tests below — works in app space, so the prefix comes
@@ -1138,6 +1206,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1138
1206
  url.pathname = appPath;
1139
1207
  }
1140
1208
 
1209
+ // No page lives at a `//x` path: anything that echoes it as a URL (the trailing-slash
1210
+ // 308, `page.url`, the client router) would read it as host x. Collapse the slashes.
1211
+ if (url.pathname.startsWith("//")) {
1212
+ return new Response(null, {
1213
+ status: 308,
1214
+ // `base +`, not withBase: withBase skips a path already under base, so `/sso//sso/x` would lose it.
1215
+ headers: { Location: base + url.pathname.replace(/^\/+/, "/") + url.search },
1216
+ });
1217
+ }
1218
+
1141
1219
  // Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
1142
1220
  // check the declared length here. A chunked upload without Content-Length
1143
1221
  // falls back to the host's own request cap (Cloudflare: 100MB on free).
@@ -1181,11 +1259,18 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1181
1259
  console.warn(`[CSRF] Blocked request: ${csrfError}`);
1182
1260
  return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
1183
1261
  }
1262
+ // A WebSocket upgrade is a GET, so checkCsrf skips it. Checked here, before
1263
+ // the hooks, so a cross-site attempt never reaches a session lookup.
1264
+ const upgradeError = isUpgradeRequest(request)
1265
+ ? checkUpgradeOrigin(request, url, CSRF_CONFIG)
1266
+ : null;
1267
+ if (upgradeError) {
1268
+ console.warn(`[CSRF] Blocked WebSocket upgrade: ${upgradeError}`);
1269
+ return Response.json({ error: "Forbidden", message: upgradeError }, { status: 403 });
1270
+ }
1184
1271
 
1185
- const isHttps =
1186
- (TRUST_PROXY && request.headers.get("x-forwarded-proto") === "https") ||
1187
- url.protocol === "https:";
1188
- cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
1272
+ // url already carries the forwarded scheme when TRUST_PROXY is on.
1273
+ cookieJar = new CookieJar(request.headers.get("cookie") ?? "", url.protocol === "https:");
1189
1274
  nonce = CSP_ENABLED ? generateNonce() : "";
1190
1275
 
1191
1276
  // Decode the data endpoint before the hooks, not inside resolve(): a guard
@@ -1237,12 +1322,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1237
1322
  response ??=
1238
1323
  userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
1239
1324
 
1325
+ // Bun already answered with 101; there is no response left to finish.
1326
+ if (isUpgraded()) return response;
1327
+
1240
1328
  // A hook that short-circuits a data request with a redirect is answering
1241
1329
  // the client router, which speaks JSON — an unconverted 3xx is followed by
1242
1330
  // `fetch` and the router receives the redirect target's HTML instead.
1243
1331
  // `Location` is copied verbatim: `redirect()` already rebased it through
1244
1332
  // `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
1245
1333
  // base by hand, so rebasing here would double the prefix on both.
1334
+
1246
1335
  if (dataReq && response.status >= 300 && response.status < 400) {
1247
1336
  const location = response.headers.get("location");
1248
1337
  if (location) {
@@ -1261,6 +1350,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1261
1350
 
1262
1351
  return finalizeResponse(response, request, nonce, cookieJar);
1263
1352
  } catch (err) {
1353
+ // Bun already sent the 101, so there is no 500 to send: close the socket
1354
+ // with 1011, the way a handler error does.
1355
+ if (isUpgraded()) {
1356
+ console.error("Hook error after the WebSocket upgrade (socket closed with 1011):", err);
1357
+ closeUpgraded();
1358
+ return new Response(null);
1359
+ }
1264
1360
  // `throw redirect()` / `throw error()` from a hook lands here — the same
1265
1361
  // escape hatch loaders have always had. Without these branches both fall
1266
1362
  // through to the 500 below, which is why the docs could only ever suggest
@@ -1271,8 +1367,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1271
1367
  ? // Shape-identical to the loader conversion below, so the
1272
1368
  // router has one payload contract regardless of who redirected.
1273
1369
  Response.json({ redirect: err.location, status: err.status })
1274
- : // Not Response.redirect(): it rejects relative URLs on Workers.
1275
- new Response(null, { status: err.status, headers: { Location: err.location } })
1370
+ : redirectResponse(err)
1276
1371
  : dataReq
1277
1372
  ? Response.json(
1278
1373
  {
@@ -1339,11 +1434,11 @@ function parseCorsMaxAge(value?: string): number | undefined {
1339
1434
  // Format: "512K", "1M", "1G", plain bytes, or "Infinity".
1340
1435
  // Default: 512K (matches SvelteKit).
1341
1436
 
1342
- function parseBodySizeLimit(value?: string): number {
1343
- if (!value) return 512 * 1024;
1344
- if (value === "Infinity") return 0; // Bun: 0 = no limit
1437
+ // "Infinity" → 0, Bun's "no limit". `key` names the variable in the error.
1438
+ function parseSize(key: string, value: string): number {
1439
+ if (value === "Infinity") return 0;
1345
1440
  const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
1346
- if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
1441
+ if (!match) throw new Error(`Invalid ${key}: "${value}"`);
1347
1442
  const num = parseFloat(match[1]);
1348
1443
  const unit = match[2].toUpperCase();
1349
1444
  if (unit === "K") return Math.floor(num * 1024);
@@ -1352,7 +1447,9 @@ function parseBodySizeLimit(value?: string): number {
1352
1447
  return Math.floor(num);
1353
1448
  }
1354
1449
 
1355
- const BODY_SIZE_LIMIT = parseBodySizeLimit(process.env.BODY_SIZE_LIMIT);
1450
+ const BODY_SIZE_LIMIT = process.env.BODY_SIZE_LIMIT
1451
+ ? parseSize("BODY_SIZE_LIMIT", process.env.BODY_SIZE_LIMIT)
1452
+ : 512 * 1024;
1356
1453
 
1357
1454
  if (BODY_SIZE_LIMIT === 0) {
1358
1455
  console.log("📦 Body size limit: none");
@@ -1366,18 +1463,39 @@ if (BODY_SIZE_LIMIT === 0) {
1366
1463
  // Raise when API routes hold streaming responses with long gaps
1367
1464
  // between chunks (e.g. AI tool calls that shell out and wait).
1368
1465
 
1369
- function parseIdleTimeout(value?: string): number {
1370
- if (!value) return 10;
1466
+ // Whole seconds, 0..max. parseInt-lenient ("10s" reads as 10), as it always was.
1467
+ function parseSeconds(key: string, value: string | undefined, fallback: number, max: number) {
1468
+ if (!value) return fallback;
1371
1469
  const n = parseInt(value, 10);
1372
- if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid IDLE_TIMEOUT: "${value}"`);
1373
- if (n > 255) throw new Error(`Invalid IDLE_TIMEOUT: "${value}" (max 255)`);
1470
+ if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid ${key}: "${value}"`);
1471
+ if (n > max) throw new Error(`Invalid ${key}: "${value}" (max ${max})`);
1374
1472
  return n;
1375
1473
  }
1376
1474
 
1377
- const IDLE_TIMEOUT = parseIdleTimeout(process.env.IDLE_TIMEOUT);
1475
+ const IDLE_TIMEOUT = parseSeconds("IDLE_TIMEOUT", process.env.IDLE_TIMEOUT, 10, 255);
1378
1476
 
1379
1477
  console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
1380
1478
 
1479
+ // ─── WebSocket Limits ─────────────────────────────────────
1480
+ // Parsed once at startup. Sizes take the BODY_SIZE_LIMIT format ("16M").
1481
+ // WS_IDLE_TIMEOUT is seconds without a frame before Bun closes the socket; Bun
1482
+ // pings idle sockets on its own, so a live peer never hits it.
1483
+
1484
+ // Bun has no "no limit" for these, so 0 and "Infinity" are refused.
1485
+ function parseWsSize(key: string, value?: string): number {
1486
+ if (!value) return 16 * 1024 * 1024;
1487
+ const n = parseSize(key, value);
1488
+ if (n === 0) throw new Error(`Invalid ${key}: "${value}" (must be above 0)`);
1489
+ return n;
1490
+ }
1491
+
1492
+ const WEBSOCKET_OPTIONS: WebSocketOptions = {
1493
+ maxPayloadLength: parseWsSize("WS_MAX_PAYLOAD", process.env.WS_MAX_PAYLOAD),
1494
+ idleTimeout: parseSeconds("WS_IDLE_TIMEOUT", process.env.WS_IDLE_TIMEOUT, 120, 960),
1495
+ backpressureLimit: parseWsSize("WS_BACKPRESSURE_LIMIT", process.env.WS_BACKPRESSURE_LIMIT),
1496
+ perMessageDeflate: process.env.WS_COMPRESS === "true",
1497
+ };
1498
+
1381
1499
  // ─── Concurrency Ceiling ──────────────────────────────────
1382
1500
  // Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
1383
1501
  // Default is unlimited so existing apps see no behavior change. When set,
@@ -1412,6 +1530,8 @@ let drainResolve: (() => void) | null = null;
1412
1530
  /** Stop taking new requests; resolves once in-flight ones have finished. */
1413
1531
  export function beginShutdown(): Promise<void> {
1414
1532
  shuttingDown = true;
1533
+ // 1001 "going away": clients reconnect, to this server's replacement.
1534
+ closeAllSockets();
1415
1535
  if (inFlight === 0) return Promise.resolve();
1416
1536
  return new Promise<void>((r) => {
1417
1537
  drainResolve = r;
@@ -1476,6 +1596,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BosiaAp
1476
1596
  // splits traffic between two different builds. Opt in only for deliberate
1477
1597
  // N-worker clustering.
1478
1598
  reusePort: process.env.BOSIA_REUSE_PORT === "1",
1599
+ websocket: WEBSOCKET_OPTIONS,
1479
1600
  },
1480
1601
  });
1481
1602
 
@@ -29,7 +29,7 @@ const quiet = { log: console.log, warn: console.warn, error: console.error };
29
29
  console.log = console.warn = console.error = () => {};
30
30
  setWarmingUp(true);
31
31
  try {
32
- await app.fetch(new Request("http://localhost/"));
32
+ await app.fetchUnobserved(new Request("http://localhost/"));
33
33
  } catch {
34
34
  // best effort
35
35
  } finally {
@@ -41,6 +41,7 @@ export default {
41
41
  fetch(request: Request, env: PlatformEnv): Response | Promise<Response> {
42
42
  // Bindings are one object per isolate — capture on the first request.
43
43
  if (!getPlatform()) setPlatform({ env });
44
- return app.fetch(request);
44
+ // No `server` passed, so nothing is ever upgraded and a Response always comes back.
45
+ return app.fetch(request) as Promise<Response>;
45
46
  },
46
47
  };
@@ -35,7 +35,8 @@ export interface BosiaPlugin {
35
35
 
36
36
  /**
37
37
  * Mount points around the framework's HTTP backend — Bosia's own small,
38
- * Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`).
38
+ * Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`,
39
+ * and observe-only `onResponse` for logging/metrics on the final response).
39
40
  */
40
41
  backend?: {
41
42
  /** Runs before framework middleware/routes — can register routes that bypass Bosia. */