@rlanz/socket 0.0.1-6 → 0.0.1-8

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 (63) hide show
  1. package/README.md +186 -147
  2. package/build/{types-DiYaFvgi.d.ts → base_channel-DPOnqned.d.ts} +185 -171
  3. package/build/chunk-6PJ4ALJD.js +115 -0
  4. package/build/chunk-6PJ4ALJD.js.map +1 -0
  5. package/build/chunk-E6ILCRL3.js +182 -0
  6. package/build/chunk-E6ILCRL3.js.map +1 -0
  7. package/build/{chunk-SHH6U4CI.js → chunk-G75J233Z.js} +1 -1
  8. package/build/chunk-G75J233Z.js.map +1 -0
  9. package/build/{chunk-B4Y3TNDI.js → chunk-IUQ3GYZZ.js} +5 -24
  10. package/build/chunk-IUQ3GYZZ.js.map +1 -0
  11. package/build/chunk-K243S3QP.js +131 -0
  12. package/build/chunk-K243S3QP.js.map +1 -0
  13. package/build/chunk-QNOOQXYY.js +899 -0
  14. package/build/chunk-QNOOQXYY.js.map +1 -0
  15. package/build/{chunk-FVPY6HZW.js → chunk-YVC7IMDN.js} +21 -5
  16. package/build/chunk-YVC7IMDN.js.map +1 -0
  17. package/build/framework-B8pJ4cCA.d.ts +13 -0
  18. package/build/index-DjBYUhFR.d.ts +150 -0
  19. package/build/index.d.ts +23 -12
  20. package/build/index.js +12 -14
  21. package/build/index.js.map +1 -1
  22. package/build/providers/socket_provider.d.ts +2 -2
  23. package/build/providers/socket_provider.js +478 -215
  24. package/build/providers/socket_provider.js.map +1 -1
  25. package/build/services/socket.d.ts +2 -2
  26. package/build/{socket_service-D3jKrleE.d.ts → socket_service-BcC9ASCE.d.ts} +8 -7
  27. package/build/src/assembler_hook.js +33 -59
  28. package/build/src/assembler_hook.js.map +1 -1
  29. package/build/src/client/index.d.ts +3 -147
  30. package/build/src/client/index.js +4 -837
  31. package/build/src/client/index.js.map +1 -1
  32. package/build/src/client/react.d.ts +13 -5
  33. package/build/src/client/react.js +27 -11
  34. package/build/src/client/react.js.map +1 -1
  35. package/build/src/client/types.d.ts +30 -36
  36. package/build/src/client/vue.d.ts +17 -6
  37. package/build/src/client/vue.js +31 -13
  38. package/build/src/client/vue.js.map +1 -1
  39. package/build/src/decorators.d.ts +3 -2
  40. package/build/src/decorators.js +1 -1
  41. package/build/src/health_check.d.ts +2 -2
  42. package/build/src/health_check.js +14 -3
  43. package/build/src/health_check.js.map +1 -1
  44. package/build/src/otel.js +0 -1
  45. package/build/src/otel.js.map +1 -1
  46. package/build/src/testing.d.ts +3 -12
  47. package/build/src/testing.js +1 -1
  48. package/build/src/types/tracing_channels.d.ts +1 -2
  49. package/build/src/types.d.ts +1 -1
  50. package/package.json +3 -9
  51. package/build/chunk-B4Y3TNDI.js.map +0 -1
  52. package/build/chunk-FVPY6HZW.js.map +0 -1
  53. package/build/chunk-HK7Z65DA.js +0 -19
  54. package/build/chunk-HK7Z65DA.js.map +0 -1
  55. package/build/chunk-SFAY2ZA4.js +0 -28
  56. package/build/chunk-SFAY2ZA4.js.map +0 -1
  57. package/build/chunk-SHH6U4CI.js.map +0 -1
  58. package/build/chunk-YAX5EHHB.js +0 -453
  59. package/build/chunk-YAX5EHHB.js.map +0 -1
  60. package/build/framework-DuW6zpPk.d.ts +0 -14
  61. package/build/src/client/svelte.d.ts +0 -23
  62. package/build/src/client/svelte.js +0 -82
  63. package/build/src/client/svelte.js.map +0 -1
