@rlanz/socket 0.0.1-0 → 0.0.1-10

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