@rlanz/socket 0.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/LICENSE.md +9 -0
- package/README.md +387 -0
- package/build/chunk-4UPUCRVG.js +1772 -0
- package/build/chunk-4UPUCRVG.js.map +1 -0
- package/build/chunk-GKAD2UOA.js +18 -0
- package/build/chunk-GKAD2UOA.js.map +1 -0
- package/build/chunk-HK7Z65DA.js +19 -0
- package/build/chunk-HK7Z65DA.js.map +1 -0
- package/build/chunk-XUDFDJME.js +42 -0
- package/build/chunk-XUDFDJME.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +21 -0
- package/build/index.js.map +1 -0
- package/build/providers/socket_provider.d.ts +40 -0
- package/build/providers/socket_provider.js +132 -0
- package/build/providers/socket_provider.js.map +1 -0
- package/build/services/socket.d.ts +12 -0
- package/build/services/socket.js +10 -0
- package/build/services/socket.js.map +1 -0
- package/build/shared_types-Dw9AphfO.d.ts +21 -0
- package/build/socket_service-jxIaH5Rs.d.ts +144 -0
- package/build/src/client/index.d.ts +106 -0
- package/build/src/client/index.js +671 -0
- package/build/src/client/index.js.map +1 -0
- package/build/src/client/types.d.ts +101 -0
- package/build/src/client/types.js +1 -0
- package/build/src/client/types.js.map +1 -0
- package/build/src/decorators.d.ts +17 -0
- package/build/src/decorators.js +9 -0
- package/build/src/decorators.js.map +1 -0
- package/build/src/health_check.d.ts +18 -0
- package/build/src/health_check.js +7 -0
- package/build/src/health_check.js.map +1 -0
- package/build/src/otel.d.ts +31 -0
- package/build/src/otel.js +276 -0
- package/build/src/otel.js.map +1 -0
- package/build/src/types/tracing_channels.d.ts +40 -0
- package/build/src/types/tracing_channels.js +1 -0
- package/build/src/types/tracing_channels.js.map +1 -0
- package/build/src/types.d.ts +5 -0
- package/build/src/types.js +1 -0
- package/build/src/types.js.map +1 -0
- package/build/types-C0rDwbry.d.ts +393 -0
- package/package.json +105 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Romain Lanz, AdonisJS Core Team, contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# @rlanz/socket
|
|
2
|
+
|
|
3
|
+
WebSocket integration for AdonisJS, powered by [`ws`](https://github.com/websockets/ws).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
yarn add @rlanz/socket
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Register the provider in your AdonisJS application:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// adonisrc.ts
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
providers: [() => import('@rlanz/socket/provider')],
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
Create a socket config file and tune the WebSocket path or heartbeat.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// config/socket.ts
|
|
26
|
+
import { defineConfig } from '@adonisjs/core/config'
|
|
27
|
+
|
|
28
|
+
export default defineConfig({
|
|
29
|
+
websocket: {
|
|
30
|
+
path: '/socket',
|
|
31
|
+
pingInterval: 25_000,
|
|
32
|
+
pingTimeout: 5_000,
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `websocket.path` defaults to `/socket`.
|
|
38
|
+
- `websocket.pingInterval` sends a WebSocket ping every configured number of milliseconds.
|
|
39
|
+
- `websocket.pingTimeout` closes connections that do not answer in time.
|
|
40
|
+
- If only one heartbeat value is configured, the other one uses the default shown above.
|
|
41
|
+
|
|
42
|
+
### Authentication
|
|
43
|
+
|
|
44
|
+
Authenticate sockets during the HTTP upgrade with `websocket.authenticate`. The hook receives the
|
|
45
|
+
upgrade request, the parsed client auth payload, and the AdonisJS HTTP context.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// config/socket.ts
|
|
49
|
+
import { defineConfig } from '@adonisjs/core/config'
|
|
50
|
+
|
|
51
|
+
export default defineConfig({
|
|
52
|
+
websocket: {
|
|
53
|
+
async authenticate({ auth, httpContext }) {
|
|
54
|
+
const user = await httpContext!.auth.authenticate()
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
user,
|
|
58
|
+
auth,
|
|
59
|
+
data: {
|
|
60
|
+
connectedAt: new Date(),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Return `false`, `null`, or throw to reject the upgrade with `401 Unauthorized`. The returned `user`
|
|
69
|
+
is available as `socket.user`, `data` is stored on `socket.raw.data`, and the HTTP context remains
|
|
70
|
+
available as `socket.raw.httpContext`.
|
|
71
|
+
|
|
72
|
+
### Horizontal Sync
|
|
73
|
+
|
|
74
|
+
Configure an [`@boringnode/bus`](https://github.com/boringnode/bus) transport to synchronize broadcasts across multiple SocketService instances.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// config/socket.ts
|
|
78
|
+
import { defineConfig } from '@adonisjs/core/config'
|
|
79
|
+
import { redis } from '@boringnode/bus/transports/redis'
|
|
80
|
+
|
|
81
|
+
export default defineConfig({
|
|
82
|
+
transport: {
|
|
83
|
+
driver: redis({
|
|
84
|
+
host: '127.0.0.1',
|
|
85
|
+
port: 6379,
|
|
86
|
+
}),
|
|
87
|
+
channel: 'socket::broadcast',
|
|
88
|
+
presenceTimeout: 100,
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
When configured, `socket.to(channel).emit(...)`, `socket.to(channel).except(socketId).emit(...)`, `socket.toUser(userId).emit(...)`, and `socket.broadcast(...)` are delivered locally and published to the bus so other instances can deliver them to their own connected sockets.
|
|
94
|
+
|
|
95
|
+
The default bus channel is `socket::broadcast`. Presence channels also use the bus to build distributed snapshots during subscribe, duplicate subscribe, join, and leave updates. `presenceTimeout` controls how long an instance waits for other instances to answer a presence snapshot request; it defaults to `100` milliseconds.
|
|
96
|
+
|
|
97
|
+
### Health Checks
|
|
98
|
+
|
|
99
|
+
Register `SocketHealthCheck` inside your AdonisJS readiness checks to report whether the WebSocket service is ready to accept traffic.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// start/health.ts
|
|
103
|
+
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
|
|
104
|
+
import socket from '@rlanz/socket/services/main'
|
|
105
|
+
import { SocketHealthCheck } from '@rlanz/socket/health_check'
|
|
106
|
+
|
|
107
|
+
export const healthChecks = new HealthChecks().register([
|
|
108
|
+
new DiskSpaceCheck(),
|
|
109
|
+
new MemoryHeapCheck(),
|
|
110
|
+
new SocketHealthCheck(socket),
|
|
111
|
+
])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The check returns `ok` only after the provider has successfully booted the WebSocket server. It reports `error` while the service is stopped, stopping, or failed, so readiness probes can remove the instance from traffic during shutdown or startup failures.
|
|
115
|
+
|
|
116
|
+
### Testing
|
|
117
|
+
|
|
118
|
+
Use `socket.fake()` to intercept outgoing events and store them in memory without touching any connected WebSocket client or distributed transport.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { test } from '@japa/runner'
|
|
122
|
+
import socket from '@rlanz/socket/services/main'
|
|
123
|
+
|
|
124
|
+
test.group('Notifications', (group) => {
|
|
125
|
+
group.each.teardown(() => {
|
|
126
|
+
socket.restore()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('broadcasts the notification', async ({ client }) => {
|
|
130
|
+
const fake = socket.fake()
|
|
131
|
+
|
|
132
|
+
await client.post('/notifications').json({ message: 'Hello' })
|
|
133
|
+
|
|
134
|
+
fake.assertBroadcasted('notification:created', {
|
|
135
|
+
data: { message: 'Hello' },
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The fake supports explicit resource management as well, so `using fake = socket.fake()` automatically restores the real socket service when the test scope exits.
|
|
142
|
+
|
|
143
|
+
Assertion helpers cover global broadcasts, channel events, user events, counts, and negative assertions.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
fake.assertBroadcasted('maintenance', { data: { active: true } })
|
|
147
|
+
fake.assertNotBroadcasted('maintenance')
|
|
148
|
+
|
|
149
|
+
fake.assertEmittedTo('chat/general', 'chat:message', {
|
|
150
|
+
data: (data) => data.text === 'Hello',
|
|
151
|
+
})
|
|
152
|
+
fake.assertNotEmittedTo('chat/general', 'chat:typing')
|
|
153
|
+
|
|
154
|
+
fake.assertEmittedToUser(1, 'notification', { data: { unread: 3 } })
|
|
155
|
+
fake.assertNotEmittedToUser(1, 'notification')
|
|
156
|
+
|
|
157
|
+
fake.assertEmittedCount(2, { target: 'channel', channel: 'chat/general' })
|
|
158
|
+
fake.assertNothingEmitted()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### OpenTelemetry
|
|
162
|
+
|
|
163
|
+
`@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.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { SocketInstrumentation } from '@rlanz/socket/otel'
|
|
167
|
+
|
|
168
|
+
const instrumentation = new SocketInstrumentation()
|
|
169
|
+
|
|
170
|
+
instrumentation.enable()
|
|
171
|
+
instrumentation.manuallyRegister()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
## Channels
|
|
177
|
+
|
|
178
|
+
Channels are discovered from `app/channels/**/*_channel.{ts,js}`. Export a default class extending `BaseChannel` and define a static `pattern`.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// app/channels/chat_channel.ts
|
|
182
|
+
import { BaseChannel } from '@rlanz/socket'
|
|
183
|
+
import type { AuthenticatedSocket } from '@rlanz/socket/types'
|
|
184
|
+
|
|
185
|
+
type User = { id: string; name: string }
|
|
186
|
+
|
|
187
|
+
export default class ChatChannel extends BaseChannel<User> {
|
|
188
|
+
static pattern = 'chat/:roomId'
|
|
189
|
+
|
|
190
|
+
async onJoin(socket: AuthenticatedSocket<User>, roomId: string) {
|
|
191
|
+
this.broadcast('chat:system', {
|
|
192
|
+
message: `${socket.user?.name ?? 'Someone'} joined ${roomId}`,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async onLeave(socket: AuthenticatedSocket<User>, roomId: string) {
|
|
197
|
+
this.broadcastExcept(socket.id, 'chat:system', {
|
|
198
|
+
message: `${socket.user?.name ?? 'Someone'} left ${roomId}`,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Middleware
|
|
205
|
+
|
|
206
|
+
Middlewares run before subscription. They may be functions or objects with a `handle` method.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { BaseChannel } from '@rlanz/socket'
|
|
210
|
+
import type { MiddlewareContext } from '@rlanz/socket/types'
|
|
211
|
+
|
|
212
|
+
type User = { id: string; name: string }
|
|
213
|
+
|
|
214
|
+
async function auth(ctx: MiddlewareContext<User>, next: () => Promise<void>) {
|
|
215
|
+
if (!ctx.socket.user) {
|
|
216
|
+
throw new Error('Unauthorized')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await ctx.socket.joinUserRoom(ctx.socket.user.id)
|
|
220
|
+
await next()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export default class ChatChannel extends BaseChannel<User> {
|
|
224
|
+
static pattern = 'chat/:roomId'
|
|
225
|
+
static middlewares = [auth]
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Presence
|
|
230
|
+
|
|
231
|
+
Enable presence with `static options = { presence: true }` and implement `getPresenceInfo`.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { BaseChannel } from '@rlanz/socket'
|
|
235
|
+
import type { AuthenticatedSocket, PresenceMember } from '@rlanz/socket/types'
|
|
236
|
+
|
|
237
|
+
type User = { id: string; name: string }
|
|
238
|
+
|
|
239
|
+
export default class RoomChannel extends BaseChannel<User> {
|
|
240
|
+
static pattern = 'rooms/:roomId'
|
|
241
|
+
static options = { presence: true }
|
|
242
|
+
|
|
243
|
+
getPresenceInfo(socket: AuthenticatedSocket<User>): PresenceMember {
|
|
244
|
+
return {
|
|
245
|
+
id: socket.user!.id,
|
|
246
|
+
name: socket.user!.name,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async onMemberJoin(socket: AuthenticatedSocket<User>, member: PresenceMember) {
|
|
251
|
+
this.broadcastExcept(socket.id, 'room:member_joined', member)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async onMemberLeave(socket: AuthenticatedSocket<User>, member: PresenceMember) {
|
|
255
|
+
this.broadcast('room:member_left', member)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Channel Messages
|
|
261
|
+
|
|
262
|
+
Incoming client messages are handled through the `handlers` map, the `@onMessage` decorator, or the `onMessage` fallback.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { BaseChannel } from '@rlanz/socket'
|
|
266
|
+
import { onMessage } from '@rlanz/socket/decorators'
|
|
267
|
+
import type { AuthenticatedSocket } from '@rlanz/socket/types'
|
|
268
|
+
|
|
269
|
+
type User = { id: string; name: string }
|
|
270
|
+
type Events = {
|
|
271
|
+
'chat:send': { body: string }
|
|
272
|
+
'chat:typing': { typing: boolean }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export default class ChatChannel extends BaseChannel<User, Events> {
|
|
276
|
+
static pattern = 'chat/:roomId'
|
|
277
|
+
|
|
278
|
+
protected handlers = {
|
|
279
|
+
'chat:send': this.sendMessage,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async sendMessage(socket: AuthenticatedSocket<User>, data: Events['chat:send']) {
|
|
283
|
+
this.broadcast('chat:message', {
|
|
284
|
+
user: socket.user,
|
|
285
|
+
body: data.body,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return { delivered: true }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@onMessage('chat:typing')
|
|
292
|
+
async typing(socket: AuthenticatedSocket<User>, data: Events['chat:typing']) {
|
|
293
|
+
this.broadcastExcept(socket.id, 'chat:typing', {
|
|
294
|
+
user: socket.user,
|
|
295
|
+
typing: data.typing,
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async onMessage(socket: AuthenticatedSocket<User>, event: string, data: unknown) {
|
|
300
|
+
console.log('Unhandled channel message', event, data)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Returning a value from a handler resolves `sendWithAck()` on the client.
|
|
306
|
+
|
|
307
|
+
## Client
|
|
308
|
+
|
|
309
|
+
Import the browser client from `@rlanz/socket/client`.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { Socket } from '@rlanz/socket/client'
|
|
313
|
+
|
|
314
|
+
const socket = new Socket({
|
|
315
|
+
url: 'http://localhost:3333',
|
|
316
|
+
path: '/socket',
|
|
317
|
+
auth: { token: 'secret' },
|
|
318
|
+
autoReconnect: true,
|
|
319
|
+
reconnectDelay: 250,
|
|
320
|
+
reconnectMaxDelay: 5000,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
socket.onStateChange((state) => {
|
|
324
|
+
console.log('socket state:', state)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
socket.on('connect', () => {
|
|
328
|
+
console.log('connected')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
await socket.connect()
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Subscribe, Listen, Send
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
const channel = socket.channel('chat/general')
|
|
338
|
+
|
|
339
|
+
channel
|
|
340
|
+
.here((users) => console.log('present users', users))
|
|
341
|
+
.joining((user) => console.log('joined', user))
|
|
342
|
+
.leaving((user) => console.log('left', user))
|
|
343
|
+
.listen('chat:message', (message) => {
|
|
344
|
+
console.log('new message', message)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
await channel.subscribe()
|
|
348
|
+
|
|
349
|
+
channel.send('chat:typing', { typing: true })
|
|
350
|
+
|
|
351
|
+
const ack = await channel.sendWithAck('chat:send', {
|
|
352
|
+
body: 'Hello from the client',
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
console.log(ack)
|
|
356
|
+
|
|
357
|
+
channel.stopListening('chat:message')
|
|
358
|
+
await channel.unsubscribe()
|
|
359
|
+
await socket.leave('chat/general')
|
|
360
|
+
socket.disconnect()
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`subscribe()` and `unsubscribe()` accept an optional `{ timeout }` in milliseconds.
|
|
364
|
+
|
|
365
|
+
The client reconnects automatically by default. `reconnectDelay` is the initial delay in
|
|
366
|
+
milliseconds, and each failed retry doubles the delay until `reconnectMaxDelay` is reached.
|
|
367
|
+
Set `autoReconnect: false` to disable reconnect attempts.
|
|
368
|
+
|
|
369
|
+
## Package Exports
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { BaseChannel, ChannelRouter, PresenceManager, SocketService } from '@rlanz/socket'
|
|
373
|
+
import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
|
|
374
|
+
import SocketProvider from '@rlanz/socket/provider'
|
|
375
|
+
import { onMessage } from '@rlanz/socket/decorators'
|
|
376
|
+
import { Socket, Channel } from '@rlanz/socket/client'
|
|
377
|
+
import type { SocketOptions, PresenceData } from '@rlanz/socket/client/types'
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Available exports:
|
|
381
|
+
|
|
382
|
+
- `@rlanz/socket`
|
|
383
|
+
- `@rlanz/socket/provider`
|
|
384
|
+
- `@rlanz/socket/decorators`
|
|
385
|
+
- `@rlanz/socket/types`
|
|
386
|
+
- `@rlanz/socket/client`
|
|
387
|
+
- `@rlanz/socket/client/types`
|