@moostjs/event-ws 0.6.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.
@@ -0,0 +1,185 @@
1
+ # Request data — @moostjs/event-ws
2
+
3
+ > Resolver decorators for extracting message data, connection info, and route parameters from WebSocket events.
4
+
5
+ ## Concepts
6
+
7
+ Moost WS provides parameter decorators that resolve values from the WebSocket event context. These decorators are applied to handler method arguments and participate in the Moost pipes pipeline (resolve, transform, validate).
8
+
9
+ There are two categories:
10
+ - **Message decorators** — only available in `@Message` handlers
11
+ - **Connection decorators** — available in all handler types (`@Message`, `@Connect`, `@Disconnect`)
12
+
13
+ Additionally, Wooks composable functions (`useWsMessage()`, `useWsConnection()`, etc.) can be called directly inside handler bodies.
14
+
15
+ ## API Reference
16
+
17
+ ### `@MessageData()`
18
+
19
+ Resolves the parsed message payload (the `data` field from the client message).
20
+
21
+ ```ts
22
+ @Message('message', '/chat/:room')
23
+ onMessage(@MessageData() data: { from: string; text: string }) {
24
+ console.log(`${data.from}: ${data.text}`)
25
+ }
26
+ ```
27
+
28
+ **Returns:** `unknown` (typed by the parameter's type annotation)
29
+ **Available in:** `@Message` only
30
+
31
+ ### `@RawMessage()`
32
+
33
+ Resolves the raw message before JSON parsing.
34
+
35
+ ```ts
36
+ @Message('debug', '/raw')
37
+ onRaw(@RawMessage() raw: Buffer | string) {
38
+ console.log('Raw message:', raw.toString())
39
+ }
40
+ ```
41
+
42
+ **Returns:** `Buffer | string`
43
+ **Available in:** `@Message` only
44
+
45
+ ### `@MessageId()`
46
+
47
+ Resolves the message correlation ID. `undefined` for fire-and-forget, `string | number` for RPC calls.
48
+
49
+ ```ts
50
+ @Message('query', '/info')
51
+ info(@MessageId() messageId: string | number | undefined) {
52
+ console.log('Correlation ID:', messageId)
53
+ return { timestamp: Date.now() }
54
+ }
55
+ ```
56
+
57
+ **Returns:** `string | number | undefined`
58
+ **Available in:** `@Message` only
59
+
60
+ ### `@MessageType()`
61
+
62
+ Resolves the event type string from the message.
63
+
64
+ ```ts
65
+ @Message('*', '/log')
66
+ onAny(@MessageType() event: string) {
67
+ console.log('Event type:', event)
68
+ }
69
+ ```
70
+
71
+ **Returns:** `string`
72
+ **Available in:** `@Message` only
73
+
74
+ ### `@MessagePath()`
75
+
76
+ Resolves the concrete message path (after routing).
77
+
78
+ ```ts
79
+ @Message('action', '/game/:id')
80
+ onAction(@MessagePath() path: string) {
81
+ console.log('Message path:', path) // e.g. "/game/42"
82
+ }
83
+ ```
84
+
85
+ **Returns:** `string`
86
+ **Available in:** `@Message` only
87
+
88
+ ### `@ConnectionId()`
89
+
90
+ Resolves the unique connection identifier (UUID).
91
+
92
+ ```ts
93
+ @Connect()
94
+ onConnect(@ConnectionId() id: string) {
95
+ console.log(`Connected: ${id}`)
96
+ }
97
+
98
+ @Message('ping', '/ping')
99
+ ping(@ConnectionId() id: string) {
100
+ return { pong: true, connectionId: id }
101
+ }
102
+
103
+ @Disconnect()
104
+ onDisconnect(@ConnectionId() id: string) {
105
+ console.log(`Disconnected: ${id}`)
106
+ }
107
+ ```
108
+
109
+ **Returns:** `string`
110
+ **Available in:** All handlers (`@Message`, `@Connect`, `@Disconnect`)
111
+
112
+ ### `@Param(name: string)`
113
+
114
+ Resolves a named route parameter from the message path. Same decorator as HTTP routing (re-exported from `moost`).
115
+
116
+ ```ts
117
+ @Message('message', ':room')
118
+ onMessage(@Param('room') room: string, @MessageData() data: { text: string }) {
119
+ console.log(`[${room}] ${data.text}`)
120
+ }
121
+ ```
122
+
123
+ **Returns:** `string`
124
+ **Available in:** `@Message` only
125
+
126
+ ### `@Params()`
127
+
128
+ Resolves all route parameters as an object. Re-exported from `moost`.
129
+
130
+ ```ts
131
+ @Message('move', '/game/:gameId/player/:playerId')
132
+ onMove(@Params() params: { gameId: string; playerId: string }) {
133
+ console.log(params) // { gameId: '1', playerId: 'alice' }
134
+ }
135
+ ```
136
+
137
+ **Returns:** `Record<string, string>`
138
+ **Available in:** `@Message` only
139
+
140
+ ## Using Composables Directly
141
+
142
+ You can also call Wooks composables inside handler bodies instead of using decorators:
143
+
144
+ ```ts
145
+ import { useWsMessage, useWsConnection } from '@moostjs/event-ws'
146
+
147
+ @Message('echo', '/echo')
148
+ echo() {
149
+ const { data, id, path, event } = useWsMessage()
150
+ const { id: connId, send } = useWsConnection()
151
+ return data
152
+ }
153
+ ```
154
+
155
+ ## HTTP Context in WS Handlers
156
+
157
+ In HTTP-integrated mode, HTTP composables from the upgrade request are available:
158
+
159
+ ```ts
160
+ import { Connect, ConnectionId } from '@moostjs/event-ws'
161
+ import { useHeaders, useRequest } from '@wooksjs/event-http'
162
+
163
+ @Connect()
164
+ onConnect(@ConnectionId() id: string) {
165
+ const { url } = useRequest()
166
+ const headers = useHeaders()
167
+ console.log('Upgrade URL:', url)
168
+ console.log('User-Agent:', headers['user-agent'])
169
+ }
170
+ ```
171
+
172
+ HTTP composables are read-only — response composables like `useResponse()` are not available in WS handlers.
173
+
174
+ ## Summary
175
+
176
+ | Decorator | Returns | Available In |
177
+ |-----------|---------|-------------|
178
+ | `@MessageData()` | Parsed message payload | `@Message` |
179
+ | `@RawMessage()` | Raw `Buffer \| string` | `@Message` |
180
+ | `@MessageId()` | Correlation ID `string \| number \| undefined` | `@Message` |
181
+ | `@MessageType()` | Event type `string` | `@Message` |
182
+ | `@MessagePath()` | Concrete message path `string` | `@Message` |
183
+ | `@ConnectionId()` | Connection UUID `string` | All handlers |
184
+ | `@Param(name)` | Named route parameter `string` | `@Message` |
185
+ | `@Params()` | All route parameters `object` | `@Message` |
@@ -0,0 +1,196 @@
1
+ # Rooms & broadcasting — @moostjs/event-ws
2
+
3
+ > Room management, broadcasting, direct sends, server-wide queries, and multi-instance scaling.
4
+
5
+ ## Concepts
6
+
7
+ Rooms group WebSocket connections for targeted broadcasting. A connection can join multiple rooms. Room names are strings — by default, the current message path is used as the room name.
8
+
9
+ Three composables handle communication:
10
+ - `useWsRooms()` — room-scoped operations (join, leave, broadcast). Available in message handlers only.
11
+ - `useWsConnection()` — direct send to the current connection. Available in all handlers.
12
+ - `useWsServer()` — server-wide operations (broadcast to all, query connections). Available in any context.
13
+
14
+ ## API Reference
15
+
16
+ ### `useWsRooms()`
17
+
18
+ Room management for the current connection. Only available in `@Message` handlers.
19
+
20
+ ```ts
21
+ const { join, leave, broadcast, rooms } = useWsRooms()
22
+ ```
23
+
24
+ | Method | Description |
25
+ |--------|-------------|
26
+ | `join(room?)` | Join a room (default: current message path) |
27
+ | `leave(room?)` | Leave a room (default: current message path) |
28
+ | `broadcast(event, data?, opts?)` | Broadcast to room members |
29
+ | `rooms()` | List rooms this connection has joined (`string[]`) |
30
+
31
+ **Broadcast options:**
32
+ ```ts
33
+ broadcast('message', data, {
34
+ room: '/custom-room', // target a different room (default: current path)
35
+ excludeSelf: false, // include the sender (default: true)
36
+ })
37
+ ```
38
+
39
+ ### `useWsConnection()`
40
+
41
+ Access the current WebSocket connection. Available in all handler types.
42
+
43
+ ```ts
44
+ const { id, send, close } = useWsConnection()
45
+ ```
46
+
47
+ | Property/Method | Description |
48
+ |----------------|-------------|
49
+ | `id` | Connection UUID (`string`) |
50
+ | `send(event, path, data?, params?)` | Push a message to this client |
51
+ | `close(code?, reason?)` | Close the connection |
52
+ | `context` | The connection `EventContext` |
53
+
54
+ ### `useWsServer()`
55
+
56
+ Server-wide operations. Available in any context.
57
+
58
+ ```ts
59
+ const server = useWsServer()
60
+ ```
61
+
62
+ | Method | Description |
63
+ |--------|-------------|
64
+ | `broadcast(event, path, data?)` | Broadcast to ALL connected clients |
65
+ | `connections()` | Get all connections (`Map<string, WsConnection>`) |
66
+ | `roomConnections(room)` | Get connections in a room (`Set<WsConnection>`) |
67
+ | `getConnection(id)` | Get connection by ID (`WsConnection \| undefined`) |
68
+
69
+ ### `currentConnection()`
70
+
71
+ Returns the connection `EventContext` regardless of context level:
72
+ - In `@Connect`/`@Disconnect`: returns `current()` directly
73
+ - In `@Message`: returns `current().parent` (the connection context)
74
+
75
+ ## Common Patterns
76
+
77
+ ### Pattern: Join a room and broadcast
78
+
79
+ ```ts
80
+ @Controller('chat')
81
+ export class ChatController {
82
+ @Message('join', ':room')
83
+ join(
84
+ @Param('room') room: string,
85
+ @ConnectionId() id: string,
86
+ @MessageData() data: { name: string },
87
+ ) {
88
+ const { join, broadcast, rooms } = useWsRooms()
89
+ join() // joins room matching current path (e.g. "/chat/general")
90
+ broadcast('system', { text: `${data.name} joined` })
91
+ return { joined: true, room, rooms: rooms() }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Pattern: Broadcast a message to a room
97
+
98
+ ```ts
99
+ @Message('message', ':room')
100
+ onMessage(@MessageData() data: { from: string; text: string }) {
101
+ const { broadcast } = useWsRooms()
102
+ broadcast('message', { from: data.from, text: data.text })
103
+ // all room members except sender receive the message
104
+ }
105
+ ```
106
+
107
+ ### Pattern: Direct send to current connection
108
+
109
+ ```ts
110
+ @Message('notify', '/self')
111
+ notify() {
112
+ const { send } = useWsConnection()
113
+ send('notification', '/alerts', { text: 'Just for you' })
114
+ }
115
+ ```
116
+
117
+ ### Pattern: Server-wide broadcast
118
+
119
+ ```ts
120
+ @Message('admin', '/announce')
121
+ announce(@MessageData() data: { text: string }) {
122
+ const server = useWsServer()
123
+ server.broadcast('announcement', '/announce', { text: data.text })
124
+ return { announced: true }
125
+ }
126
+ ```
127
+
128
+ ### Pattern: Send to a specific connection by ID
129
+
130
+ ```ts
131
+ @Message('dm', '/direct')
132
+ directMessage(@MessageData() data: { targetId: string; text: string }) {
133
+ const server = useWsServer()
134
+ const target = server.getConnection(data.targetId)
135
+ if (target) {
136
+ target.send('dm', '/direct', { text: data.text })
137
+ }
138
+ }
139
+ ```
140
+
141
+ ## Multi-Instance Broadcasting
142
+
143
+ For horizontal scaling, implement `WsBroadcastTransport` to relay room broadcasts across instances:
144
+
145
+ ```ts
146
+ import type { WsBroadcastTransport } from '@moostjs/event-ws'
147
+
148
+ class RedisBroadcastTransport implements WsBroadcastTransport {
149
+ publish(channel: string, payload: string) {
150
+ redis.publish(channel, payload)
151
+ }
152
+ subscribe(channel: string, handler: (payload: string) => void) {
153
+ redis.subscribe(channel, handler)
154
+ }
155
+ unsubscribe(channel: string) {
156
+ redis.unsubscribe(channel)
157
+ }
158
+ }
159
+ ```
160
+
161
+ Pass it in adapter options:
162
+
163
+ ```ts
164
+ const ws = new MoostWs({
165
+ httpApp: http.getHttpApp(),
166
+ wooksWs: {
167
+ broadcastTransport: new RedisBroadcastTransport(),
168
+ },
169
+ })
170
+ ```
171
+
172
+ Channels follow the pattern `ws:room:<room-path>`.
173
+
174
+ ### WsBroadcastTransport Interface
175
+
176
+ ```ts
177
+ interface WsBroadcastTransport {
178
+ publish(channel: string, payload: string): void | Promise<void>
179
+ subscribe(channel: string, handler: (payload: string) => void): void | Promise<void>
180
+ unsubscribe(channel: string): void | Promise<void>
181
+ }
182
+ ```
183
+
184
+ ## Best Practices
185
+
186
+ - Let rooms auto-clean on disconnect — don't manually leave in `@Disconnect` handlers
187
+ - Use `excludeSelf: true` (default) to prevent echo in chat-like scenarios
188
+ - Use `useWsServer()` sparingly — prefer room-scoped broadcasts over server-wide
189
+ - For large-scale deployments, implement `WsBroadcastTransport` with Redis/NATS
190
+
191
+ ## Gotchas
192
+
193
+ - `useWsRooms()` throws if called outside a message context (e.g. inside `@Connect`)
194
+ - `join()` without arguments uses the current message path as the room name, including the controller prefix
195
+ - `useWsConnection().send()` silently drops messages if the socket is not in OPEN state
196
+ - Broadcast `excludeId` only prevents echo on the originating server instance — the same user's other connections still receive it
@@ -0,0 +1,115 @@
1
+ # Routing — @moostjs/event-ws
2
+
3
+ > Event+path routing, controller prefixes, parametric routes, and wildcards.
4
+
5
+ ## Concepts
6
+
7
+ WebSocket message routing uses a two-dimensional scheme: messages are matched by both **event type** and **path**. This is powered by the same Wooks router used for HTTP routes.
8
+
9
+ Every client message carries: `{ event: "message", path: "/chat/general", data: {...} }`
10
+
11
+ The `@Message` decorator matches both dimensions. The `event` must match exactly. The `path` supports parametric patterns (`:param`) and wildcards (`*`).
12
+
13
+ ## Routing Rules
14
+
15
+ ### Event + Path
16
+
17
+ ```ts
18
+ @Message('message', '/chat/general')
19
+ onMessage(@MessageData() data: { text: string }) {
20
+ // matches event="message" at path="/chat/general"
21
+ }
22
+ ```
23
+
24
+ ### Controller Prefixes
25
+
26
+ The `@Controller` prefix is prepended to handler paths:
27
+
28
+ ```ts
29
+ @Controller('game')
30
+ export class GameController {
31
+ @Message('move', 'board/:id')
32
+ // effective path: /game/board/:id
33
+ onMove(@Param('id') id: string) { /* ... */ }
34
+ }
35
+ ```
36
+
37
+ Nested controllers with `@ImportController` compose prefixes:
38
+
39
+ ```ts
40
+ @Controller('v2')
41
+ export class V2Controller {
42
+ @ImportController(() => GameController)
43
+ game!: GameController
44
+ // GameController routes become /v2/game/board/:id
45
+ }
46
+ ```
47
+
48
+ ### Parametric Routes
49
+
50
+ Use `:param` syntax for path parameters:
51
+
52
+ ```ts
53
+ @Controller('chat')
54
+ export class ChatController {
55
+ @Message('join', ':room')
56
+ join(@Param('room') room: string) { /* ... */ }
57
+
58
+ @Message('dm', ':sender/:receiver')
59
+ dm(
60
+ @Param('sender') sender: string,
61
+ @Param('receiver') receiver: string,
62
+ ) { /* ... */ }
63
+ }
64
+ ```
65
+
66
+ Client message `{ event: "dm", path: "/chat/alice/bob" }` resolves `sender="alice"`, `receiver="bob"`.
67
+
68
+ ### All Route Parameters
69
+
70
+ Use `@Params()` to get all parameters as an object:
71
+
72
+ ```ts
73
+ import { Params } from 'moost'
74
+
75
+ @Message('action', ':type/:id')
76
+ handle(@Params() params: { type: string; id: string }) {
77
+ console.log(params) // { type: 'move', id: '42' }
78
+ }
79
+ ```
80
+
81
+ ### Wildcards
82
+
83
+ ```ts
84
+ @Message('log', '/events/*')
85
+ handleAllEvents(@Param('*') subPath: string) {
86
+ // matches /events/user/login, /events/system/error, etc.
87
+ }
88
+ ```
89
+
90
+ ### Path Omission
91
+
92
+ When `path` is omitted, the method name becomes the path:
93
+
94
+ ```ts
95
+ @Controller('api')
96
+ export class ApiController {
97
+ @Message('query')
98
+ status() {
99
+ // effective path: /api/status
100
+ return { ok: true }
101
+ }
102
+ }
103
+ ```
104
+
105
+ ## Best Practices
106
+
107
+ - Use controller prefixes to namespace related handlers
108
+ - Prefer explicit `path` argument over relying on method name inference for clarity
109
+ - Use parametric routes (`:room`) rather than separate handlers per room
110
+
111
+ ## Gotchas
112
+
113
+ - Event matching is exact — no wildcard support on the event field itself
114
+ - Path parameters are always strings, even if they look like numbers
115
+ - Leading slash in `@Message` path is optional — `':room'` and `'/:room'` behave the same when composed with a controller prefix
@@ -0,0 +1,209 @@
1
+ # Testing — @moostjs/event-ws
2
+
3
+ > Unit-testing WebSocket handlers with mock contexts using prepareTestWsMessageContext and prepareTestWsConnectionContext.
4
+
5
+ ## Concepts
6
+
7
+ `@moostjs/event-ws` re-exports test helpers from `@wooksjs/event-ws` that create mock event contexts for unit-testing handlers and composables without starting a real server.
8
+
9
+ Two context factories match the two context layers:
10
+ 1. **`prepareTestWsConnectionContext`** — for `@Connect`/`@Disconnect` handler logic
11
+ 2. **`prepareTestWsMessageContext`** — for `@Message` handler logic (includes a parent connection context)
12
+
13
+ Both return a **runner function** `<T>(cb: () => T) => T` that executes a callback inside a fully initialized event context.
14
+
15
+ ## API Reference
16
+
17
+ ### `prepareTestWsMessageContext(options)`
18
+
19
+ Creates a message context with a parent connection context. Both contexts are fully seeded.
20
+
21
+ ```ts
22
+ interface TTestWsMessageContext {
23
+ event: string // required — message event type
24
+ path: string // required — message route path
25
+ data?: unknown // parsed message payload
26
+ messageId?: string | number // correlation ID
27
+ rawMessage?: Buffer | string // raw message before parsing
28
+ id?: string // connection ID (default: 'test-conn-id')
29
+ params?: Record<string, string | string[]> // pre-set route parameters
30
+ parentCtx?: EventContext // optional parent context (e.g. HTTP)
31
+ }
32
+ ```
33
+
34
+ **Returns:** `<T>(cb: (...a: any[]) => T) => T`
35
+
36
+ ```ts
37
+ import { prepareTestWsMessageContext, useWsMessage, useWsConnection } from '@moostjs/event-ws'
38
+
39
+ const runInCtx = prepareTestWsMessageContext({
40
+ event: 'message',
41
+ path: '/chat/general',
42
+ data: { from: 'Alice', text: 'Hello!' },
43
+ messageId: 1,
44
+ })
45
+
46
+ runInCtx(() => {
47
+ const { data, id, path, event } = useWsMessage<{ from: string; text: string }>()
48
+ expect(data.from).toBe('Alice')
49
+ expect(id).toBe(1)
50
+ })
51
+ ```
52
+
53
+ ### `prepareTestWsConnectionContext(options?)`
54
+
55
+ Creates a connection context for testing connection lifecycle handlers.
56
+
57
+ ```ts
58
+ interface TTestWsConnectionContext {
59
+ id?: string // connection ID (default: 'test-conn-id')
60
+ params?: Record<string, string | string[]> // pre-set route parameters
61
+ parentCtx?: EventContext // optional parent context
62
+ }
63
+ ```
64
+
65
+ **Returns:** `<T>(cb: (...a: any[]) => T) => T`
66
+
67
+ ```ts
68
+ import { prepareTestWsConnectionContext, useWsConnection } from '@moostjs/event-ws'
69
+
70
+ const runInCtx = prepareTestWsConnectionContext({ id: 'conn-456' })
71
+
72
+ runInCtx(() => {
73
+ const { id } = useWsConnection()
74
+ expect(id).toBe('conn-456')
75
+ })
76
+ ```
77
+
78
+ ## Common Patterns
79
+
80
+ ### Pattern: Testing message data access
81
+
82
+ ```ts
83
+ import { describe, it, expect } from 'vitest'
84
+ import { prepareTestWsMessageContext, useWsMessage } from '@moostjs/event-ws'
85
+
86
+ describe('ChatController', () => {
87
+ it('should access message data', () => {
88
+ const runInCtx = prepareTestWsMessageContext({
89
+ event: 'message',
90
+ path: '/chat/general',
91
+ data: { from: 'Alice', text: 'Hello!' },
92
+ messageId: 1,
93
+ })
94
+
95
+ runInCtx(() => {
96
+ const { data, id, path, event } = useWsMessage<{ from: string; text: string }>()
97
+ expect(data.from).toBe('Alice')
98
+ expect(data.text).toBe('Hello!')
99
+ expect(id).toBe(1)
100
+ expect(path).toBe('/chat/general')
101
+ expect(event).toBe('message')
102
+ })
103
+ })
104
+ })
105
+ ```
106
+
107
+ ### Pattern: Testing with route parameters
108
+
109
+ ```ts
110
+ import { prepareTestWsMessageContext } from '@moostjs/event-ws'
111
+ import { useRouteParams } from '@wooksjs/event-core'
112
+
113
+ it('should resolve route params', () => {
114
+ const runInCtx = prepareTestWsMessageContext({
115
+ event: 'join',
116
+ path: '/chat/rooms/lobby',
117
+ params: { room: 'lobby' },
118
+ data: { name: 'Alice' },
119
+ })
120
+
121
+ runInCtx(() => {
122
+ const { get } = useRouteParams<{ room: string }>()
123
+ expect(get('room')).toBe('lobby')
124
+ })
125
+ })
126
+ ```
127
+
128
+ ### Pattern: Testing connection ID
129
+
130
+ ```ts
131
+ it('should access connection id in message context', () => {
132
+ const runInCtx = prepareTestWsMessageContext({
133
+ event: 'join',
134
+ path: '/chat/general',
135
+ data: { name: 'Alice' },
136
+ id: 'conn-123',
137
+ })
138
+
139
+ runInCtx(() => {
140
+ const { id } = useWsConnection()
141
+ expect(id).toBe('conn-123')
142
+ })
143
+ })
144
+ ```
145
+
146
+ ### Pattern: Testing with HTTP parent context
147
+
148
+ For handlers that access HTTP composables from the upgrade request:
149
+
150
+ ```ts
151
+ import { EventContext } from '@wooksjs/event-core'
152
+ import { prepareTestWsMessageContext, currentConnection } from '@moostjs/event-ws'
153
+
154
+ it('should have access to parent HTTP context', () => {
155
+ const httpCtx = new EventContext({ logger: console as any })
156
+
157
+ const runInCtx = prepareTestWsMessageContext({
158
+ event: 'test',
159
+ path: '/test',
160
+ parentCtx: httpCtx,
161
+ })
162
+
163
+ runInCtx(() => {
164
+ const connCtx = currentConnection()
165
+ expect(connCtx.parent).toBe(httpCtx)
166
+ })
167
+ })
168
+ ```
169
+
170
+ ### Pattern: Testing handler functions directly
171
+
172
+ Extract handler logic into testable functions:
173
+
174
+ ```ts
175
+ import { prepareTestWsMessageContext, useWsRooms } from '@moostjs/event-ws'
176
+
177
+ function handleJoin(room: string, name: string) {
178
+ const { join, broadcast, rooms } = useWsRooms()
179
+ join()
180
+ broadcast('system', { text: `${name} joined` })
181
+ return { joined: true, room, rooms: rooms() }
182
+ }
183
+
184
+ it('should join a room and return room list', () => {
185
+ const runInCtx = prepareTestWsMessageContext({
186
+ event: 'join',
187
+ path: '/chat/general',
188
+ data: { name: 'Alice' },
189
+ })
190
+
191
+ const result = runInCtx(() => handleJoin('general', 'Alice'))
192
+ expect(result.joined).toBe(true)
193
+ expect(result.room).toBe('general')
194
+ })
195
+ ```
196
+
197
+ ## Best Practices
198
+
199
+ - Use test helpers rather than manually constructing `EventContext`
200
+ - Keep handler logic testable by extracting business logic into composable-using functions
201
+ - Test edge cases with different message data, missing fields, and error conditions
202
+ - Use `parentCtx` to simulate HTTP-integrated mode
203
+ - Default connection ID is `'test-conn-id'` — override with the `id` option when needed
204
+
205
+ ## Gotchas
206
+
207
+ - `useWsRooms()` and `useWsServer()` depend on adapter state — for full integration testing with rooms and broadcasting, you may need to set up `WsRoomManager` manually
208
+ - The runner function is synchronous — wrap async handler logic in a returned promise if needed
209
+ - Route parameters must be pre-set via the `params` option — the test context doesn't run the router