@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/index.cjs +445 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.mjs +298 -0
- package/package.json +62 -0
- package/scripts/setup-skills.js +78 -0
- package/skills/moostjs-event-ws/SKILL.md +42 -0
- package/skills/moostjs-event-ws/core.md +157 -0
- package/skills/moostjs-event-ws/handlers.md +162 -0
- package/skills/moostjs-event-ws/protocol.md +181 -0
- package/skills/moostjs-event-ws/request-data.md +185 -0
- package/skills/moostjs-event-ws/rooms.md +196 -0
- package/skills/moostjs-event-ws/routing.md +115 -0
- package/skills/moostjs-event-ws/testing.md +209 -0
|
@@ -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
|