bosia 1.2.6 → 1.2.8

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.
@@ -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";
@@ -72,8 +73,18 @@ import {
72
73
  renderSSRStream,
73
74
  renderErrorPage,
74
75
  renderPageWithFormData,
76
+ routeCacheFlag,
75
77
  } from "./renderer.ts";
76
78
  import { getServerTime } from "../lib/utils.ts";
79
+ import {
80
+ closeAllSockets,
81
+ isUpgradeRequest,
82
+ isUpgraded,
83
+ closeUpgraded,
84
+ upgradeRequest,
85
+ type WebSocketHandler,
86
+ type WebSocketOptions,
87
+ } from "./websocket.ts";
77
88
 
78
89
  // ─── User Hooks ──────────────────────────────────────────
79
90
  // Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
@@ -213,7 +224,7 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
213
224
  let fresh = false;
214
225
  for (const [key, val] of url.searchParams.entries()) {
215
226
  if (key === "_invalidated") {
216
- invalidatedBits = val;
227
+ invalidatedBits = val; // last wins: the client appends its mask after the page's query
217
228
  continue;
218
229
  }
219
230
  // Set by the client router when the fetch follows an invalidate():
@@ -224,6 +235,11 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
224
235
  }
225
236
  routeUrl.searchParams.append(key, val);
226
237
  }
238
+ // Only a 0/1 mask counts: it goes into the cache key, where a stray `?` or
239
+ // `|o=` would file the entry under the wrong path for invalidate(). Anything
240
+ // else runs and sends every loader, as having no mask does. A page's own 0/1
241
+ // `?_invalidated=` with no client mask after it is still read as the mask.
242
+ if (invalidatedBits !== null && !/^[01]*$/.test(invalidatedBits)) invalidatedBits = null;
227
243
  return { routeUrl, invalidatedBits, fresh };
228
244
  }
229
245
 