package/README.md CHANGED
@@ -8,12 +8,24 @@ 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
+ ],
17
29
  hooks: {
18
30
  init: [() => import('@rlanz/socket/assembler_hook')],
19
31
  },
@@ -21,10 +33,11 @@ export default defineConfig({
21
33
  ```
22
34
 
23
35
  The Assembler hook generates both the application registry used for end-to-end client types and the
24
- server manifest consumed by the provider. They come from the same channel file list, so runtime
25
- routing cannot drift from the generated client contract. Keep the standard AdonisJS package import
26
- mapping `#generated/*` pointed at `./.adonisjs/server/*.js`; the provider loads
27
- `#generated/socket_channels` through that convention.
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.
28
41
 
29
42
  ## Configuration
30
43
 
@@ -37,7 +50,9 @@ import { defineConfig } from '@rlanz/socket'
37
50
  export default defineConfig({
38
51
  websocket: {
39
52
  path: '/socket',
40
- allowedOrigins: ['https://app.example.com'],
53
+ origin: (origin, ctx) => {
54
+ return ['https://app.example.com', 'https://admin.example.com'].includes(origin)
55
+ },
41
56
  pingInterval: '25s',
42
57
  pingTimeout: '5s',
43
58
  maxBufferedAmount: 16 * 1024 * 1024,
@@ -49,9 +64,12 @@ export default defineConfig({
49
64
  ```
50
65
 
51
66
  - `websocket.path` defaults to `/socket`.
52
- - Browser upgrades are restricted to the request's own origin by default. Use
53
- `websocket.allowedOrigins` to add exact `http://` or `https://` origins. Clients that do not send
54
- an `Origin` header remain supported.
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.
55
73
  - `websocket.middleware` runs AdonisJS HTTP middleware for the WebSocket upgrade request.
56
74
  - `websocket.pingInterval` sends a WebSocket ping every configured duration.
57
75
  - `websocket.pingTimeout` closes connections that do not answer in time.
@@ -79,7 +97,7 @@ Fetch CORS response checks to WebSockets. The origin check therefore runs before
79
97
  and authentication, which protects cookie- or session-authenticated sockets from cross-site
80
98
  WebSocket hijacking. A reverse proxy must preserve the public `Host` and overwrite
81
99
  `X-Forwarded-Proto` with the trusted public protocol. If it rewrites either value, add the public
82
- origin explicitly to `websocket.allowedOrigins` instead of relying on implicit same-origin
100
+ origin explicitly with `websocket.origin` instead of relying on implicit same-origin
83
101
  detection.
84
102
 
85
103
  ### Authentication
@@ -101,7 +119,7 @@ import { authenticateWithAdonisAuth, defineConfig } from '@rlanz/socket'
101
119
 
102
120
  type User = { id: string | number; name: string }
103
121
 
104
- export default defineConfig<User>({
122
+ export default defineConfig({
105
123
  websocket: {
106
124
  middleware: [
107
125
  () => import('@adonisjs/session/session_middleware'),
@@ -114,8 +132,10 @@ export default defineConfig<User>({
114
132
  ```
115
133
 
116
134
  Return `false`, `null`, or throw to reject the upgrade with `401 Unauthorized`. The returned user is
117
- available as `socket.user`. Every connected socket retains the initial Adonis HTTP context at
118
- `socket.raw.httpContext`; use `socket.raw.data` for mutable socket-lifetime state.
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.
119
139
 
120
140
  The retained HTTP context describes the initial upgrade handshake. After the `101` response, it is
121
141
  not an active HTTP request: do not write through `httpContext.response` or expect it to remain
@@ -136,6 +156,12 @@ async authenticate({ httpContext }) {
136
156
 
137
157
  Configure an [`@boringnode/bus`](https://github.com/boringnode/bus) transport to synchronize broadcasts across multiple application instances.
138
158
 
159
+ Install the peer dependency required by the selected transport. For Redis:
160
+
161
+ ```sh
162
+ yarn add ioredis
163
+ ```
164
+
139
165
  ```ts
140
166
  // config/socket.ts
141
167
  import { redis } from '@boringnode/bus/transports/redis'
@@ -149,14 +175,22 @@ export default defineConfig({
149
175
  }),
150
176
  channel: 'socket::broadcast',
151
177
  presenceTimeout: '100ms',
178
+ retryQueue: {
179
+ maxSize: 1000,
180
+ },
152
181
  },
153
182
  })
154
183
  ```
155
184
 
156
- 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.
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.
157
186
 
158
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`.
159
188
 
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.
193
+
160
194
  ### Health Checks
161
195
 
162
196
  Register `SocketHealthCheck` inside your AdonisJS readiness checks to report whether the WebSocket service is ready to accept traffic.
@@ -203,22 +237,19 @@ test.group('Notifications', (group) => {
203
237
 
204
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.
205
239
 
206
- Assertion helpers cover global broadcasts, channel events, user events, counts, and negative assertions.
240
+ Five assertion helpers cover global broadcasts, channel events, counts, and negative assertions.
207
241
 
208
242
  ```ts
209
243
  fake.assertBroadcasted('maintenance', { data: { active: true } })
210
- fake.assertNotBroadcasted('maintenance')
244
+ fake.assertNotBroadcasted('deploy:started')
211
245
 
212
246
  fake.assertEmittedTo('chat/general', 'chat:message', {
213
247
  data: (data) => data.text === 'Hello',
214
248
  })
215
249
  fake.assertNotEmittedTo('chat/general', 'chat:typing')
216
250
 
217
- fake.assertEmittedToUser(1, 'notification', { data: { unread: 3 } })
218
- fake.assertNotEmittedToUser(1, 'notification')
219
-
220
- fake.assertEmittedCount(2, { target: 'channel', channel: 'chat/general' })
221
- fake.assertNothingEmitted()
251
+ fake.assertCount(2, { target: 'channel', channel: 'chat/general' })
252
+ fake.assertCount(0)
222
253
  ```
223
254
 
224
255
  ### OpenTelemetry
@@ -258,7 +289,7 @@ At the reverse proxy or load balancer:
258
289
  `X-Forwarded-Proto` value, so client-supplied versions of those headers must not reach the app.
259
290
 
260
291
  Browser origins are not authorized by AdonisJS CORS middleware. Configure exact public origins in
261
- [`websocket.allowedOrigins`](#configuration) when the browser origin differs from the public socket
292
+ [`websocket.origin`](#configuration) when the browser origin differs from the public socket
262
293
  origin or when the proxy rewrites `Host`. Clients without an `Origin` header are accepted, so use
263
294
  upgrade authentication for non-browser clients rather than treating the origin check as
264
295
  authentication.
@@ -267,7 +298,7 @@ authentication.
267
298
 
268
299
  A load balancer may route each new upgrade to any ready instance and must keep that upgraded TCP
269
300
  connection attached to that instance. The package neither implements nor guarantees sticky
270
- sessions. Without a [transport](#horizontal-sync), subscriptions, user rooms, broadcasts, and
301
+ sessions. Without a [transport](#horizontal-sync), subscriptions, broadcasts, and
271
302
  presence are local to one instance; affinity is not a replacement for configuring the transport.
272
303
  All instances in one logical deployment must use a compatible shared transport and the same bus
273
304
  channel.
@@ -282,10 +313,11 @@ large fleet could reconnect simultaneously.
282
313
  ### Transport, presence, and delivery semantics
283
314
 
284
315
  Local broadcasts are sent to currently connected local recipients and are also published to the
285
- configured `@boringnode/bus` transport. Publication is fire-and-forget from the socket API. This
286
- package does not persist bus messages, wait for remote delivery, acknowledge recipients, or replay
287
- missed events; the guarantees of a particular transport do not turn socket delivery into a durable
288
- application queue.
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.
289
321
 
290
322
  Distributed presence is also best-effort and non-durable. A snapshot contains the replies received
291
323
  before `transport.presenceTimeout`; partitions, slow instances, concurrent changes, and restarts can
@@ -311,7 +343,7 @@ Review the limits under [Configuration](#configuration) against real payloads an
311
343
 
312
344
  - enable heartbeat explicitly; it is disabled when neither `pingInterval` nor `pingTimeout` is set;
313
345
  - tune inbound/outbound payload sizes, the per-socket fixed-window message rate, serialized message
314
- queue depth, subscriptions per socket, and channel-name length;
346
+ queue depth, subscriptions per socket, channel-name length, and the transport retry queue;
315
347
  - treat rate and queue limits as per-connection safeguards, not perimeter abuse prevention; enforce
316
348
  aggregate connection and request limits at the proxy or application boundary; and
317
349
  - tune `maxBufferedAmount` for available memory. Slow consumers and oversized outbound frames are
@@ -341,7 +373,7 @@ counters count immediate sends to local `ws` objects, not application-level rece
341
373
 
342
374
  - [ ] Proxy upgrades on the configured path with HTTP/1.1 `Upgrade`/`Connection` headers.
343
375
  - [ ] Terminate TLS safely; preserve a validated public `Host` and overwrite `X-Forwarded-Proto`.
344
- - [ ] Configure exact `allowedOrigins` and upgrade authentication; protect the upstream from direct
376
+ - [ ] Configure an exact `origin` policy and upgrade authentication; protect the upstream from direct
345
377
  access.
346
378
  - [ ] Set proxy idle timeouts and explicitly configure/test heartbeat behavior.
347
379
  - [ ] Configure a shared transport and bus channel for every instance that must exchange broadcasts
@@ -359,11 +391,13 @@ counters count immediate sends to local `ws` objects, not application-level rece
359
391
 
360
392
  Channels are discovered from `app/channels/**/*_channel.{ts,js}`. Export a default class extending
361
393
  `BaseChannel`. Client-to-server payloads and acknowledgements are inferred from public handler
362
- methods. The optional second generic declares server-to-client events and defaults to `unknown`.
394
+ methods. The optional second generic declares server-to-client events. When omitted, the channel
395
+ cannot broadcast server events.
363
396
 
364
397
  ```ts
365
398
  // app/channels/chat_channel.ts
366
399
  import { BaseChannel } from '@rlanz/socket'
400
+ import { onMessage } from '@rlanz/socket/decorators'
367
401
  import type { AuthenticatedSocket } from '@rlanz/socket/types'
368
402
 
369
403
  type User = { id: string; name: string }
@@ -376,11 +410,7 @@ type ServerEvents = {
376
410
  export default class ChatChannel extends BaseChannel<User, ServerEvents> {
377
411
  static pattern = 'chat/:roomId'
378
412
 
379
- protected handlers = {
380
- 'chat:send': this.sendMessage,
381
- 'chat:ping': this.ping,
382
- }
383
-
413
+ @onMessage('chat:send')
384
414
  async sendMessage(
385
415
  socket: AuthenticatedSocket<User>,
386
416
  payload: { text: string }
@@ -390,22 +420,35 @@ export default class ChatChannel extends BaseChannel<User, ServerEvents> {
390
420
  return message
391
421
  }
392
422
 
423
+ @onMessage('chat:ping')
393
424
  ping(_socket: AuthenticatedSocket<User>, _payload: undefined): void {}
394
425
  }
395
426
  ```
396
427
 
397
- The event name comes from `handlers` or `@onMessage('event')`, the handler's second parameter becomes
398
- the client payload, and `Awaited<ReturnType<handler>>` becomes its acknowledgement. No duplicate
399
- client event map is required. `ServerEvents` remains explicit because generation does not scan
400
- `broadcast()` calls; it checks `broadcast()` and `broadcastExcept()`. Omitting it preserves the broad
401
- legacy broadcast API:
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:
402
430
 
403
431
  ```ts
404
- export default class ChatChannel extends BaseChannel<User> {
405
- // Server events remain unknown and may be typed by callers when listening.
432
+ import { inject } from '@adonisjs/core'
433
+ import { BaseChannel } from '@rlanz/socket'
434
+ import MessageService from '#services/message_service'
435
+
436
+ @inject()
437
+ export default class ChatChannel extends BaseChannel {
438
+ static pattern = 'chat/:roomId'
439
+
440
+ constructor(private messages: MessageService) {
441
+ super()
442
+ }
406
443
  }
407
444
  ```
408
445
 
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.
451
+
409
452
  The Assembler hook generates an explicit `AppSocket` registry. Pass it once to the browser client:
410
453
 
411
454
  ```ts
@@ -413,7 +456,7 @@ import { Socket } from '@rlanz/socket/client'
413
456
  import type { AppSocket } from '#generated/socket'
414
457
 
415
458
  const socket = new Socket<AppSocket>()
416
- const channel = socket.channel('chat/general')
459
+ const channel = socket.channel('chat/:roomId', { roomId: 'general' })
417
460
 
418
461
  channel.listen('chat:message', (message) => console.log(message.id))
419
462
  channel.send('chat:ping')
@@ -422,15 +465,37 @@ const message = await channel.sendWithAck('chat:send', { text: 'Hello' })
422
465
  console.log(message.id)
423
466
  ```
424
467
 
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.
473
+
474
+ Client code can also extract event payloads without constructing a channel first:
475
+
476
+ ```ts
477
+ import type { ChannelBroadcastPayload, ChannelClientEventPayload } from '@rlanz/socket/client'
478
+ import type { AppSocket } from '#generated/socket'
479
+
480
+ type IncomingMessage = ChannelBroadcastPayload<AppSocket, 'chat/:roomId', 'chat:message'>
481
+ type OutgoingMessage = ChannelClientEventPayload<AppSocket, 'chat/:roomId', 'chat:send'>
482
+ ```
483
+
425
484
  The scanner recognizes default-exported classes that directly extend `BaseChannel`, direct
426
- string-literal `static pattern` values, and handlers declared as `'event': this.publicMethod`. It also
427
- recognizes `@onMessage('event')` imported from `@rlanz/socket/decorators`. Handler methods must be
485
+ string-literal `static pattern` values, and `@onMessage('event')` imported from
486
+ `@rlanz/socket/decorators`. Handler methods must be
428
487
  named, public instance methods so the generated registry can reference their parameter and return
429
- types. Abstract channels, inline/computed handlers, private/protected/static methods, rest parameters,
430
- and required parameters after the payload fail generation with a diagnostic. A handler may accept no
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
431
490
  parameters, only the socket, a required payload, or an optional payload. Intermediate channel
432
- inheritance is deliberately omitted from generated contracts and makes the client registry
433
- uncertain; runtime channel inheritance remains valid.
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.
434
499
 
435
500
  The default Assembler hook discovers `app/channels/**/*_channel.{ts,js}` and generates both
436
501
  `.adonisjs/client/socket.ts` and `.adonisjs/server/socket_channels.ts`. Configure the frontend
@@ -454,7 +519,12 @@ export default generateSocketRegistry({
454
519
  ```ts
455
520
  // adonisrc.ts
456
521
  export default defineConfig({
457
- providers: [() => import('@rlanz/socket/provider')],
522
+ providers: [
523
+ {
524
+ file: () => import('@rlanz/socket/provider'),
525
+ environment: ['web'],
526
+ },
527
+ ],
458
528
  hooks: {
459
529
  init: [() => import('./hooks/socket.js')],
460
530
  },
@@ -462,59 +532,54 @@ export default defineConfig({
462
532
  ```
463
533
 
464
534
  Generated matching supports literals, required parameters, a final optional parameter, and a final
465
- wildcard. Dynamic or unsupported patterns are omitted and mark the registry uncertain, causing
466
- `Socket<AppSocket>` to fall back conservatively to untyped channels. Plain dynamic channel names and
467
- ambiguous matches also remain untyped; unmatched concrete literals are rejected when the registry is
468
- certain. Generation provides compile-time types only and does not validate runtime payloads.
469
- Whispers intentionally remain caller-generic because they are peer-to-peer. Runtime channel files
470
- always come from the generated server manifest; the former `channels.patterns` runtime setting is no
471
- longer supported and is rejected to prevent a second discovery source.
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.
540
+
541
+ ### Middleware
472
542
 
473
- Legacy untyped channels remain supported without the generated generic:
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.
474
547
 
475
548
  ```ts
476
- // app/channels/chat_channel.ts
477
549
  import { BaseChannel } from '@rlanz/socket'
478
- import type { AuthenticatedSocket } from '@rlanz/socket/types'
550
+ import type { MiddlewareContext } from '@rlanz/socket/types'
479
551
 
480
552
  type User = { id: string; name: string }
481
553
 
554
+ async function auth(ctx: MiddlewareContext<User>, next: () => Promise<void>) {
555
+ ctx.socket.getUserOrFail()
556
+ await next()
557
+ }
558
+
482
559
  export default class ChatChannel extends BaseChannel<User> {
483
560
  static pattern = 'chat/:roomId'
484
-
485
- async onJoin(socket: AuthenticatedSocket<User>, roomId: string) {
486
- this.broadcast('chat:system', {
487
- message: `${socket.user?.name ?? 'Someone'} joined ${roomId}`,
488
- })
489
- }
490
-
491
- async onLeave(socket: AuthenticatedSocket<User>, roomId: string) {
492
- this.broadcastExcept(socket.id, 'chat:system', {
493
- message: `${socket.user?.name ?? 'Someone'} left ${roomId}`,
494
- })
495
- }
561
+ static middlewares = [auth]
496
562
  }
497
563
  ```
498
564
 
499
- ### Middleware
500
-
501
- Middlewares run before subscription. They may be functions or objects with a `handle` method.
502
-
503
565
  ```ts
504
- import { BaseChannel } from '@rlanz/socket'
566
+ import { inject } from '@adonisjs/core'
505
567
  import type { MiddlewareContext } from '@rlanz/socket/types'
568
+ import RoomService from '#services/room_service'
506
569
 
507
- type User = { id: string; name: string }
570
+ @inject()
571
+ class EnsureRoomAccess {
572
+ constructor(private rooms: RoomService) {}
508
573
 
509
- async function auth(ctx: MiddlewareContext<User>, next: () => Promise<void>) {
510
- const user = ctx.socket.getUserOrFail()
511
- await ctx.socket.joinUserRoom(user.id)
512
- await next()
574
+ async handle(ctx: MiddlewareContext<User>, next: () => Promise<void>) {
575
+ await this.rooms.authorize(ctx.socket.getUserOrFail(), ctx.params.roomId)
576
+ await next()
577
+ }
513
578
  }
514
579
 
515
580
  export default class ChatChannel extends BaseChannel<User> {
516
581
  static pattern = 'chat/:roomId'
517
- static middlewares = [auth]
582
+ static middlewares = [EnsureRoomAccess]
518
583
  }
519
584
  ```
520
585
 
@@ -532,8 +597,12 @@ import { BaseChannel } from '@rlanz/socket'
532
597
  import type { AuthenticatedSocket, PresenceInfo, PresenceMember } from '@rlanz/socket/types'
533
598
 
534
599
  type User = { id: string; name: string }
600
+ type ServerEvents = {
601
+ 'room:member_joined': PresenceMember
602
+ 'room:member_left': PresenceMember
603
+ }
535
604
 
536
- export default class RoomChannel extends BaseChannel<User> {
605
+ export default class RoomChannel extends BaseChannel<User, ServerEvents> {
537
606
  static pattern = 'rooms/:roomId'
538
607
  static options = { presence: true }
539
608
 
@@ -582,21 +651,23 @@ an exception from a later hook can be retried safely.
582
651
 
583
652
  ## Channel Messages
584
653
 
585
- Incoming client messages are handled through the `handlers` map, the `@onMessage` decorator, or the `onMessage` fallback.
654
+ Incoming client messages are handled through the `@onMessage` decorator.
655
+ Unknown events receive a negative acknowledgement.
586
656
 
587
657
  ```ts
588
658
  import { BaseChannel } from '@rlanz/socket'
659
+ import { onMessage } from '@rlanz/socket/decorators'
589
660
  import type { AuthenticatedSocket } from '@rlanz/socket/types'
590
661
 
591
662
  type User = { id: string; name: string }
663
+ type ServerEvents = {
664
+ 'chat:message': { user: User | undefined; body: string }
665
+ }
592
666
 
593
- export default class ChatChannel extends BaseChannel<User> {
667
+ export default class ChatChannel extends BaseChannel<User, ServerEvents> {
594
668
  static pattern = 'chat/:roomId'
595
669
 
596
- protected handlers = {
597
- 'chat:send': this.sendMessage,
598
- }
599
-
670
+ @onMessage('chat:send')
600
671
  async sendMessage(socket: AuthenticatedSocket<User>, data: { body: string }) {
601
672
  this.broadcast('chat:message', {
602
673
  user: socket.user,
@@ -605,10 +676,6 @@ export default class ChatChannel extends BaseChannel<User> {
605
676
 
606
677
  return { delivered: true }
607
678
  }
608
-
609
- async onMessage(socket: AuthenticatedSocket<User>, event: string, data: unknown) {
610
- console.log('Unhandled channel message', event, data)
611
- }
612
679
  }
613
680
  ```
614
681
 
@@ -642,8 +709,9 @@ Import the browser client from `@rlanz/socket/client`.
642
709
 
643
710
  ```ts
644
711
  import { Socket } from '@rlanz/socket/client'
712
+ import type { AppSocket } from '#generated/socket'
645
713
 
646
- const socket = new Socket({
714
+ const socket = new Socket<AppSocket>({
647
715
  url: 'http://localhost:3333',
648
716
  path: '/socket',
649
717
  autoReconnect: true,
@@ -665,7 +733,7 @@ await socket.connect()
665
733
  ### Subscribe, Listen, Send
666
734
 
667
735
  ```ts
668
- const channel = socket.channel('chat/general')
736
+ const channel = socket.channel('chat/:roomId', { roomId: 'general' })
669
737
 
670
738
  channel
671
739
  .here((users) => console.log('present users', users))
@@ -691,7 +759,7 @@ console.log(ack)
691
759
  channel.stopListening('chat:message')
692
760
  channel.stopListeningForWhisper('typing')
693
761
  await channel.unsubscribe()
694
- await socket.leave('chat/general')
762
+ await socket.leave(channel.name)
695
763
  socket.disconnect()
696
764
  ```
697
765
 
@@ -701,7 +769,7 @@ The client reconnects automatically by default. `reconnectDelay` is the initial
701
769
  milliseconds, and each failed retry doubles the delay until `reconnectMaxDelay` is reached.
702
770
  Set `autoReconnect: false` to disable reconnect attempts.
703
771
 
704
- ### React, Vue, and Svelte
772
+ ### React and Vue
705
773
 
706
774
  Framework adapters keep the generated `AppSocket` type at one application-level factory. Channel
707
775
  names, client methods, server events, and payloads then remain inferred without repeating the
@@ -725,8 +793,13 @@ export const { SocketProvider, useChannel, useChannelEvent, useSocketState } =
725
793
  </SocketProvider>
726
794
 
727
795
  function Chat() {
728
- const channel = useChannel('chat/general')
729
- useChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
796
+ const channel = useChannel('chat/:roomId', { roomId: 'general' })
797
+ useChannelEvent(
798
+ 'chat/:roomId',
799
+ { roomId: 'general' },
800
+ 'chat:message',
801
+ (message) => console.log(message.id)
802
+ )
730
803
 
731
804
  return <button onClick={() => channel?.send('chat:send', { text: 'Hello' })}>Send</button>
732
805
  }
@@ -748,63 +821,31 @@ export const { provideSocket, useChannel, useChannelEvent, useSocketState } =
748
821
  provideSocket(socket, { owned: true })
749
822
 
750
823
  // In a descendant component setup
751
- const channel = useChannel('chat/general')
752
- useChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
824
+ const channel = useChannel('chat/:roomId', { roomId: 'general' })
825
+ useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) =>
826
+ console.log(message.id)
827
+ )
753
828
 
754
829
  function send() {
755
830
  channel.value?.send('chat:send', { text: 'Hello' })
756
831
  }
757
832
  ```
758
833
 
759
- Svelte:
760
-
761
- ```ts
762
- // src/socket.ts
763
- import { Socket } from '@rlanz/socket/client'
764
- import { createSocketContext } from '@rlanz/socket/client/svelte'
765
- import type { AppSocket } from '#generated/socket'
766
-
767
- export const socket = new Socket<AppSocket>()
768
- export const { setSocket, useSocketState, channel, onChannelEvent } =
769
- createSocketContext<AppSocket>()
770
- ```
771
-
772
- ```svelte
773
- <!-- In the root component -->
774
- <script lang="ts">
775
- import { setSocket, socket } from './socket'
776
-
777
- setSocket(socket, { owned: true })
778
- </script>
779
- ```
780
-
781
- ```svelte
782
- <!-- In a descendant component -->
783
- <script lang="ts">
784
- import { channel, onChannelEvent } from './socket'
785
-
786
- const chat = channel('chat/general')
787
- onChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
788
- </script>
789
-
790
- <button on:click={() => $chat?.send('chat:send', { text: 'Hello' })}>Send</button>
791
- ```
792
-
793
834
  Adapters acquire and share channel subscriptions, attach listeners before subscribing, and release
794
- them when the component, scope, or store consumer is disposed. React returns `null` until its passive
795
- effect acquires the channel; Vue exposes a nullable shallow ref and Svelte exposes a nullable readable
796
- store. Multiple consumers of the same channel share one subscription, including across development
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
797
838
  remounts, without removing listeners registered through the direct client API.
798
839
 
799
840
  Sockets are borrowed by default: the adapter neither connects nor disconnects them. Pass `owned` to
800
- `SocketProvider`, `{ owned: true }` to `provideSocket`, or `{ owned: true }` to `setSocket` only when
801
- that framework root exclusively owns the socket lifecycle. Otherwise call `socket.connect()` and
802
- `socket.disconnect()` in application-owned lifecycle code.
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.
803
844
 
804
845
  ## Package Exports
805
846
 
806
847
  ```ts
807
- import { BaseChannel, ChannelRouter, PresenceManager } from '@rlanz/socket'
848
+ import { BaseChannel, SocketResponseError, defineConfig } from '@rlanz/socket'
808
849
  import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
809
850
  import SocketProvider from '@rlanz/socket/provider'
810
851
  import socket from '@rlanz/socket/services/main'
@@ -812,11 +853,10 @@ import { onMessage } from '@rlanz/socket/decorators'
812
853
  import { SocketHealthCheck } from '@rlanz/socket/health_check'
813
854
  import { SocketInstrumentation } from '@rlanz/socket/otel'
814
855
  import { SocketFake } from '@rlanz/socket/testing'
815
- import { Socket, Channel } from '@rlanz/socket/client'
856
+ import { Socket } from '@rlanz/socket/client'
816
857
  import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
817
858
  import { createSocketHooks } from '@rlanz/socket/client/react'
818
859
  import { createSocketComposables } from '@rlanz/socket/client/vue'
819
- import { createSocketContext } from '@rlanz/socket/client/svelte'
820
860
  import type { SocketOptions, PresenceData } from '@rlanz/socket/client/types'
821
861
  ```
822
862
 
@@ -836,4 +876,3 @@ Available exports:
836
876
  - `@rlanz/socket/client/types`
837
877
  - `@rlanz/socket/client/react`
838
878
  - `@rlanz/socket/client/vue`
839
- - `@rlanz/socket/client/svelte`