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,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 {