bosia 1.2.6 → 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.
@@ -0,0 +1,144 @@
1
+ // ─── Dev Proxy: WebSockets ────────────────────────────────
2
+ // The dev proxy forwards HTTP with fetch(), which can't carry an upgrade. This
3
+ // opens the app's socket first and upgrades the client only once the app has
4
+ // accepted, so a refusal reaches the client as a failed handshake — what a
5
+ // browser sees in prod too (it never learns the status). Then frames are piped
6
+ // both ways, type kept.
7
+ //
8
+ // Simple version: no backpressure across the proxy hop, frames sent during a
9
+ // restart are dropped, and cookies the app sets on its 101 don't reach the
10
+ // client (Bun's client WebSocket doesn't expose handshake headers). If that
11
+ // ever matters, buffer with a cap (close with 1013 past it) and do the
12
+ // upstream handshake over a raw socket to read its headers.
13
+
14
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
15
+
16
+ import { closeEach } from "./websocket.ts";
17
+
18
+ interface ProxyData {
19
+ upstream: WebSocket;
20
+ client: ServerWebSocket<ProxyData> | null;
21
+ /** Frames the app sent before the client socket opened. */
22
+ pending: (string | ArrayBuffer)[];
23
+ /** The app closed before the client socket opened. */
24
+ early: { code: number; reason: string } | null;
25
+ }
26
+
27
+ // Handshake headers belong to each hop; everything else (cookie,
28
+ // authorization, origin, x-forwarded-*) goes through to the app.
29
+ const HOP_HEADERS = new Set([
30
+ "host",
31
+ "connection",
32
+ "upgrade",
33
+ "content-length",
34
+ "sec-websocket-key",
35
+ "sec-websocket-version",
36
+ "sec-websocket-extensions",
37
+ "sec-websocket-protocol",
38
+ ]);
39
+
40
+ const UPSTREAM_TIMEOUT_MS = 10_000;
41
+
42
+ const proxied = new Set<ServerWebSocket<ProxyData>>();
43
+
44
+ /**
45
+ * A close code `close()` accepts. 1005/1006/1015 only describe a close:
46
+ * 1005 (no code) becomes 1000, 1006/1015 (connection lost) become `lost`.
47
+ * Reserved or out-of-range codes become 1000; Bun's client close() throws on them.
48
+ */
49
+ export function toSendableCode(code: number, lost: number): number {
50
+ if (code === 1006 || code === 1015) return lost;
51
+ const valid =
52
+ (code >= 1000 && code <= 1003) ||
53
+ (code >= 1007 && code <= 1014) ||
54
+ (code >= 3000 && code <= 4999);
55
+ return valid ? code : 1000;
56
+ }
57
+
58
+ /**
59
+ * Upgrade `req` through to `target` (an http:// URL on the app server).
60
+ * Returns undefined once upgraded, or the Response to send instead.
61
+ */
62
+ export async function proxyUpgrade(
63
+ req: Request,
64
+ server: Server<ProxyData>,
65
+ target: URL,
66
+ inHeaders: Headers,
67
+ ): Promise<Response | undefined> {
68
+ const headers: Record<string, string> = {};
69
+ for (const [k, v] of inHeaders) if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
70
+ const protocols =
71
+ req.headers
72
+ .get("sec-websocket-protocol")
73
+ ?.split(",")
74
+ .map((p) => p.trim())
75
+ .filter(Boolean) ?? [];
76
+
77
+ const url = new URL(target);
78
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
79
+ const upstream = new WebSocket(url.href, { headers, protocols } as any);
80
+ upstream.binaryType = "arraybuffer";
81
+ const data: ProxyData = { upstream, client: null, pending: [], early: null };
82
+
83
+ upstream.addEventListener("message", (e) => {
84
+ if (data.client) data.client.send(e.data);
85
+ else data.pending.push(e.data);
86
+ });
87
+ upstream.addEventListener("close", (e) => {
88
+ if (!data.client) data.early = { code: e.code, reason: e.reason };
89
+ else if (proxied.has(data.client)) data.client.close(toSendableCode(e.code, 1011), e.reason);
90
+ });
91
+
92
+ const opened = await new Promise<boolean>((resolve) => {
93
+ const timer = setTimeout(() => resolve(false), UPSTREAM_TIMEOUT_MS);
94
+ const done = (ok: boolean) => {
95
+ clearTimeout(timer);
96
+ resolve(ok);
97
+ };
98
+ upstream.addEventListener("open", () => done(true), { once: true });
99
+ upstream.addEventListener("error", () => done(false), { once: true });
100
+ upstream.addEventListener("close", () => done(false), { once: true });
101
+ });
102
+ if (!opened) {
103
+ upstream.close();
104
+ return new Response("WebSocket upgrade refused by app", { status: 502 });
105
+ }
106
+
107
+ const ok = server.upgrade(req, {
108
+ data,
109
+ headers: new Headers(upstream.protocol ? { "Sec-WebSocket-Protocol": upstream.protocol } : {}),
110
+ });
111
+ if (ok) return undefined;
112
+ upstream.close();
113
+ return new Response("Bad WebSocket handshake", { status: 400 });
114
+ }
115
+
116
+ export const proxyHandlers: WebSocketHandler<ProxyData> = {
117
+ // The app enforces WS_MAX_PAYLOAD (and closes with 1009, passed through);
118
+ // the proxy must not cut frames off at Bun's 16 MB default first.
119
+ maxPayloadLength: 2 ** 30,
120
+ open(ws) {
121
+ const data = ws.data;
122
+ data.client = ws;
123
+ proxied.add(ws);
124
+ for (const frame of data.pending) ws.send(frame);
125
+ data.pending = [];
126
+ if (data.early) ws.close(toSendableCode(data.early.code, 1011), data.early.reason);
127
+ },
128
+ message(ws, message) {
129
+ const { upstream } = ws.data;
130
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(message);
131
+ },
132
+ close(ws, code, reason) {
133
+ proxied.delete(ws);
134
+ const { upstream } = ws.data;
135
+ if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
136
+ upstream.close(toSendableCode(code, 1001), reason);
137
+ }
138
+ },
139
+ };
140
+
141
+ /** Close every proxied client — 1012 on a rebuild, so clients reconnect to the new app. */
142
+ export function closeProxiedSockets(code: number, reason: string): void {
143
+ closeEach(proxied, code, reason);
144
+ }
package/src/core/env.ts CHANGED
@@ -24,6 +24,10 @@ const FRAMEWORK_VARS = new Set([
24
24
  "PRERENDER_CONCURRENCY",
25
25
  "IDLE_TIMEOUT",
26
26
  "MAX_INFLIGHT",
27
+ "WS_MAX_PAYLOAD",
28
+ "WS_IDLE_TIMEOUT",
29
+ "WS_BACKPRESSURE_LIMIT",
30
+ "WS_COMPRESS",
27
31
  "TRUST_PROXY",
28
32
  "CSP_DIRECTIVES",
29
33
  "DISABLE_X_FRAME_OPTIONS",
@@ -1,7 +1,7 @@
1
1
  // ─── Error / Redirect Helpers ────────────────────────────
2
2
  // Throw these from load() functions; the server catches and handles them.
3
3
 
4
- import { withBase } from "./basePath.ts";
4
+ import { DANGEROUS_PROTOCOL, withBase } from "./basePath.ts";
5
5
  import { currentBase } from "./appBase.ts";
6
6
 
7
7
  // Identity across bundle boundaries. `dist/hooks.server.js` keeps "bosia"
@@ -59,17 +59,26 @@ export class Redirect {
59
59
  (HttpError.prototype as any)[HTTP_ERROR_BRAND] = true;
60
60
  (Redirect.prototype as any)[REDIRECT_BRAND] = true;
61
61
 
62
- const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
62
+ // Any parseable origin works as the base: only "did it stay on it?" is asked.
63
+ const SELF = "http://bosia.invalid";
63
64
 
64
65
  function validateRedirectLocation(location: string, options?: RedirectOptions): void {
65
66
  const trimmed = location.trim();
67
+ // Parsed like the browser will parse the Location: `java\tscript:` is javascript:,
68
+ // `htt\tps://evil.com` and `/\\evil.com` are another origin.
69
+ let url: URL;
70
+ try {
71
+ url = new URL(trimmed, SELF);
72
+ } catch {
73
+ throw new Error(`redirect(): invalid URL "${location}".`);
74
+ }
66
75
 
67
76
  // Dangerous schemes are rejected even when `allowExternal: true` —
68
77
  // `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
69
- if (DANGEROUS_SCHEMES.test(trimmed)) {
78
+ if (DANGEROUS_PROTOCOL.test(url.protocol)) {
70
79
  throw new Error(
71
80
  `redirect(): dangerous scheme in URL "${location}". ` +
72
- `Only relative paths and same-origin URLs are allowed.`,
81
+ `Use a relative path or an http(s) URL with { allowExternal: true }.`,
73
82
  );
74
83
  }
75
84
 
@@ -83,8 +92,16 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
83
92
  );
84
93
  }
85
94
 
86
- // Allow relative paths (no scheme)
87
- if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(trimmed)) return;
95
+ // Relative only when it isn't a URL on its own: `http:evil.com` resolves against
96
+ // SELF as a path, but on an https page the browser reads it as http://evil.com.
97
+ // try/new URL, not URL.canParse: this file ships to browsers without it (Safari < 17).
98
+ let absolute = true;
99
+ try {
100
+ new URL(trimmed);
101
+ } catch {
102
+ absolute = false;
103
+ }
104
+ if (!absolute && url.origin === SELF) return;
88
105
 
89
106
  // It's an absolute URL — reject external origins
90
107
  throw new Error(
@@ -111,11 +111,11 @@ export function accessLog(options: AccessLogOptions = {}): BosiaPlugin {
111
111
  before(app) {
112
112
  // onResponse swallows a throw here (a broken filter/format/sink),
113
113
  // so logging can never change or fail a response.
114
- return app.onResponse(({ request, response, start }) => {
114
+ return app.onResponse(({ request, status, start }) => {
115
115
  log({
116
116
  method: request.method,
117
117
  path: pathOf(request.url),
118
- status: response.status,
118
+ status,
119
119
  ms: performance.now() - start,
120
120
  request,
121
121
  });
@@ -0,0 +1,48 @@
1
+ // Behind a reverse proxy the inbound `Host`/scheme is the proxy's inner hop
2
+ // (e.g. `localhost:PORT` over plain HTTP). X-Forwarded-Host/-Proto carry the
3
+ // public origin, but any client can send them, so they count only when the
4
+ // operator sets TRUST_PROXY=true.
5
+
6
+ /**
7
+ * First value of an `X-Forwarded-*` header: each proxy in a chain appends its
8
+ * own ("https, http"), and the first is the client-facing one.
9
+ */
10
+ function firstForwarded(request: Request, name: string): string | undefined {
11
+ return request.headers.get(name)?.split(",")[0]?.trim() || undefined;
12
+ }
13
+
14
+ const SCHEMES: Record<string, string> = {
15
+ http: "http:",
16
+ https: "https:",
17
+ // Traefik sends ws/wss on upgrades; the origin a browser sends is http(s).
18
+ ws: "http:",
19
+ wss: "https:",
20
+ };
21
+
22
+ /**
23
+ * Point `url` at the public origin from X-Forwarded-Host/-Proto, in place, so
24
+ * `event.url`, redirects and the CSRF/WebSocket origin checks all see it.
25
+ * Does nothing unless `trustProxy`. A malformed host leaves `url` as it was,
26
+ * scheme included; an unknown scheme is ignored.
27
+ */
28
+ export function applyForwarded(request: Request, url: URL, trustProxy: boolean): void {
29
+ if (!trustProxy) return;
30
+ const proto = SCHEMES[firstForwarded(request, "x-forwarded-proto")?.toLowerCase() ?? ""];
31
+ const host = firstForwarded(request, "x-forwarded-host");
32
+ let parsed: URL | undefined;
33
+ if (host) {
34
+ // The parser would quietly keep part of `evil.com@app.com` or `app.com/x`.
35
+ if (/[\s/?#@\\]/.test(host)) return;
36
+ try {
37
+ parsed = new URL(`${proto ?? url.protocol}//${host}`);
38
+ } catch {
39
+ return;
40
+ }
41
+ }
42
+ if (proto) url.protocol = proto;
43
+ if (parsed) {
44
+ // hostname + port, not `host`: setting `host` without a port keeps the inner one.
45
+ url.hostname = parsed.hostname;
46
+ url.port = parsed.port;
47
+ }
48
+ }
@@ -76,7 +76,7 @@ const enc = new TextEncoder();
76
76
  //
77
77
  // `url` is app-space (server.ts strips BASE_PATH exactly once, at the top of
78
78
  // handleRequest), but every value the client half writes to these fields is
79
- // browser-space: hydrate.ts assigns raw window.location.pathname, and
79
+ // browser-space: hydrate.ts assigns locationPath() (the address bar), and
80
80
  // clientRoutes are generated with the prefix already in them. So the base goes
81
81
  // back on before seeding, or a mounted app server-renders `/admin/audit` and
82
82
  // hydration flips it to `/sso/admin/audit`.
@@ -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";
@@ -74,6 +75,15 @@ import {
74
75
  renderPageWithFormData,
75
76
  } from "./renderer.ts";
76
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";
77
87
 
78
88
  // ─── User Hooks ──────────────────────────────────────────
79
89
  // Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
@@ -375,6 +385,11 @@ async function serveStaticFile(path: string, request: Request): Promise<Response
375
385
  return null;
376
386
  }
377
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
+
378
393
  async function resolve(event: RequestEvent): Promise<Response> {
379
394
  const { request, url, locals, cookies } = event;
380
395
  const path = url.pathname;
@@ -650,12 +665,42 @@ async function resolve(event: RequestEvent): Promise<Response> {
650
665
  let releaseApiMiss: (() => void) | null = null;
651
666
  try {
652
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
+
653
689
  // HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
654
690
  // strips the body on the way out, for every route kind.
655
691
  const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
656
692
 
657
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
+ }
658
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");
659
704
  if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
660
705
  return Response.json(
661
706
  { error: `Method ${method} not allowed` },
@@ -709,10 +754,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
709
754
 
710
755
  // Redirect returned (not thrown) — convert to a 303 Response.
711
756
  if (isRedirect(handlerResult)) {
712
- return new Response(null, {
713
- status: handlerResult.status,
714
- headers: { Location: handlerResult.location },
715
- });
757
+ return redirectResponse(handlerResult);
716
758
  }
717
759
 
718
760
  const response = handlerResult as Response;
@@ -784,10 +826,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
784
826
  // `throw redirect(303, "/")` from a +server.ts handler — turn it into
785
827
  // a real 303 instead of a 500. Mirrors the page-action handler below.
786
828
  if (isRedirect(err)) {
787
- return new Response(null, {
788
- status: err.status,
789
- headers: { Location: err.location },
790
- });
829
+ return redirectResponse(err);
791
830
  }
792
831
  if (isHttpError(err)) {
793
832
  return Response.json({ error: err.message }, { status: err.status });
@@ -1071,9 +1110,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
1071
1110
  // (preview/proxy hubs, design tools, etc.). Other security headers stay on.
1072
1111
  const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
1073
1112
 
1074
- // Trust `x-forwarded-proto` header behind a TLS-terminating proxy when computing
1075
- // per-request HTTPS-ness (drives `Secure` cookie flag). Off by default — the
1076
- // 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.
1077
1117
  const TRUST_PROXY = process.env.TRUST_PROXY === "true";
1078
1118
 
1079
1119
  const SECURITY_HEADERS: Record<string, string> = {
@@ -1153,19 +1193,7 @@ function finalizeResponse(
1153
1193
  }
1154
1194
 
1155
1195
  async function handleRequest(request: Request, url: URL): Promise<Response> {
1156
- // Behind a trusted proxy the inbound `Host`/scheme is the proxy's inner hop
1157
- // (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
1158
- // misreports the public origin. Rebuild it from `X-Forwarded-Host`/`-Proto`
1159
- // so `event.url` — and every absolute redirect, canonical URL, and
1160
- // `url.origin` the app derives — points at the public-facing origin instead
1161
- // of localhost. Gated on TRUST_PROXY since these headers are client-spoofable
1162
- // when no proxy strips them.
1163
- if (TRUST_PROXY) {
1164
- const fwdHost = request.headers.get("x-forwarded-host");
1165
- if (fwdHost) url.host = fwdHost;
1166
- const fwdProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
1167
- if (fwdProto) url.protocol = `${fwdProto}:`;
1168
- }
1196
+ applyForwarded(request, url, TRUST_PROXY);
1169
1197
 
1170
1198
  // Mounted under BASE_PATH? Everything downstream — hooks, the router, the
1171
1199
  // /_health and /__bosia tests below — works in app space, so the prefix comes
@@ -1178,6 +1206,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1178
1206
  url.pathname = appPath;
1179
1207
  }
1180
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
+
1181
1219
  // Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
1182
1220
  // check the declared length here. A chunked upload without Content-Length
1183
1221
  // falls back to the host's own request cap (Cloudflare: 100MB on free).
@@ -1221,11 +1259,18 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1221
1259
  console.warn(`[CSRF] Blocked request: ${csrfError}`);
1222
1260
  return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
1223
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
+ }
1224
1271
 
1225
- const isHttps =
1226
- (TRUST_PROXY && request.headers.get("x-forwarded-proto") === "https") ||
1227
- url.protocol === "https:";
1228
- 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:");
1229
1274
  nonce = CSP_ENABLED ? generateNonce() : "";
1230
1275
 
1231
1276
  // Decode the data endpoint before the hooks, not inside resolve(): a guard
@@ -1277,12 +1322,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1277
1322
  response ??=
1278
1323
  userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
1279
1324
 
1325
+ // Bun already answered with 101; there is no response left to finish.
1326
+ if (isUpgraded()) return response;
1327
+
1280
1328
  // A hook that short-circuits a data request with a redirect is answering
1281
1329
  // the client router, which speaks JSON — an unconverted 3xx is followed by
1282
1330
  // `fetch` and the router receives the redirect target's HTML instead.
1283
1331
  // `Location` is copied verbatim: `redirect()` already rebased it through
1284
1332
  // `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
1285
1333
  // base by hand, so rebasing here would double the prefix on both.
1334
+
1286
1335
  if (dataReq && response.status >= 300 && response.status < 400) {
1287
1336
  const location = response.headers.get("location");
1288
1337
  if (location) {
@@ -1301,6 +1350,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1301
1350
 
1302
1351
  return finalizeResponse(response, request, nonce, cookieJar);
1303
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
+ }
1304
1360
  // `throw redirect()` / `throw error()` from a hook lands here — the same
1305
1361
  // escape hatch loaders have always had. Without these branches both fall
1306
1362
  // through to the 500 below, which is why the docs could only ever suggest
@@ -1311,8 +1367,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1311
1367
  ? // Shape-identical to the loader conversion below, so the
1312
1368
  // router has one payload contract regardless of who redirected.
1313
1369
  Response.json({ redirect: err.location, status: err.status })
1314
- : // Not Response.redirect(): it rejects relative URLs on Workers.
1315
- new Response(null, { status: err.status, headers: { Location: err.location } })
1370
+ : redirectResponse(err)
1316
1371
  : dataReq
1317
1372
  ? Response.json(
1318
1373
  {
@@ -1379,11 +1434,11 @@ function parseCorsMaxAge(value?: string): number | undefined {
1379
1434
  // Format: "512K", "1M", "1G", plain bytes, or "Infinity".
1380
1435
  // Default: 512K (matches SvelteKit).
1381
1436
 
1382
- function parseBodySizeLimit(value?: string): number {
1383
- if (!value) return 512 * 1024;
1384
- 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;
1385
1440
  const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
1386
- if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
1441
+ if (!match) throw new Error(`Invalid ${key}: "${value}"`);
1387
1442
  const num = parseFloat(match[1]);
1388
1443
  const unit = match[2].toUpperCase();
1389
1444
  if (unit === "K") return Math.floor(num * 1024);
@@ -1392,7 +1447,9 @@ function parseBodySizeLimit(value?: string): number {
1392
1447
  return Math.floor(num);
1393
1448
  }
1394
1449
 
1395
- 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;
1396
1453
 
1397
1454
  if (BODY_SIZE_LIMIT === 0) {
1398
1455
  console.log("📦 Body size limit: none");
@@ -1406,18 +1463,39 @@ if (BODY_SIZE_LIMIT === 0) {
1406
1463
  // Raise when API routes hold streaming responses with long gaps
1407
1464
  // between chunks (e.g. AI tool calls that shell out and wait).
1408
1465
 
1409
- function parseIdleTimeout(value?: string): number {
1410
- 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;
1411
1469
  const n = parseInt(value, 10);
1412
- if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid IDLE_TIMEOUT: "${value}"`);
1413
- 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})`);
1414
1472
  return n;
1415
1473
  }
1416
1474
 
1417
- const IDLE_TIMEOUT = parseIdleTimeout(process.env.IDLE_TIMEOUT);
1475
+ const IDLE_TIMEOUT = parseSeconds("IDLE_TIMEOUT", process.env.IDLE_TIMEOUT, 10, 255);
1418
1476
 
1419
1477
  console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
1420
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
+
1421
1499
  // ─── Concurrency Ceiling ──────────────────────────────────
1422
1500
  // Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
1423
1501
  // Default is unlimited so existing apps see no behavior change. When set,
@@ -1452,6 +1530,8 @@ let drainResolve: (() => void) | null = null;
1452
1530
  /** Stop taking new requests; resolves once in-flight ones have finished. */
1453
1531
  export function beginShutdown(): Promise<void> {
1454
1532
  shuttingDown = true;
1533
+ // 1001 "going away": clients reconnect, to this server's replacement.
1534
+ closeAllSockets();
1455
1535
  if (inFlight === 0) return Promise.resolve();
1456
1536
  return new Promise<void>((r) => {
1457
1537
  drainResolve = r;
@@ -1516,6 +1596,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BosiaAp
1516
1596
  // splits traffic between two different builds. Opt in only for deliberate
1517
1597
  // N-worker clustering.
1518
1598
  reusePort: process.env.BOSIA_REUSE_PORT === "1",
1599
+ websocket: WEBSOCKET_OPTIONS,
1519
1600
  },
1520
1601
  });
1521
1602
 
@@ -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
  };