//
// "bun-types" is currently conflicting with "ws" types.
// @ts-ignore
import { Server, ServerWebSocket, WebSocketHandler } from 'bun';
import { matchMaker, Protocol, Transport, debugAndPrintError, getBearerToken, CloseCode, connectClientToRoom, spliceOne, isDevMode, type Router } from '@colyseus/core';
import { WebSocketClient, WebSocketWrapper } from './WebSocketClient.ts';
import type { Application } from "express";
import bunExpress, { IncomingMessage, ServerResponse } from 'bun-serve-express';
// Bun global is available at runtime
declare const Bun: any;
export type TransportOptions = Partial, "message" | "open" | "drain" | "close" | "ping" | "pong">>;
interface WebSocketData {
url: string;
searchParams: URLSearchParams;
headers: Headers;
remoteAddress: string;
}
export class BunWebSockets extends Transport {
protected clients: ServerWebSocket[] = [];
protected clientWrappers = new WeakMap, WebSocketWrapper>();
private _server: Server | undefined;
private _expressApp: Application | undefined;
private _router: Router | undefined;
private _originalRawSend: typeof WebSocketClient.prototype.raw | null = null;
private options: TransportOptions = {};
constructor(options: TransportOptions = {}) {
super();
if (options.maxPayloadLength === undefined) {
options.maxPayloadLength = 4 * 1024;
}
this.options = options;
}
public getExpressApp(): Application {
if (!this._expressApp) {
// @ts-ignore
this._expressApp = bunExpress({});
}
return this._expressApp;
}
public bindRouter(router: Router) {
this._router = router;
}
public listen(port: number, hostname?: string, backlog?: number, listeningListener?: () => void) {
const self = this;
this._server = Bun.serve({
port,
hostname,
async fetch(req, server) {
const url = new URL(req.url);
// Try to upgrade to WebSocket
if (server.upgrade(req, {
data: {
url: url.pathname,
searchParams: url.searchParams,
headers: req.headers as Headers,
remoteAddress: server.requestIP(req)?.address || 'unknown',
}
})) {
return; // WebSocket upgrade successful
}
// Handle HTTP requests through router
// Write CORS headers
const corsHeaders = {
...matchMaker.controller.DEFAULT_CORS_HEADERS,
...matchMaker.controller.getCorsHeaders(req.headers)
};
// Handle OPTIONS requests
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders
});
}
if (self._router?.findRoute(req.method, url.pathname) !== undefined) {
const response = await self._router.handler(req);
// Add CORS headers to response
const headers = new Headers(response.headers);
Object.entries(corsHeaders).forEach(([key, value]) => {
if (!headers.has(key)) {
headers.set(key, value.toString());
}
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
} else if (self._expressApp) {
// Fallback to express routes
const ereq = new IncomingMessage(req, url, self._expressApp);
const eres = new ServerResponse(ereq, self._expressApp);
// Apply CORS headers through the Express response wrapper
Object.entries(corsHeaders).forEach(([key, value]) => {
eres.setHeader(key, value.toString());
});
// Read the request body before passing to express
if (req.method !== "GET" && req.method !== "HEAD") {
await (ereq as any).readBody();
}
(ereq as any).complete = true;
self._expressApp['handle'](ereq, eres);
return await eres.getBunResponse();
}
return new Response("Not Found", { status: 404 });
},
websocket: {
...this.options,
async open(ws) {
await self.onConnection(ws);
},
message(ws, message) {
self.clientWrappers.get(ws)?.emit('message', Buffer.from(message));
},
close(ws, code, reason) {
// remove from client list
spliceOne(self.clients, self.clients.indexOf(ws));
const clientWrapper = self.clientWrappers.get(ws);
if (clientWrapper) {
self.clientWrappers.delete(ws);
// emit 'close' on wrapper
clientWrapper.emit('close', code);
}
},
}
});
listeningListener?.();
return this;
}
public shutdown() {
if (this._server) {
this._server.stop(true);
}
}
public simulateLatency(milliseconds: number) {
if (this._originalRawSend == null) {
this._originalRawSend = WebSocketClient.prototype.raw;
}
const originalRawSend = this._originalRawSend;
WebSocketClient.prototype.raw = milliseconds <= Number.EPSILON ? originalRawSend : function (...args: any[]) {
let [buf, ...rest] = args;
buf = Buffer.from(buf);
// @ts-ignore
setTimeout(() => originalRawSend.apply(this, [buf, ...rest]), milliseconds);
};
}
protected async onConnection(rawClient: ServerWebSocket) {
const wrapper = new WebSocketWrapper(rawClient);
// keep reference to client and its wrapper
this.clients.push(rawClient);
this.clientWrappers.set(rawClient, wrapper);
const url = rawClient.data.url;
const searchParams = rawClient.data.searchParams;
const sessionId = searchParams.get("sessionId");
const processAndRoomId = url.match(/\/[a-zA-Z0-9_\-]+\/([a-zA-Z0-9_\-]+)$/);
const roomId = processAndRoomId && processAndRoomId[1];
// If sessionId is not provided, allow ping-pong utility.
if (!sessionId && !roomId) {
// Disconnect automatically after 1 second if no message is received.
const timeout = setTimeout(() => rawClient.close(CloseCode.NORMAL_CLOSURE), 1000);
wrapper.on('message', (_) => rawClient.send(new Uint8Array([Protocol.PING])));
wrapper.on('close', () => clearTimeout(timeout));
return;
}
const room = matchMaker.getLocalRoomById(roomId);
const client = new WebSocketClient(sessionId, wrapper);
const reconnectionToken = searchParams.get("reconnectionToken");
const skipHandshake = searchParams.has("skipHandshake");
try {
await connectClientToRoom(room, client, {
token: searchParams.get("_authToken") ?? getBearerToken(rawClient.data.headers.get('authorization')),
headers: rawClient.data.headers,
ip: rawClient.data.headers.get('x-real-ip') ?? rawClient.data.headers.get('x-forwarded-for') ?? rawClient.data.remoteAddress,
}, {
reconnectionToken,
skipHandshake
});
} catch (e: any) {
debugAndPrintError(e);
// send error code to client then terminate.
// Use MAY_TRY_RECONNECT in devMode so the SDK retries — the seat
// may not be reserved yet during HMR reload.
client.error(e.code, e.message, () =>
rawClient.close(reconnectionToken
? (isDevMode)
? CloseCode.MAY_TRY_RECONNECT
: CloseCode.FAILED_TO_RECONNECT
: CloseCode.WITH_ERROR));
}
}
}