@firtoz/websocket-do 9.0.0 → 12.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 +63 -60
- package/package.json +28 -12
- package/src/BaseSession.ts +15 -5
- package/src/{ZodSession.ts → StandardSchemaSession.ts} +71 -70
- package/src/{ZodWebSocketClient.ts → StandardSchemaWebSocketClient.ts} +30 -50
- package/src/{ZodWebSocketDO.ts → StandardSchemaWebSocketDO.ts} +29 -22
- package/src/index.ts +15 -12
- package/src/parseStandardSchema.ts +17 -0
- package/src/standardSchemaMsgpack.ts +17 -0
- package/src/standardSchemaRpc.ts +83 -0
- package/src/standardSchemaRpcReact.ts +107 -0
- package/src/zodMsgpack.ts +0 -13
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Type-safe WebSocket session management for Cloudflare Durable Objects with Hono
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- 🔒 **Type-safe** - Full TypeScript support with generic types for messages and session data
|
|
14
|
-
- ✨ **
|
|
14
|
+
- ✨ **Standard Schema validation** - Runtime message validation with `StandardSchemaWebSocketClient` and `StandardSchemaSession` (Zod, Valibot, ArkType, …)
|
|
15
15
|
- 🌐 **WebSocket Management** - Built on Cloudflare Durable Objects for stateful WebSocket connections
|
|
16
16
|
- 🎯 **Session-based** - Abstract session class for easy implementation of custom WebSocket logic
|
|
17
17
|
- 🔄 **State Persistence** - Automatic serialization/deserialization of session data
|
|
@@ -34,10 +34,7 @@ This package requires the following peer dependencies:
|
|
|
34
34
|
bun add hono @firtoz/hono-fetcher
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
**For
|
|
38
|
-
```bash
|
|
39
|
-
bun add zod msgpackr
|
|
40
|
-
```
|
|
37
|
+
**For schemas:** use any [Standard Schema v1](https://standardschema.dev/) library (e.g. Zod, Valibot). **`msgpackr`** is a normal dependency of this package (buffer / msgpack mode); you do not add it separately unless your bundler needs it hoisted.
|
|
41
38
|
|
|
42
39
|
For TypeScript support, use `wrangler types` to generate accurate types from your `wrangler.jsonc`:
|
|
43
40
|
|
|
@@ -101,8 +98,8 @@ const chatSessionHandlers: BaseSessionHandlers<
|
|
|
101
98
|
// Handle binary messages if needed
|
|
102
99
|
},
|
|
103
100
|
|
|
104
|
-
handleClose: async () => {
|
|
105
|
-
console.log('Session closed');
|
|
101
|
+
handleClose: async (session) => {
|
|
102
|
+
console.log('Session closed', session.data);
|
|
106
103
|
},
|
|
107
104
|
};
|
|
108
105
|
|
|
@@ -140,8 +137,8 @@ class ChatSession extends BaseSession<
|
|
|
140
137
|
handleBufferMessage: async (message) => {
|
|
141
138
|
// Handle binary messages if needed
|
|
142
139
|
},
|
|
143
|
-
handleClose: async () => {
|
|
144
|
-
console.log(`Session closed for user ${
|
|
140
|
+
handleClose: async (session) => {
|
|
141
|
+
console.log(`Session closed for user ${session.data.userId}`);
|
|
145
142
|
},
|
|
146
143
|
});
|
|
147
144
|
}
|
|
@@ -214,13 +211,13 @@ export default {
|
|
|
214
211
|
};
|
|
215
212
|
```
|
|
216
213
|
|
|
217
|
-
##
|
|
214
|
+
## StandardSchemaWebSocketClient (Type-Safe Client)
|
|
218
215
|
|
|
219
|
-
`
|
|
216
|
+
`StandardSchemaWebSocketClient` provides a type-safe WebSocket client with automatic validation (Standard Schema v1) for both incoming and outgoing messages.
|
|
220
217
|
|
|
221
218
|
### Features
|
|
222
219
|
|
|
223
|
-
- ✅ **Automatic validation** - All messages validated with
|
|
220
|
+
- ✅ **Automatic validation** - All messages validated with your Standard Schema–compatible schemas
|
|
224
221
|
- 🎯 **Full type inference** - TypeScript types automatically inferred from schemas
|
|
225
222
|
- 📦 **Dual mode** - Supports both JSON and msgpack (buffer) serialization
|
|
226
223
|
- 🔗 **DO Integration** - Works seamlessly with `honoDoFetcher` WebSocket connections
|
|
@@ -229,7 +226,7 @@ export default {
|
|
|
229
226
|
### Basic Usage
|
|
230
227
|
|
|
231
228
|
```typescript
|
|
232
|
-
import {
|
|
229
|
+
import { StandardSchemaWebSocketClient } from '@firtoz/websocket-do';
|
|
233
230
|
import { z } from 'zod';
|
|
234
231
|
|
|
235
232
|
// Define your message schemas
|
|
@@ -249,8 +246,8 @@ type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
|
249
246
|
// Create WebSocket connection (regular or via honoDoFetcher)
|
|
250
247
|
const ws = new WebSocket('wss://example.com/chat');
|
|
251
248
|
|
|
252
|
-
// Wrap with
|
|
253
|
-
const client = new
|
|
249
|
+
// Wrap with StandardSchemaWebSocketClient
|
|
250
|
+
const client = new StandardSchemaWebSocketClient({
|
|
254
251
|
webSocket: ws, // Can also use 'url' instead
|
|
255
252
|
clientSchema: ClientMessageSchema,
|
|
256
253
|
serverSchema: ServerMessageSchema,
|
|
@@ -272,7 +269,7 @@ Perfect for connecting to Durable Objects:
|
|
|
272
269
|
|
|
273
270
|
```typescript
|
|
274
271
|
import { honoDoFetcherWithName } from '@firtoz/hono-fetcher';
|
|
275
|
-
import {
|
|
272
|
+
import { StandardSchemaWebSocketClient } from '@firtoz/websocket-do';
|
|
276
273
|
|
|
277
274
|
// 1. Connect to DO via honoDoFetcher
|
|
278
275
|
const api = honoDoFetcherWithName(env.CHAT_ROOM, 'room-1');
|
|
@@ -281,8 +278,8 @@ const wsResp = await api.websocket({
|
|
|
281
278
|
config: { autoAccept: false }, // Let client control acceptance
|
|
282
279
|
});
|
|
283
280
|
|
|
284
|
-
// 2. Wrap with
|
|
285
|
-
const client = new
|
|
281
|
+
// 2. Wrap with StandardSchemaWebSocketClient for type safety!
|
|
282
|
+
const client = new StandardSchemaWebSocketClient({
|
|
286
283
|
webSocket: wsResp.webSocket,
|
|
287
284
|
clientSchema: ClientMessageSchema,
|
|
288
285
|
serverSchema: ServerMessageSchema,
|
|
@@ -307,7 +304,7 @@ client.send({ type: 'chat', text: 'Hello from typed client!' });
|
|
|
307
304
|
For better performance and smaller payloads, use buffer mode with msgpack:
|
|
308
305
|
|
|
309
306
|
```typescript
|
|
310
|
-
const client = new
|
|
307
|
+
const client = new StandardSchemaWebSocketClient({
|
|
311
308
|
webSocket: ws,
|
|
312
309
|
clientSchema: ClientMessageSchema,
|
|
313
310
|
serverSchema: ServerMessageSchema,
|
|
@@ -327,14 +324,16 @@ client.send({ type: 'chat', text: 'Efficient binary message!' });
|
|
|
327
324
|
#### Constructor Options
|
|
328
325
|
|
|
329
326
|
```typescript
|
|
330
|
-
|
|
327
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
328
|
+
|
|
329
|
+
interface StandardSchemaWebSocketClientOptions<TClientMessage, TServerMessage> {
|
|
331
330
|
// Connection (provide one)
|
|
332
331
|
url?: string; // Create new WebSocket
|
|
333
332
|
webSocket?: WebSocket; // Use existing WebSocket (e.g., from honoDoFetcher)
|
|
334
333
|
|
|
335
334
|
// Schemas (required)
|
|
336
|
-
clientSchema:
|
|
337
|
-
serverSchema:
|
|
335
|
+
clientSchema: StandardSchemaV1<unknown, TClientMessage>;
|
|
336
|
+
serverSchema: StandardSchemaV1<unknown, TServerMessage>;
|
|
338
337
|
|
|
339
338
|
// Serialization
|
|
340
339
|
enableBufferMessages?: boolean; // Use msgpack instead of JSON (default: false)
|
|
@@ -350,18 +349,18 @@ interface ZodWebSocketClientOptions<TClientMessage, TServerMessage> {
|
|
|
350
349
|
|
|
351
350
|
#### Methods
|
|
352
351
|
|
|
353
|
-
- `send(message: TClientMessage): void
|
|
352
|
+
- `send(message: TClientMessage): Promise<void>` - Send a validated message (async Standard Schema validation)
|
|
354
353
|
- `close(code?: number, reason?: string): void` - Close the connection
|
|
355
354
|
- `waitForOpen(): Promise<void>` - Wait for connection to open
|
|
356
355
|
|
|
357
|
-
##
|
|
356
|
+
## StandardSchemaSession (Validated Sessions)
|
|
358
357
|
|
|
359
|
-
`
|
|
358
|
+
`StandardSchemaSession` extends `BaseSession` with automatic validation for incoming messages.
|
|
360
359
|
|
|
361
360
|
### Basic Usage
|
|
362
361
|
|
|
363
362
|
```typescript
|
|
364
|
-
import {
|
|
363
|
+
import { StandardSchemaSession } from '@firtoz/websocket-do';
|
|
365
364
|
import { z } from 'zod';
|
|
366
365
|
|
|
367
366
|
// Define schemas
|
|
@@ -384,7 +383,7 @@ interface SessionData {
|
|
|
384
383
|
}
|
|
385
384
|
|
|
386
385
|
// Implement validated session
|
|
387
|
-
class ChatSession extends
|
|
386
|
+
class ChatSession extends StandardSchemaSession<
|
|
388
387
|
SessionData,
|
|
389
388
|
ServerMessage,
|
|
390
389
|
ClientMessage,
|
|
@@ -393,7 +392,7 @@ class ChatSession extends ZodSession<
|
|
|
393
392
|
constructor(
|
|
394
393
|
websocket: WebSocket,
|
|
395
394
|
sessions: Map<WebSocket, ChatSession>,
|
|
396
|
-
options:
|
|
395
|
+
options: StandardSchemaSessionOptions<ClientMessage, ServerMessage>
|
|
397
396
|
) {
|
|
398
397
|
super(websocket, sessions, options, {
|
|
399
398
|
createData: (ctx) => ({ name: 'Anonymous' }),
|
|
@@ -417,18 +416,18 @@ class ChatSession extends ZodSession<
|
|
|
417
416
|
}
|
|
418
417
|
},
|
|
419
418
|
|
|
420
|
-
handleClose: async () => {
|
|
421
|
-
console.log(`${
|
|
419
|
+
handleClose: async (session) => {
|
|
420
|
+
console.log(`${session.data.name} disconnected`);
|
|
422
421
|
},
|
|
423
422
|
});
|
|
424
423
|
}
|
|
425
424
|
}
|
|
426
425
|
```
|
|
427
426
|
|
|
428
|
-
### Buffer Mode with
|
|
427
|
+
### Buffer Mode with StandardSchemaSession
|
|
429
428
|
|
|
430
429
|
```typescript
|
|
431
|
-
class ChatSession extends
|
|
430
|
+
class ChatSession extends StandardSchemaSession<...> {
|
|
432
431
|
constructor(
|
|
433
432
|
websocket: WebSocket,
|
|
434
433
|
sessions: Map<WebSocket, ChatSession>
|
|
@@ -443,8 +442,8 @@ class ChatSession extends ZodSession<...> {
|
|
|
443
442
|
// Messages automatically decoded from msgpack
|
|
444
443
|
// Handle validated message
|
|
445
444
|
},
|
|
446
|
-
handleClose: async () => {
|
|
447
|
-
console.log('Session closed');
|
|
445
|
+
handleClose: async (session) => {
|
|
446
|
+
console.log('Session closed', session.data);
|
|
448
447
|
},
|
|
449
448
|
});
|
|
450
449
|
}
|
|
@@ -521,13 +520,17 @@ constructor(
|
|
|
521
520
|
|
|
522
521
|
```typescript
|
|
523
522
|
type BaseSessionHandlers<TData, TServerMessage, TClientMessage, TEnv> = {
|
|
524
|
-
createData
|
|
523
|
+
createData?: (ctx: Context<{ Bindings: TEnv }>) => TData;
|
|
525
524
|
handleMessage: (message: TClientMessage) => Promise<void>;
|
|
526
525
|
handleBufferMessage: (message: ArrayBuffer) => Promise<void>;
|
|
527
|
-
handleClose: (
|
|
526
|
+
handleClose: (
|
|
527
|
+
session: BaseSession<TData, TServerMessage, TClientMessage, TEnv>,
|
|
528
|
+
) => Promise<void>;
|
|
528
529
|
};
|
|
529
530
|
```
|
|
530
531
|
|
|
532
|
+
If `createData` is omitted, `startFresh` sets `data` to `{}` (use an empty `TData` such as `Record<string, never>`).
|
|
533
|
+
|
|
531
534
|
#### Methods
|
|
532
535
|
|
|
533
536
|
- `send(message: TServerMessage): void`
|
|
@@ -537,7 +540,7 @@ type BaseSessionHandlers<TData, TServerMessage, TClientMessage, TEnv> = {
|
|
|
537
540
|
- Send message to all connected sessions
|
|
538
541
|
|
|
539
542
|
- `startFresh(ctx: Context): void`
|
|
540
|
-
- Initialize new session (called automatically)
|
|
543
|
+
- Initialize new session (called automatically). Uses `createData` when provided; otherwise `{}`.
|
|
541
544
|
|
|
542
545
|
- `resume(): void`
|
|
543
546
|
- Resume existing session after hibernation (called automatically)
|
|
@@ -635,8 +638,8 @@ class GameSession extends BaseSession<GameData, ServerMsg, ClientMsg, Env> {
|
|
|
635
638
|
// Handle buffer messages if needed
|
|
636
639
|
},
|
|
637
640
|
|
|
638
|
-
handleClose: async () => {
|
|
639
|
-
console.log('Game session closed');
|
|
641
|
+
handleClose: async (session) => {
|
|
642
|
+
console.log('Game session closed', session.data);
|
|
640
643
|
},
|
|
641
644
|
});
|
|
642
645
|
}
|
|
@@ -678,8 +681,8 @@ class MySession extends BaseSession<...> {
|
|
|
678
681
|
// Handle buffer messages
|
|
679
682
|
},
|
|
680
683
|
|
|
681
|
-
handleClose: async () => {
|
|
682
|
-
console.log('Session closed');
|
|
684
|
+
handleClose: async (session) => {
|
|
685
|
+
console.log('Session closed', session.data);
|
|
683
686
|
},
|
|
684
687
|
});
|
|
685
688
|
}
|
|
@@ -693,22 +696,22 @@ This package exports the following:
|
|
|
693
696
|
### Classes
|
|
694
697
|
- `BaseWebSocketDO` - Base class for WebSocket Durable Objects (composition-based)
|
|
695
698
|
- `BaseSession` - Concrete session class with handler injection
|
|
696
|
-
- `
|
|
697
|
-
- `
|
|
698
|
-
- `
|
|
699
|
+
- `StandardSchemaWebSocketClient` - Type-safe WebSocket client with Standard Schema validation
|
|
700
|
+
- `StandardSchemaSession` - Concrete session class with validation and handler injection
|
|
701
|
+
- `StandardSchemaWebSocketDO` - Base class for WebSocket DOs with Standard Schema sessions
|
|
699
702
|
- `WebsocketWrapper` - Low-level WebSocket wrapper with typed attachments
|
|
700
703
|
|
|
701
704
|
### Types
|
|
702
705
|
- `BaseSessionHandlers` - Handler interface for `BaseSession`
|
|
703
706
|
- `BaseWebSocketDOOptions` - Options interface for `BaseWebSocketDO`
|
|
704
|
-
- `
|
|
705
|
-
- `
|
|
706
|
-
- `
|
|
707
|
-
- `
|
|
708
|
-
- `
|
|
707
|
+
- `StandardSchemaSessionHandlers` - Handler interface for `StandardSchemaSession`
|
|
708
|
+
- `StandardSchemaSessionOptions` - Options interface for `StandardSchemaSession`
|
|
709
|
+
- `StandardSchemaSessionOptionsOrFactory` - Options or factory function for `StandardSchemaSession`
|
|
710
|
+
- `StandardSchemaWebSocketDOOptions` - Options interface for `StandardSchemaWebSocketDO`
|
|
711
|
+
- `StandardSchemaWebSocketClientOptions` - Options interface for `StandardSchemaWebSocketClient`
|
|
709
712
|
|
|
710
713
|
### Utilities
|
|
711
|
-
- `
|
|
714
|
+
- `standardSchemaMsgpack` - Msgpack encode/decode with Standard Schema validation
|
|
712
715
|
|
|
713
716
|
## Complete Example
|
|
714
717
|
|
|
@@ -734,7 +737,7 @@ export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
|
734
737
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
735
738
|
|
|
736
739
|
// do.ts - Server-side (Durable Object)
|
|
737
|
-
import { BaseWebSocketDO,
|
|
740
|
+
import { BaseWebSocketDO, StandardSchemaSession, type StandardSchemaSessionOptions } from '@firtoz/websocket-do';
|
|
738
741
|
import { ClientMessageSchema, ServerMessageSchema } from './schemas';
|
|
739
742
|
|
|
740
743
|
interface SessionData {
|
|
@@ -742,11 +745,11 @@ interface SessionData {
|
|
|
742
745
|
joinedAt: number;
|
|
743
746
|
}
|
|
744
747
|
|
|
745
|
-
class ChatSession extends
|
|
748
|
+
class ChatSession extends StandardSchemaSession<SessionData, ServerMessage, ClientMessage, Env> {
|
|
746
749
|
constructor(
|
|
747
750
|
websocket: WebSocket,
|
|
748
751
|
sessions: Map<WebSocket, ChatSession>,
|
|
749
|
-
options:
|
|
752
|
+
options: StandardSchemaSessionOptions<ClientMessage, ServerMessage>
|
|
750
753
|
) {
|
|
751
754
|
super(websocket, sessions, options, {
|
|
752
755
|
createData: () => ({
|
|
@@ -775,8 +778,8 @@ class ChatSession extends ZodSession<SessionData, ServerMessage, ClientMessage,
|
|
|
775
778
|
}
|
|
776
779
|
},
|
|
777
780
|
|
|
778
|
-
handleClose: async () => {
|
|
779
|
-
console.log(`${
|
|
781
|
+
handleClose: async (session) => {
|
|
782
|
+
console.log(`${session.data.name} disconnected`);
|
|
780
783
|
},
|
|
781
784
|
});
|
|
782
785
|
}
|
|
@@ -806,7 +809,7 @@ export class ChatRoomDO extends BaseWebSocketDO<ChatSession, Env> {
|
|
|
806
809
|
}
|
|
807
810
|
|
|
808
811
|
// client.ts - Client-side
|
|
809
|
-
import {
|
|
812
|
+
import { StandardSchemaWebSocketClient } from '@firtoz/websocket-do';
|
|
810
813
|
import { honoDoFetcherWithName } from '@firtoz/hono-fetcher';
|
|
811
814
|
import { ClientMessageSchema, ServerMessageSchema } from './schemas';
|
|
812
815
|
|
|
@@ -818,8 +821,8 @@ async function connectToChat(env: Env, roomName: string) {
|
|
|
818
821
|
config: { autoAccept: false },
|
|
819
822
|
});
|
|
820
823
|
|
|
821
|
-
// 2. Wrap with
|
|
822
|
-
const client = new
|
|
824
|
+
// 2. Wrap with StandardSchemaWebSocketClient
|
|
825
|
+
const client = new StandardSchemaWebSocketClient({
|
|
823
826
|
webSocket: wsResp.webSocket,
|
|
824
827
|
clientSchema: ClientMessageSchema,
|
|
825
828
|
serverSchema: ServerMessageSchema,
|
|
@@ -868,8 +871,8 @@ This package includes comprehensive integration tests in a separate test package
|
|
|
868
871
|
- ✅ Real-time WebSocket message exchange
|
|
869
872
|
- ✅ WebSocket session management
|
|
870
873
|
- ✅ Type-safe DO client integration
|
|
871
|
-
- ✅
|
|
872
|
-
- ✅ Integration between honoDoFetcher and
|
|
874
|
+
- ✅ Standard Schema validation in both JSON and msgpack modes
|
|
875
|
+
- ✅ Integration between honoDoFetcher and StandardSchemaWebSocketClient
|
|
873
876
|
|
|
874
877
|
For detailed information about testing capabilities, example implementations, comprehensive test coverage, and setup instructions, see the [websocket-do-test](../../tests/websocket-do-test/) package.
|
|
875
878
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/websocket-do",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
4
|
"description": "Type-safe WebSocket session management for Cloudflare Durable Objects with Hono integration",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -11,10 +11,20 @@
|
|
|
11
11
|
"import": "./src/index.ts",
|
|
12
12
|
"require": "./src/index.ts"
|
|
13
13
|
},
|
|
14
|
-
"./
|
|
15
|
-
"types": "./src/
|
|
16
|
-
"import": "./src/
|
|
17
|
-
"require": "./src/
|
|
14
|
+
"./schema-client": {
|
|
15
|
+
"types": "./src/StandardSchemaWebSocketClient.ts",
|
|
16
|
+
"import": "./src/StandardSchemaWebSocketClient.ts",
|
|
17
|
+
"require": "./src/StandardSchemaWebSocketClient.ts"
|
|
18
|
+
},
|
|
19
|
+
"./rpc": {
|
|
20
|
+
"types": "./src/standardSchemaRpc.ts",
|
|
21
|
+
"import": "./src/standardSchemaRpc.ts",
|
|
22
|
+
"require": "./src/standardSchemaRpc.ts"
|
|
23
|
+
},
|
|
24
|
+
"./rpc-react": {
|
|
25
|
+
"types": "./src/standardSchemaRpcReact.ts",
|
|
26
|
+
"import": "./src/standardSchemaRpcReact.ts",
|
|
27
|
+
"require": "./src/standardSchemaRpcReact.ts"
|
|
18
28
|
}
|
|
19
29
|
},
|
|
20
30
|
"files": [
|
|
@@ -51,13 +61,18 @@
|
|
|
51
61
|
},
|
|
52
62
|
"peerDependencies": {
|
|
53
63
|
"@cloudflare/workers-types": "^4.20260329.1",
|
|
54
|
-
"@firtoz/hono-fetcher": "^2.
|
|
55
|
-
"hono": "^4.12.9"
|
|
64
|
+
"@firtoz/hono-fetcher": "^2.6.0",
|
|
65
|
+
"hono": "^4.12.9",
|
|
66
|
+
"react": ">=18.0.0"
|
|
56
67
|
},
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"react": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"@standard-schema/spec": "^1.1.0",
|
|
75
|
+
"msgpackr": "^1.11.9"
|
|
61
76
|
},
|
|
62
77
|
"engines": {
|
|
63
78
|
"node": ">=18.0.0"
|
|
@@ -68,6 +83,7 @@
|
|
|
68
83
|
"devDependencies": {
|
|
69
84
|
"@types/react": "^19.2.14",
|
|
70
85
|
"bun-types": "^1.3.11",
|
|
71
|
-
"typescript": "^6.0.2"
|
|
86
|
+
"typescript": "^6.0.2",
|
|
87
|
+
"zod": "^4.3.6"
|
|
72
88
|
}
|
|
73
89
|
}
|
package/src/BaseSession.ts
CHANGED
|
@@ -53,14 +53,21 @@ export type SessionEnv<
|
|
|
53
53
|
|
|
54
54
|
export type BaseSessionHandlers<
|
|
55
55
|
TData,
|
|
56
|
-
|
|
56
|
+
TServerMessage,
|
|
57
57
|
TClientMessage,
|
|
58
58
|
TEnv extends object = Cloudflare.Env,
|
|
59
59
|
> = {
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Per-connection state. If omitted, `startFresh` initializes `data` as `{}`
|
|
62
|
+
* (use empty `TData`, e.g. `Record<string, never>`).
|
|
63
|
+
*/
|
|
64
|
+
createData?: (ctx: Context<{ Bindings: TEnv }>) => TData;
|
|
61
65
|
handleMessage: (message: TClientMessage) => Promise<void>;
|
|
62
66
|
handleBufferMessage: (message: ArrayBuffer) => Promise<void>;
|
|
63
|
-
|
|
67
|
+
/** Called when this connection closes; `session` is this {@link BaseSession} instance. */
|
|
68
|
+
handleClose: (
|
|
69
|
+
session: BaseSession<TData, TServerMessage, TClientMessage, TEnv>,
|
|
70
|
+
) => Promise<void>;
|
|
64
71
|
};
|
|
65
72
|
|
|
66
73
|
export class BaseSession<
|
|
@@ -100,7 +107,10 @@ export class BaseSession<
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
public startFresh(ctx: Context<{ Bindings: TEnv }>) {
|
|
103
|
-
this.data =
|
|
110
|
+
this.data =
|
|
111
|
+
this.handlers.createData !== undefined
|
|
112
|
+
? this.handlers.createData(ctx)
|
|
113
|
+
: ({} as TData);
|
|
104
114
|
this.wrapper.serializeAttachment(this.data);
|
|
105
115
|
}
|
|
106
116
|
|
|
@@ -137,6 +147,6 @@ export class BaseSession<
|
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
async handleClose(): Promise<void> {
|
|
140
|
-
return this.handlers.handleClose();
|
|
150
|
+
return this.handlers.handleClose(this);
|
|
141
151
|
}
|
|
142
152
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
1
2
|
import type { Context } from "hono";
|
|
2
|
-
import type { ZodType } from "zod";
|
|
3
3
|
import { BaseSession } from "./BaseSession";
|
|
4
|
-
import {
|
|
4
|
+
import { parseStandardSchema } from "./parseStandardSchema";
|
|
5
|
+
import { standardSchemaMsgpack } from "./standardSchemaMsgpack";
|
|
5
6
|
|
|
6
|
-
export type
|
|
7
|
-
clientSchema:
|
|
8
|
-
serverSchema:
|
|
7
|
+
export type StandardSchemaSessionOptions<TClientMessage, TServerMessage> = {
|
|
8
|
+
clientSchema: StandardSchemaV1<unknown, TClientMessage>;
|
|
9
|
+
serverSchema: StandardSchemaV1<unknown, TServerMessage>;
|
|
9
10
|
serializeJson?: (value: unknown) => string;
|
|
10
11
|
deserializeJson?: (raw: string) => unknown;
|
|
11
12
|
enableBufferMessages?: boolean;
|
|
@@ -15,9 +16,9 @@ export type ZodSessionOptions<TClientMessage, TServerMessage> = {
|
|
|
15
16
|
) => Promise<void>;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
export type
|
|
19
|
+
export type StandardSchemaSessionHandlers<
|
|
19
20
|
TData,
|
|
20
|
-
|
|
21
|
+
TServerMessage,
|
|
21
22
|
TClientMessage,
|
|
22
23
|
TEnv extends object,
|
|
23
24
|
> = {
|
|
@@ -27,27 +28,36 @@ export type ZodSessionHandlers<
|
|
|
27
28
|
error: unknown,
|
|
28
29
|
originalMessage: unknown,
|
|
29
30
|
) => Promise<void>;
|
|
30
|
-
handleClose: (
|
|
31
|
+
handleClose: (
|
|
32
|
+
session: StandardSchemaSession<TData, TServerMessage, TClientMessage, TEnv>,
|
|
33
|
+
) => Promise<void>;
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
export class
|
|
36
|
+
export class StandardSchemaSession<
|
|
34
37
|
TData,
|
|
35
38
|
TServerMessage,
|
|
36
39
|
TClientMessage,
|
|
37
40
|
TEnv extends object = Cloudflare.Env,
|
|
38
41
|
> extends BaseSession<TData, TServerMessage, TClientMessage, TEnv> {
|
|
39
|
-
private readonly clientCodec: ReturnType<
|
|
40
|
-
|
|
42
|
+
private readonly clientCodec: ReturnType<
|
|
43
|
+
typeof standardSchemaMsgpack<TClientMessage>
|
|
44
|
+
>;
|
|
45
|
+
private readonly serverCodec: ReturnType<
|
|
46
|
+
typeof standardSchemaMsgpack<TServerMessage>
|
|
47
|
+
>;
|
|
41
48
|
protected readonly enableBufferMessages: boolean;
|
|
42
49
|
|
|
43
50
|
constructor(
|
|
44
51
|
websocket: WebSocket,
|
|
45
52
|
sessions: Map<
|
|
46
53
|
WebSocket,
|
|
47
|
-
|
|
54
|
+
StandardSchemaSession<TData, TServerMessage, TClientMessage, TEnv>
|
|
55
|
+
>,
|
|
56
|
+
private readonly options: StandardSchemaSessionOptions<
|
|
57
|
+
TClientMessage,
|
|
58
|
+
TServerMessage
|
|
48
59
|
>,
|
|
49
|
-
private readonly
|
|
50
|
-
private readonly zodHandlers: ZodSessionHandlers<
|
|
60
|
+
private readonly schemaHandlers: StandardSchemaSessionHandlers<
|
|
51
61
|
TData,
|
|
52
62
|
TServerMessage,
|
|
53
63
|
TClientMessage,
|
|
@@ -55,25 +65,33 @@ export class ZodSession<
|
|
|
55
65
|
>,
|
|
56
66
|
) {
|
|
57
67
|
super(websocket, sessions, {
|
|
58
|
-
createData:
|
|
68
|
+
createData: schemaHandlers.createData,
|
|
59
69
|
handleMessage: async (message) => {
|
|
60
70
|
return this._internalHandleMessage(message);
|
|
61
71
|
},
|
|
62
72
|
handleBufferMessage: async (message) => {
|
|
63
73
|
return this._internalHandleBufferMessage(message);
|
|
64
74
|
},
|
|
65
|
-
handleClose: async (
|
|
66
|
-
|
|
75
|
+
handleClose: async (
|
|
76
|
+
session: BaseSession<TData, TServerMessage, TClientMessage, TEnv>,
|
|
77
|
+
) => {
|
|
78
|
+
return schemaHandlers.handleClose(
|
|
79
|
+
session as StandardSchemaSession<
|
|
80
|
+
TData,
|
|
81
|
+
TServerMessage,
|
|
82
|
+
TClientMessage,
|
|
83
|
+
TEnv
|
|
84
|
+
>,
|
|
85
|
+
);
|
|
67
86
|
},
|
|
68
87
|
});
|
|
69
88
|
|
|
70
|
-
this.clientCodec =
|
|
71
|
-
this.serverCodec =
|
|
89
|
+
this.clientCodec = standardSchemaMsgpack(options.clientSchema);
|
|
90
|
+
this.serverCodec = standardSchemaMsgpack(options.serverSchema);
|
|
72
91
|
this.enableBufferMessages = options.enableBufferMessages ?? false;
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
public async handleRawMessage(rawMessage: string): Promise<void> {
|
|
76
|
-
// If buffer messages are enabled, reject string messages
|
|
77
95
|
if (this.enableBufferMessages) {
|
|
78
96
|
console.error(
|
|
79
97
|
"String messages not allowed when buffer messages are enabled",
|
|
@@ -86,17 +104,18 @@ export class ZodSession<
|
|
|
86
104
|
|
|
87
105
|
try {
|
|
88
106
|
const parsed = this.deserializeJson(rawMessage);
|
|
89
|
-
const validatedMessage =
|
|
90
|
-
|
|
107
|
+
const validatedMessage = await parseStandardSchema(
|
|
108
|
+
this.options.clientSchema,
|
|
109
|
+
parsed,
|
|
110
|
+
);
|
|
111
|
+
await this.schemaHandlers.handleValidatedMessage(validatedMessage);
|
|
91
112
|
} catch (error) {
|
|
92
113
|
console.error("Invalid client message received:", error);
|
|
93
114
|
await this._internalHandleValidationError(error, rawMessage);
|
|
94
115
|
}
|
|
95
116
|
}
|
|
96
117
|
|
|
97
|
-
// Internal method used by the base class handlers
|
|
98
118
|
private async _internalHandleMessage(message: TClientMessage): Promise<void> {
|
|
99
|
-
// If buffer messages are enabled, reject JSON messages
|
|
100
119
|
if (this.enableBufferMessages) {
|
|
101
120
|
console.error(
|
|
102
121
|
"String messages not allowed when buffer messages are enabled",
|
|
@@ -108,48 +127,44 @@ export class ZodSession<
|
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
const validatedMessage = await parseStandardSchema(
|
|
131
|
+
this.options.clientSchema,
|
|
132
|
+
message,
|
|
133
|
+
);
|
|
134
|
+
await this.schemaHandlers.handleValidatedMessage(validatedMessage);
|
|
114
135
|
} catch (error) {
|
|
115
136
|
console.error("Invalid client message received:", error);
|
|
116
137
|
await this._internalHandleValidationError(error, message);
|
|
117
138
|
}
|
|
118
139
|
}
|
|
119
140
|
|
|
120
|
-
// Internal method used by the base class handlers
|
|
121
141
|
private async _internalHandleBufferMessage(
|
|
122
142
|
buffer: ArrayBuffer,
|
|
123
143
|
): Promise<void> {
|
|
124
|
-
// If buffer messages are disabled, reject buffer messages
|
|
125
144
|
if (!this.enableBufferMessages) {
|
|
126
145
|
console.error(
|
|
127
146
|
"Buffer messages not allowed when buffer messages are disabled",
|
|
128
147
|
);
|
|
129
|
-
// We can't use sendProtocolError here because it would send JSON
|
|
130
|
-
// Just close the connection or ignore
|
|
131
148
|
return;
|
|
132
149
|
}
|
|
133
150
|
|
|
134
151
|
try {
|
|
135
152
|
const bytes = new Uint8Array(buffer);
|
|
136
|
-
const decodedMessage = this.clientCodec.decode(bytes);
|
|
137
|
-
await this.
|
|
153
|
+
const decodedMessage = await this.clientCodec.decode(bytes);
|
|
154
|
+
await this.schemaHandlers.handleValidatedMessage(decodedMessage);
|
|
138
155
|
} catch (error) {
|
|
139
156
|
console.error("Failed to decode buffer message:", error);
|
|
140
157
|
await this._internalHandleValidationError(error, buffer);
|
|
141
158
|
}
|
|
142
159
|
}
|
|
143
160
|
|
|
144
|
-
// Internal validation error handler
|
|
145
161
|
private async _internalHandleValidationError(
|
|
146
162
|
error: unknown,
|
|
147
163
|
originalMessage: unknown,
|
|
148
164
|
): Promise<void> {
|
|
149
|
-
if (this.
|
|
150
|
-
await this.
|
|
165
|
+
if (this.schemaHandlers.handleValidationError) {
|
|
166
|
+
await this.schemaHandlers.handleValidationError(error, originalMessage);
|
|
151
167
|
} else {
|
|
152
|
-
// Default implementation logs and continues
|
|
153
168
|
console.error(
|
|
154
169
|
"Validation error:",
|
|
155
170
|
error,
|
|
@@ -159,50 +174,38 @@ export class ZodSession<
|
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
|
|
162
|
-
// Type-safe send method that automatically uses the correct format
|
|
163
177
|
public send(message: TServerMessage): void {
|
|
164
178
|
if (this.enableBufferMessages) {
|
|
165
|
-
this.
|
|
179
|
+
void this.sendBufferAsync(message).catch((error: unknown) => {
|
|
180
|
+
console.error("Failed to encode buffer message:", error);
|
|
181
|
+
});
|
|
166
182
|
} else {
|
|
167
|
-
this.
|
|
183
|
+
void this.sendJsonAsync(message).catch((error: unknown) => {
|
|
184
|
+
console.error("Invalid server message to send:", error);
|
|
185
|
+
});
|
|
168
186
|
}
|
|
169
187
|
}
|
|
170
188
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
this.websocket.send(this.serializeJson(validatedMessage));
|
|
180
|
-
} catch (error) {
|
|
181
|
-
console.error("Invalid server message to send:", error);
|
|
182
|
-
}
|
|
189
|
+
private async sendJsonAsync(message: TServerMessage): Promise<void> {
|
|
190
|
+
const validatedMessage = await parseStandardSchema(
|
|
191
|
+
this.options.serverSchema,
|
|
192
|
+
message,
|
|
193
|
+
);
|
|
194
|
+
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
195
|
+
this.websocket.send(this.serializeJson(validatedMessage));
|
|
183
196
|
}
|
|
184
197
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
191
|
-
|
|
192
|
-
this.websocket.send(encodedMessage);
|
|
193
|
-
} catch (error) {
|
|
194
|
-
console.error("Failed to encode buffer message:", error);
|
|
195
|
-
}
|
|
198
|
+
private async sendBufferAsync(message: TServerMessage): Promise<void> {
|
|
199
|
+
const encodedMessage = await this.serverCodec.encode(message);
|
|
200
|
+
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
201
|
+
this.websocket.send(encodedMessage);
|
|
196
202
|
}
|
|
197
203
|
|
|
198
|
-
// Send a protocol error message (always as JSON for compatibility by default)
|
|
199
204
|
private async sendProtocolError(errorMessage: string): Promise<void> {
|
|
200
205
|
try {
|
|
201
|
-
// Use custom handler if provided, otherwise use default
|
|
202
206
|
if (this.options.sendProtocolError) {
|
|
203
207
|
await this.options.sendProtocolError(this.websocket, errorMessage);
|
|
204
208
|
} else {
|
|
205
|
-
// Default implementation: send a simple error object - no schema validation needed
|
|
206
209
|
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
207
210
|
this.websocket.send(this.serializeJson({ error: errorMessage }));
|
|
208
211
|
}
|
|
@@ -223,13 +226,11 @@ export class ZodSession<
|
|
|
223
226
|
: JSON.parse(raw);
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
// Type-safe broadcast that validates server messages
|
|
227
|
-
// Automatically uses the correct format based on session configuration
|
|
228
229
|
public broadcast(message: TServerMessage, excludeSelf = false): void {
|
|
229
230
|
for (const session of this.sessions.values()) {
|
|
230
231
|
if (excludeSelf && session === this) continue;
|
|
231
|
-
if (session instanceof
|
|
232
|
-
session.send(message);
|
|
232
|
+
if (session instanceof StandardSchemaSession) {
|
|
233
|
+
session.send(message);
|
|
233
234
|
}
|
|
234
235
|
}
|
|
235
236
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
1
2
|
import { pack, unpack } from "msgpackr";
|
|
2
|
-
import
|
|
3
|
+
import { parseStandardSchema } from "./parseStandardSchema";
|
|
3
4
|
|
|
4
|
-
export interface
|
|
5
|
+
export interface StandardSchemaWebSocketClientOptions<
|
|
6
|
+
TClientMessage,
|
|
7
|
+
TServerMessage,
|
|
8
|
+
> {
|
|
5
9
|
/**
|
|
6
10
|
* URL to connect to (required if webSocket not provided)
|
|
7
11
|
*/
|
|
@@ -11,8 +15,8 @@ export interface ZodWebSocketClientOptions<TClientMessage, TServerMessage> {
|
|
|
11
15
|
* Useful when getting a WebSocket from honoDoFetcher
|
|
12
16
|
*/
|
|
13
17
|
webSocket?: WebSocket;
|
|
14
|
-
clientSchema:
|
|
15
|
-
serverSchema:
|
|
18
|
+
clientSchema: StandardSchemaV1<unknown, TClientMessage>;
|
|
19
|
+
serverSchema: StandardSchemaV1<unknown, TServerMessage>;
|
|
16
20
|
serializeJson?: (value: unknown) => string;
|
|
17
21
|
deserializeJson?: (raw: string) => unknown;
|
|
18
22
|
enableBufferMessages?: boolean;
|
|
@@ -23,10 +27,10 @@ export interface ZodWebSocketClientOptions<TClientMessage, TServerMessage> {
|
|
|
23
27
|
onValidationError?: (error: Error, rawMessage: unknown) => void;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
export class
|
|
30
|
+
export class StandardSchemaWebSocketClient<TClientMessage, TServerMessage> {
|
|
27
31
|
private ws: WebSocket;
|
|
28
|
-
private readonly clientSchema:
|
|
29
|
-
private readonly serverSchema:
|
|
32
|
+
private readonly clientSchema: StandardSchemaV1<unknown, TClientMessage>;
|
|
33
|
+
private readonly serverSchema: StandardSchemaV1<unknown, TServerMessage>;
|
|
30
34
|
private readonly serializeJson: (value: unknown) => string;
|
|
31
35
|
private readonly deserializeJson: (raw: string) => unknown;
|
|
32
36
|
private readonly enableBufferMessages: boolean;
|
|
@@ -37,7 +41,10 @@ export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
|
37
41
|
) => void;
|
|
38
42
|
|
|
39
43
|
constructor(
|
|
40
|
-
options:
|
|
44
|
+
options: StandardSchemaWebSocketClientOptions<
|
|
45
|
+
TClientMessage,
|
|
46
|
+
TServerMessage
|
|
47
|
+
>,
|
|
41
48
|
) {
|
|
42
49
|
this.clientSchema = options.clientSchema;
|
|
43
50
|
this.serverSchema = options.serverSchema;
|
|
@@ -47,29 +54,24 @@ export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
|
47
54
|
this.onMessageCallback = options.onMessage;
|
|
48
55
|
this.onValidationError = options.onValidationError;
|
|
49
56
|
|
|
50
|
-
// Use provided WebSocket or create new one from URL
|
|
51
57
|
if (options.webSocket) {
|
|
52
|
-
// Use existing WebSocket (e.g., from honoDoFetcher)
|
|
53
58
|
this.ws = options.webSocket;
|
|
54
59
|
} else if (options.url) {
|
|
55
|
-
// Create new WebSocket from URL
|
|
56
60
|
this.ws = new WebSocket(options.url);
|
|
57
61
|
} else {
|
|
58
62
|
throw new Error("Either 'url' or 'webSocket' must be provided");
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
// Set binary type for buffer messages
|
|
62
65
|
if (this.enableBufferMessages) {
|
|
63
66
|
this.ws.binaryType = "arraybuffer";
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
// Setup event handlers
|
|
67
69
|
this.ws.addEventListener("open", (event) => {
|
|
68
70
|
options.onOpen?.(event);
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
this.ws.addEventListener("message", (event) => {
|
|
72
|
-
this.
|
|
74
|
+
void this.handleMessageEvent(event);
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
this.ws.addEventListener("close", (event) => {
|
|
@@ -81,12 +83,11 @@ export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
|
81
83
|
});
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
private
|
|
86
|
+
private async handleMessageEvent(event: MessageEvent): Promise<void> {
|
|
85
87
|
try {
|
|
86
88
|
let parsedMessage: TServerMessage;
|
|
87
89
|
|
|
88
90
|
if (this.enableBufferMessages) {
|
|
89
|
-
// Buffer mode: expect ArrayBuffer
|
|
90
91
|
if (!(event.data instanceof ArrayBuffer)) {
|
|
91
92
|
console.error(
|
|
92
93
|
"Expected ArrayBuffer but received:",
|
|
@@ -99,11 +100,9 @@ export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
// Unpack and validate
|
|
103
103
|
const unpacked = unpack(new Uint8Array(event.data));
|
|
104
|
-
parsedMessage = this.serverSchema
|
|
104
|
+
parsedMessage = await parseStandardSchema(this.serverSchema, unpacked);
|
|
105
105
|
} else {
|
|
106
|
-
// JSON mode: expect string
|
|
107
106
|
if (typeof event.data !== "string") {
|
|
108
107
|
console.error("Expected string but received:", typeof event.data);
|
|
109
108
|
this.onValidationError?.(
|
|
@@ -113,12 +112,10 @@ export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
|
113
112
|
return;
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
// Parse and validate
|
|
117
115
|
const parsed = this.deserializeJson(event.data);
|
|
118
|
-
parsedMessage = this.serverSchema
|
|
116
|
+
parsedMessage = await parseStandardSchema(this.serverSchema, parsed);
|
|
119
117
|
}
|
|
120
118
|
|
|
121
|
-
// Call message handler
|
|
122
119
|
this.onMessageCallback?.(parsedMessage);
|
|
123
120
|
} catch (error) {
|
|
124
121
|
console.error("Failed to process message:", error);
|
|
@@ -130,51 +127,34 @@ export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
|
130
127
|
}
|
|
131
128
|
|
|
132
129
|
/**
|
|
133
|
-
* Send a message (automatically encodes based on mode)
|
|
130
|
+
* Send a message (automatically encodes based on mode).
|
|
134
131
|
*/
|
|
135
|
-
send(message: TClientMessage): void {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
async send(message: TClientMessage): Promise<void> {
|
|
133
|
+
const validatedMessage = await parseStandardSchema(
|
|
134
|
+
this.clientSchema,
|
|
135
|
+
message,
|
|
136
|
+
);
|
|
139
137
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// Encode as JSON
|
|
146
|
-
this.ws.send(this.serializeJson(validatedMessage));
|
|
147
|
-
}
|
|
148
|
-
} catch (error) {
|
|
149
|
-
console.error("Failed to send message:", error);
|
|
150
|
-
throw error;
|
|
138
|
+
if (this.enableBufferMessages) {
|
|
139
|
+
const packed = pack(validatedMessage);
|
|
140
|
+
this.ws.send(new Uint8Array(packed));
|
|
141
|
+
} else {
|
|
142
|
+
this.ws.send(this.serializeJson(validatedMessage));
|
|
151
143
|
}
|
|
152
144
|
}
|
|
153
145
|
|
|
154
|
-
/**
|
|
155
|
-
* Close the WebSocket connection
|
|
156
|
-
*/
|
|
157
146
|
close(code?: number, reason?: string): void {
|
|
158
147
|
this.ws.close(code, reason);
|
|
159
148
|
}
|
|
160
149
|
|
|
161
|
-
/**
|
|
162
|
-
* Get the current WebSocket ready state
|
|
163
|
-
*/
|
|
164
150
|
get readyState(): number {
|
|
165
151
|
return this.ws.readyState;
|
|
166
152
|
}
|
|
167
153
|
|
|
168
|
-
/**
|
|
169
|
-
* Get the underlying WebSocket instance (use with caution)
|
|
170
|
-
*/
|
|
171
154
|
get socket(): WebSocket {
|
|
172
155
|
return this.ws;
|
|
173
156
|
}
|
|
174
157
|
|
|
175
|
-
/**
|
|
176
|
-
* Wait for the connection to open
|
|
177
|
-
*/
|
|
178
158
|
async waitForOpen(): Promise<void> {
|
|
179
159
|
if (this.ws.readyState === WebSocket.OPEN) {
|
|
180
160
|
return;
|
|
@@ -5,62 +5,65 @@ import type {
|
|
|
5
5
|
SessionServerMessage,
|
|
6
6
|
} from "./BaseSession";
|
|
7
7
|
import { BaseWebSocketDO } from "./BaseWebSocketDO";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
StandardSchemaSession,
|
|
10
|
+
StandardSchemaSessionOptions,
|
|
11
|
+
} from "./StandardSchemaSession";
|
|
9
12
|
|
|
10
|
-
export type
|
|
13
|
+
export type StandardSchemaSessionOptionsOrFactory<
|
|
11
14
|
TClientMessage,
|
|
12
15
|
TServerMessage,
|
|
13
16
|
TEnv extends Cloudflare.Env = Cloudflare.Env,
|
|
14
17
|
> =
|
|
15
|
-
|
|
|
18
|
+
| StandardSchemaSessionOptions<TClientMessage, TServerMessage>
|
|
16
19
|
| ((
|
|
17
20
|
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
18
21
|
websocket: WebSocket,
|
|
19
|
-
) =>
|
|
22
|
+
) => StandardSchemaSessionOptions<TClientMessage, TServerMessage>);
|
|
20
23
|
|
|
21
|
-
export type
|
|
24
|
+
export type StandardSchemaWebSocketDOOptions<
|
|
22
25
|
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
23
|
-
TSession extends
|
|
26
|
+
TSession extends StandardSchemaSession<any, any, any, any>,
|
|
24
27
|
TClientMessage,
|
|
25
28
|
TServerMessage,
|
|
26
29
|
TEnv extends SessionEnv<TSession>,
|
|
27
30
|
> = {
|
|
28
|
-
|
|
31
|
+
standardSchemaSessionOptions: StandardSchemaSessionOptionsOrFactory<
|
|
29
32
|
TClientMessage,
|
|
30
33
|
TServerMessage,
|
|
31
34
|
TEnv
|
|
32
35
|
>;
|
|
33
|
-
|
|
36
|
+
createStandardSchemaSession: (
|
|
34
37
|
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
35
38
|
websocket: WebSocket,
|
|
36
|
-
options:
|
|
39
|
+
options: StandardSchemaSessionOptions<TClientMessage, TServerMessage>,
|
|
37
40
|
) => TSession | Promise<TSession>;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
|
-
export abstract class
|
|
43
|
+
export abstract class StandardSchemaWebSocketDO<
|
|
41
44
|
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
42
|
-
TSession extends
|
|
45
|
+
TSession extends StandardSchemaSession<any, any, any, any>,
|
|
43
46
|
TClientMessage extends
|
|
44
47
|
SessionClientMessage<TSession> = SessionClientMessage<TSession>,
|
|
45
48
|
TServerMessage extends
|
|
46
49
|
SessionServerMessage<TSession> = SessionServerMessage<TSession>,
|
|
47
50
|
TEnv extends SessionEnv<TSession> = SessionEnv<TSession>,
|
|
48
51
|
> extends BaseWebSocketDO<TSession, TEnv> {
|
|
49
|
-
protected readonly
|
|
52
|
+
protected readonly standardSchemaSessionOptions: StandardSchemaSessionOptionsOrFactory<
|
|
50
53
|
TClientMessage,
|
|
51
54
|
TServerMessage,
|
|
52
55
|
TEnv
|
|
53
56
|
>;
|
|
54
|
-
protected readonly
|
|
57
|
+
protected readonly createStandardSchemaSessionFn: (
|
|
55
58
|
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
56
59
|
websocket: WebSocket,
|
|
57
|
-
options:
|
|
60
|
+
options: StandardSchemaSessionOptions<TClientMessage, TServerMessage>,
|
|
58
61
|
) => TSession | Promise<TSession>;
|
|
59
62
|
|
|
60
63
|
constructor(
|
|
61
64
|
ctx: DurableObjectState,
|
|
62
65
|
env: TEnv,
|
|
63
|
-
options:
|
|
66
|
+
options: StandardSchemaWebSocketDOOptions<
|
|
64
67
|
TSession,
|
|
65
68
|
TClientMessage,
|
|
66
69
|
TServerMessage,
|
|
@@ -69,15 +72,19 @@ export abstract class ZodWebSocketDO<
|
|
|
69
72
|
) {
|
|
70
73
|
super(ctx, env, {
|
|
71
74
|
createSession: (ctx, websocket) => {
|
|
72
|
-
const
|
|
73
|
-
typeof options.
|
|
74
|
-
? options.
|
|
75
|
-
: options.
|
|
75
|
+
const schemaOptions =
|
|
76
|
+
typeof options.standardSchemaSessionOptions === "function"
|
|
77
|
+
? options.standardSchemaSessionOptions(ctx, websocket)
|
|
78
|
+
: options.standardSchemaSessionOptions;
|
|
76
79
|
|
|
77
|
-
return options.
|
|
80
|
+
return options.createStandardSchemaSession(
|
|
81
|
+
ctx,
|
|
82
|
+
websocket,
|
|
83
|
+
schemaOptions,
|
|
84
|
+
);
|
|
78
85
|
},
|
|
79
86
|
});
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
87
|
+
this.standardSchemaSessionOptions = options.standardSchemaSessionOptions;
|
|
88
|
+
this.createStandardSchemaSessionFn = options.createStandardSchemaSession;
|
|
82
89
|
}
|
|
83
90
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export {
|
|
|
2
2
|
BaseSession,
|
|
3
3
|
type BaseSessionHandlers,
|
|
4
4
|
type SessionClientMessage,
|
|
5
|
+
type SessionEnv,
|
|
6
|
+
type SessionServerMessage,
|
|
5
7
|
} from "./BaseSession";
|
|
6
8
|
export {
|
|
7
9
|
BaseWebSocketDO,
|
|
@@ -9,17 +11,18 @@ export {
|
|
|
9
11
|
} from "./BaseWebSocketDO";
|
|
10
12
|
export { WebsocketWrapper } from "./WebsocketWrapper";
|
|
11
13
|
export {
|
|
12
|
-
|
|
13
|
-
type
|
|
14
|
-
type
|
|
15
|
-
} from "./
|
|
14
|
+
StandardSchemaSession,
|
|
15
|
+
type StandardSchemaSessionHandlers,
|
|
16
|
+
type StandardSchemaSessionOptions,
|
|
17
|
+
} from "./StandardSchemaSession";
|
|
16
18
|
export {
|
|
17
|
-
|
|
18
|
-
type
|
|
19
|
-
} from "./
|
|
19
|
+
StandardSchemaWebSocketClient,
|
|
20
|
+
type StandardSchemaWebSocketClientOptions,
|
|
21
|
+
} from "./StandardSchemaWebSocketClient";
|
|
20
22
|
export {
|
|
21
|
-
type
|
|
22
|
-
|
|
23
|
-
type
|
|
24
|
-
} from "./
|
|
25
|
-
export {
|
|
23
|
+
type StandardSchemaSessionOptionsOrFactory,
|
|
24
|
+
StandardSchemaWebSocketDO,
|
|
25
|
+
type StandardSchemaWebSocketDOOptions,
|
|
26
|
+
} from "./StandardSchemaWebSocketDO";
|
|
27
|
+
export { parseStandardSchema } from "./parseStandardSchema";
|
|
28
|
+
export { standardSchemaMsgpack } from "./standardSchemaMsgpack";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates {@link value} with a Standard Schema v1 schema and returns the output,
|
|
5
|
+
* or throws an {@link Error} whose message aggregates issue messages.
|
|
6
|
+
*/
|
|
7
|
+
export async function parseStandardSchema<T>(
|
|
8
|
+
schema: StandardSchemaV1<unknown, T>,
|
|
9
|
+
value: unknown,
|
|
10
|
+
): Promise<T> {
|
|
11
|
+
const result = await schema["~standard"].validate(value);
|
|
12
|
+
if (result.issues) {
|
|
13
|
+
const messages = result.issues.map((issue) => issue.message).join("; ");
|
|
14
|
+
throw new Error(messages || "Validation failed");
|
|
15
|
+
}
|
|
16
|
+
return result.value;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import { pack, unpack } from "msgpackr";
|
|
3
|
+
import { parseStandardSchema } from "./parseStandardSchema";
|
|
4
|
+
|
|
5
|
+
export const standardSchemaMsgpack = <T>(
|
|
6
|
+
schema: StandardSchemaV1<unknown, T>,
|
|
7
|
+
) => ({
|
|
8
|
+
async encode(value: T): Promise<Uint8Array> {
|
|
9
|
+
const validated = await parseStandardSchema(schema, value);
|
|
10
|
+
const packed = pack(validated);
|
|
11
|
+
return new Uint8Array(packed);
|
|
12
|
+
},
|
|
13
|
+
async decode(bytes: Uint8Array): Promise<T> {
|
|
14
|
+
const unpacked = unpack(bytes);
|
|
15
|
+
return parseStandardSchema(schema, unpacked);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type StandardSchemaWebSocketClientOptions,
|
|
3
|
+
StandardSchemaWebSocketClient,
|
|
4
|
+
} from "./StandardSchemaWebSocketClient";
|
|
5
|
+
|
|
6
|
+
export type StandardSchemaWebSocketRpcSessionConstructorOptions<
|
|
7
|
+
TClientMsg,
|
|
8
|
+
TServerMsg,
|
|
9
|
+
TPending extends { reject: (error: Error) => void },
|
|
10
|
+
> = Omit<
|
|
11
|
+
StandardSchemaWebSocketClientOptions<TClientMsg, TServerMsg>,
|
|
12
|
+
"onMessage"
|
|
13
|
+
> & {
|
|
14
|
+
onMessage: (
|
|
15
|
+
message: TServerMsg,
|
|
16
|
+
session: StandardSchemaWebSocketRpcSession<
|
|
17
|
+
TClientMsg,
|
|
18
|
+
TServerMsg,
|
|
19
|
+
TPending
|
|
20
|
+
>,
|
|
21
|
+
) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WebSocket client session with a pending map for request/response RPC and a
|
|
26
|
+
* monotonic id helper. Wire formats stay in your Standard Schema schemas; you dispatch
|
|
27
|
+
* {@link TServerMsg} in `onMessage` (typically with `switch` + `exhaustiveGuard`)
|
|
28
|
+
* and resolve/reject entries in {@link pending}.
|
|
29
|
+
*/
|
|
30
|
+
export class StandardSchemaWebSocketRpcSession<
|
|
31
|
+
TClientMsg,
|
|
32
|
+
TServerMsg,
|
|
33
|
+
TPending extends { reject: (error: Error) => void },
|
|
34
|
+
> {
|
|
35
|
+
readonly pending = new Map<string, TPending>();
|
|
36
|
+
readonly client: StandardSchemaWebSocketClient<TClientMsg, TServerMsg>;
|
|
37
|
+
private idSeq = 0;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
options: StandardSchemaWebSocketRpcSessionConstructorOptions<
|
|
41
|
+
TClientMsg,
|
|
42
|
+
TServerMsg,
|
|
43
|
+
TPending
|
|
44
|
+
>,
|
|
45
|
+
) {
|
|
46
|
+
const { onMessage, ...clientOptions } = options;
|
|
47
|
+
this.client = new StandardSchemaWebSocketClient({
|
|
48
|
+
...clientOptions,
|
|
49
|
+
onMessage: (message) => {
|
|
50
|
+
onMessage(message, this);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
nextId(prefix: string): string {
|
|
56
|
+
return `${prefix}-${++this.idSeq}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
rejectAllPending(reason: Error): void {
|
|
60
|
+
for (const [, pending] of this.pending) {
|
|
61
|
+
pending.reject(reason);
|
|
62
|
+
}
|
|
63
|
+
this.pending.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close(code?: number, reason?: string): void {
|
|
67
|
+
this.client.close(code, reason);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createStandardSchemaWebSocketRpcSession<
|
|
72
|
+
TClientMsg,
|
|
73
|
+
TServerMsg,
|
|
74
|
+
TPending extends { reject: (error: Error) => void },
|
|
75
|
+
>(
|
|
76
|
+
options: StandardSchemaWebSocketRpcSessionConstructorOptions<
|
|
77
|
+
TClientMsg,
|
|
78
|
+
TServerMsg,
|
|
79
|
+
TPending
|
|
80
|
+
>,
|
|
81
|
+
): StandardSchemaWebSocketRpcSession<TClientMsg, TServerMsg, TPending> {
|
|
82
|
+
return new StandardSchemaWebSocketRpcSession(options);
|
|
83
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { DependencyList, RefObject } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type StandardSchemaWebSocketRpcSession,
|
|
5
|
+
type StandardSchemaWebSocketRpcSessionConstructorOptions,
|
|
6
|
+
createStandardSchemaWebSocketRpcSession,
|
|
7
|
+
} from "./standardSchemaRpc";
|
|
8
|
+
|
|
9
|
+
/** Options for {@link useStandardSchemaWebSocketRpc} (same as constructor options for {@link StandardSchemaWebSocketRpcSession}). */
|
|
10
|
+
export type UseStandardSchemaWebSocketRpcOptions<
|
|
11
|
+
TClientMsg,
|
|
12
|
+
TServerMsg,
|
|
13
|
+
TPending extends { reject: (error: Error) => void },
|
|
14
|
+
> = StandardSchemaWebSocketRpcSessionConstructorOptions<
|
|
15
|
+
TClientMsg,
|
|
16
|
+
TServerMsg,
|
|
17
|
+
TPending
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Connects a {@link StandardSchemaWebSocketRpcSession} in an effect: rejects all pending
|
|
22
|
+
* RPCs and closes the socket on cleanup or when `deps` change.
|
|
23
|
+
*
|
|
24
|
+
* Callback refs keep the latest `onMessage` / `onOpen` / `onClose` without
|
|
25
|
+
* listing them in `deps`, so inline handlers do not reconnect every render.
|
|
26
|
+
*
|
|
27
|
+
* Pass `deps` as the second argument; keep it aligned with values used to
|
|
28
|
+
* build `url` / `webSocket`.
|
|
29
|
+
*/
|
|
30
|
+
export function useStandardSchemaWebSocketRpc<
|
|
31
|
+
TClientMsg,
|
|
32
|
+
TServerMsg,
|
|
33
|
+
TPending extends { reject: (error: Error) => void },
|
|
34
|
+
>(
|
|
35
|
+
options: UseStandardSchemaWebSocketRpcOptions<
|
|
36
|
+
TClientMsg,
|
|
37
|
+
TServerMsg,
|
|
38
|
+
TPending
|
|
39
|
+
>,
|
|
40
|
+
deps: DependencyList,
|
|
41
|
+
): {
|
|
42
|
+
ready: boolean;
|
|
43
|
+
sessionRef: RefObject<StandardSchemaWebSocketRpcSession<
|
|
44
|
+
TClientMsg,
|
|
45
|
+
TServerMsg,
|
|
46
|
+
TPending
|
|
47
|
+
> | null>;
|
|
48
|
+
} {
|
|
49
|
+
const { onMessage, onOpen, onClose, ...clientOptions } = options;
|
|
50
|
+
|
|
51
|
+
const onMessageRef = useRef(onMessage);
|
|
52
|
+
onMessageRef.current = onMessage;
|
|
53
|
+
const onOpenRef = useRef(onOpen);
|
|
54
|
+
onOpenRef.current = onOpen;
|
|
55
|
+
const onCloseRef = useRef(onClose);
|
|
56
|
+
onCloseRef.current = onClose;
|
|
57
|
+
|
|
58
|
+
const [ready, setReady] = useState(false);
|
|
59
|
+
const sessionRef = useRef<StandardSchemaWebSocketRpcSession<
|
|
60
|
+
TClientMsg,
|
|
61
|
+
TServerMsg,
|
|
62
|
+
TPending
|
|
63
|
+
> | null>(null);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
let cancelled = false;
|
|
67
|
+
setReady(false);
|
|
68
|
+
|
|
69
|
+
const session = createStandardSchemaWebSocketRpcSession<
|
|
70
|
+
TClientMsg,
|
|
71
|
+
TServerMsg,
|
|
72
|
+
TPending
|
|
73
|
+
>({
|
|
74
|
+
...clientOptions,
|
|
75
|
+
onMessage: (msg, s) => {
|
|
76
|
+
onMessageRef.current(msg, s);
|
|
77
|
+
},
|
|
78
|
+
onOpen: (event) => {
|
|
79
|
+
if (!cancelled) {
|
|
80
|
+
setReady(true);
|
|
81
|
+
}
|
|
82
|
+
onOpenRef.current?.(event);
|
|
83
|
+
},
|
|
84
|
+
onClose: (event) => {
|
|
85
|
+
if (!cancelled) {
|
|
86
|
+
setReady(false);
|
|
87
|
+
}
|
|
88
|
+
onCloseRef.current?.(event);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
sessionRef.current = session;
|
|
93
|
+
if (session.client.socket.readyState === WebSocket.OPEN) {
|
|
94
|
+
setReady(true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
cancelled = true;
|
|
99
|
+
sessionRef.current = null;
|
|
100
|
+
session.rejectAllPending(new Error("WebSocket closed"));
|
|
101
|
+
session.close();
|
|
102
|
+
};
|
|
103
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: deps is the explicit contract
|
|
104
|
+
}, deps);
|
|
105
|
+
|
|
106
|
+
return { ready, sessionRef };
|
|
107
|
+
}
|
package/src/zodMsgpack.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { pack, unpack } from "msgpackr";
|
|
2
|
-
import type { ZodType } from "zod";
|
|
3
|
-
|
|
4
|
-
export const zodMsgpack = <T>(schema: ZodType<T>) => ({
|
|
5
|
-
encode(value: T): Uint8Array {
|
|
6
|
-
const validated = schema.parse(value);
|
|
7
|
-
const packed = pack(validated);
|
|
8
|
-
return new Uint8Array(packed);
|
|
9
|
-
},
|
|
10
|
-
decode(bytes: Uint8Array): T {
|
|
11
|
-
return schema.parse(unpack(bytes));
|
|
12
|
-
},
|
|
13
|
-
});
|