@rlanz/socket 0.0.1-9 → 0.1.1

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.
Files changed (43) hide show
  1. package/README.md +562 -556
  2. package/build/{base_channel-DPOnqned.d.ts → base_channel-CasBQV5I.d.ts} +7 -25
  3. package/build/{chunk-QNOOQXYY.js → chunk-3FY7EIZF.js} +252 -200
  4. package/build/chunk-3FY7EIZF.js.map +1 -0
  5. package/build/{chunk-6PJ4ALJD.js → chunk-DM3UQMWW.js} +5 -3
  6. package/build/chunk-DM3UQMWW.js.map +1 -0
  7. package/build/chunk-NUPBNOP3.js +61 -0
  8. package/build/chunk-NUPBNOP3.js.map +1 -0
  9. package/build/{chunk-YVC7IMDN.js → chunk-OAI6SAER.js} +2 -2
  10. package/build/chunk-YSW4MNFR.js +7 -0
  11. package/build/chunk-YSW4MNFR.js.map +1 -0
  12. package/build/{framework-B8pJ4cCA.d.ts → framework-Cb71JA5f.d.ts} +1 -1
  13. package/build/{index-DjBYUhFR.d.ts → index-DCPtfkJL.d.ts} +1 -1
  14. package/build/index.d.ts +3 -2
  15. package/build/providers/socket_provider.d.ts +3 -2
  16. package/build/providers/socket_provider.js +312 -130
  17. package/build/providers/socket_provider.js.map +1 -1
  18. package/build/services/socket.d.ts +3 -2
  19. package/build/shared_types-B11CaOr5.d.ts +26 -0
  20. package/build/{socket_service-BcC9ASCE.d.ts → socket_service-C2YRbhrO.d.ts} +1 -1
  21. package/build/src/assembler_hook.js +54 -30
  22. package/build/src/assembler_hook.js.map +1 -1
  23. package/build/src/client/index.d.ts +3 -8
  24. package/build/src/client/index.js +3 -2
  25. package/build/src/client/react.d.ts +3 -8
  26. package/build/src/client/react.js +4 -3
  27. package/build/src/client/react.js.map +1 -1
  28. package/build/src/client/types.d.ts +18 -13
  29. package/build/src/client/vue.d.ts +3 -8
  30. package/build/src/client/vue.js +4 -3
  31. package/build/src/client/vue.js.map +1 -1
  32. package/build/src/decorators.d.ts +2 -1
  33. package/build/src/health_check.d.ts +3 -2
  34. package/build/src/otel.d.ts +1 -0
  35. package/build/src/otel.js +211 -27
  36. package/build/src/otel.js.map +1 -1
  37. package/build/src/types.d.ts +2 -1
  38. package/package.json +31 -7
  39. package/build/chunk-6PJ4ALJD.js.map +0 -1
  40. package/build/chunk-D3HUBCBW.js +0 -18
  41. package/build/chunk-D3HUBCBW.js.map +0 -1
  42. package/build/chunk-QNOOQXYY.js.map +0 -1
  43. /package/build/{chunk-YVC7IMDN.js.map → chunk-OAI6SAER.js.map} +0 -0
package/README.md CHANGED
@@ -1,21 +1,31 @@
1
1
  # @rlanz/socket
2
2
 