@@ -281,17 +297,9 @@ function routesFor(
281
297
 
282
298
  const jsonEncoder = new TextEncoder();
283
299
 
284
- /** A page route's `export const cache` flag: the build-time read when it has
285
- * one, otherwise the compiled page module's own export (see renderer.ts). */
286
- async function pageRouteCacheable(route: (typeof serverRoutes)[number]): Promise<boolean> {
287
- const flag = (route as { cache?: boolean | null }).cache;
288
- if (flag === true || flag === false) return flag;
289
- return (await route.pageModule()).cache !== false;
290
- }
291
-
292
300
  /**
293
301
  * Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
294
- * depth i. Only '0' has a meaning; any other char counts as '1'.
302
+ * depth i. Only 0/1 reach here: parseDataRequest drops any other mask.
295
303
  * - Page '0': skip the page loader.
296
304
  * - Layout '0': omit its data from the response. The loader still runs,
297
305
  * since it may be a guard.
@@ -375,6 +383,11 @@ async function serveStaticFile(path: string, request: Request): Promise<Response
375
383
  return null;
376
384
  }
377
385
 
386
+ // Not Response.redirect(): it rejects relative URLs on Workers.
387
+ function redirectResponse(r: Redirect): Response {
388
+ return new Response(null, { status: r.status, headers: { Location: r.location } });
389
+ }
390
+
378
391
  async function resolve(event: RequestEvent): Promise<Response> {
379
392
  const { request, url, locals, cookies } = event;
380
393
  const path = url.pathname;
@@ -473,7 +486,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
473
486
  !warmingUp &&
474
487
  method === "GET" &&
475
488
  pageMatch !== null &&
476
- (await pageRouteCacheable(pageMatch.route));
489
+ (await routeCacheFlag(pageMatch.route));
477
490
  if (dataCacheable && !fresh) {
478
491
  // Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry
479
492
  // first, then this URL's own, as before — see cache.ts.
@@ -650,12 +663,42 @@ async function resolve(event: RequestEvent): Promise<Response> {
650
663
  let releaseApiMiss: (() => void) | null = null;
651
664
  try {
652
665
  const mod = await apiMatch.route.module();
666
+
667
+ // WebSocket upgrade — before the response cache, which must never see it.
668
+ const ws = (mod as { websocket?: WebSocketHandler<any, any> }).websocket;
669
+ if (ws && isUpgradeRequest(request)) {
670
+ event.params = apiMatch.params;
671
+ const result = ws.upgrade ? await ws.upgrade(event) : undefined;
672
+ if (result instanceof Response) return result;
673
+ if (isRedirect(result)) {
674
+ return redirectResponse(result);
675
+ }
676
+ if (result !== undefined && (result === null || typeof result !== "object")) {
677
+ throw new Error(
678
+ `websocket.upgrade() in ${apiMatch.route.id} must return an object, a Response or nothing`,
679
+ );
680
+ }
681
+ // Cookies a hook set before resolve() ride on the 101; later ones are lost.
682
+ const headers = new Headers();
683
+ for (const cookie of (cookies as CookieJar).outgoing) headers.append("Set-Cookie", cookie);
684
+ return upgradeRequest(ws, result ?? {}, headers);
685
+ }
686
+
653
687
  // HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
654
688
  // strips the body on the way out, for every route kind.
655
689
  const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
656
690
 
657
691
  if (!handler) {
692
+ // A websocket-only route asked over plain HTTP.
693
+ if (ws && (method === "GET" || method === "HEAD")) {
694
+ return new Response("Upgrade Required", {
695
+ status: 426,
696
+ headers: { Upgrade: "websocket", Connection: "Upgrade" },
697
+ });
698
+ }
658
699
  const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
700
+ // A websocket route takes GET (the upgrade's method); HEAD answers 426.
701
+ if (ws && !allowed.includes("GET")) allowed.push("GET");
659
702
  if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
660
703
  return Response.json(
661
704
  { error: `Method ${method} not allowed` },
@@ -709,10 +752,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
709
752
 
710
753
  // Redirect returned (not thrown) — convert to a 303 Response.
711
754
  if (isRedirect(handlerResult)) {
712
- return new Response(null, {
713
- status: handlerResult.status,
714
- headers: { Location: handlerResult.location },
715
- });
755
+ return redirectResponse(handlerResult);
716
756
  }
717
757
 
718
758
  const response = handlerResult as Response;
@@ -784,10 +824,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
784
824
  // `throw redirect(303, "/")` from a +server.ts handler — turn it into
785
825
  // a real 303 instead of a 500. Mirrors the page-action handler below.
786
826
  if (isRedirect(err)) {
787
- return new Response(null, {
788
- status: err.status,
789
- headers: { Location: err.location },
790
- });
827
+ return redirectResponse(err);
791
828
  }
792
829
  if (isHttpError(err)) {
793
830
  return Response.json({ error: err.message }, { status: err.status });
@@ -1071,9 +1108,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
1071
1108
  // (preview/proxy hubs, design tools, etc.). Other security headers stay on.
1072
1109
  const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
1073
1110
 
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.
1111
+ // Behind a reverse proxy, rebuild the request url from X-Forwarded-Host/-Proto
1112
+ // (applyForwarded): event.url, redirects, the CSRF/WebSocket origin checks and
1113
+ // the `Secure` cookie flag all follow it. Off by default — the headers are
1114
+ // spoofable from any client that talks directly to the app.
1077
1115
  const TRUST_PROXY = process.env.TRUST_PROXY === "true";
1078
1116
 
1079
1117
  const SECURITY_HEADERS: Record<string, string> = {
@@ -1153,19 +1191,7 @@ function finalizeResponse(
1153
1191
  }
1154
1192
 
1155
1193
  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
- }
1194
+ applyForwarded(request, url, TRUST_PROXY);
1169
1195
 
1170
1196
  // Mounted under BASE_PATH? Everything downstream — hooks, the router, the
1171
1197
  // /_health and /__bosia tests below — works in app space, so the prefix comes
@@ -1178,6 +1204,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1178
1204
  url.pathname = appPath;
1179
1205
  }
1180
1206
 
1207
+ // No page lives at a `//x` path: anything that echoes it as a URL (the trailing-slash
1208
+ // 308, `page.url`, the client router) would read it as host x. Collapse the slashes.
1209
+ if (url.pathname.startsWith("//")) {
1210
+ return new Response(null, {
1211
+ status: 308,
1212
+ // `base +`, not withBase: withBase skips a path already under base, so `/sso//sso/x` would lose it.
1213
+ headers: { Location: base + url.pathname.replace(/^\/+/, "/") + url.search },
1214
+ });
1215
+ }
1216
+
1181
1217
  // Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
1182
1218
  // check the declared length here. A chunked upload without Content-Length
1183
1219
  // falls back to the host's own request cap (Cloudflare: 100MB on free).
@@ -1221,11 +1257,18 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1221
1257
  console.warn(`[CSRF] Blocked request: ${csrfError}`);
1222
1258
  return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
1223
1259
  }
1260
+ // A WebSocket upgrade is a GET, so checkCsrf skips it. Checked here, before
1261
+ // the hooks, so a cross-site attempt never reaches a session lookup.
1262
+ const upgradeError = isUpgradeRequest(request)
1263
+ ? checkUpgradeOrigin(request, url, CSRF_CONFIG)
1264
+ : null;
1265
+ if (upgradeError) {
1266
+ console.warn(`[CSRF] Blocked WebSocket upgrade: ${upgradeError}`);
1267
+ return Response.json({ error: "Forbidden", message: upgradeError }, { status: 403 });
1268
+ }
1224
1269
 
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);
1270
+ // url already carries the forwarded scheme when TRUST_PROXY is on.
1271
+ cookieJar = new CookieJar(request.headers.get("cookie") ?? "", url.protocol === "https:");
1229
1272
  nonce = CSP_ENABLED ? generateNonce() : "";
1230
1273
 
1231
1274
  // Decode the data endpoint before the hooks, not inside resolve(): a guard
@@ -1277,12 +1320,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1277
1320
  response ??=
1278
1321
  userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
1279
1322
 
1323
+ // Bun already answered with 101; there is no response left to finish.
1324
+ if (isUpgraded()) return response;
1325
+
1280
1326
  // A hook that short-circuits a data request with a redirect is answering
1281
1327
  // the client router, which speaks JSON — an unconverted 3xx is followed by
1282
1328
  // `fetch` and the router receives the redirect target's HTML instead.
1283
1329
  // `Location` is copied verbatim: `redirect()` already rebased it through
1284
1330
  // `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
1285
1331
  // base by hand, so rebasing here would double the prefix on both.
1332
+
1286
1333
  if (dataReq && response.status >= 300 && response.status < 400) {
1287
1334
  const location = response.headers.get("location");
1288
1335
  if (location) {
@@ -1301,6 +1348,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1301
1348
 
1302
1349
  return finalizeResponse(response, request, nonce, cookieJar);
1303
1350
  } catch (err) {
1351
+ // Bun already sent the 101, so there is no 500 to send: close the socket
1352
+ // with 1011, the way a handler error does.
1353
+ if (isUpgraded()) {
1354
+ console.error("Hook error after the WebSocket upgrade (socket closed with 1011):", err);
1355
+ closeUpgraded();
1356
+ return new Response(null);
1357
+ }
1304
1358
  // `throw redirect()` / `throw error()` from a hook lands here — the same
1305
1359
  // escape hatch loaders have always had. Without these branches both fall
1306
1360
  // through to the 500 below, which is why the docs could only ever suggest
@@ -1311,8 +1365,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
1311
1365
  ? // Shape-identical to the loader conversion below, so the
1312
1366
  // router has one payload contract regardless of who redirected.
1313
1367
  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 } })
1368
+ : redirectResponse(err)
1316
1369
  : dataReq
1317
1370
  ? Response.json(
1318
1371
  {
@@ -1379,11 +1432,11 @@ function parseCorsMaxAge(value?: string): number | undefined {
1379
1432
  // Format: "512K", "1M", "1G", plain bytes, or "Infinity".
1380
1433
  // Default: 512K (matches SvelteKit).
1381
1434
 
1382
- function parseBodySizeLimit(value?: string): number {
1383
- if (!value) return 512 * 1024;
1384
- if (value === "Infinity") return 0; // Bun: 0 = no limit
1435
+ // "Infinity" → 0, Bun's "no limit". `key` names the variable in the error.
1436
+ function parseSize(key: string, value: string): number {
1437
+ if (value === "Infinity") return 0;
1385
1438
  const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
1386
- if (!match) throw new Error(`Invalid BODY_SIZE_LIMIT: "${value}"`);
1439
+ if (!match) throw new Error(`Invalid ${key}: "${value}"`);
1387
1440
  const num = parseFloat(match[1]);
1388
1441
  const unit = match[2].toUpperCase();
1389
1442
  if (unit === "K") return Math.floor(num * 1024);
@@ -1392,7 +1445,9 @@ function parseBodySizeLimit(value?: string): number {
1392
1445
  return Math.floor(num);
1393
1446
  }
1394
1447
 
1395
- const BODY_SIZE_LIMIT = parseBodySizeLimit(process.env.BODY_SIZE_LIMIT);
1448
+ const BODY_SIZE_LIMIT = process.env.BODY_SIZE_LIMIT
1449
+ ? parseSize("BODY_SIZE_LIMIT", process.env.BODY_SIZE_LIMIT)
1450
+ : 512 * 1024;
1396
1451
 
1397
1452
  if (BODY_SIZE_LIMIT === 0) {
1398
1453
  console.log("📦 Body size limit: none");
@@ -1406,18 +1461,39 @@ if (BODY_SIZE_LIMIT === 0) {
1406
1461
  // Raise when API routes hold streaming responses with long gaps
1407
1462
  // between chunks (e.g. AI tool calls that shell out and wait).
1408
1463
 
1409
- function parseIdleTimeout(value?: string): number {
1410
- if (!value) return 10;
1464
+ // Whole seconds, 0..max. parseInt-lenient ("10s" reads as 10), as it always was.
1465
+ function parseSeconds(key: string, value: string | undefined, fallback: number, max: number) {
1466
+ if (!value) return fallback;
1411
1467
  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)`);
1468
+ if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid ${key}: "${value}"`);
1469
+ if (n > max) throw new Error(`Invalid ${key}: "${value}" (max ${max})`);
1414
1470
  return n;
1415
1471
  }
1416
1472
 
1417
- const IDLE_TIMEOUT = parseIdleTimeout(process.env.IDLE_TIMEOUT);
1473
+ const IDLE_TIMEOUT = parseSeconds("IDLE_TIMEOUT", process.env.IDLE_TIMEOUT, 10, 255);
1418
1474
 
1419
1475
  console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
1420
1476
 
1477
+ // ─── WebSocket Limits ─────────────────────────────────────
1478
+ // Parsed once at startup. Sizes take the BODY_SIZE_LIMIT format ("16M").
1479
+ // WS_IDLE_TIMEOUT is seconds without a frame before Bun closes the socket; Bun
1480
+ // pings idle sockets on its own, so a live peer never hits it.
1481
+
1482
+ // Bun has no "no limit" for these, so 0 and "Infinity" are refused.
1483
+ function parseWsSize(key: string, value?: string): number {
1484
+ if (!value) return 16 * 1024 * 1024;
1485
+ const n = parseSize(key, value);
1486
+ if (n === 0) throw new Error(`Invalid ${key}: "${value}" (must be above 0)`);
1487
+ return n;
1488
+ }
1489
+
1490
+ const WEBSOCKET_OPTIONS: WebSocketOptions = {
1491
+ maxPayloadLength: parseWsSize("WS_MAX_PAYLOAD", process.env.WS_MAX_PAYLOAD),
1492
+ idleTimeout: parseSeconds("WS_IDLE_TIMEOUT", process.env.WS_IDLE_TIMEOUT, 120, 960),
1493
+ backpressureLimit: parseWsSize("WS_BACKPRESSURE_LIMIT", process.env.WS_BACKPRESSURE_LIMIT),
1494
+ perMessageDeflate: process.env.WS_COMPRESS === "true",
1495
+ };
1496
+
1421
1497
  // ─── Concurrency Ceiling ──────────────────────────────────
1422
1498
  // Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
1423
1499
  // Default is unlimited so existing apps see no behavior change. When set,
@@ -1452,6 +1528,8 @@ let drainResolve: (() => void) | null = null;
1452
1528
  /** Stop taking new requests; resolves once in-flight ones have finished. */
1453
1529
  export function beginShutdown(): Promise<void> {
1454
1530
  shuttingDown = true;
1531
+ // 1001 "going away": clients reconnect, to this server's replacement.
1532
+ closeAllSockets();
1455
1533
  if (inFlight === 0) return Promise.resolve();
1456
1534
  return new Promise<void>((r) => {
1457
1535
  drainResolve = r;
@@ -1480,7 +1558,6 @@ function loadBuiltManifest(): RouteManifest {
1480
1558
  layoutServers: [],
1481
1559
  errorPages: [],
1482
1560
  trailingSlash: r.trailingSlash,
1483
- cache: r.cache ?? null,
1484
1561
  prerender: false,
1485
1562
  })),
1486
1563
  apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, id: r.id, server: "" })),
