@firtoz/websocket-do 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,321 @@
1
+ # @firtoz/websocket-do
2
+
3
+ Type-safe WebSocket session management for Cloudflare Durable Objects with Hono integration.
4
+
5
+ ## Features
6
+
7
+ - 🔒 **Type-safe** - Full TypeScript support with generic types for messages and session data
8
+ - 🌐 **WebSocket Management** - Built on Cloudflare Durable Objects for stateful WebSocket connections
9
+ - 🎯 **Session-based** - Abstract session class for easy implementation of custom WebSocket logic
10
+ - 🔄 **State Persistence** - Automatic serialization/deserialization of session data
11
+ - 📡 **Broadcasting** - Built-in support for broadcasting messages to all connected clients
12
+ - 🚀 **Hono Integration** - Seamless integration with Hono framework for routing
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bun add @firtoz/websocket-do
18
+ ```
19
+
20
+ ### Peer Dependencies
21
+
22
+ This package requires the following peer dependencies:
23
+
24
+ ```bash
25
+ bun add hono @cloudflare/workers-types @firtoz/hono-fetcher
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Define Your Message Types
31
+
32
+ ```typescript
33
+ type ServerMessage =
34
+ | { type: 'welcome'; userId: string }
35
+ | { type: 'chat'; message: string; from: string };
36
+
37
+ type ClientMessage =
38
+ | { type: 'chat'; message: string }
39
+ | { type: 'ping' };
40
+
41
+ interface SessionData {
42
+ userId: string;
43
+ joinedAt: number;
44
+ }
45
+ ```
46
+
47
+ ### 2. Implement Your Session
48
+
49
+ ```typescript
50
+ import { BaseSession } from '@firtoz/websocket-do';
51
+ import type { Context } from 'hono';
52
+
53
+ class ChatSession extends BaseSession<
54
+ Env,
55
+ SessionData,
56
+ ServerMessage,
57
+ ClientMessage
58
+ > {
59
+ protected createData(ctx: Context<{ Bindings: Env }>): SessionData {
60
+ return {
61
+ userId: crypto.randomUUID(),
62
+ joinedAt: Date.now(),
63
+ };
64
+ }
65
+
66
+ async handleMessage(message: ClientMessage): Promise<void> {
67
+ switch (message.type) {
68
+ case 'chat':
69
+ // Broadcast to all sessions
70
+ this.broadcast({
71
+ type: 'chat',
72
+ message: message.message,
73
+ from: this.data.userId,
74
+ });
75
+ break;
76
+ case 'ping':
77
+ this.send({ type: 'welcome', userId: this.data.userId });
78
+ break;
79
+ }
80
+ }
81
+
82
+ async handleBufferMessage(message: ArrayBuffer): Promise<void> {
83
+ // Handle binary messages if needed
84
+ }
85
+
86
+ async handleClose(): Promise<void> {
87
+ console.log(`Session closed for user ${this.data.userId}`);
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### 3. Implement Your Durable Object
93
+
94
+ ```typescript
95
+ import { BaseWebSocketDO } from '@firtoz/websocket-do';
96
+ import { Hono } from 'hono';
97
+
98
+ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
99
+ app = this.getBaseApp()
100
+ .get('/info', (ctx) => {
101
+ return ctx.json({
102
+ connectedUsers: this.sessions.size,
103
+ });
104
+ });
105
+
106
+ protected createSession(websocket: WebSocket): ChatSession {
107
+ return new ChatSession(websocket, this.sessions);
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### 4. Configure Your Worker
113
+
114
+ ```typescript
115
+ // wrangler.toml
116
+ [[durable_objects.bindings]]
117
+ name = "CHAT_ROOM"
118
+ class_name = "ChatRoomDO"
119
+ script_name = "your-worker-name"
120
+
121
+ [[migrations]]
122
+ tag = "v1"
123
+ new_classes = ["ChatRoomDO"]
124
+ ```
125
+
126
+ ### 5. Access from Your Worker
127
+
128
+ ```typescript
129
+ export default {
130
+ async fetch(request: Request, env: Env): Promise<Response> {
131
+ const url = new URL(request.url);
132
+
133
+ if (url.pathname === '/chat') {
134
+ const id = env.CHAT_ROOM.idFromName('global-chat');
135
+ const stub = env.CHAT_ROOM.get(id);
136
+
137
+ // Proxy to the Durable Object
138
+ return stub.fetch(request);
139
+ }
140
+
141
+ return new Response('Not found', { status: 404 });
142
+ }
143
+ };
144
+ ```
145
+
146
+ ## API Reference
147
+
148
+ ### `BaseWebSocketDO<TEnv, TSession>`
149
+
150
+ Abstract class for creating WebSocket-enabled Durable Objects.
151
+
152
+ #### Type Parameters
153
+
154
+ - `TEnv` - Your Cloudflare Worker environment bindings
155
+ - `TSession` - Your session class extending `BaseSession`
156
+
157
+ #### Methods
158
+
159
+ - `abstract createSession(websocket: WebSocket): TSession | Promise<TSession>`
160
+ - Factory method to create session instances
161
+
162
+ - `getBaseApp(): Hono`
163
+ - Returns a base Hono app with `/websocket` endpoint configured
164
+
165
+ - `handleSession(ctx: Context, ws: WebSocket): Promise<void>`
166
+ - Handles new WebSocket connections
167
+
168
+ #### Properties
169
+
170
+ - `sessions: Map<WebSocket, TSession>` - Map of all active sessions
171
+ - `app: Hono` - Your Hono application (must be implemented)
172
+
173
+ ### `BaseSession<TEnv, TData, TServerMessage, TClientMessage>`
174
+
175
+ Abstract class for managing individual WebSocket sessions.
176
+
177
+ #### Type Parameters
178
+
179
+ - `TEnv` - Your Cloudflare Worker environment bindings
180
+ - `TData` - Type of data stored in the session
181
+ - `TServerMessage` - Union type of messages sent to clients
182
+ - `TClientMessage` - Union type of messages received from clients
183
+
184
+ #### Methods
185
+
186
+ - `abstract createData(ctx: Context): TData`
187
+ - Creates initial session data
188
+
189
+ - `abstract handleMessage(message: TClientMessage): Promise<void>`
190
+ - Handles text messages from client
191
+
192
+ - `abstract handleBufferMessage(message: ArrayBuffer): Promise<void>`
193
+ - Handles binary messages from client
194
+
195
+ - `abstract handleClose(): Promise<void>`
196
+ - Cleanup when session closes
197
+
198
+ - `protected send(message: TServerMessage): void`
199
+ - Send message to this session's client
200
+
201
+ - `protected broadcast(message: TServerMessage, excludeSelf?: boolean): void`
202
+ - Send message to all connected sessions
203
+
204
+ - `startFresh(ctx: Context): void`
205
+ - Initialize new session (called automatically)
206
+
207
+ - `resume(): void`
208
+ - Resume existing session after hibernation (called automatically)
209
+
210
+ - `update(): void`
211
+ - Manually update serialized session data
212
+
213
+ #### Properties
214
+
215
+ - `data: TData` - Current session data
216
+ - `websocket: WebSocket` - The underlying WebSocket
217
+
218
+ ### `WebsocketWrapper<TAttachment, TMessage>`
219
+
220
+ Low-level wrapper for typed WebSocket operations.
221
+
222
+ #### Methods
223
+
224
+ - `send(message: TMessage): void`
225
+ - Send JSON-serialized message
226
+
227
+ - `deserializeAttachment(): TAttachment`
228
+ - Get attached session data
229
+
230
+ - `serializeAttachment(attachment: TAttachment): void`
231
+ - Update attached session data
232
+
233
+ ## Advanced Usage
234
+
235
+ ### Custom Routes
236
+
237
+ You can extend the base app with custom routes:
238
+
239
+ ```typescript
240
+ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
241
+ app = this.getBaseApp()
242
+ .get('/stats', (ctx) => {
243
+ const users = Array.from(this.sessions.values()).map(s => ({
244
+ userId: s.data.userId,
245
+ joinedAt: s.data.joinedAt,
246
+ }));
247
+
248
+ return ctx.json({ users, count: users.length });
249
+ })
250
+ .post('/broadcast', async (ctx) => {
251
+ const { message } = await ctx.req.json();
252
+
253
+ for (const session of this.sessions.values()) {
254
+ session.send({ type: 'admin', message });
255
+ }
256
+
257
+ return ctx.json({ success: true });
258
+ });
259
+ }
260
+ ```
261
+
262
+ ### State Persistence
263
+
264
+ Session data is automatically serialized and persists across hibernation:
265
+
266
+ ```typescript
267
+ class GameSession extends BaseSession<Env, GameData, ServerMsg, ClientMsg> {
268
+ protected createData(ctx: Context): GameData {
269
+ return {
270
+ playerName: ctx.req.query('name') || 'Anonymous',
271
+ score: 0,
272
+ inventory: [],
273
+ };
274
+ }
275
+
276
+ async handleMessage(message: ClientMsg): Promise<void> {
277
+ if (message.type === 'collectItem') {
278
+ this.data.inventory.push(message.item);
279
+ this.data.score += 10;
280
+
281
+ // Persist changes
282
+ this.update();
283
+
284
+ this.send({ type: 'scoreUpdate', score: this.data.score });
285
+ }
286
+ }
287
+ }
288
+ ```
289
+
290
+ ### Error Handling
291
+
292
+ Errors in message handlers are caught and logged, but don't crash the connection:
293
+
294
+ ```typescript
295
+ async handleMessage(message: ClientMessage): Promise<void> {
296
+ try {
297
+ // Your logic here
298
+ if (message.type === 'dangerous') {
299
+ throw new Error('Invalid operation');
300
+ }
301
+ } catch (error) {
302
+ // Send error to client
303
+ this.send({
304
+ type: 'error',
305
+ message: error instanceof Error ? error.message : 'Unknown error'
306
+ });
307
+
308
+ // Optionally close the connection
309
+ this.websocket.close(1008, 'Policy violation');
310
+ }
311
+ }
312
+ ```
313
+
314
+ ## License
315
+
316
+ MIT
317
+
318
+ ## Contributing
319
+
320
+ See [CONTRIBUTING.md](../../CONTRIBUTING.md) for details on how to contribute to this package.
321
+
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@firtoz/websocket-do",
3
+ "version": "1.0.0",
4
+ "description": "Type-safe WebSocket session management for Cloudflare Durable Objects with Hono integration",
5
+ "main": "./src/index.ts",
6
+ "module": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts",
12
+ "require": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/**/*.ts",
17
+ "!src/**/*.test.ts",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "biome lint src --write",
23
+ "format": "biome format src --write",
24
+ "test": "bun test",
25
+ "test:watch": "bun test --watch"
26
+ },
27
+ "keywords": [
28
+ "typescript",
29
+ "websocket",
30
+ "durable-objects",
31
+ "cloudflare",
32
+ "cloudflare-workers",
33
+ "hono",
34
+ "session-management",
35
+ "type-safe"
36
+ ],
37
+ "author": "Firtina Ozbalikchi <firtoz@github.com>",
38
+ "license": "MIT",
39
+ "homepage": "https://github.com/firtoz/fullstack-toolkit#readme",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/firtoz/fullstack-toolkit.git",
43
+ "directory": "packages/websocket-do"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/firtoz/fullstack-toolkit/issues"
47
+ },
48
+ "peerDependencies": {
49
+ "@cloudflare/workers-types": "^4.20251004.0",
50
+ "@firtoz/hono-fetcher": "workspace:*",
51
+ "hono": "^4.9.9"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "devDependencies": {
60
+ "bun-types": "^1.2.23"
61
+ }
62
+ }
@@ -0,0 +1,75 @@
1
+ import type { Context } from "hono";
2
+ import { WebsocketWrapper } from "./WebsocketWrapper";
3
+
4
+ export type SessionClientMessage<TSession extends BaseSession> =
5
+ TSession extends BaseSession<never, never, infer TClientMessage, never>
6
+ ? TClientMessage
7
+ : never;
8
+
9
+ export abstract class BaseSession<
10
+ // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
11
+ TEnv extends object = any,
12
+ // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
13
+ TData = any,
14
+ // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
15
+ TServerMessage = any,
16
+ // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
17
+ TClientMessage = any,
18
+ > {
19
+ private _data!: TData;
20
+
21
+ public get data(): TData {
22
+ return this._data;
23
+ }
24
+
25
+ private set data(data: TData) {
26
+ this._data = data;
27
+ }
28
+
29
+ private readonly wrapper: WebsocketWrapper<TData, TServerMessage>;
30
+
31
+ constructor(
32
+ public websocket: WebSocket,
33
+ protected sessions: Map<
34
+ WebSocket,
35
+ BaseSession<TEnv, TData, TServerMessage, TClientMessage>
36
+ >,
37
+ ) {
38
+ this.wrapper = new WebsocketWrapper<TData, TServerMessage>(websocket);
39
+ }
40
+
41
+ public startFresh(ctx: Context<{ Bindings: TEnv }>) {
42
+ this.data = this.createData(ctx);
43
+ this.wrapper.serializeAttachment(this.data);
44
+ }
45
+
46
+ public resume() {
47
+ const existingData = this.wrapper.deserializeAttachment();
48
+ if (existingData) {
49
+ this.data = existingData;
50
+ } else {
51
+ throw new Error("No data to resume");
52
+ }
53
+ }
54
+
55
+ public update() {
56
+ this.wrapper.serializeAttachment(this.data);
57
+ }
58
+
59
+ protected abstract createData(ctx: Context<{ Bindings: TEnv }>): TData;
60
+
61
+ protected broadcast(message: TServerMessage, excludeSelf = false) {
62
+ for (const session of this.sessions.values()) {
63
+ if (excludeSelf && session === this) continue;
64
+ session.send(message);
65
+ }
66
+ }
67
+
68
+ protected send(message: TServerMessage) {
69
+ this.wrapper.send(message);
70
+ }
71
+
72
+ abstract handleMessage(message: TClientMessage): Promise<void>;
73
+ abstract handleBufferMessage(message: ArrayBuffer): Promise<void>;
74
+ abstract handleClose(): Promise<void>;
75
+ }
@@ -0,0 +1,178 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import type { DOWithHonoApp } from "@firtoz/hono-fetcher/honoDoFetcher";
3
+ import { type Context, Hono } from "hono";
4
+ import type { BaseSession, SessionClientMessage } from "./BaseSession";
5
+
6
+ export abstract class BaseWebSocketDO<
7
+ TEnv extends object,
8
+ TSession extends BaseSession<TEnv>,
9
+ >
10
+ extends DurableObject<TEnv>
11
+ implements DOWithHonoApp
12
+ {
13
+ protected readonly sessions = new Map<WebSocket, TSession>();
14
+
15
+ constructor(ctx: DurableObjectState, env: TEnv) {
16
+ super(ctx, env);
17
+
18
+ this.ctx.blockConcurrencyWhile(async () => {
19
+ const websockets = this.ctx.getWebSockets();
20
+ await Promise.all(
21
+ websockets.map(async (websocket) => {
22
+ try {
23
+ const session = await this.createSession(websocket);
24
+ session.resume();
25
+ this.sessions.set(websocket, session);
26
+ } catch (error) {
27
+ console.error(`Error during session setup: ${error}`);
28
+ await this.webSocketError(websocket, error);
29
+ }
30
+ }),
31
+ );
32
+ });
33
+ }
34
+
35
+ protected getBaseApp() {
36
+ return new Hono<{ Bindings: TEnv }>().get(
37
+ "/websocket",
38
+ async (ctx): Promise<Response> => {
39
+ const { req } = ctx;
40
+ if (req.header("Upgrade") !== "websocket") {
41
+ console.error("Expected websocket");
42
+ return ctx.text("Expected websocket", 400);
43
+ }
44
+
45
+ const [client, server] = Object.values(new WebSocketPair()) as [
46
+ WebSocket,
47
+ WebSocket,
48
+ ];
49
+
50
+ try {
51
+ await this.handleSession(ctx, server);
52
+ return new Response(null, { status: 101, webSocket: client });
53
+ } catch (error) {
54
+ console.error(error);
55
+ client.accept();
56
+ client.send(
57
+ JSON.stringify({
58
+ error: "Uncaught exception during session setup.",
59
+ }),
60
+ );
61
+ client.close(1011, "Uncaught exception during session setup.");
62
+ return new Response(null, { status: 101, webSocket: client });
63
+ }
64
+ },
65
+ );
66
+ }
67
+
68
+ abstract app: Hono<{ Bindings: TEnv }>;
69
+
70
+ protected abstract createSession(
71
+ websocket: WebSocket,
72
+ ): TSession | Promise<TSession>;
73
+
74
+ async handleSession(
75
+ ctx: Context<{ Bindings: TEnv }>,
76
+ ws: WebSocket,
77
+ ): Promise<void> {
78
+ this.ctx.acceptWebSocket(ws);
79
+ try {
80
+ const session = await this.createSession(ws);
81
+ session.startFresh(ctx);
82
+ this.sessions.set(ws, session);
83
+ } catch (error) {
84
+ console.error(`Error during session setup: ${error}`);
85
+ await this.webSocketError(ws, error);
86
+ }
87
+ }
88
+
89
+ override async webSocketMessage(
90
+ ws: WebSocket,
91
+ message: string | ArrayBuffer,
92
+ ): Promise<void> {
93
+ const session = this.sessions.get(ws);
94
+ if (!session) return;
95
+
96
+ try {
97
+ if (message instanceof ArrayBuffer) {
98
+ await session.handleBufferMessage(message);
99
+ return;
100
+ }
101
+
102
+ const parsed = JSON.parse(message) as SessionClientMessage<TSession>;
103
+ await session.handleMessage(parsed);
104
+ } catch (error) {
105
+ console.error(`Error during session message: ${error}`);
106
+ // Let the implementer decide how to handle errors in their session implementation
107
+ // The session can optionally implement error handling that closes the connection if needed
108
+ }
109
+ }
110
+
111
+ override async webSocketClose(
112
+ ws: WebSocket,
113
+ _code: number,
114
+ _reason: string,
115
+ _wasClean: boolean,
116
+ ) {
117
+ const session = this.sessions.get(ws);
118
+ if (!session) return;
119
+
120
+ try {
121
+ await this.#handleClose(session);
122
+ } catch (error) {
123
+ console.error(`Error during session close: ${error}`);
124
+ } finally {
125
+ // Call close() for both OPEN and CLOSING states
126
+ // For CLOSING, this can help ensure the WebSocket fully transitions to CLOSED
127
+ if (
128
+ ws.readyState === WebSocket.OPEN ||
129
+ ws.readyState === WebSocket.CLOSING
130
+ ) {
131
+ ws.close(1000, "Normal closure");
132
+ }
133
+ }
134
+ }
135
+
136
+ override async webSocketError(ws: WebSocket, error: unknown) {
137
+ const session = this.sessions.get(ws);
138
+ if (!session) {
139
+ // Call close() for both OPEN and CLOSING states
140
+ if (
141
+ ws.readyState === WebSocket.OPEN ||
142
+ ws.readyState === WebSocket.CLOSING
143
+ ) {
144
+ ws.close(1011, "Error during session setup.");
145
+ }
146
+ return;
147
+ }
148
+
149
+ console.error(`Error for session: ${error}`);
150
+ try {
151
+ await this.#handleClose(session);
152
+ } catch (error) {
153
+ console.error(`Error during session close: ${error}`);
154
+ } finally {
155
+ // Call close() for both OPEN and CLOSING states
156
+ if (
157
+ ws.readyState === WebSocket.OPEN ||
158
+ ws.readyState === WebSocket.CLOSING
159
+ ) {
160
+ ws.close(1011, "Error during session.");
161
+ }
162
+ }
163
+ }
164
+
165
+ async #handleClose(session: TSession) {
166
+ try {
167
+ await session.handleClose();
168
+ } catch (error) {
169
+ console.error(`Error during session close: ${error}`);
170
+ } finally {
171
+ this.sessions.delete(session.websocket);
172
+ }
173
+ }
174
+
175
+ override fetch(request: Request): Response | Promise<Response> {
176
+ return this.app.fetch(request, this.env);
177
+ }
178
+ }
@@ -0,0 +1,16 @@
1
+ export class WebsocketWrapper<TAttachment, TMessage> {
2
+ public constructor(public webSocket: WebSocket) {}
3
+
4
+ public send(message: TMessage) {
5
+ if (this.webSocket.readyState !== WebSocket.OPEN) return;
6
+ this.webSocket.send(JSON.stringify(message));
7
+ }
8
+
9
+ public deserializeAttachment() {
10
+ return this.webSocket.deserializeAttachment() as TAttachment;
11
+ }
12
+
13
+ public serializeAttachment(attachment: TAttachment) {
14
+ this.webSocket.serializeAttachment(attachment);
15
+ }
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { BaseSession, type SessionClientMessage } from "./BaseSession";
2
+ export { BaseWebSocketDO } from "./BaseWebSocketDO";
3
+ export { WebsocketWrapper } from "./WebsocketWrapper";