Ecopages0.2.0-rc.1

WebSockets

Ecopages provides a runtime-agnostic WebSocket API that works identically on Bun and Node. Register WebSocket handlers with app.websocket() using the same dynamic segment syntax as HTTP routes.

Quick Start

import { createApp } from '@ecopages/core/create-app';
import appConfig from './eco.config';
 
const app = await createApp({ appConfig });
 
app.websocket('/ws/echo', {
	onMessage(socket, message) {
		if (message.kind === 'text') {
			socket.send(message.text);
		}
	},
});
 
await app.start();

Connect from the browser:

const ws = new WebSocket('/ws/echo');
ws.onmessage = (event) => console.log(event.data);
ws.send('Hello');

Dynamic Segments

Use :param syntax to capture URL segments. The handler receives typed params per connection.

app.websocket<{ username: string }, { roomId: string }>('/ws/chat/:roomId', {
	async context({ params, search }) {
		return { username: search.username ?? 'anonymous' };
	},
	onConnect(socket) {
		console.log(`${socket.context.username} joined room ${socket.params.roomId}`);
	},
});

A single registration matches all variations of the pattern. /ws/chat/abc, /ws/chat/xyz, etc. each receive their own params.roomId.

Handler Interface

EcopagesWebSocketHandler<TContext, TParams> accepts these optional hooks:

HookWhenPurpose
context()Once per upgradeBuild per-connection state. Return value becomes socket.context. Rejecting the promise aborts the upgrade.
onConnect(socket)After upgradeRegister the client, send initial data.
onMessage(socket, message)Each frameHandle incoming text or binary frames.
onClose(socket, event)After disconnectClean up resources.
onError(socket, error)On errorLog or recover from errors.
import type { EcopagesWebSocketHandler } from '@ecopages/core';
 
const handler: EcopagesWebSocketHandler<{ userId: string }, { channel: string }> = {
	async context({ params, search, request }) {
		const userId = await authenticateRequest(request);
		return { userId };
	},
	onConnect(socket) {
		subscribe(socket.params.channel, socket);
	},
	onMessage(socket, message) {
		if (message.kind === 'text') {
			broadcast(socket.params.channel, message.text);
		}
	},
	onClose(socket) {
		unsubscribe(socket.params.channel, socket);
	},
	onError(socket, error) {
		console.error(`Error for ${socket.context.userId}:`, error);
	},
};

Socket API

Each EcopagesSocket instance provides:

MethodSignatureDescription
send(message: OutgoingWebSocketMessage) => voidSend text, binary, or stream data.
sendStream(stream: ReadableStream<Uint8Array>) => Promise<void>Stream binary data to the client.
close(code?: number, reason?: string) => voidClose the connection.

Read-only properties:

PropertyTypeDescription
kindstringThe registered route pattern (e.g. /ws/chat/:roomId).
paramsTParamsDynamic segments captured at match time.
searchRecord<string, string>Query string parameters.
contextTContextPer-connection state from context().

Message Types

Incoming messages use a discriminated union:

type IncomingWebSocketMessage =
	| { kind: 'text'; text: string }
	| { kind: 'binary'; data: Uint8Array };

Outgoing messages accept: string, Uint8Array, ArrayBuffer, ArrayBufferView, or Blob.

Broadcasting

Build a room-based broadcast using a Map of client sets:

import type { EcopagesSocket } from '@ecopages/core';
 
const rooms = new Map<string, Set<EcopagesSocket>>();
 
function broadcast(roomId: string, payload: unknown): void {
	const clients = rooms.get(roomId);
	if (!clients) return;
	const msg = JSON.stringify(payload);
	for (const client of clients) {
		try {
			client.send(msg);
		} catch {
			clients.delete(client);
		}
	}
}
 
app.websocket('/ws/room/:id', {
	onConnect(socket) {
		const { id } = socket.params;
		let clients = rooms.get(id);
		if (!clients) {
			clients = new Set();
			rooms.set(id, clients);
		}
		clients.add(socket);
	},
	onMessage(socket, message) {
		if (message.kind === 'text') {
			broadcast(socket.params.id, { text: message.text });
		}
	},
	onClose(socket) {
		const clients = rooms.get(socket.params.id);
		clients?.delete(socket);
		if (clients?.size === 0) {
			rooms.delete(socket.params.id);
		}
	},
});

