@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 +321 -0
- package/package.json +62 -0
- package/src/BaseSession.ts +75 -0
- package/src/BaseWebSocketDO.ts +178 -0
- package/src/WebsocketWrapper.ts +16 -0
- package/src/index.ts +3 -0
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