3
- WebSocket integration for AdonisJS, powered by [`ws`](https://github.com/websockets/ws).
3
+ A WebSocket server and typed client for AdonisJS, powered by
4
+ [`ws`](https://github.com/websockets/ws).
4
5
 
5
- ## Installation
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
- ```sh
8
- yarn add @rlanz/socket
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
- Configure the package in your AdonisJS application:
20
+ ## Install
12
21
 
13
22
  ```sh
23
+ yarn add @rlanz/socket
14
24
  node ace configure @rlanz/socket
15
25
  ```
16
26
 
17
- This registers the provider for the `web` environment only, so Ace commands do not boot the
18
- WebSocket integration, and installs the Assembler hook:
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 Assembler hook generates both the application registry used for end-to-end client types and 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
- ## Configuration
47
+ ## Quick start
43
48
 
44
- Create a socket config file and tune the WebSocket path or heartbeat.
49
+ Create a channel in `app/channels`.
45
50
 
46
51
  ```ts
47
- // config/socket.ts
48
- import { defineConfig } from '@rlanz/socket'
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
- export default defineConfig({
51
- websocket: {
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
- - `websocket.path` defaults to `/socket`.
67
- - Browser upgrades are restricted to the request's own origin by default. `websocket.origin`
68
- accepts the same values as AdonisJS CORS `origin`: a boolean, a string (including `'*'` or a
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
- ### Authentication
64
+ export default class ChatChannel extends BaseChannel<User, ServerEvents> {
65
+ static pattern = 'chat/:roomId'
104
66
 
105
- Authenticate sockets during the HTTP upgrade with `websocket.authenticate`. The hook receives an
106
- AdonisJS HTTP context and returns the authenticated user.
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
- Define `websocket.middleware` when your WebSocket upgrade authentication depends on AdonisJS
109
- middleware such as sessions or auth initialization. These middleware run only for the HTTP upgrade
110
- request, before `websocket.authenticate`, so they are the right place to prepare `httpContext.auth`,
111
- `httpContext.session`, and other request-scoped services used during the handshake.
74
+ this.broadcastExcept(socket.id, 'chat:message', message)
75
+ return message
76
+ }
77
+ }
78
+ ```
112
79
 
113
- They are not channel middleware. Channel authorization and per-channel behavior still live on the
114
- channel itself through `static middlewares`.
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
- // config/socket.ts
118
- import { authenticateWithAdonisAuth, defineConfig } from '@rlanz/socket'
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
- export default defineConfig({
123
- websocket: {
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
- authenticate: authenticateWithAdonisAuth<User>(),
130
- },
90
+ channel.listen('chat:message', (message) => {
91
+ console.log(message.id)
131
92
  })
132
- ```
133
93
 
134
- Return `false`, `null`, or throw to reject the upgrade with `401 Unauthorized`. The returned user is
135
- available as `socket.user`.
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
- ```ts
149
- async authenticate({ httpContext }) {
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
- ### Horizontal Sync
101
+ TypeScript rejects unknown channels, missing channel parameters, unknown events, invalid payloads,
102
+ and invalid server broadcasts.
156
103
 
157
- Configure an [`@boringnode/bus`](https://github.com/boringnode/bus) transport to synchronize broadcasts across multiple application instances.
104
+ ## Configure the server
158
105
 
159
- Install the peer dependency required by the selected transport. For Redis:
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
- transport: {
172
- driver: redis({
173
- host: '127.0.0.1',
174
- port: 6379,
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
- When configured, `socket.to(channel).emit(...)`, `socket.to(channel).except(socketId).emit(...)`, and `socket.broadcast(...)` are delivered locally and published to the bus so other instances can deliver them to their own connected sockets.
122
+ ### WebSocket options
186
123
 
187
- 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 `100ms`.
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
- Failed publications enter an in-memory retry queue. It is enabled by default, deduplicates identical
190
- messages, and retains at most `1000` entries; when full, the oldest entry is discarded. Configure
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
- ### Health Checks
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
- Register `SocketHealthCheck` inside your AdonisJS readiness checks to report whether the WebSocket service is ready to accept traffic.
148
+ ### Origin checks
197
149
 
198
- ```ts
199
- // start/health.ts
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
- export const healthChecks = new HealthChecks().register([
205
- new DiskSpaceCheck(),
206
- new MemoryHeapCheck(),
207
- new SocketHealthCheck(socket),
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
- 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.
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
- ### Testing
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
- Use `socket.fake()` to intercept outgoing events and store them in memory without touching any connected WebSocket client or distributed transport.
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
- ```ts
218
- import { test } from '@japa/runner'
219
- import socket from '@rlanz/socket/services/main'
168
+ ### Authentication
220
169
 
221
- test.group('Notifications', (group) => {
222
- group.each.teardown(() => {
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
- test('broadcasts the notification', async ({ client }) => {
227
- const fake = socket.fake()
173
+ ```ts
174
+ // config/socket.ts
175
+ import { authenticateWithAdonisAuth, defineConfig } from '@rlanz/socket'
228
176
 
229
- await client.post('/notifications').json({ message: 'Hello' })
177
+ type User = { id: string; name: string }
230
178
 
231
- fake.assertBroadcasted('notification:created', {
232
- data: { message: 'Hello' },
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
- The fake supports explicit resource management as well, so `using fake = socket.fake()` automatically restores the real socket service when the test scope exits.
190
+ Return `false`, `null`, or throw to reject the upgrade with `401 Unauthorized`. A successful result
191
+ becomes `socket.user`.
239
192
 
240
- Five assertion helpers cover global broadcasts, channel events, counts, and negative assertions.
193
+ You can provide a custom handler instead.
241
194
 
242
195
  ```ts
243
- fake.assertBroadcasted('maintenance', { data: { active: true } })
244
- fake.assertNotBroadcasted('deploy:started')
245
-
246
- fake.assertEmittedTo('chat/general', 'chat:message', {
247
- data: (data) => data.text === 'Hello',
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
- ### OpenTelemetry
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.
258
-
259
- ```ts
260
- import { SocketInstrumentation } from '@rlanz/socket/otel'
206
+ Upgrade middleware and channel middleware have different jobs. Upgrade middleware prepares the HTTP
207
+ handshake. Channel middleware authorizes one subscription.
261
208
 
262
- const instrumentation = new SocketInstrumentation()
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.
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.
371
213
 
372
- ### Production checklist
214
+ ## Define channels
373
215
 
374
- - [ ] Proxy upgrades on the configured path with HTTP/1.1 `Upgrade`/`Connection` headers.
375
- - [ ] Terminate TLS safely; preserve a validated public `Host` and overwrite `X-Forwarded-Proto`.
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
-
403
- type User = { id: string; name: string }
404
- type Message = { id: string; text: string }
405
221
 
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
- Channel instances are resolved through the AdonisJS container for every new subscription, so
429
- constructor injection works as it does for other AdonisJS classes:
227
+ Patterns support:
430
228
 
431
- ```ts
432
- import { inject } from '@adonisjs/core'
433
- import { BaseChannel } from '@rlanz/socket'
434
- import MessageService from '#services/message_service'
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
- @inject()
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
- constructor(private messages: MessageService) {
441
- super()
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
- The event name comes from `@onMessage('event')`, the handler's second parameter becomes the client
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
- The Assembler hook generates an explicit `AppSocket` registry. Pass it once to the browser client:
245
+ ### Generated client contracts
453
246
 
454
- ```ts
455
- import { Socket } from '@rlanz/socket/client'
456
- import type { AppSocket } from '#generated/socket'
247
+ The Assembler hook writes:
457
248
 
458
- const socket = new Socket<AppSocket>()
459
- const channel = socket.channel('chat/:roomId', { roomId: 'general' })
249
+ - `.adonisjs/client/socket.ts` for browser types
250
+ - `.adonisjs/server/socket_channels.ts` for runtime channel discovery
460
251
 
461
- channel.listen('chat:message', (message) => console.log(message.id))
462
- channel.send('chat:ping')
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
- const message = await channel.sendWithAck('chat:send', { text: 'Hello' })
465
- console.log(message.id)
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 generated registry is a map keyed by each declared channel pattern. The client selects that
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
- Client code can also extract event payloads without constructing a channel first:
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
- ```ts
477
- import type { ChannelBroadcastPayload, ChannelClientEventPayload } from '@rlanz/socket/client'
478
- import type { AppSocket } from '#generated/socket'
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
- type IncomingMessage = ChannelBroadcastPayload<AppSocket, 'chat/:roomId', 'chat:message'>
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
- The scanner recognizes default-exported classes that directly extend `BaseChannel`, direct
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
- Generated matching supports literals, required parameters, a final optional parameter, and a final
535
- wildcard. Dynamic or unsupported patterns are omitted. Plain dynamic channel names, ambiguous
536
- matches, and unmatched literals are rejected at compile time; the browser client must receive the
537
- generated registry as `Socket<AppSocket>`. Generation provides compile-time types only and does not
538
- validate runtime payloads. Whispers intentionally remain caller-generic because they are
539
- peer-to-peer. Runtime channel files always come from the generated server manifest.
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
- Middlewares run before subscription. They may be functions, objects with a `handle` method, or
544
- classes. Middleware classes are resolved through the AdonisJS container for every subscription, so
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,147 +361,173 @@ 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 intentionally exposed to clients. Unexpected middleware,
587
- join, and message-handler errors are logged server-side and replaced with a generic protocol error,
588
- so internal exception details do not leak. Use `SocketResponseError` only for safe, user-facing
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
- ### Presence
370
+ ## Exchange messages
592
371
 
593
- Enable presence with `static options = { presence: true }` and implement `getPresenceInfo`.
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 type { AuthenticatedSocket, PresenceInfo, PresenceMember } from '@rlanz/socket/types'
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
- 'room:member_joined': PresenceMember
602
- 'room:member_left': PresenceMember
382
+ 'chat:message': { user: User | undefined; body: string }
603
383
  }
604
384
 
605
- export default class RoomChannel extends BaseChannel<User, ServerEvents> {
606
- static pattern = 'rooms/:roomId'
607
- static options = { presence: true }
385
+ export default class ChatChannel extends BaseChannel<User, ServerEvents> {
386
+ static pattern = 'chat/:roomId'
608
387
 
609
- getPresenceInfo(socket: AuthenticatedSocket<User>): PresenceInfo {
610
- const user = socket.getUserOrFail()
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
- async onMemberJoin(socket: AuthenticatedSocket<User>, member: PresenceMember) {
621
- this.broadcastExcept(socket.id, 'room:member_joined', member)
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
- async onMemberLeave(socket: AuthenticatedSocket<User>, member: PresenceMember) {
625
- this.broadcast('room:member_left', member)
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
- The `id` identifies one member across multiple sockets or browser tabs. The optional `data` object
631
- defines the channel-specific public fields exposed on that member. Snapshots and member hooks flatten
632
- these values into `{ id, ...data, joinedAt }`.
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
- Custom presence fields are not normalized or recursively validated. WebSocket and distributed
635
- transport serialization may transform them, so use JSON-compatible values when local and distributed
636
- snapshots must have the same shape.
452
+ ```ts
453
+ channel.listenForWhisper<{ typing: boolean }>('typing', ({ typing }) => {
454
+ console.log('typing:', typing)
455
+ })
637
456
 
638
- Presence is exposed per user ID: multiple sockets or browser tabs for the same ID remain separate
639
- broadcast destinations but produce one `users` entry and contribute one to `count`. `onMemberJoin`
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
- Presence snapshots are validated for JSON serialization before join hooks run. Hooks can still
649
- perform arbitrary external side effects and are not database transactions; keep them idempotent so
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
- ## Channel Messages
463
+ ## Presence
653
464
 
654
- Incoming client messages are handled through the `@onMessage` decorator.
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 { onMessage } from '@rlanz/socket/decorators'
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
- 'chat:message': { user: User | undefined; body: string }
474
+ 'room:member_joined': PresenceMember
475
+ 'room:member_left': PresenceMember
665
476
  }
666
477
 
667
- export default class ChatChannel extends BaseChannel<User, ServerEvents> {
668
- static pattern = 'chat/:roomId'
478
+ export default class RoomChannel extends BaseChannel<User, ServerEvents> {
479
+ static pattern = 'rooms/:roomId'
480
+ static options = { presence: true }
669
481
 
670
- @onMessage('chat:send')
671
- async sendMessage(socket: AuthenticatedSocket<User>, data: { body: string }) {
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 { delivered: true }
485
+ return {
486
+ id: user.id,
487
+ data: { name: user.name },
488
+ }
678
489
  }
679
- }
680
- ```
681
490
 
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
-
687
- ### Client-to-client events
491
+ async onMemberJoin(socket: AuthenticatedSocket<User>, member: PresenceMember) {
492
+ this.broadcastExcept(socket.id, 'room:member_joined', member)
493
+ }
688
494
 
689
- Use `whisper()` for ephemeral events that should be relayed to the other members of the same
690
- channel without writing a channel message handler. Whispers are only accepted from subscribed
691
- sockets and are delivered to everyone else on the channel.
495
+ async onMemberLeave(_socket: AuthenticatedSocket<User>, member: PresenceMember) {
496
+ this.broadcast('room:member_left', member)
497
+ }
498
+ }
499
+ ```
692
500
 
693
- ```ts
694
- channel.listenForWhisper('typing', (payload) => {
695
- console.log('typing status', payload)
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
- channel.whisper('typing', {
699
- typing: true,
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
- Whispered events are delivered under an internal `client:` prefix, so they cannot impersonate
704
- server-emitted channel events.
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
- ## Client
513
+ ## Use the browser client
707
514
 
708
- Import the browser client from `@rlanz/socket/client`.
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: 'http://localhost:3333',
522
+ url: 'https://app.example.com',
716
523
  path: '/socket',
717
524
  autoReconnect: true,
718
525
  reconnectDelay: 250,
719
526
  reconnectMaxDelay: 5000,
527
+ onResubscribeError({ channel, error }) {
528
+ if (error.message === 'Session expired') return
529
+ console.error(`Could not resubscribe to ${channel}`, error)
530
+ },
720
531
  })
721
532
 
722
533
  socket.onStateChange((state) => {
@@ -730,7 +541,7 @@ socket.on('connect', () => {
730
541
  await socket.connect()
731
542
  ```
732
543
 
733
- ### Subscribe, Listen, Send
544
+ Subscribe before sending channel events.
734
545
 
735
546
  ```ts
736
547
  const channel = socket.channel('chat/:roomId', { roomId: 'general' })
@@ -739,22 +550,13 @@ channel
739
550
  .here((users) => console.log('present users', users))
740
551
  .joining((user) => console.log('joined', user))
741
552
  .leaving((user) => console.log('left', user))
742
- .listen('chat:message', (message) => {
743
- console.log('new message', message)
744
- })
745
- .listenForWhisper('typing', (payload) => {
746
- console.log('typing', payload)
747
- })
553
+ .listen('chat:message', (message) => console.log(message))
554
+ .listenForWhisper('typing', (payload) => console.log(payload))
748
555
 
749
556
  await channel.subscribe()
750
557
 
751
- channel.whisper('typing', { typing: true })
752
-
753
- const ack = await channel.sendWithAck('chat:send', {
754
- body: 'Hello from the client',
755
- })
756
-
757
- console.log(ack)
558
+ channel.send('chat:send', { body: 'No acknowledgement needed' })
559
+ const ack = await channel.sendWithAck('chat:send', { body: 'Wait for the server' })
758
560
 
759
561
  channel.stopListening('chat:message')
760
562
  channel.stopListeningForWhisper('typing')
@@ -763,19 +565,20 @@ await socket.leave(channel.name)
763
565
  socket.disconnect()
764
566
  ```
765
567
 
766
- `subscribe()` and `unsubscribe()` accept an optional `{ timeout }` in milliseconds.
568
+ `subscribe()` and `unsubscribe()` accept `{ timeout }` in milliseconds. The default is `5000`.
767
569
 
768
- The client reconnects automatically by default. `reconnectDelay` is the initial delay in
769
- milliseconds, and each failed retry doubles the delay until `reconnectMaxDelay` is reached.
770
- Set `autoReconnect: false` to disable reconnect attempts.
570
+ Automatic reconnection starts at `250ms` and doubles up to `5s`. After reconnecting, the client
571
+ subscribes again to desired channels. Pending acknowledgements fail and authentication and channel
572
+ middleware run again. Events sent while disconnected are not replayed. A rejected automatic
573
+ subscription calls `onResubscribeError` with the channel name and error. The client does not report
574
+ this protocol outcome as a global JavaScript error.
771
575
 
772
- ### React and Vue
576
+ Set `autoReconnect: false` to disable retries. A deliberate server disconnect is terminal by
577
+ default. Use `shouldReconnect(closeEvent)` to override the decision for any close code or reason.
773
578
 
774
- Framework adapters keep the generated `AppSocket` type at one application-level factory. Channel
775
- names, client methods, server events, and payloads then remain inferred without repeating the
776
- generic in every component.
579
+ ### React
777
580
 
778
- React:
581
+ Create one typed set of hooks for the application.
779
582
 
780
583
  ```tsx
781
584
  // src/socket.ts
@@ -786,26 +589,31 @@ import type { AppSocket } from '#generated/socket'
786
589
  export const socket = new Socket<AppSocket>()
787
590
  export const { SocketProvider, useChannel, useChannelEvent, useSocketState } =
788
591
  createSocketHooks<AppSocket>()
592
+ ```
789
593
 
790
- // app.tsx
791
- <SocketProvider socket={socket} owned>
792
- <Chat />
793
- </SocketProvider>
594
+ ```tsx
595
+ function App() {
596
+ return (
597
+ <SocketProvider socket={socket} owned>
598
+ <Chat />
599
+ </SocketProvider>
600
+ )
601
+ }
794
602
 
795
603
  function Chat() {
796
604
  const channel = useChannel('chat/:roomId', { roomId: 'general' })
797
- useChannelEvent(
798
- 'chat/:roomId',
799
- { roomId: 'general' },
800
- 'chat:message',
801
- (message) => console.log(message.id)
605
+
606
+ useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) =>
607
+ console.log(message.id)
802
608
  )
803
609
 
804
610
  return <button onClick={() => channel?.send('chat:send', { text: 'Hello' })}>Send</button>
805
611
  }
806
612
  ```
807
613
 
808
- Vue:
614
+ ### Vue
615
+
616
+ Create one typed set of composables for the application.
809
617
 
810
618
  ```ts
811
619
  // src/socket.ts
@@ -816,51 +624,249 @@ import type { AppSocket } from '#generated/socket'
816
624
  export const socket = new Socket<AppSocket>()
817
625
  export const { provideSocket, useChannel, useChannelEvent, useSocketState } =
818
626
  createSocketComposables<AppSocket>()
627
+ ```
819
628
 
820
- // In the root component setup
629
+ ```ts
630
+ // Root component
821
631
  provideSocket(socket, { owned: true })
822
632
 
823
- // In a descendant component setup
633
+ // Descendant component
824
634
  const channel = useChannel('chat/:roomId', { roomId: 'general' })
825
- useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) =>
826
- console.log(message.id)
827
- )
828
635
 
829
- function send() {
830
- channel.value?.send('chat:send', { text: 'Hello' })
831
- }
636
+ useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) => {
637
+ console.log(message.id)
638
+ })
832
639
  ```
833
640
 
834
- Adapters acquire and share channel subscriptions, attach listeners before subscribing, and release
835
- them when the component or scope is disposed. React returns `null` until its passive effect acquires
836
- the channel; Vue exposes a nullable shallow ref. Multiple consumers of the same channel share one
837
- subscription, including across development
838
- remounts, without removing listeners registered through the direct client API.
641
+ The adapters share one subscription between consumers of the same channel and release it after the
642
+ last consumer unmounts. React returns `null` until its effect acquires the channel. Vue returns a
643
+ nullable shallow ref.
839
644
 
840
- Sockets are borrowed by default: the adapter neither connects nor disconnects them. Pass `owned` to
841
- `SocketProvider` or `{ owned: true }` to `provideSocket` only when that framework root exclusively
842
- owns the socket lifecycle. Otherwise call `socket.connect()` and `socket.disconnect()` in
843
- application-owned lifecycle code.
645
+ Adapters borrow the socket by default and do not connect or disconnect it. Pass `owned` to
646
+ `SocketProvider` or `{ owned: true }` to `provideSocket` only when that framework root owns the
647
+ socket lifecycle.
844
648
 
845
- ## Package Exports
649
+ ## Test broadcasts
650
+
651
+ `socket.fake()` captures outgoing events without writing to WebSocket clients or the distributed
652
+ transport.
846
653
 
847
654
  ```ts
848
- import { BaseChannel, SocketResponseError, defineConfig } from '@rlanz/socket'
849
- import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
850
- import SocketProvider from '@rlanz/socket/provider'
655
+ import { test } from '@japa/runner'
851
656
  import socket from '@rlanz/socket/services/main'
852
- import { onMessage } from '@rlanz/socket/decorators'
657
+
658
+ test.group('Notifications', (group) => {
659
+ group.each.teardown(() => socket.restore())
660
+
661
+ test('broadcasts a notification', async ({ client }) => {
662
+ const fake = socket.fake()
663
+
664
+ await client.post('/notifications').json({ message: 'Hello' })
665
+
666
+ fake.assertBroadcasted('notification:created', {
667
+ data: { message: 'Hello' },
668
+ })
669
+ })
670
+ })
671
+ ```
672
+
673
+ Use `using fake = socket.fake()` to restore the real service when the scope exits.
674
+
675
+ ```ts
676
+ fake.assertBroadcasted('maintenance', { data: { active: true } })
677
+ fake.assertNotBroadcasted('deploy:started')
678
+
679
+ fake.assertEmittedTo('chat/general', 'chat:message', {
680
+ data: (data) => data.text === 'Hello',
681
+ })
682
+ fake.assertNotEmittedTo('chat/general', 'chat:typing')
683
+
684
+ fake.assertCount(2, { target: 'channel', channel: 'chat/general' })
685
+ fake.assertCount(0)
686
+ ```
687
+
688
+ ## Run multiple instances
689
+
690
+ Without a transport, subscriptions, broadcasts, and presence stay inside one application instance.
691
+ Configure an `@boringnode/bus` transport when multiple instances must exchange events.
692
+
693
+ Install the dependency required by your transport. For Redis:
694
+
695
+ ```sh
696
+ yarn add ioredis
697
+ ```
698
+
699
+ ```ts
700
+ // config/socket.ts
701
+ import { redis } from '@boringnode/bus/transports/redis'
702
+ import { defineConfig } from '@rlanz/socket'
703
+
704
+ export default defineConfig({
705
+ transport: {
706
+ driver: redis({
707
+ host: '127.0.0.1',
708
+ port: 6379,
709
+ }),
710
+ channel: 'socket::broadcast',
711
+ presenceTimeout: '100ms',
712
+ retryQueue: {
713
+ maxSize: 1000,
714
+ },
715
+ },
716
+ })
717
+ ```
718
+
719
+ All instances in one deployment must use a compatible transport and the same bus channel. The
720
+ default channel is `socket::broadcast`.
721
+
722
+ Broadcasts are delivered locally and published to the transport. Publication does not wait for
723
+ remote delivery. Failed publications enter a bounded in-memory retry queue. The queue holds `1000`
724
+ entries by default, removes duplicate messages, and drops the oldest entry when full. Configure
725
+ `transport.retryQueue` to change these rules or disable retries. Setting `maxSize: null` creates an
726
+ unbounded queue. A transport reconnect can deliver queued events after newer events.
727
+
728
+ Presence uses the transport to collect snapshots. `presenceTimeout`, which defaults to `100ms`, sets
729
+ how long an instance waits for replies.
730
+
731
+ The transport does not make events durable. The package does not persist messages, acknowledge
732
+ recipients, or recover events after a process restart. Persist application state separately when a
733
+ client must recover missed events.
734
+
735
+ ## Production
736
+
737
+ The package attaches to the AdonisJS HTTP server. HTTP and WebSocket traffic share the same process,
738
+ listener, event loop, memory, and failure domain. A process failure disconnects every socket on that
739
+ instance.
740
+
741
+ ### Reverse proxy
742
+
743
+ Configure the proxy or load balancer to:
744
+
745
+ - route `websocket.path` to the AdonisJS HTTP listener
746
+ - forward HTTP/1.1 upgrades with `Upgrade` and `Connection: upgrade`
747
+ - use `wss://` publicly and terminate TLS safely
748
+ - preserve a validated public `Host`
749
+ - replace `X-Forwarded-Proto` with the trusted public protocol
750
+ - keep upgraded connections attached to their original instance
751
+ - set the idle timeout above the heartbeat cadence
752
+ - prevent direct access to the upstream
753
+
754
+ Sticky sessions are not required and do not replace a shared transport.
755
+
756
+ ### Delivery guarantees
757
+
758
+ Socket delivery is temporary and best-effort:
759
+
760
+ - reconnecting creates a new socket session
761
+ - pending acknowledgements fail on disconnect
762
+ - events sent while disconnected are not replayed
763
+ - `sendWithAck()` confirms that the server handler returned, not that a broadcast reached a client
764
+ - transport publication does not confirm remote delivery
765
+ - distributed presence can be incomplete during failures or concurrent changes
766
+
767
+ Applications that need durable notifications or at-least-once processing must store events or state
768
+ outside this package. Define application-level event IDs, cursors, deduplication, acknowledgements,
769
+ and replay where needed.
770
+
771
+ ### Shutdown and resource limits
772
+
773
+ During shutdown, the service becomes unready, rejects new upgrades, closes existing sockets, runs
774
+ subscription and disconnect hooks, and then disconnects the bus. It does not drain established
775
+ sockets or wait for pending client acknowledgements.
776
+
777
+ After `shutdownTimeout`, the service releases its internal socket and subscription state. JavaScript
778
+ handlers that ignore cancellation can continue running.
779
+
780
+ Remove an instance from new traffic before a rolling restart when your platform allows it. Give the
781
+ process enough termination time to run hooks and close the bus.
782
+
783
+ There is no built-in total connection limit. Each connection consumes a file descriptor, memory,
784
+ heartbeat state, message queues, and channel subscriptions. Set memory and file descriptor limits,
785
+ cap connections at the edge, and load test realistic connection counts, payloads, message rates, and
786
+ broadcast fan-out.
787
+
788
+ Slow consumers are disconnected when `maxBufferedAmount` is exceeded. Oversized outbound messages
789
+ are rejected rather than buffered or retried.
790
+
791
+ ### Production checklist
792
+
793
+ - [ ] Forward WebSocket upgrades on the configured path.
794
+ - [ ] Preserve `Host` and replace `X-Forwarded-Proto` at the proxy.
795
+ - [ ] Configure exact browser origins and upgrade authentication.
796
+ - [ ] Set and test the heartbeat and proxy idle timeout.
797
+ - [ ] Configure one shared transport for every related instance.
798
+ - [ ] Make clients tolerate reconnects, repeated subscriptions, duplicates, and missed events.
799
+ - [ ] Store and replay application-critical events outside the socket transport.
800
+ - [ ] Tune payload, rate, queue, subscription, connection, memory, and file descriptor limits.
801
+ - [ ] Add readiness, liveness, logs, metrics, and transport monitoring.
802
+ - [ ] Allow enough shutdown time for hooks and bus disconnection.
803
+
804
+ ## Health checks
805
+
806
+ Add `SocketHealthCheck` to the application readiness checks.
807
+
808
+ ```ts
809
+ // start/health.ts
810
+ import { DiskSpaceCheck, HealthChecks, MemoryHeapCheck } from '@adonisjs/core/health'
853
811
  import { SocketHealthCheck } from '@rlanz/socket/health_check'
812
+ import socket from '@rlanz/socket/services/main'
813
+
814
+ export const healthChecks = new HealthChecks().register([
815
+ new DiskSpaceCheck(),
816
+ new MemoryHeapCheck(),
817
+ new SocketHealthCheck(socket),
818
+ ])
819
+ ```
820
+
821
+ The check reports `ok` after the WebSocket service boots. It reports `error` while the service is
822
+ starting, stopping, stopped, or failed. Metadata includes local connection and channel counts.
823
+
824
+ This check does not probe the transport, remote instances, or end-to-end delivery. Monitor those
825
+ separately and keep a process liveness check so a readiness failure does not cause a restart loop.
826
+
827
+ ## OpenTelemetry
828
+
829
+ The package emits `diagnostics_channel` events and provides optional OpenTelemetry instrumentation.
830
+
831
+ ```ts
832
+ import { registerInstrumentations } from '@opentelemetry/instrumentation'
854
833
  import { SocketInstrumentation } from '@rlanz/socket/otel'
855
- import { SocketFake } from '@rlanz/socket/testing'
856
- import { Socket } from '@rlanz/socket/client'
834
+
835
+ const unregister = registerInstrumentations({
836
+ instrumentations: [new SocketInstrumentation()],
837
+ })
838
+ ```
839
+
840
+ You can pass the same instrumentation to an OpenTelemetry `NodeSDK` or an integration such as
841
+ `@adonisjs/otel`. Registration enables it. Call `unregister()` only when removing a standalone
842
+ registration.
843
+
844
+ The instrumentation creates spans for connections, disconnections, subscriptions,
845
+ unsubscriptions, incoming channel messages, and broadcast delivery. It records counters for active
846
+ connections, active subscriptions, received messages, broadcasts, and local deliveries.
847
+
848
+ Also monitor event-loop delay, memory, CPU, open file descriptors, proxy upgrade failures, reconnect
849
+ rates, and transport health. A broadcast delivery counter records a write to a local `ws` object. It
850
+ does not confirm that the application received the event.
851
+
852
+ ## Package exports
853
+
854
+ ```ts
855
+ import { BaseChannel, SocketResponseError, defineConfig } from '@rlanz/socket'
857
856
  import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
857
+ import { Socket } from '@rlanz/socket/client'
858
858
  import { createSocketHooks } from '@rlanz/socket/client/react'
859
859
  import { createSocketComposables } from '@rlanz/socket/client/vue'
860
- import type { SocketOptions, PresenceData } from '@rlanz/socket/client/types'
860
+ import { onMessage } from '@rlanz/socket/decorators'
861
+ import { SocketHealthCheck } from '@rlanz/socket/health_check'
862
+ import { SocketInstrumentation } from '@rlanz/socket/otel'
863
+ import socket from '@rlanz/socket/services/main'
864
+ import { SocketFake } from '@rlanz/socket/testing'
865
+ import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
866
+ import type { PresenceData, SocketOptions } from '@rlanz/socket/client/types'
861
867
  ```
862
868
 
863
- Available exports:
869
+ Available entry points:
864
870
 
865
871
  - `@rlanz/socket`
866
872
  - `@rlanz/socket/provider`