Full Example: Room-Based Chat

This is the pattern used in the kitchen-sink playground:

import type { EcopagesSocket, EcopagesWebSocketHandler } from '@ecopages/core';
 
type ChatData = { username: string; roomId: string };
 
const clients = new Map<string, Set<EcopagesSocket<ChatData>>>();
const history = new Map<string, { id: string; username: string; text: string; ts: number }[]>();
 
export const chatHandler: EcopagesWebSocketHandler<ChatData, { roomId: string }> = {
	async context({ params, search }) {
		return {
			username: search.username ?? 'anonymous',
			roomId: params.roomId,
		};
	},
 
	onConnect(socket) {
		const { roomId } = socket.context;
		let room = clients.get(roomId);
		if (!room) {
			room = new Set();
			clients.set(roomId, room);
		}
		room.add(socket);
 
		socket.send(JSON.stringify({
			type: 'history',
			messages: history.get(roomId) ?? [],
		}));
	},
 
	onMessage(socket, message) {
		if (message.kind !== 'text') return;
 
		let payload: { type?: string; text?: string };
		try {
			payload = JSON.parse(message.text);
		} catch {
			return;
		}
 
		if (payload.type !== 'message' || !payload.text?.trim()) return;
 
		const msg = {
			id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
			username: socket.context.username,
			text: payload.text.trim(),
			ts: Date.now(),
		};
 
		let roomHistory = history.get(socket.context.roomId);
		if (!roomHistory) {
			roomHistory = [];
			history.set(socket.context.roomId, roomHistory);
		}
		roomHistory.push(msg);
 
		const room = clients.get(socket.context.roomId);
		if (!room) return;
		const out = JSON.stringify({ type: 'message', message: msg });
		for (const client of room) {
			try { client.send(out); } catch { room.delete(client); }
		}
	},
 
	onClose(socket) {
		const room = clients.get(socket.context.roomId);
		room?.delete(socket);
		if (room?.size === 0) {
			clients.delete(socket.context.roomId);
		}
	},
};

Register it:

// app.ts
import { createApp } from '@ecopages/core/create-app';
import { chatHandler } from './src/handlers/chat';
import appConfig from './eco.config';
 
const app = await createApp({ appConfig });
 
app.websocket('/ws/chat/:roomId', chatHandler);
 
await app.start();

Connect with a username:

const ws = new WebSocket('/ws/chat/lobby?username=alice');
 
ws.onmessage = (event) => {
	const data = JSON.parse(event.data);
	if (data.type === 'history') {
		console.log('Chat history:', data.messages);
	} else if (data.type === 'message') {
		console.log(`${data.message.username}: ${data.message.text}`);
	}
};
 
ws.send(JSON.stringify({ type: 'message', text: 'Hello everyone!' }));

Route Matching

WebSocket routes use the same pattern matching as HTTP routes. The framework selects the most specific match based on:

  1. More literal segments beat more dynamic segments.
  2. Fewer dynamic segments beat more dynamic segments.
  3. Longer patterns beat shorter patterns.
app.websocket('/ws/admin', adminHandler);       // Matches /ws/admin
app.websocket('/ws/:type', typeHandler);         // Matches /ws/anything
app.websocket('/ws/:type/:id', detailHandler);   // Matches /ws/anything/something

A request to /ws/admin matches the literal pattern, not the dynamic one.

Runtime Compatibility

The WebSocket API is runtime-agnostic. The same handler code works on both Bun and Node without changes. Each runtime uses its native WebSocket implementation under the hood:

  • Bun: Uses Bun's built-in WebSocket server.
  • Node: Uses the ws package with HTTP upgrade handling.

Import types from @ecopages/core (not from runtime-specific modules):

import type {
	EcopagesWebSocketHandler,
	EcopagesSocket,
	IncomingWebSocketMessage,
} from '@ecopages/core';

No Manual Upgrade Route

The framework handles the HTTP-to-WebSocket upgrade implicitly. Do not register a manual GET route for your WebSocket paths. The app.websocket() call is sufficient.