@@ -1516,6 +1593,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BosiaAp
1516
1593
  // splits traffic between two different builds. Opt in only for deliberate
1517
1594
  // N-worker clustering.
1518
1595
  reusePort: process.env.BOSIA_REUSE_PORT === "1",
1596
+ websocket: WEBSOCKET_OPTIONS,
1519
1597
  },
1520
1598
  });
1521
1599
 
@@ -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
  };
package/src/core/types.ts CHANGED
@@ -28,14 +28,6 @@ export interface PageRoute {
28
28
  errorPages: { path: string; depth: number }[];
29
29
  /** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
30
30
  trailingSlash: TrailingSlash;
31
- /**
32
- * Build-time read of `export const cache` in +page.svelte, so the renderer can
33
- * decide cacheability on a cache hit without importing the page module:
34
- * `true` — no `cache` export → cacheable
35
- * `false` — statically `cache = false` → opted out
36
- * `null` — a `cache` export exists but isn't a literal `false` (dynamic) → import at runtime
37
- */
38
- cache: boolean | null;
39
31
  /** `export const prerender = true` in +page.server.ts — its data is a static file at runtime. */
40
32
  prerender: boolean;
41
33
  }
@@ -0,0 +1,232 @@
1
+ // ─── WebSockets (Bun only) ────────────────────────────────
2
+ // A `+server.ts` exports `websocket = { upgrade?, open, message, close, … }`.
3
+ // resolve() runs `upgrade()` after the hooks, then hands the request to Bun's
4
+ // `server.upgrade()`. Bun.serve takes one websocket handler for the whole
5
+ // server, so the upgrade carries the route's handlers in an envelope; open()
6
+ // keeps them per socket and sets `ws.data` to what upgrade() returned.
7
+ //
8
+ // Subprotocols are not selectable yet: `upgrade()` returns only data. To add
9
+ // it, let the export declare `protocols: string[]` and send the first one the
10
+ // client offers as `Sec-WebSocket-Protocol` in server.upgrade's headers. The
11
+ // dev proxy already passes it through.
12
+
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+
15
+ import type { Server, ServerWebSocket, WebSocketHandler as BunWebSocketHandler } from "bun";
16
+
17
+ import type { RequestEvent } from "./hooks.ts";
18
+
19
+ /** What a `+server.ts` exports as `websocket`. `ws` is Bun's ServerWebSocket. */
20
+ export interface WebSocketHandler<T extends object = {}, E = RequestEvent> {
21
+ /**
22
+ * Runs after `hooks.server.ts`, so `event.locals` is set. Return the
23
+ * connection's `ws.data` (a fresh object per connection), or a Response to
24
+ * refuse. A thrown `error()` / `redirect()` answers like an API handler.
25
+ */
26
+ upgrade?(event: E): T | Response | void | Promise<T | Response | void>;
27
+ open?(ws: ServerWebSocket<T>): unknown;
28
+ /** `string` for text frames, `Buffer` for binary frames. */
29
+ message?(ws: ServerWebSocket<T>, message: string | Buffer): unknown;
30
+ /** The socket can take more data again after `send()` returned -1. */
31
+ drain?(ws: ServerWebSocket<T>): unknown;
32
+ close?(ws: ServerWebSocket<T>, code: number, reason: string): unknown;
33
+ ping?(ws: ServerWebSocket<T>, data: Buffer): unknown;
34
+ pong?(ws: ServerWebSocket<T>, data: Buffer): unknown;
35
+ }
36
+
37
+ export interface WebSocketOptions {
38
+ maxPayloadLength: number;
39
+ idleTimeout: number;
40
+ backpressureLimit: number;
41
+ perMessageDeflate: boolean;
42
+ }
43
+
44
+ type Socket = ServerWebSocket<any>;
45
+
46
+ /** What server.upgrade() carries until open() unwraps it. */
47
+ class Envelope {
48
+ constructor(
49
+ readonly handler: WebSocketHandler<any, any>,
50
+ readonly data: object,
51
+ ) {}
52
+ }
53
+
54
+ // Per socket, not on ws.data: one object returned by two routes (a cached
55
+ // session user, say) must not carry either route's handlers.
56
+ const routeOf = new WeakMap<Socket, WebSocketHandler<any, any>>();
57
+
58
+ // Sec-WebSocket-Key too: a stray Upgrade header on a plain GET (a proxy, a
59
+ // scanner) must reach the GET handler, not run upgrade() and then 400.
60
+ export function isUpgradeRequest(request: Request): boolean {
61
+ return (
62
+ request.method === "GET" &&
63
+ request.headers.get("upgrade")?.toLowerCase() === "websocket" &&
64
+ request.headers.has("sec-websocket-key")
65
+ );
66
+ }
67
+
68
+ // One per upgrade request, set by BosiaApp.fetch. It holds the Request Bun gave
69
+ // (server.upgrade() needs that one, and a hook may pass resolve() a new one)
70
+ // and whether the upgrade happened — a per-request flag, so a hook that
71
+ // rebuilds or mutates the returned Response can't hide or leak it.
72
+ interface UpgradeContext {
73
+ request?: Request;
74
+ server?: Server<any>;
75
+ upgraded: boolean;
76
+ /** Set by open(), which Bun runs inside server.upgrade(). */
77
+ socket?: Socket;
78
+ }
79
+
80
+ const upgrades = new AsyncLocalStorage<UpgradeContext>();
81
+
82
+ export async function runUpgrade<T>(
83
+ request: Request,
84
+ server: Server<any>,
85
+ fn: () => Promise<T>,
86
+ ): Promise<T> {
87
+ const ctx: UpgradeContext = { request, server, upgraded: false };
88
+ try {
89
+ return await upgrades.run(ctx, fn);
90
+ } finally {
91
+ // A timer or pool started during this request keeps the context: it must
92
+ // not hold the Request, nor see isUpgraded() in its own later fetches.
93
+ ctx.request = ctx.server = ctx.socket = undefined;
94
+ ctx.upgraded = false;
95
+ }
96
+ }
97
+
98
+ /** True once this request has been upgraded: Bun already sent the 101. */
99
+ export function isUpgraded(): boolean {
100
+ return upgrades.getStore()?.upgraded === true;
101
+ }
102
+
103
+ /** A hook threw after the 101: close the socket, as a handler error would. */
104
+ export function closeUpgraded(): void {
105
+ const ws = upgrades.getStore()?.socket;
106
+ if (ws) closeEach([ws], 1011, "Internal error");
107
+ }
108
+
109
+ let closing = false;
110
+
111
+ // Bun answers without a protocol, so a browser that asked for one fails the
112
+ // handshake with no error on the server. Warn once per route, not per connect.
113
+ const warnedProtocol = new WeakSet<WebSocketHandler<any, any>>();
114
+
115
+ function warnProtocol(handler: WebSocketHandler<any, any>, request: Request): void {
116
+ const asked = request.headers.get("sec-websocket-protocol");
117
+ if (!asked || warnedProtocol.has(handler)) return;
118
+ warnedProtocol.add(handler);
119
+ console.warn(
120
+ `[WebSocket] ${new URL(request.url).pathname}: the client asked for Sec-WebSocket-Protocol ` +
121
+ `"${asked}", but Bosia can't pick one yet. Browsers fail this handshake; drop the ` +
122
+ `protocols argument from new WebSocket().`,
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Upgrade the current request with `data` as `ws.data`. Returns a placeholder
128
+ * Response (Bun ignores it; isUpgraded() is the signal), or the Response to
129
+ * send instead (bad handshake, a runtime without WebSockets).
130
+ */
131
+ export function upgradeRequest(
132
+ handler: WebSocketHandler<any, any>,
133
+ data: object,
134
+ headers: Headers,
135
+ ): Response {
136
+ const ctx = upgrades.getStore();
137
+ if (!ctx?.request || !ctx.server) {
138
+ return new Response("WebSockets are not supported here", { status: 501 });
139
+ }
140
+ warnProtocol(handler, ctx.request);
141
+ if (!ctx.server.upgrade(ctx.request, { data: new Envelope(handler, data) as any, headers })) {
142
+ return new Response("Bad WebSocket handshake", { status: 400 });
143
+ }
144
+ ctx.upgraded = true;
145
+ return new Response(null, { status: 200 });
146
+ }
147
+
148
+ // ─── Shared Bun handlers ─────────────────────────────────
149
+
150
+ const sockets = new Set<Socket>();
151
+
152
+ function fail(ws: Socket, err: unknown): void {
153
+ console.error("WebSocket handler error:", err);
154
+ closeEach([ws], 1011, "Internal error");
155
+ }
156
+
157
+ // A user handler's throw or rejection closes that socket with 1011; it must
158
+ // never reach the prod `uncaughtException` handler, which exits the process.
159
+ //
160
+ // Bun runs open() inside server.upgrade(), so in the upgrade request's async
161
+ // context; exit() it, so a timer or callback started in open() doesn't hold
162
+ // that request or see isUpgraded() in its own in-process fetches. (Other
163
+ // events run outside it; exit() is then a no-op.)
164
+ function run(ws: Socket, name: Exclude<keyof WebSocketHandler, "upgrade">, args: unknown[]): void {
165
+ upgrades.exit(() => call(ws, name, args));
166
+ }
167
+
168
+ function call(ws: Socket, name: Exclude<keyof WebSocketHandler, "upgrade">, args: unknown[]): void {
169
+ const handler = routeOf.get(ws);
170
+ const fn = handler?.[name] as ((...a: unknown[]) => unknown) | undefined;
171
+ if (!fn) return;
172
+ try {
173
+ const out = fn.call(handler, ws, ...args);
174
+ if (out instanceof Promise) out.catch((err) => fail(ws, err));
175
+ } catch (err) {
176
+ fail(ws, err);
177
+ }
178
+ }
179
+
180
+ export const websocketHandlers: BunWebSocketHandler<any> = {
181
+ open(ws) {
182
+ // The very object upgrade() returned becomes ws.data (an app may keep it
183
+ // in a registry).
184
+ const envelope = ws.data as Envelope;
185
+ routeOf.set(ws, envelope.handler);
186
+ ws.data = envelope.data;
187
+ sockets.add(ws);
188
+ const ctx = upgrades.getStore();
189
+ if (ctx) ctx.socket = ws;
190
+ run(ws, "open", []);
191
+ // An upgrade still in its async upgrade() during the shutdown sweep opens
192
+ // after it: the app has seen open() and gets close(), so it can clean up.
193
+ if (closing) ws.close(1001, "Server shutting down");
194
+ },
195
+ message(ws, message) {
196
+ run(ws, "message", [message]);
197
+ },
198
+ drain(ws) {
199
+ run(ws, "drain", []);
200
+ },
201
+ close(ws, code, reason) {
202
+ sockets.delete(ws);
203
+ run(ws, "close", [code, reason]);
204
+ },
205
+ ping(ws, data) {
206
+ run(ws, "ping", [data]);
207
+ },
208
+ pong(ws, data) {
209
+ run(ws, "pong", [data]);
210
+ },
211
+ };
212
+
213
+ /** Shutdown: close every open socket with 1001 (clients reconnect elsewhere),
214
+ * and any that opens from here on. */
215
+ export function closeAllSockets(): void {
216
+ closing = true;
217
+ closeEach(sockets, 1001, "Server shutting down");
218
+ }
219
+
220
+ export function closeEach(
221
+ list: Iterable<ServerWebSocket<any>>,
222
+ code: number,
223
+ reason: string,
224
+ ): void {
225
+ for (const ws of [...list]) {
226
+ try {
227
+ ws.close(code, reason);
228
+ } catch {
229
+ /* already closed */
230
+ }
231
+ }
232
+ }
@@ -19,6 +19,7 @@ export interface WorkersGuardHit {
19
19
  }
20
20
 
21
21
  const SERVER_FILE = /(^|\/)(hooks\.server\.(ts|js)|\+server\.(ts|js)|\+[^/]*\.server\.(ts|js))$/;
22
+ const API_FILE = /(^|\/)\+server\.(ts|js)$/;
22
23
  const LIB_SERVER = /^lib\/server\/.*\.(ts|js)$/;
23
24
  const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
24
25
 
@@ -54,13 +55,27 @@ export function findWorkersIncompatible(srcDir = "./src"): WorkersGuardHit[] {
54
55
  const lines = source.split("\n");
55
56
 
56
57
  let imports: { path: string }[] = [];
58
+ let exports: string[] = [];
57
59
  try {
58
- imports = new Bun.Transpiler({ loader: rel.endsWith(".js") ? "js" : "ts" }).scan(
59
- source,
60
- ).imports;
60
+ ({ imports, exports } = new Bun.Transpiler({
61
+ loader: rel.endsWith(".js") ? "js" : "ts",
62
+ }).scan(source));
61
63
  } catch {
62
64
  continue; // syntax error — the real build reports it better
63
65
  }
66
+ // Any export form (`const`, `let`, `export { websocket }`) shows up here.
67
+ if (API_FILE.test(rel) && exports.includes("websocket")) {
68
+ const i = Math.max(
69
+ 0,
70
+ lines.findIndex((l) => /\bwebsocket\b/.test(l) && /\bexport\b/.test(l)),
71
+ );
72
+ hits.push({
73
+ file,
74
+ line: i + 1,
75
+ col: 1,
76
+ message: "exports `websocket` — WebSockets are Bun-only for now",
77
+ });
78
+ }
64
79
  for (const { path } of imports) {
65
80
  const message = badImport(path);
66
81
  if (!message) continue;
package/src/lib/index.ts CHANGED
@@ -34,6 +34,7 @@ export type {
34
34
  PlatformEnv,
35
35
  } from "../core/hooks.ts";
36
36
  export type { CsrfConfig } from "../core/csrf.ts";
37
+ export type { WebSocketHandler } from "../core/websocket.ts";
37
38
  export type { CorsConfig } from "../core/cors.ts";
38
39
  export { defineConfig } from "../core/types/plugin.ts";
39
40
  export type {