@rlanz/socket 0.0.1-9 → 0.1.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 +556 -556
- package/build/{base_channel-DPOnqned.d.ts → base_channel-CasBQV5I.d.ts} +7 -25
- package/build/{chunk-6PJ4ALJD.js → chunk-DM3UQMWW.js} +5 -3
- package/build/chunk-DM3UQMWW.js.map +1 -0
- package/build/{chunk-QNOOQXYY.js → chunk-DR6GWQUW.js} +242 -199
- package/build/chunk-DR6GWQUW.js.map +1 -0
- package/build/chunk-NUPBNOP3.js +61 -0
- package/build/chunk-NUPBNOP3.js.map +1 -0
- package/build/{chunk-YVC7IMDN.js → chunk-UE75J3JE.js} +2 -2
- package/build/chunk-YSW4MNFR.js +7 -0
- package/build/chunk-YSW4MNFR.js.map +1 -0
- package/build/{framework-B8pJ4cCA.d.ts → framework-Cb71JA5f.d.ts} +1 -1
- package/build/{index-DjBYUhFR.d.ts → index-DCPtfkJL.d.ts} +1 -1
- package/build/index.d.ts +3 -2
- package/build/providers/socket_provider.d.ts +3 -2
- package/build/providers/socket_provider.js +312 -130
- package/build/providers/socket_provider.js.map +1 -1
- package/build/services/socket.d.ts +3 -2
- package/build/shared_types-B11CaOr5.d.ts +26 -0
- package/build/{socket_service-BcC9ASCE.d.ts → socket_service-C2YRbhrO.d.ts} +1 -1
- package/build/src/assembler_hook.js +54 -30
- package/build/src/assembler_hook.js.map +1 -1
- package/build/src/client/index.d.ts +2 -7
- package/build/src/client/index.js +3 -2
- package/build/src/client/react.d.ts +3 -8
- package/build/src/client/react.js +4 -3
- package/build/src/client/react.js.map +1 -1
- package/build/src/client/types.d.ts +9 -13
- package/build/src/client/vue.d.ts +3 -8
- package/build/src/client/vue.js +4 -3
- package/build/src/client/vue.js.map +1 -1
- package/build/src/decorators.d.ts +2 -1
- package/build/src/health_check.d.ts +3 -2
- package/build/src/otel.d.ts +1 -0
- package/build/src/otel.js +211 -27
- package/build/src/otel.js.map +1 -1
- package/build/src/types.d.ts +2 -1
- package/package.json +31 -7
- package/build/chunk-6PJ4ALJD.js.map +0 -1
- package/build/chunk-D3HUBCBW.js +0 -18
- package/build/chunk-D3HUBCBW.js.map +0 -1
- package/build/chunk-QNOOQXYY.js.map +0 -1
- /package/build/{chunk-YVC7IMDN.js.map → chunk-UE75J3JE.js.map} +0 -0
package/README.md
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
# @rlanz/socket
|
|
2
2
|
|
|
3
|
-
WebSocket
|
|
3
|
+
A WebSocket server and typed client for AdonisJS, powered by
|
|
4
|
+
[`ws`](https://github.com/websockets/ws).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
> [!WARNING]
|
|
7
|
+
> This package is available for testing and early production use. It will move to
|
|
8
|
+
> `@adonisjs/socket` before its official release. Expect breaking changes and a migration.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
`@rlanz/socket` provides:
|
|
11
|
+
|
|
12
|
+
- AdonisJS channels with middleware, lifecycle hooks, and dependency injection
|
|
13
|
+
- end-to-end types generated from server channel classes
|
|
14
|
+
- presence channels and client-to-client events
|
|
15
|
+
- automatic client reconnection and re-subscription
|
|
16
|
+
- cross-instance broadcasts through `@boringnode/bus`
|
|
17
|
+
- React and Vue adapters
|
|
18
|
+
- health checks, OpenTelemetry instrumentation, and test fakes
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
## Install
|
|
12
21
|
|
|
13
22
|
```sh
|
|
23
|
+
yarn add @rlanz/socket
|
|
14
24
|
node ace configure @rlanz/socket
|
|
15
25
|
```
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
The configure command registers the provider in the `web` environment and adds the Assembler hook
|
|
28
|
+
that discovers channels and generates client types.
|
|
19
29
|
|
|
20
30
|
```ts
|
|
21
31
|
// adonisrc.ts
|
|
@@ -32,478 +42,234 @@ export default defineConfig({
|
|
|
32
42
|
})
|
|
33
43
|
```
|
|
34
44
|
|
|
35
|
-
The
|
|
36
|
-
server manifest consumed by the provider from the same channel file list. The client registry is a
|
|
37
|
-
typed subset: channels whose inheritance, pattern, or handlers cannot be represented statically are
|
|
38
|
-
still registered at runtime but are omitted from generated client types with a diagnostic. Keep the
|
|
39
|
-
standard AdonisJS package import mapping `#generated/*` pointed at `./.adonisjs/server/*.js`; the
|
|
40
|
-
provider loads `#generated/socket_channels` through that convention.
|
|
45
|
+
The package requires Node.js 24 or newer and AdonisJS 7.
|
|
41
46
|
|
|
42
|
-
##
|
|
47
|
+
## Quick start
|
|
43
48
|
|
|
44
|
-
Create a
|
|
49
|
+
Create a channel in `app/channels`.
|
|
45
50
|
|
|
46
51
|
```ts
|
|
47
|
-
//
|
|
48
|
-
import {
|
|
52
|
+
// app/channels/chat_channel.ts
|
|
53
|
+
import { BaseChannel } from '@rlanz/socket'
|
|
54
|
+
import { onMessage } from '@rlanz/socket/decorators'
|
|
55
|
+
import type { AuthenticatedSocket } from '@rlanz/socket/types'
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
path: '/socket',
|
|
53
|
-
origin: (origin, ctx) => {
|
|
54
|
-
return ['https://app.example.com', 'https://admin.example.com'].includes(origin)
|
|
55
|
-
},
|
|
56
|
-
pingInterval: '25s',
|
|
57
|
-
pingTimeout: '5s',
|
|
58
|
-
maxBufferedAmount: 16 * 1024 * 1024,
|
|
59
|
-
maxOutboundPayload: 1024 * 1024,
|
|
60
|
-
maxSubscriptionsPerSocket: 100,
|
|
61
|
-
maxChannelNameLength: 255,
|
|
62
|
-
},
|
|
63
|
-
})
|
|
64
|
-
```
|
|
57
|
+
type User = { id: string; name: string }
|
|
58
|
+
type Message = { id: string; text: string }
|
|
65
59
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
comma-separated list), an array of strings, or `(origin, httpContext) => value`. Matching is
|
|
70
|
-
case-sensitive and, like `@adonisjs/cors`, comma-separated entries are not whitespace-trimmed. A
|
|
71
|
-
configured policy replaces the same-origin default. Clients without an `Origin` header remain
|
|
72
|
-
supported.
|
|
73
|
-
- `websocket.middleware` runs AdonisJS HTTP middleware for the WebSocket upgrade request.
|
|
74
|
-
- `websocket.pingInterval` sends a WebSocket ping every configured duration.
|
|
75
|
-
- `websocket.pingTimeout` closes connections that do not answer in time.
|
|
76
|
-
- If only one heartbeat value is configured, the other one uses the default shown above.
|
|
77
|
-
- `websocket.maxPayload` limits inbound message payloads; it defaults to 1 MiB.
|
|
78
|
-
- `websocket.maxQueuedMessages` limits unresolved non-ping protocol messages per socket; it defaults
|
|
79
|
-
to `100`.
|
|
80
|
-
- `websocket.maxMessagesPerInterval` limits inbound protocol messages per
|
|
81
|
-
`websocket.messageRateInterval`; they default to `1000` messages per `1s`.
|
|
82
|
-
- `websocket.maxBufferedAmount` closes slow outbound sockets when the buffered bytes plus the next
|
|
83
|
-
protocol message would exceed the configured limit; it defaults to 16 MiB.
|
|
84
|
-
- `websocket.maxOutboundPayload` limits every serialized outbound event, ACK, pong, and protocol
|
|
85
|
-
error; it defaults to 1 MiB.
|
|
86
|
-
- `websocket.maxSubscriptionsPerSocket` limits active channel subscriptions retained by one socket;
|
|
87
|
-
it defaults to `100`.
|
|
88
|
-
- `websocket.maxChannelNameLength` limits channel names before routing; it defaults to `255`
|
|
89
|
-
characters.
|
|
90
|
-
|
|
91
|
-
`websocket.middleware` is different from channel middleware. Use it to prepare request-scoped
|
|
92
|
-
services such as sessions or auth during the initial HTTP upgrade; use `static middlewares` on a
|
|
93
|
-
channel class to authorize or enrich individual channel subscriptions.
|
|
94
|
-
|
|
95
|
-
AdonisJS CORS middleware does not authorize the Node.js `upgrade` event and browsers do not apply
|
|
96
|
-
Fetch CORS response checks to WebSockets. The origin check therefore runs before upgrade middleware
|
|
97
|
-
and authentication, which protects cookie- or session-authenticated sockets from cross-site
|
|
98
|
-
WebSocket hijacking. A reverse proxy must preserve the public `Host` and overwrite
|
|
99
|
-
`X-Forwarded-Proto` with the trusted public protocol. If it rewrites either value, add the public
|
|
100
|
-
origin explicitly with `websocket.origin` instead of relying on implicit same-origin
|
|
101
|
-
detection.
|
|
60
|
+
type ServerEvents = {
|
|
61
|
+
'chat:message': Message
|
|
62
|
+
}
|
|
102
63
|
|
|
103
|
-
|
|
64
|
+
export default class ChatChannel extends BaseChannel<User, ServerEvents> {
|
|
65
|
+
static pattern = 'chat/:roomId'
|
|
104
66
|
|
|
105
|
-
|
|
106
|
-
|
|
67
|
+
@onMessage('chat:send')
|
|
68
|
+
async sendMessage(
|
|
69
|
+
socket: AuthenticatedSocket<User>,
|
|
70
|
+
payload: { text: string }
|
|
71
|
+
): Promise<Message> {
|
|
72
|
+
const message = { id: crypto.randomUUID(), text: payload.text }
|
|
107
73
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
74
|
+
this.broadcastExcept(socket.id, 'chat:message', message)
|
|
75
|
+
return message
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
112
79
|
|
|
113
|
-
|
|
114
|
-
channel
|
|
80
|
+
The Assembler hook generates an `AppSocket` type from this class. Give it to the browser client to
|
|
81
|
+
type channel parameters, event names, payloads, and acknowledgements.
|
|
115
82
|
|
|
116
83
|
```ts
|
|
117
|
-
|
|
118
|
-
import {
|
|
119
|
-
|
|
120
|
-
type User = { id: string | number; name: string }
|
|
84
|
+
import { Socket } from '@rlanz/socket/client'
|
|
85
|
+
import type { AppSocket } from '#generated/socket'
|
|
121
86
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
middleware: [
|
|
125
|
-
() => import('@adonisjs/session/session_middleware'),
|
|
126
|
-
() => import('@adonisjs/auth/initialize_auth_middleware'),
|
|
127
|
-
],
|
|
87
|
+
const socket = new Socket<AppSocket>()
|
|
88
|
+
const channel = socket.channel('chat/:roomId', { roomId: 'general' })
|
|
128
89
|
|
|
129
|
-
|
|
130
|
-
|
|
90
|
+
channel.listen('chat:message', (message) => {
|
|
91
|
+
console.log(message.id)
|
|
131
92
|
})
|
|
132
|
-
```
|
|
133
93
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
Every connected socket retains the initial Adonis HTTP context at `socket.raw.httpContext`; use
|
|
138
|
-
`socket.raw.data` for mutable socket-lifetime state.
|
|
139
|
-
|
|
140
|
-
The retained HTTP context describes the initial upgrade handshake. After the `101` response, it is
|
|
141
|
-
not an active HTTP request: do not write through `httpContext.response` or expect it to remain
|
|
142
|
-
available through AdonisJS async-local storage. The upgrade pipeline receives the context explicitly
|
|
143
|
-
and does not install it in async-local storage during the handshake either. Copy durable values into
|
|
144
|
-
`socket.user` or `socket.raw.data` instead of keeping response-bound resources alive.
|
|
145
|
-
|
|
146
|
-
Override `websocket.authenticate` when you need custom behavior:
|
|
94
|
+
await socket.connect()
|
|
95
|
+
await channel.subscribe()
|
|
147
96
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await httpContext.auth.authenticateUsing()
|
|
151
|
-
return httpContext.auth.getUserOrFail()
|
|
152
|
-
}
|
|
97
|
+
const message = await channel.sendWithAck('chat:send', { text: 'Hello' })
|
|
98
|
+
console.log(message.id)
|
|
153
99
|
```
|
|
154
100
|
|
|
155
|
-
|
|
101
|
+
TypeScript rejects unknown channels, missing channel parameters, unknown events, invalid payloads,
|
|
102
|
+
and invalid server broadcasts.
|
|
156
103
|
|
|
157
|
-
Configure
|
|
104
|
+
## Configure the server
|
|
158
105
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
```sh
|
|
162
|
-
yarn add ioredis
|
|
163
|
-
```
|
|
106
|
+
Create `config/socket.ts` when you need to change the defaults.
|
|
164
107
|
|
|
165
108
|
```ts
|
|
166
109
|
// config/socket.ts
|
|
167
|
-
import { redis } from '@boringnode/bus/transports/redis'
|
|
168
110
|
import { defineConfig } from '@rlanz/socket'
|
|
169
111
|
|
|
170
112
|
export default defineConfig({
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
channel: 'socket::broadcast',
|
|
177
|
-
presenceTimeout: '100ms',
|
|
178
|
-
retryQueue: {
|
|
179
|
-
maxSize: 1000,
|
|
180
|
-
},
|
|
113
|
+
websocket: {
|
|
114
|
+
path: '/socket',
|
|
115
|
+
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
116
|
+
pingInterval: '25s',
|
|
117
|
+
pingTimeout: '5s',
|
|
181
118
|
},
|
|
182
119
|
})
|
|
183
120
|
```
|
|
184
121
|
|
|
185
|
-
|
|
122
|
+
### WebSocket options
|
|
186
123
|
|
|
187
|
-
|
|
124
|
+
| Option | Default | Purpose |
|
|
125
|
+
| --------------------------- | ----------- | -------------------------------------------------------- |
|
|
126
|
+
| `path` | `/socket` | WebSocket endpoint |
|
|
127
|
+
| `origin` | Same origin | Browser origin policy |
|
|
128
|
+
| `middleware` | `[]` | AdonisJS HTTP middleware run during upgrade |
|
|
129
|
+
| `authenticate` | None | Resolves the user attached to the socket |
|
|
130
|
+
| `pingInterval` | Disabled | Interval between WebSocket pings |
|
|
131
|
+
| `pingTimeout` | Disabled | Time allowed for a pong response |
|
|
132
|
+
| `maxPayload` | 1 MiB | Maximum inbound WebSocket message size |
|
|
133
|
+
| `maxQueuedMessages` | `100` | Pending non-ping messages allowed per socket |
|
|
134
|
+
| `maxMessagesPerInterval` | `1000` | Messages accepted per rate limit window |
|
|
135
|
+
| `messageRateInterval` | `1s` | Per-socket rate limit window |
|
|
136
|
+
| `maxBufferedAmount` | 16 MiB | Outbound buffer limit before disconnecting a slow socket |
|
|
137
|
+
| `maxOutboundPayload` | 1 MiB | Maximum serialized outbound message size |
|
|
138
|
+
| `maxSubscriptionsPerSocket` | `100` | Active subscriptions allowed per socket |
|
|
139
|
+
| `maxChannelNameLength` | `255` | Maximum channel name length |
|
|
140
|
+
| `shutdownTimeout` | `5s` | Time allowed for handlers and hooks during shutdown |
|
|
188
141
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`transport.retryQueue` to change the limit, retry interval, deduplication, or to disable retries.
|
|
192
|
-
Setting `maxSize: null` explicitly opts into an unbounded queue.
|
|
142
|
+
Heartbeat is disabled when neither `pingInterval` nor `pingTimeout` is set. If you set only one,
|
|
143
|
+
the other defaults to `25s` or `5s` respectively.
|
|
193
144
|
|
|
194
|
-
|
|
145
|
+
The message rate and queue limits protect individual connections. They do not replace connection
|
|
146
|
+
limits or abuse protection at the proxy.
|
|
195
147
|
|
|
196
|
-
|
|
148
|
+
### Origin checks
|
|
197
149
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
|
|
201
|
-
import socket from '@rlanz/socket/services/main'
|
|
202
|
-
import { SocketHealthCheck } from '@rlanz/socket/health_check'
|
|
150
|
+
Browser upgrades are restricted to the request's origin by default. The `origin` option accepts the
|
|
151
|
+
same values as the AdonisJS CORS `origin` option:
|
|
203
152
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
])
|
|
209
|
-
```
|
|
153
|
+
- a boolean
|
|
154
|
+
- a string, including `'*'` or a comma-separated list
|
|
155
|
+
- an array of strings
|
|
156
|
+
- `(origin, httpContext) => value`
|
|
210
157
|
|
|
211
|
-
|
|
158
|
+
Matching is case-sensitive. Comma-separated entries are not trimmed. A configured policy replaces
|
|
159
|
+
the same-origin default. Clients without an `Origin` header remain supported.
|
|
212
160
|
|
|
213
|
-
|
|
161
|
+
AdonisJS CORS middleware does not authorize WebSocket upgrades. The package checks the origin before
|
|
162
|
+
upgrade middleware and authentication. This protects cookie-authenticated sockets from cross-site
|
|
163
|
+
WebSocket hijacking.
|
|
214
164
|
|
|
215
|
-
|
|
165
|
+
Your reverse proxy must preserve the public `Host` and replace `X-Forwarded-Proto` with the trusted
|
|
166
|
+
public protocol. If the proxy rewrites the host, configure the public origin explicitly.
|
|
216
167
|
|
|
217
|
-
|
|
218
|
-
import { test } from '@japa/runner'
|
|
219
|
-
import socket from '@rlanz/socket/services/main'
|
|
168
|
+
### Authentication
|
|
220
169
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
socket.restore()
|
|
224
|
-
})
|
|
170
|
+
Use `websocket.middleware` to prepare request services such as sessions and auth. Then resolve the
|
|
171
|
+
user with `websocket.authenticate`.
|
|
225
172
|
|
|
226
|
-
|
|
227
|
-
|
|
173
|
+
```ts
|
|
174
|
+
// config/socket.ts
|
|
175
|
+
import { authenticateWithAdonisAuth, defineConfig } from '@rlanz/socket'
|
|
228
176
|
|
|
229
|
-
|
|
177
|
+
type User = { id: string; name: string }
|
|
230
178
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
179
|
+
export default defineConfig({
|
|
180
|
+
websocket: {
|
|
181
|
+
middleware: [
|
|
182
|
+
() => import('@adonisjs/session/session_middleware'),
|
|
183
|
+
() => import('@adonisjs/auth/initialize_auth_middleware'),
|
|
184
|
+
],
|
|
185
|
+
authenticate: authenticateWithAdonisAuth<User>(),
|
|
186
|
+
},
|
|
235
187
|
})
|
|
236
188
|
```
|
|
237
189
|
|
|
238
|
-
|
|
190
|
+
Return `false`, `null`, or throw to reject the upgrade with `401 Unauthorized`. A successful result
|
|
191
|
+
becomes `socket.user`.
|
|
239
192
|
|
|
240
|
-
|
|
193
|
+
You can provide a custom handler instead.
|
|
241
194
|
|
|
242
195
|
```ts
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
196
|
+
export default defineConfig({
|
|
197
|
+
websocket: {
|
|
198
|
+
async authenticate({ httpContext }) {
|
|
199
|
+
await httpContext.auth.authenticateUsing()
|
|
200
|
+
return httpContext.auth.getUserOrFail()
|
|
201
|
+
},
|
|
202
|
+
},
|
|
248
203
|
})
|
|
249
|
-
fake.assertNotEmittedTo('chat/general', 'chat:typing')
|
|
250
|
-
|
|
251
|
-
fake.assertCount(2, { target: 'channel', channel: 'chat/general' })
|
|
252
|
-
fake.assertCount(0)
|
|
253
204
|
```
|
|
254
205
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
`@rlanz/socket` emits `diagnostics_channel` tracing events and ships an optional OpenTelemetry instrumentation. This lets an AdonisJS integration such as `@adonisjs/otel` register the instrumentation without coupling the socket provider to a telemetry runtime.
|
|
206
|
+
Upgrade middleware and channel middleware have different jobs. Upgrade middleware prepares the HTTP
|
|
207
|
+
handshake. Channel middleware authorizes one subscription.
|
|
258
208
|
|
|
259
|
-
|
|
260
|
-
|
|
209
|
+
Every socket retains the initial HTTP context at `socket.raw.httpContext`. This context describes the
|
|
210
|
+
handshake. It is not an active request after the `101` response and is not installed in AdonisJS
|
|
211
|
+
async-local storage. Put mutable socket-lifetime state in `socket.raw.data`. Copy durable identity
|
|
212
|
+
into `socket.user` instead of retaining response-bound resources.
|
|
261
213
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
instrumentation.enable()
|
|
265
|
-
instrumentation.manuallyRegister()
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
The instrumentation creates spans for socket connect/disconnect, subscribe/unsubscribe, incoming channel messages, and broadcast delivery. It also records counters for active connections, active subscriptions, received channel messages, broadcast operations, and broadcast deliveries.
|
|
269
|
-
|
|
270
|
-
## Production and scaling
|
|
271
|
-
|
|
272
|
-
### Process and reverse proxy
|
|
273
|
-
|
|
274
|
-
The provider attaches its WebSocket `upgrade` handler to the AdonisJS Node HTTP server after that
|
|
275
|
-
server is ready. WebSockets therefore use the same process, listener, event loop, memory, and failure
|
|
276
|
-
domain as HTTP; there is no separate socket process or port. Size each application instance for its
|
|
277
|
-
combined HTTP and long-lived WebSocket workload, and remember that a process failure drops every
|
|
278
|
-
socket connected to that instance.
|
|
279
|
-
|
|
280
|
-
At the reverse proxy or load balancer:
|
|
281
|
-
|
|
282
|
-
- route `websocket.path` to the AdonisJS HTTP listener and forward WebSocket upgrade requests using
|
|
283
|
-
HTTP/1.1 with the `Upgrade` and `Connection: upgrade` headers;
|
|
284
|
-
- terminate TLS there if desired, use `wss://` publicly, preserve a validated public `Host`, and
|
|
285
|
-
**overwrite** `X-Forwarded-Proto` with the trusted public protocol (`http` or `https`);
|
|
286
|
-
- allow long-lived upgraded connections and set the proxy idle timeout above the configured
|
|
287
|
-
heartbeat cadence; and
|
|
288
|
-
- restrict direct access to the upstream. Same-origin validation trusts `Host` and the first
|
|
289
|
-
`X-Forwarded-Proto` value, so client-supplied versions of those headers must not reach the app.
|
|
290
|
-
|
|
291
|
-
Browser origins are not authorized by AdonisJS CORS middleware. Configure exact public origins in
|
|
292
|
-
[`websocket.origin`](#configuration) when the browser origin differs from the public socket
|
|
293
|
-
origin or when the proxy rewrites `Host`. Clients without an `Origin` header are accepted, so use
|
|
294
|
-
upgrade authentication for non-browser clients rather than treating the origin check as
|
|
295
|
-
authentication.
|
|
296
|
-
|
|
297
|
-
### Multiple instances and reconnects
|
|
298
|
-
|
|
299
|
-
A load balancer may route each new upgrade to any ready instance and must keep that upgraded TCP
|
|
300
|
-
connection attached to that instance. The package neither implements nor guarantees sticky
|
|
301
|
-
sessions. Without a [transport](#horizontal-sync), subscriptions, broadcasts, and
|
|
302
|
-
presence are local to one instance; affinity is not a replacement for configuring the transport.
|
|
303
|
-
All instances in one logical deployment must use a compatible shared transport and the same bus
|
|
304
|
-
channel.
|
|
305
|
-
|
|
306
|
-
The bundled client reconnects by default with exponential backoff from `250ms` to `5s` and
|
|
307
|
-
re-subscribes desired channels after establishing a new connection, which may be on another
|
|
308
|
-
instance. A reconnect is a new session: pending acknowledgements are rejected, authentication and
|
|
309
|
-
channel middleware run again, and events sent while disconnected are not replayed. The built-in
|
|
310
|
-
backoff has no jitter; stagger deployments or add reconnect staggering in the application when a
|
|
311
|
-
large fleet could reconnect simultaneously.
|
|
312
|
-
|
|
313
|
-
### Transport, presence, and delivery semantics
|
|
314
|
-
|
|
315
|
-
Local broadcasts are sent to currently connected local recipients and are also published to the
|
|
316
|
-
configured `@boringnode/bus` transport. Publication is fire-and-forget from the socket API. Failed
|
|
317
|
-
publications may be retried from the bounded, in-memory retry queue after the transport reconnects,
|
|
318
|
-
so stale events remain possible. The package does not persist bus messages, wait for remote
|
|
319
|
-
delivery, acknowledge recipients, or recover events after a process restart; the guarantees of a
|
|
320
|
-
particular transport do not turn socket delivery into a durable application queue.
|
|
321
|
-
|
|
322
|
-
Distributed presence is also best-effort and non-durable. A snapshot contains the replies received
|
|
323
|
-
before `transport.presenceTimeout`; partitions, slow instances, concurrent changes, and restarts can
|
|
324
|
-
produce incomplete snapshots or duplicate/omitted member transitions. Presence hooks must be
|
|
325
|
-
idempotent and presence must not be used as an authoritative online-user or membership store.
|
|
326
|
-
|
|
327
|
-
`sendWithAck()` only confirms that the receiving server-side handler completed and returned a
|
|
328
|
-
result. It does not confirm that a broadcast reached any client. Applications that need durable
|
|
329
|
-
notifications, recovery after reconnect, or at-least-once processing must persist events or state
|
|
330
|
-
outside this package and define their own event IDs, cursors, deduplication, acknowledgement, and
|
|
331
|
-
replay protocol.
|
|
332
|
-
|
|
333
|
-
### Restarts and resource limits
|
|
334
|
-
|
|
335
|
-
On AdonisJS shutdown, the service becomes unready, stops accepting upgrades, terminates existing
|
|
336
|
-
WebSockets, runs their subscription/disconnect finalizers, and then disconnects the bus. It does
|
|
337
|
-
**not** gracefully drain established sockets or wait for in-flight client acknowledgements. For a
|
|
338
|
-
rolling restart, remove the instance from new HTTP/upgrade traffic first when the platform permits,
|
|
339
|
-
expect connected clients to reconnect, and give the process enough termination grace to finish its
|
|
340
|
-
socket finalizers and bus shutdown before sending a forced kill.
|
|
341
|
-
|
|
342
|
-
Review the limits under [Configuration](#configuration) against real payloads and fan-out:
|
|
343
|
-
|
|
344
|
-
- enable heartbeat explicitly; it is disabled when neither `pingInterval` nor `pingTimeout` is set;
|
|
345
|
-
- tune inbound/outbound payload sizes, the per-socket fixed-window message rate, serialized message
|
|
346
|
-
queue depth, subscriptions per socket, channel-name length, and the transport retry queue;
|
|
347
|
-
- treat rate and queue limits as per-connection safeguards, not perimeter abuse prevention; enforce
|
|
348
|
-
aggregate connection and request limits at the proxy or application boundary; and
|
|
349
|
-
- tune `maxBufferedAmount` for available memory. Slow consumers and oversized outbound frames are
|
|
350
|
-
disconnected rather than buffered without bound or retried.
|
|
351
|
-
|
|
352
|
-
There is no built-in total connection limit. Each connection consumes a file descriptor plus
|
|
353
|
-
application, subscription, queue, heartbeat, and `ws` memory. Set process/container memory limits,
|
|
354
|
-
raise and monitor file-descriptor limits where appropriate, cap connections at the edge, and load
|
|
355
|
-
test representative connection counts, message rates, payloads, and broadcast fan-out before
|
|
356
|
-
production.
|
|
357
|
-
|
|
358
|
-
### Health and monitoring
|
|
359
|
-
|
|
360
|
-
Expose the application-owned readiness endpoint containing [`SocketHealthCheck`](#health-checks)
|
|
361
|
-
and route new upgrades only to ready instances. The check reports local service startup/shutdown
|
|
362
|
-
state and local connection/channel counts; it does not probe the transport backend, remote
|
|
363
|
-
instances, or end-to-end publish delivery. Monitor those dependencies separately and keep a normal
|
|
364
|
-
process liveness check so readiness failures do not automatically cause restart loops.
|
|
365
|
-
|
|
366
|
-
Enable the optional [OpenTelemetry instrumentation](#opentelemetry) and alert on connection and
|
|
367
|
-
disconnection changes, active connections/subscriptions, message errors, and broadcast volume and
|
|
368
|
-
local delivery counts. Also collect process event-loop delay, memory, CPU, open file descriptors,
|
|
369
|
-
proxy upgrade/rejection metrics, reconnect rates, logs, and transport health. Broadcast delivery
|
|
370
|
-
counters count immediate sends to local `ws` objects, not application-level receipt.
|
|
371
|
-
|
|
372
|
-
### Production checklist
|
|
214
|
+
## Define channels
|
|
373
215
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
- [ ] Configure an exact `origin` policy and upgrade authentication; protect the upstream from direct
|
|
377
|
-
access.
|
|
378
|
-
- [ ] Set proxy idle timeouts and explicitly configure/test heartbeat behavior.
|
|
379
|
-
- [ ] Configure a shared transport and bus channel for every instance that must exchange broadcasts
|
|
380
|
-
or presence.
|
|
381
|
-
- [ ] Make clients tolerate reconnect, re-authentication, re-subscription, duplicate handling, and
|
|
382
|
-
gaps during deploys or failures.
|
|
383
|
-
- [ ] Persist and replay application-critical events outside the socket bus.
|
|
384
|
-
- [ ] Tune payload, rate, queue, subscription, backpressure, connection, memory, and file-descriptor
|
|
385
|
-
limits using load tests.
|
|
386
|
-
- [ ] Wire readiness, liveness, OpenTelemetry, logs, proxy metrics, and transport monitoring.
|
|
387
|
-
- [ ] Give rolling shutdown enough grace for finalizers and bus disconnect; do not expect socket
|
|
388
|
-
draining.
|
|
389
|
-
|
|
390
|
-
## Channels
|
|
391
|
-
|
|
392
|
-
Channels are discovered from `app/channels/**/*_channel.{ts,js}`. Export a default class extending
|
|
393
|
-
`BaseChannel`. Client-to-server payloads and acknowledgements are inferred from public handler
|
|
394
|
-
methods. The optional second generic declares server-to-client events. When omitted, the channel
|
|
395
|
-
cannot broadcast server events.
|
|
216
|
+
The default Assembler hook discovers `app/channels/**/*_channel.{ts,js}`. Each file must export a
|
|
217
|
+
default class that extends `BaseChannel`.
|
|
396
218
|
|
|
397
219
|
```ts
|
|
398
|
-
// app/channels/chat_channel.ts
|
|
399
220
|
import { BaseChannel } from '@rlanz/socket'
|
|
400
|
-
import { onMessage } from '@rlanz/socket/decorators'
|
|
401
|
-
import type { AuthenticatedSocket } from '@rlanz/socket/types'
|
|
402
221
|
|
|
403
|
-
|
|
404
|
-
type Message = { id: string; text: string }
|
|
405
|
-
|
|
406
|
-
type ServerEvents = {
|
|
407
|
-
'chat:message': Message
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
export default class ChatChannel extends BaseChannel<User, ServerEvents> {
|
|
222
|
+
export default class ChatChannel extends BaseChannel {
|
|
411
223
|
static pattern = 'chat/:roomId'
|
|
412
|
-
|
|
413
|
-
@onMessage('chat:send')
|
|
414
|
-
async sendMessage(
|
|
415
|
-
socket: AuthenticatedSocket<User>,
|
|
416
|
-
payload: { text: string }
|
|
417
|
-
): Promise<Message> {
|
|
418
|
-
const message = { id: crypto.randomUUID(), text: payload.text }
|
|
419
|
-
this.broadcastExcept(socket.id, 'chat:message', message)
|
|
420
|
-
return message
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
@onMessage('chat:ping')
|
|
424
|
-
ping(_socket: AuthenticatedSocket<User>, _payload: undefined): void {}
|
|
425
224
|
}
|
|
426
225
|
```
|
|
427
226
|
|
|
428
|
-
|
|
429
|
-
constructor injection works as it does for other AdonisJS classes:
|
|
227
|
+
Patterns support:
|
|
430
228
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
229
|
+
- literal segments such as `announcements`
|
|
230
|
+
- required parameters such as `chat/:roomId`
|
|
231
|
+
- one final optional parameter such as `threads/:threadId?`
|
|
232
|
+
- one final wildcard such as `files/*`
|
|
435
233
|
|
|
436
|
-
|
|
437
|
-
export default class ChatChannel extends BaseChannel {
|
|
438
|
-
static pattern = 'chat/:roomId'
|
|
234
|
+
Generated clients use the declared pattern and a separate parameter object.
|
|
439
235
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
236
|
+
```ts
|
|
237
|
+
socket.channel('announcements')
|
|
238
|
+
socket.channel('chat/:roomId', { roomId: 'general' })
|
|
239
|
+
socket.channel('threads/:threadId?', {})
|
|
240
|
+
socket.channel('files/*', { wildcard: 'docs/guides/start' })
|
|
444
241
|
```
|
|
445
242
|
|
|
446
|
-
|
|
447
|
-
payload, and `Awaited<ReturnType<handler>>` becomes its acknowledgement. No duplicate client event
|
|
448
|
-
map is required. `ServerEvents` remains explicit because generation does not scan
|
|
449
|
-
`broadcast()` calls; it checks `broadcast()` and `broadcastExcept()`. Omitting it makes both methods
|
|
450
|
-
reject every event at compile time.
|
|
243
|
+
An untyped `new Socket()` accepts concrete channel names instead.
|
|
451
244
|
|
|
452
|
-
|
|
245
|
+
### Generated client contracts
|
|
453
246
|
|
|
454
|
-
|
|
455
|
-
import { Socket } from '@rlanz/socket/client'
|
|
456
|
-
import type { AppSocket } from '#generated/socket'
|
|
247
|
+
The Assembler hook writes:
|
|
457
248
|
|
|
458
|
-
|
|
459
|
-
|
|
249
|
+
- `.adonisjs/client/socket.ts` for browser types
|
|
250
|
+
- `.adonisjs/server/socket_channels.ts` for runtime channel discovery
|
|
460
251
|
|
|
461
|
-
|
|
462
|
-
|
|
252
|
+
Configure the frontend project to resolve an alias such as `#generated/socket` to the client file.
|
|
253
|
+
This alias is separate from the AdonisJS server mapping for `#generated/*`.
|
|
463
254
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
```
|
|
255
|
+
Keep the standard AdonisJS `#generated/*` mapping pointed at `./.adonisjs/server/*.js`. The provider
|
|
256
|
+
uses it to load the generated channel manifest.
|
|
467
257
|
|
|
468
|
-
The
|
|
469
|
-
pattern explicitly and constructs the concrete name from typed parameters, so its types never need
|
|
470
|
-
to reproduce runtime route ordering or specificity. Every registry entry carries its generated
|
|
471
|
-
parameter shape; static channels use `undefined`, while dynamic channels expose their required,
|
|
472
|
-
optional, or wildcard parameters.
|
|
258
|
+
The generator recognizes:
|
|
473
259
|
|
|
474
|
-
|
|
260
|
+
- default-exported classes that directly extend `BaseChannel`
|
|
261
|
+
- direct string values assigned to `static pattern`
|
|
262
|
+
- named public methods decorated with `@onMessage('event')`
|
|
263
|
+
- required, optional, or absent handler payloads
|
|
475
264
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
265
|
+
Runtime inheritance remains valid, but channels that use intermediate inheritance or patterns that
|
|
266
|
+
cannot be read statically are omitted from client types. The generator reports why it omitted them.
|
|
267
|
+
Abstract channels, private or static handlers, rest parameters, and required parameters after the
|
|
268
|
+
payload fail generation.
|
|
479
269
|
|
|
480
|
-
|
|
481
|
-
type OutgoingMessage = ChannelClientEventPayload<AppSocket, 'chat/:roomId', 'chat:send'>
|
|
482
|
-
```
|
|
270
|
+
Generated types validate TypeScript calls. They do not validate runtime payloads.
|
|
483
271
|
|
|
484
|
-
|
|
485
|
-
string-literal `static pattern` values, and `@onMessage('event')` imported from
|
|
486
|
-
`@rlanz/socket/decorators`. Handler methods must be
|
|
487
|
-
named, public instance methods so the generated registry can reference their parameter and return
|
|
488
|
-
types. Abstract channels, private/protected/static handlers, rest parameters, and required parameters
|
|
489
|
-
after the payload fail generation with a diagnostic. A handler may accept no
|
|
490
|
-
parameters, only the socket, a required payload, or an optional payload. Intermediate channel
|
|
491
|
-
inheritance is deliberately omitted from generated contracts; runtime channel inheritance remains
|
|
492
|
-
valid.
|
|
493
|
-
|
|
494
|
-
Generated patterns support literal segments, required parameters, one final optional parameter, and
|
|
495
|
-
one final wildcard. Dynamic patterns always receive a parameter object; pass `{}` to omit a final
|
|
496
|
-
optional parameter. Wildcards use the explicit `wildcard` parameter and may contain slashes. Static
|
|
497
|
-
channels need no parameter object. An untyped `new Socket()` continues to accept raw concrete channel
|
|
498
|
-
names.
|
|
499
|
-
|
|
500
|
-
The default Assembler hook discovers `app/channels/**/*_channel.{ts,js}` and generates both
|
|
501
|
-
`.adonisjs/client/socket.ts` and `.adonisjs/server/socket_channels.ts`. Configure the frontend
|
|
502
|
-
project to resolve an alias such as `#generated/socket` to the client file; this frontend mapping is
|
|
503
|
-
separate from the AdonisJS server `#generated/*` mapping. Channels are discovered in `app/channels`
|
|
504
|
-
by default. Any source inside `app` is supported, and generated imports use the corresponding
|
|
505
|
-
AdonisJS `#app` alias without TypeScript extensions. To customize discovery or client output, replace
|
|
506
|
-
the package hook in `adonisrc.ts` with a local hook:
|
|
272
|
+
To change channel discovery or the generated client path, replace the package hook with a local one.
|
|
507
273
|
|
|
508
274
|
```ts
|
|
509
275
|
// hooks/socket.ts
|
|
@@ -531,19 +297,33 @@ export default defineConfig({
|
|
|
531
297
|
})
|
|
532
298
|
```
|
|
533
299
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
300
|
+
Any source directory inside `app` is supported. Generated imports use the matching AdonisJS `#app`
|
|
301
|
+
alias without TypeScript extensions.
|
|
302
|
+
|
|
303
|
+
### Dependency injection
|
|
304
|
+
|
|
305
|
+
The AdonisJS container creates a channel instance for every subscription. Constructor injection
|
|
306
|
+
works like it does in controllers and other container-managed classes.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { inject } from '@adonisjs/core'
|
|
310
|
+
import { BaseChannel } from '@rlanz/socket'
|
|
311
|
+
import MessageService from '#services/message_service'
|
|
312
|
+
|
|
313
|
+
@inject()
|
|
314
|
+
export default class ChatChannel extends BaseChannel {
|
|
315
|
+
static pattern = 'chat/:roomId'
|
|
316
|
+
|
|
317
|
+
constructor(private messages: MessageService) {
|
|
318
|
+
super()
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
```
|
|
540
322
|
|
|
541
323
|
### Middleware
|
|
542
324
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
constructor injection works with `@inject()`. Classes in the same middleware chain share one
|
|
546
|
-
container resolver, matching AdonisJS HTTP middleware scoping.
|
|
325
|
+
Channel middleware runs before subscription. A middleware can be a function, an object with a
|
|
326
|
+
`handle` method, or a class.
|
|
547
327
|
|
|
548
328
|
```ts
|
|
549
329
|
import { BaseChannel } from '@rlanz/socket'
|
|
@@ -562,11 +342,16 @@ export default class ChatChannel extends BaseChannel<User> {
|
|
|
562
342
|
}
|
|
563
343
|
```
|
|
564
344
|
|
|
345
|
+
The container resolves middleware classes for every subscription, so they support constructor
|
|
346
|
+
injection too.
|
|
347
|
+
|
|
565
348
|
```ts
|
|
566
349
|
import { inject } from '@adonisjs/core'
|
|
567
350
|
import type { MiddlewareContext } from '@rlanz/socket/types'
|
|
568
351
|
import RoomService from '#services/room_service'
|
|
569
352
|
|
|
353
|
+
type User = { id: string; name: string }
|
|
354
|
+
|
|
570
355
|
@inject()
|
|
571
356
|
class EnsureRoomAccess {
|
|
572
357
|
constructor(private rooms: RoomService) {}
|
|
@@ -576,143 +361,165 @@ class EnsureRoomAccess {
|
|
|
576
361
|
await next()
|
|
577
362
|
}
|
|
578
363
|
}
|
|
579
|
-
|
|
580
|
-
export default class ChatChannel extends BaseChannel<User> {
|
|
581
|
-
static pattern = 'chat/:roomId'
|
|
582
|
-
static middlewares = [EnsureRoomAccess]
|
|
583
|
-
}
|
|
584
364
|
```
|
|
585
365
|
|
|
586
|
-
Only `SocketResponseError` messages are
|
|
587
|
-
join, and message-handler errors
|
|
588
|
-
|
|
589
|
-
messages.
|
|
366
|
+
Only `SocketResponseError` messages are sent to clients. The server logs unexpected middleware,
|
|
367
|
+
join, and message-handler errors and returns a generic protocol error. Use `SocketResponseError`
|
|
368
|
+
only for messages that are safe to expose.
|
|
590
369
|
|
|
591
|
-
|
|
370
|
+
## Exchange messages
|
|
592
371
|
|
|
593
|
-
|
|
372
|
+
Decorate a channel method with `@onMessage` to handle a client event.
|
|
594
373
|
|
|
595
374
|
```ts
|
|
596
375
|
import { BaseChannel } from '@rlanz/socket'
|
|
597
|
-
import
|
|
376
|
+
import { onMessage } from '@rlanz/socket/decorators'
|
|
377
|
+
import type { AuthenticatedSocket } from '@rlanz/socket/types'
|
|
598
378
|
|
|
599
379
|
type User = { id: string; name: string }
|
|
380
|
+
|
|
600
381
|
type ServerEvents = {
|
|
601
|
-
'
|
|
602
|
-
'room:member_left': PresenceMember
|
|
382
|
+
'chat:message': { user: User | undefined; body: string }
|
|
603
383
|
}
|
|
604
384
|
|
|
605
|
-
export default class
|
|
606
|
-
static pattern = '
|
|
607
|
-
static options = { presence: true }
|
|
385
|
+
export default class ChatChannel extends BaseChannel<User, ServerEvents> {
|
|
386
|
+
static pattern = 'chat/:roomId'
|
|
608
387
|
|
|
609
|
-
|
|
610
|
-
|
|
388
|
+
@onMessage('chat:send')
|
|
389
|
+
async sendMessage(socket: AuthenticatedSocket<User>, payload: { body: string }) {
|
|
390
|
+
this.broadcast('chat:message', {
|
|
391
|
+
user: socket.user,
|
|
392
|
+
body: payload.body,
|
|
393
|
+
})
|
|
611
394
|
|
|
612
|
-
return {
|
|
613
|
-
id: user.id,
|
|
614
|
-
data: {
|
|
615
|
-
name: user.name,
|
|
616
|
-
},
|
|
617
|
-
}
|
|
395
|
+
return { delivered: true }
|
|
618
396
|
}
|
|
397
|
+
}
|
|
398
|
+
```
|
|
619
399
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
400
|
+
The handler's second parameter defines the client payload. Its awaited return value defines the
|
|
401
|
+
acknowledgement returned by `sendWithAck()`.
|
|
623
402
|
|
|
624
|
-
|
|
625
|
-
|
|
403
|
+
```ts
|
|
404
|
+
const result = await channel.sendWithAck('chat:send', { body: 'Hello' })
|
|
405
|
+
console.log(result.delivered)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Unknown events receive a negative acknowledgement. Handler results must be JSON-serializable. The
|
|
409
|
+
server rejects a non-serializable result instead of sending a broken acknowledgement.
|
|
410
|
+
|
|
411
|
+
The second `BaseChannel` generic declares server events. `broadcast()` and `broadcastExcept()` use
|
|
412
|
+
this map to check event names and payloads. A channel without this generic cannot broadcast.
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
type ServerEvents = {
|
|
416
|
+
'chat:message': { id: string; body: string }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export default class ChatChannel extends BaseChannel<User, ServerEvents> {
|
|
420
|
+
publish(message: ServerEvents['chat:message'], socket: AuthenticatedSocket<User>) {
|
|
421
|
+
this.broadcast('chat:message', message)
|
|
422
|
+
this.broadcastExcept(socket.id, 'chat:message', message)
|
|
626
423
|
}
|
|
627
424
|
}
|
|
628
425
|
```
|
|
629
426
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
427
|
+
Use the socket service to broadcast outside a channel instance.
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
import socket from '@rlanz/socket/services/main'
|
|
431
|
+
|
|
432
|
+
socket.broadcast('maintenance', { active: true })
|
|
433
|
+
socket.to('chat/general').emit('chat:message', message)
|
|
434
|
+
socket.to('chat/general').except(socketId).emit('chat:message', message)
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
You can extract payload types from a generated registry without creating a channel.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import type { ChannelBroadcastPayload, ChannelClientEventPayload } from '@rlanz/socket/client'
|
|
441
|
+
import type { AppSocket } from '#generated/socket'
|
|
442
|
+
|
|
443
|
+
type IncomingMessage = ChannelBroadcastPayload<AppSocket, 'chat/:roomId', 'chat:message'>
|
|
444
|
+
type OutgoingMessage = ChannelClientEventPayload<AppSocket, 'chat/:roomId', 'chat:send'>
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Client-to-client events
|
|
448
|
+
|
|
449
|
+
Use `whisper()` for temporary events such as typing indicators. The server accepts whispers only
|
|
450
|
+
from subscribed sockets and forwards them to the other channel members.
|
|
633
451
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
452
|
+
```ts
|
|
453
|
+
channel.listenForWhisper<{ typing: boolean }>('typing', ({ typing }) => {
|
|
454
|
+
console.log('typing:', typing)
|
|
455
|
+
})
|
|
637
456
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
runs for the first locally observed connection and `onMemberLeave` for the last; `onJoin` and
|
|
641
|
-
`onLeave` still run for every socket. Across multiple application instances, member hooks are
|
|
642
|
-
best-effort and must be idempotent because concurrent joins, leaves, timeouts, or partitions can
|
|
643
|
-
duplicate or omit a transition. When two connections provide different metadata, the earliest
|
|
644
|
-
connection is the representative. Distributed snapshots are deduplicated across the responses
|
|
645
|
-
received before `transport.presenceTimeout` and therefore remain a best-effort, non-durable view
|
|
646
|
-
during network partitions.
|
|
457
|
+
channel.whisper('typing', { typing: true })
|
|
458
|
+
```
|
|
647
459
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
an exception from a later hook can be retried safely.
|
|
460
|
+
Whispers use an internal `client:` prefix and cannot impersonate server events. Their types remain
|
|
461
|
+
caller-defined because they do not come from a server handler.
|
|
651
462
|
|
|
652
|
-
##
|
|
463
|
+
## Presence
|
|
653
464
|
|
|
654
|
-
|
|
655
|
-
Unknown events receive a negative acknowledgement.
|
|
465
|
+
Enable presence on a channel and return the public data for each member.
|
|
656
466
|
|
|
657
467
|
```ts
|
|
658
468
|
import { BaseChannel } from '@rlanz/socket'
|
|
659
|
-
import {
|
|
660
|
-
import type { AuthenticatedSocket } from '@rlanz/socket/types'
|
|
469
|
+
import type { AuthenticatedSocket, PresenceInfo, PresenceMember } from '@rlanz/socket/types'
|
|
661
470
|
|
|
662
471
|
type User = { id: string; name: string }
|
|
472
|
+
|
|
663
473
|
type ServerEvents = {
|
|
664
|
-
'
|
|
474
|
+
'room:member_joined': PresenceMember
|
|
475
|
+
'room:member_left': PresenceMember
|
|
665
476
|
}
|
|
666
477
|
|
|
667
|
-
export default class
|
|
668
|
-
static pattern = '
|
|
478
|
+
export default class RoomChannel extends BaseChannel<User, ServerEvents> {
|
|
479
|
+
static pattern = 'rooms/:roomId'
|
|
480
|
+
static options = { presence: true }
|
|
669
481
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
this.broadcast('chat:message', {
|
|
673
|
-
user: socket.user,
|
|
674
|
-
body: data.body,
|
|
675
|
-
})
|
|
482
|
+
getPresenceInfo(socket: AuthenticatedSocket<User>): PresenceInfo {
|
|
483
|
+
const user = socket.getUserOrFail()
|
|
676
484
|
|
|
677
|
-
return {
|
|
485
|
+
return {
|
|
486
|
+
id: user.id,
|
|
487
|
+
data: { name: user.name },
|
|
488
|
+
}
|
|
678
489
|
}
|
|
679
|
-
}
|
|
680
|
-
```
|
|
681
|
-
|
|
682
|
-
Returning a value from a handler resolves `sendWithAck()` on the client. Handler results and
|
|
683
|
-
presence snapshots must be JSON-serializable. A non-serializable handler result receives an
|
|
684
|
-
immediate negative acknowledgement; a non-serializable initial presence snapshot rolls back the
|
|
685
|
-
subscription instead of leaving client and server state out of sync.
|
|
686
490
|
|
|
687
|
-
|
|
491
|
+
async onMemberJoin(socket: AuthenticatedSocket<User>, member: PresenceMember) {
|
|
492
|
+
this.broadcastExcept(socket.id, 'room:member_joined', member)
|
|
493
|
+
}
|
|
688
494
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
495
|
+
async onMemberLeave(_socket: AuthenticatedSocket<User>, member: PresenceMember) {
|
|
496
|
+
this.broadcast('room:member_left', member)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
```
|
|
692
500
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
501
|
+
The member `id` groups multiple sockets or browser tabs for the same person. These sockets remain
|
|
502
|
+
separate broadcast targets but produce one entry in the presence snapshot. `onMemberJoin` runs for
|
|
503
|
+
the first locally observed socket. `onMemberLeave` runs after the last one leaves. `onJoin` and
|
|
504
|
+
`onLeave` still run for every socket.
|
|
697
505
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
})
|
|
701
|
-
```
|
|
506
|
+
Snapshots flatten `data` into `{ id, ...data, joinedAt }`. Use JSON-compatible values. The package
|
|
507
|
+
checks snapshot serialization before running join hooks.
|
|
702
508
|
|
|
703
|
-
|
|
704
|
-
|
|
509
|
+
Distributed presence is a best-effort view, not an authoritative membership store. Timeouts,
|
|
510
|
+
partitions, restarts, and concurrent changes can produce incomplete snapshots or repeated or missing
|
|
511
|
+
member hooks. Keep presence hooks idempotent.
|
|
705
512
|
|
|
706
|
-
##
|
|
513
|
+
## Use the browser client
|
|
707
514
|
|
|
708
|
-
|
|
515
|
+
The client uses the current browser origin and `/socket` by default.
|
|
709
516
|
|
|
710
517
|
```ts
|
|
711
518
|
import { Socket } from '@rlanz/socket/client'
|
|
712
519
|
import type { AppSocket } from '#generated/socket'
|
|
713
520
|
|
|
714
521
|
const socket = new Socket<AppSocket>({
|
|
715
|
-
url: '
|
|
522
|
+
url: 'https://app.example.com',
|
|
716
523
|
path: '/socket',
|
|
717
524
|
autoReconnect: true,
|
|
718
525
|
reconnectDelay: 250,
|
|
@@ -730,7 +537,7 @@ socket.on('connect', () => {
|
|
|
730
537
|
await socket.connect()
|
|
731
538
|
```
|
|
732
539
|
|
|
733
|
-
|
|
540
|
+
Subscribe before sending channel events.
|
|
734
541
|
|
|
735
542
|
```ts
|
|
736
543
|
const channel = socket.channel('chat/:roomId', { roomId: 'general' })
|
|
@@ -739,22 +546,13 @@ channel
|
|
|
739
546
|
.here((users) => console.log('present users', users))
|
|
740
547
|
.joining((user) => console.log('joined', user))
|
|
741
548
|
.leaving((user) => console.log('left', user))
|
|
742
|
-
.listen('chat:message', (message) =>
|
|
743
|
-
|
|
744
|
-
})
|
|
745
|
-
.listenForWhisper('typing', (payload) => {
|
|
746
|
-
console.log('typing', payload)
|
|
747
|
-
})
|
|
549
|
+
.listen('chat:message', (message) => console.log(message))
|
|
550
|
+
.listenForWhisper('typing', (payload) => console.log(payload))
|
|
748
551
|
|
|
749
552
|
await channel.subscribe()
|
|
750
553
|
|
|
751
|
-
channel.
|
|
752
|
-
|
|
753
|
-
const ack = await channel.sendWithAck('chat:send', {
|
|
754
|
-
body: 'Hello from the client',
|
|
755
|
-
})
|
|
756
|
-
|
|
757
|
-
console.log(ack)
|
|
554
|
+
channel.send('chat:send', { body: 'No acknowledgement needed' })
|
|
555
|
+
const ack = await channel.sendWithAck('chat:send', { body: 'Wait for the server' })
|
|
758
556
|
|
|
759
557
|
channel.stopListening('chat:message')
|
|
760
558
|
channel.stopListeningForWhisper('typing')
|
|
@@ -763,19 +561,18 @@ await socket.leave(channel.name)
|
|
|
763
561
|
socket.disconnect()
|
|
764
562
|
```
|
|
765
563
|
|
|
766
|
-
`subscribe()` and `unsubscribe()` accept
|
|
564
|
+
`subscribe()` and `unsubscribe()` accept `{ timeout }` in milliseconds. The default is `5000`.
|
|
767
565
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
566
|
+
Automatic reconnection starts at `250ms` and doubles up to `5s`. After reconnecting, the client
|
|
567
|
+
subscribes again to desired channels. Pending acknowledgements fail and authentication and channel
|
|
568
|
+
middleware run again. Events sent while disconnected are not replayed.
|
|
771
569
|
|
|
772
|
-
|
|
570
|
+
Set `autoReconnect: false` to disable retries. A deliberate server disconnect is terminal by
|
|
571
|
+
default. Use `shouldReconnect(closeEvent)` to override the decision for any close code or reason.
|
|
773
572
|
|
|
774
|
-
|
|
775
|
-
names, client methods, server events, and payloads then remain inferred without repeating the
|
|
776
|
-
generic in every component.
|
|
573
|
+
### React
|
|
777
574
|
|
|
778
|
-
|
|
575
|
+
Create one typed set of hooks for the application.
|
|
779
576
|
|
|
780
577
|
```tsx
|
|
781
578
|
// src/socket.ts
|
|
@@ -786,26 +583,31 @@ import type { AppSocket } from '#generated/socket'
|
|
|
786
583
|
export const socket = new Socket<AppSocket>()
|
|
787
584
|
export const { SocketProvider, useChannel, useChannelEvent, useSocketState } =
|
|
788
585
|
createSocketHooks<AppSocket>()
|
|
586
|
+
```
|
|
789
587
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
588
|
+
```tsx
|
|
589
|
+
function App() {
|
|
590
|
+
return (
|
|
591
|
+
<SocketProvider socket={socket} owned>
|
|
592
|
+
<Chat />
|
|
593
|
+
</SocketProvider>
|
|
594
|
+
)
|
|
595
|
+
}
|
|
794
596
|
|
|
795
597
|
function Chat() {
|
|
796
598
|
const channel = useChannel('chat/:roomId', { roomId: 'general' })
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
'chat:message',
|
|
801
|
-
(message) => console.log(message.id)
|
|
599
|
+
|
|
600
|
+
useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) =>
|
|
601
|
+
console.log(message.id)
|
|
802
602
|
)
|
|
803
603
|
|
|
804
604
|
return <button onClick={() => channel?.send('chat:send', { text: 'Hello' })}>Send</button>
|
|
805
605
|
}
|
|
806
606
|
```
|
|
807
607
|
|
|
808
|
-
Vue
|
|
608
|
+
### Vue
|
|
609
|
+
|
|
610
|
+
Create one typed set of composables for the application.
|
|
809
611
|
|
|
810
612
|
```ts
|
|
811
613
|
// src/socket.ts
|
|
@@ -816,51 +618,249 @@ import type { AppSocket } from '#generated/socket'
|
|
|
816
618
|
export const socket = new Socket<AppSocket>()
|
|
817
619
|
export const { provideSocket, useChannel, useChannelEvent, useSocketState } =
|
|
818
620
|
createSocketComposables<AppSocket>()
|
|
621
|
+
```
|
|
819
622
|
|
|
820
|
-
|
|
623
|
+
```ts
|
|
624
|
+
// Root component
|
|
821
625
|
provideSocket(socket, { owned: true })
|
|
822
626
|
|
|
823
|
-
//
|
|
627
|
+
// Descendant component
|
|
824
628
|
const channel = useChannel('chat/:roomId', { roomId: 'general' })
|
|
825
|
-
useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) =>
|
|
826
|
-
console.log(message.id)
|
|
827
|
-
)
|
|
828
629
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
630
|
+
useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) => {
|
|
631
|
+
console.log(message.id)
|
|
632
|
+
})
|
|
832
633
|
```
|
|
833
634
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
subscription, including across development
|
|
838
|
-
remounts, without removing listeners registered through the direct client API.
|
|
635
|
+
The adapters share one subscription between consumers of the same channel and release it after the
|
|
636
|
+
last consumer unmounts. React returns `null` until its effect acquires the channel. Vue returns a
|
|
637
|
+
nullable shallow ref.
|
|
839
638
|
|
|
840
|
-
|
|
841
|
-
`SocketProvider` or `{ owned: true }` to `provideSocket` only when that framework root
|
|
842
|
-
|
|
843
|
-
application-owned lifecycle code.
|
|
639
|
+
Adapters borrow the socket by default and do not connect or disconnect it. Pass `owned` to
|
|
640
|
+
`SocketProvider` or `{ owned: true }` to `provideSocket` only when that framework root owns the
|
|
641
|
+
socket lifecycle.
|
|
844
642
|
|
|
845
|
-
##
|
|
643
|
+
## Test broadcasts
|
|
644
|
+
|
|
645
|
+
`socket.fake()` captures outgoing events without writing to WebSocket clients or the distributed
|
|
646
|
+
transport.
|
|
846
647
|
|
|
847
648
|
```ts
|
|
848
|
-
import {
|
|
849
|
-
import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
|
|
850
|
-
import SocketProvider from '@rlanz/socket/provider'
|
|
649
|
+
import { test } from '@japa/runner'
|
|
851
650
|
import socket from '@rlanz/socket/services/main'
|
|
852
|
-
|
|
651
|
+
|
|
652
|
+
test.group('Notifications', (group) => {
|
|
653
|
+
group.each.teardown(() => socket.restore())
|
|
654
|
+
|
|
655
|
+
test('broadcasts a notification', async ({ client }) => {
|
|
656
|
+
const fake = socket.fake()
|
|
657
|
+
|
|
658
|
+
await client.post('/notifications').json({ message: 'Hello' })
|
|
659
|
+
|
|
660
|
+
fake.assertBroadcasted('notification:created', {
|
|
661
|
+
data: { message: 'Hello' },
|
|
662
|
+
})
|
|
663
|
+
})
|
|
664
|
+
})
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
Use `using fake = socket.fake()` to restore the real service when the scope exits.
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
fake.assertBroadcasted('maintenance', { data: { active: true } })
|
|
671
|
+
fake.assertNotBroadcasted('deploy:started')
|
|
672
|
+
|
|
673
|
+
fake.assertEmittedTo('chat/general', 'chat:message', {
|
|
674
|
+
data: (data) => data.text === 'Hello',
|
|
675
|
+
})
|
|
676
|
+
fake.assertNotEmittedTo('chat/general', 'chat:typing')
|
|
677
|
+
|
|
678
|
+
fake.assertCount(2, { target: 'channel', channel: 'chat/general' })
|
|
679
|
+
fake.assertCount(0)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
## Run multiple instances
|
|
683
|
+
|
|
684
|
+
Without a transport, subscriptions, broadcasts, and presence stay inside one application instance.
|
|
685
|
+
Configure an `@boringnode/bus` transport when multiple instances must exchange events.
|
|
686
|
+
|
|
687
|
+
Install the dependency required by your transport. For Redis:
|
|
688
|
+
|
|
689
|
+
```sh
|
|
690
|
+
yarn add ioredis
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
// config/socket.ts
|
|
695
|
+
import { redis } from '@boringnode/bus/transports/redis'
|
|
696
|
+
import { defineConfig } from '@rlanz/socket'
|
|
697
|
+
|
|
698
|
+
export default defineConfig({
|
|
699
|
+
transport: {
|
|
700
|
+
driver: redis({
|
|
701
|
+
host: '127.0.0.1',
|
|
702
|
+
port: 6379,
|
|
703
|
+
}),
|
|
704
|
+
channel: 'socket::broadcast',
|
|
705
|
+
presenceTimeout: '100ms',
|
|
706
|
+
retryQueue: {
|
|
707
|
+
maxSize: 1000,
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
})
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
All instances in one deployment must use a compatible transport and the same bus channel. The
|
|
714
|
+
default channel is `socket::broadcast`.
|
|
715
|
+
|
|
716
|
+
Broadcasts are delivered locally and published to the transport. Publication does not wait for
|
|
717
|
+
remote delivery. Failed publications enter a bounded in-memory retry queue. The queue holds `1000`
|
|
718
|
+
entries by default, removes duplicate messages, and drops the oldest entry when full. Configure
|
|
719
|
+
`transport.retryQueue` to change these rules or disable retries. Setting `maxSize: null` creates an
|
|
720
|
+
unbounded queue. A transport reconnect can deliver queued events after newer events.
|
|
721
|
+
|
|
722
|
+
Presence uses the transport to collect snapshots. `presenceTimeout`, which defaults to `100ms`, sets
|
|
723
|
+
how long an instance waits for replies.
|
|
724
|
+
|
|
725
|
+
The transport does not make events durable. The package does not persist messages, acknowledge
|
|
726
|
+
recipients, or recover events after a process restart. Persist application state separately when a
|
|
727
|
+
client must recover missed events.
|
|
728
|
+
|
|
729
|
+
## Production
|
|
730
|
+
|
|
731
|
+
The package attaches to the AdonisJS HTTP server. HTTP and WebSocket traffic share the same process,
|
|
732
|
+
listener, event loop, memory, and failure domain. A process failure disconnects every socket on that
|
|
733
|
+
instance.
|
|
734
|
+
|
|
735
|
+
### Reverse proxy
|
|
736
|
+
|
|
737
|
+
Configure the proxy or load balancer to:
|
|
738
|
+
|
|
739
|
+
- route `websocket.path` to the AdonisJS HTTP listener
|
|
740
|
+
- forward HTTP/1.1 upgrades with `Upgrade` and `Connection: upgrade`
|
|
741
|
+
- use `wss://` publicly and terminate TLS safely
|
|
742
|
+
- preserve a validated public `Host`
|
|
743
|
+
- replace `X-Forwarded-Proto` with the trusted public protocol
|
|
744
|
+
- keep upgraded connections attached to their original instance
|
|
745
|
+
- set the idle timeout above the heartbeat cadence
|
|
746
|
+
- prevent direct access to the upstream
|
|
747
|
+
|
|
748
|
+
Sticky sessions are not required and do not replace a shared transport.
|
|
749
|
+
|
|
750
|
+
### Delivery guarantees
|
|
751
|
+
|
|
752
|
+
Socket delivery is temporary and best-effort:
|
|
753
|
+
|
|
754
|
+
- reconnecting creates a new socket session
|
|
755
|
+
- pending acknowledgements fail on disconnect
|
|
756
|
+
- events sent while disconnected are not replayed
|
|
757
|
+
- `sendWithAck()` confirms that the server handler returned, not that a broadcast reached a client
|
|
758
|
+
- transport publication does not confirm remote delivery
|
|
759
|
+
- distributed presence can be incomplete during failures or concurrent changes
|
|
760
|
+
|
|
761
|
+
Applications that need durable notifications or at-least-once processing must store events or state
|
|
762
|
+
outside this package. Define application-level event IDs, cursors, deduplication, acknowledgements,
|
|
763
|
+
and replay where needed.
|
|
764
|
+
|
|
765
|
+
### Shutdown and resource limits
|
|
766
|
+
|
|
767
|
+
During shutdown, the service becomes unready, rejects new upgrades, closes existing sockets, runs
|
|
768
|
+
subscription and disconnect hooks, and then disconnects the bus. It does not drain established
|
|
769
|
+
sockets or wait for pending client acknowledgements.
|
|
770
|
+
|
|
771
|
+
After `shutdownTimeout`, the service releases its internal socket and subscription state. JavaScript
|
|
772
|
+
handlers that ignore cancellation can continue running.
|
|
773
|
+
|
|
774
|
+
Remove an instance from new traffic before a rolling restart when your platform allows it. Give the
|
|
775
|
+
process enough termination time to run hooks and close the bus.
|
|
776
|
+
|
|
777
|
+
There is no built-in total connection limit. Each connection consumes a file descriptor, memory,
|
|
778
|
+
heartbeat state, message queues, and channel subscriptions. Set memory and file descriptor limits,
|
|
779
|
+
cap connections at the edge, and load test realistic connection counts, payloads, message rates, and
|
|
780
|
+
broadcast fan-out.
|
|
781
|
+
|
|
782
|
+
Slow consumers are disconnected when `maxBufferedAmount` is exceeded. Oversized outbound messages
|
|
783
|
+
are rejected rather than buffered or retried.
|
|
784
|
+
|
|
785
|
+
### Production checklist
|
|
786
|
+
|
|
787
|
+
- [ ] Forward WebSocket upgrades on the configured path.
|
|
788
|
+
- [ ] Preserve `Host` and replace `X-Forwarded-Proto` at the proxy.
|
|
789
|
+
- [ ] Configure exact browser origins and upgrade authentication.
|
|
790
|
+
- [ ] Set and test the heartbeat and proxy idle timeout.
|
|
791
|
+
- [ ] Configure one shared transport for every related instance.
|
|
792
|
+
- [ ] Make clients tolerate reconnects, repeated subscriptions, duplicates, and missed events.
|
|
793
|
+
- [ ] Store and replay application-critical events outside the socket transport.
|
|
794
|
+
- [ ] Tune payload, rate, queue, subscription, connection, memory, and file descriptor limits.
|
|
795
|
+
- [ ] Add readiness, liveness, logs, metrics, and transport monitoring.
|
|
796
|
+
- [ ] Allow enough shutdown time for hooks and bus disconnection.
|
|
797
|
+
|
|
798
|
+
## Health checks
|
|
799
|
+
|
|
800
|
+
Add `SocketHealthCheck` to the application readiness checks.
|
|
801
|
+
|
|
802
|
+
```ts
|
|
803
|
+
// start/health.ts
|
|
804
|
+
import { DiskSpaceCheck, HealthChecks, MemoryHeapCheck } from '@adonisjs/core/health'
|
|
853
805
|
import { SocketHealthCheck } from '@rlanz/socket/health_check'
|
|
806
|
+
import socket from '@rlanz/socket/services/main'
|
|
807
|
+
|
|
808
|
+
export const healthChecks = new HealthChecks().register([
|
|
809
|
+
new DiskSpaceCheck(),
|
|
810
|
+
new MemoryHeapCheck(),
|
|
811
|
+
new SocketHealthCheck(socket),
|
|
812
|
+
])
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
The check reports `ok` after the WebSocket service boots. It reports `error` while the service is
|
|
816
|
+
starting, stopping, stopped, or failed. Metadata includes local connection and channel counts.
|
|
817
|
+
|
|
818
|
+
This check does not probe the transport, remote instances, or end-to-end delivery. Monitor those
|
|
819
|
+
separately and keep a process liveness check so a readiness failure does not cause a restart loop.
|
|
820
|
+
|
|
821
|
+
## OpenTelemetry
|
|
822
|
+
|
|
823
|
+
The package emits `diagnostics_channel` events and provides optional OpenTelemetry instrumentation.
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
import { registerInstrumentations } from '@opentelemetry/instrumentation'
|
|
854
827
|
import { SocketInstrumentation } from '@rlanz/socket/otel'
|
|
855
|
-
|
|
856
|
-
|
|
828
|
+
|
|
829
|
+
const unregister = registerInstrumentations({
|
|
830
|
+
instrumentations: [new SocketInstrumentation()],
|
|
831
|
+
})
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
You can pass the same instrumentation to an OpenTelemetry `NodeSDK` or an integration such as
|
|
835
|
+
`@adonisjs/otel`. Registration enables it. Call `unregister()` only when removing a standalone
|
|
836
|
+
registration.
|
|
837
|
+
|
|
838
|
+
The instrumentation creates spans for connections, disconnections, subscriptions,
|
|
839
|
+
unsubscriptions, incoming channel messages, and broadcast delivery. It records counters for active
|
|
840
|
+
connections, active subscriptions, received messages, broadcasts, and local deliveries.
|
|
841
|
+
|
|
842
|
+
Also monitor event-loop delay, memory, CPU, open file descriptors, proxy upgrade failures, reconnect
|
|
843
|
+
rates, and transport health. A broadcast delivery counter records a write to a local `ws` object. It
|
|
844
|
+
does not confirm that the application received the event.
|
|
845
|
+
|
|
846
|
+
## Package exports
|
|
847
|
+
|
|
848
|
+
```ts
|
|
849
|
+
import { BaseChannel, SocketResponseError, defineConfig } from '@rlanz/socket'
|
|
857
850
|
import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
|
|
851
|
+
import { Socket } from '@rlanz/socket/client'
|
|
858
852
|
import { createSocketHooks } from '@rlanz/socket/client/react'
|
|
859
853
|
import { createSocketComposables } from '@rlanz/socket/client/vue'
|
|
860
|
-
import
|
|
854
|
+
import { onMessage } from '@rlanz/socket/decorators'
|
|
855
|
+
import { SocketHealthCheck } from '@rlanz/socket/health_check'
|
|
856
|
+
import { SocketInstrumentation } from '@rlanz/socket/otel'
|
|
857
|
+
import socket from '@rlanz/socket/services/main'
|
|
858
|
+
import { SocketFake } from '@rlanz/socket/testing'
|
|
859
|
+
import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
|
|
860
|
+
import type { PresenceData, SocketOptions } from '@rlanz/socket/client/types'
|
|
861
861
|
```
|
|
862
862
|
|
|
863
|
-
Available
|
|
863
|
+
Available entry points:
|
|
864
864
|
|
|
865
865
|
- `@rlanz/socket`
|
|
866
866
|
- `@rlanz/socket/provider`
|