@rlanz/socket 0.0.1-5 → 0.0.1-7

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 (64) hide show
  1. package/README.md +174 -160
  2. package/build/{types-C7Q36ryo.d.ts → base_channel-Dr40l7CW.d.ts} +178 -169
  3. package/build/chunk-2X6WGCH5.js +120 -0
  4. package/build/chunk-2X6WGCH5.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-E6ILCRL3.js +182 -0
  8. package/build/chunk-E6ILCRL3.js.map +1 -0
  9. package/build/{chunk-SHH6U4CI.js → chunk-G75J233Z.js} +1 -1
  10. package/build/chunk-G75J233Z.js.map +1 -0
  11. package/build/{chunk-B4Y3TNDI.js → chunk-IUQ3GYZZ.js} +5 -24
  12. package/build/chunk-IUQ3GYZZ.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-ANT2E3LT.js → chunk-YVC7IMDN.js} +21 -5
  16. package/build/chunk-YVC7IMDN.js.map +1 -0
  17. package/build/framework-B-4kGmAw.d.ts +13 -0
  18. package/build/index-Fg-tkHhP.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 +474 -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-mybqv1hj.d.ts → socket_service-BYEB8LIN.d.ts} +7 -7
  27. package/build/src/assembler_hook.d.ts +6 -4
  28. package/build/src/assembler_hook.js +49 -63
  29. package/build/src/assembler_hook.js.map +1 -1
  30. package/build/src/client/index.d.ts +3 -147
  31. package/build/src/client/index.js +4 -837
  32. package/build/src/client/index.js.map +1 -1
  33. package/build/src/client/react.d.ts +13 -5
  34. package/build/src/client/react.js +27 -11
  35. package/build/src/client/react.js.map +1 -1
  36. package/build/src/client/types.d.ts +30 -35
  37. package/build/src/client/vue.d.ts +17 -5
  38. package/build/src/client/vue.js +31 -13
  39. package/build/src/client/vue.js.map +1 -1
  40. package/build/src/decorators.d.ts +3 -2
  41. package/build/src/decorators.js +1 -1
  42. package/build/src/health_check.d.ts +2 -2
  43. package/build/src/health_check.js +14 -3
  44. package/build/src/health_check.js.map +1 -1
  45. package/build/src/otel.js +0 -1
  46. package/build/src/otel.js.map +1 -1
  47. package/build/src/testing.d.ts +3 -12
  48. package/build/src/testing.js +1 -1
  49. package/build/src/types/tracing_channels.d.ts +1 -2
  50. package/build/src/types.d.ts +1 -1
  51. package/package.json +2 -9
  52. package/build/chunk-ANT2E3LT.js.map +0 -1
  53. package/build/chunk-B4Y3TNDI.js.map +0 -1
  54. package/build/chunk-HK7Z65DA.js +0 -19
  55. package/build/chunk-HK7Z65DA.js.map +0 -1
  56. package/build/chunk-SFAY2ZA4.js +0 -28
  57. package/build/chunk-SFAY2ZA4.js.map +0 -1
  58. package/build/chunk-SHH6U4CI.js.map +0 -1
  59. package/build/chunk-YAX5EHHB.js +0 -453
  60. package/build/chunk-YAX5EHHB.js.map +0 -1
  61. package/build/framework-CvxgHwbv.d.ts +0 -12
  62. package/build/src/client/svelte.d.ts +0 -22
  63. package/build/src/client/svelte.js +0 -82
  64. 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,39 +465,66 @@ 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
437
502
  project to resolve an alias such as `#generated/socket` to the client file; this frontend mapping is
438
- separate from the AdonisJS server `#generated/*` mapping. Generated channel imports are relative by
439
- default, so an `importAlias` is optional. To customize discovery, replace the package hook in
440
- `adonisrc.ts` with a local hook:
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:
441
507
 
442
508
  ```ts
443
509
  // hooks/socket.ts
444
510
  import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
445
511
 
446
512
  export default generateSocketRegistry({
447
- source: 'realtime',
513
+ source: './app/realtime',
448
514
  glob: ['**/*.ts'],
449
515
  output: '.adonisjs/client/socket.d.ts',
450
- importAlias: '#realtime',
451
516
  })
452
517
  ```
453
518
 
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,56 +532,24 @@ 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.
472
-
473
- Legacy untyped channels remain supported without the generated generic:
474
-
475
- ```ts
476
- // app/channels/chat_channel.ts
477
- import { BaseChannel } from '@rlanz/socket'
478
- import type { AuthenticatedSocket } from '@rlanz/socket/types'
479
-
480
- type User = { id: string; name: string }
481
-
482
- export default class ChatChannel extends BaseChannel<User> {
483
- 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
- }
496
- }
497
- ```
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.
498
540
 
499
541
  ### Middleware
500
542
 
501
543
  Middlewares run before subscription. They may be functions or objects with a `handle` method.
502
544
 
503
545
  ```ts
504
- import { BaseChannel, SocketResponseError } from '@rlanz/socket'
546
+ import { BaseChannel } from '@rlanz/socket'
505
547
  import type { MiddlewareContext } from '@rlanz/socket/types'
506
548
 
507
549
  type User = { id: string; name: string }
508
550
 
509
551
  async function auth(ctx: MiddlewareContext<User>, next: () => Promise<void>) {
510
- if (!ctx.socket.user) {
511
- throw new SocketResponseError('Unauthorized')
512
- }
513
-
514
- await ctx.socket.joinUserRoom(ctx.socket.user.id)
552
+ ctx.socket.getUserOrFail()
515
553
  await next()
516
554
  }
517
555
 
@@ -535,16 +573,22 @@ import { BaseChannel } from '@rlanz/socket'
535
573
  import type { AuthenticatedSocket, PresenceInfo, PresenceMember } from '@rlanz/socket/types'
536
574
 
537
575
  type User = { id: string; name: string }
576
+ type ServerEvents = {
577
+ 'room:member_joined': PresenceMember
578
+ 'room:member_left': PresenceMember
579
+ }
538
580
 
539
- export default class RoomChannel extends BaseChannel<User> {
581
+ export default class RoomChannel extends BaseChannel<User, ServerEvents> {
540
582
  static pattern = 'rooms/:roomId'
541
583
  static options = { presence: true }
542
584
 
543
585
  getPresenceInfo(socket: AuthenticatedSocket<User>): PresenceInfo {
586
+ const user = socket.getUserOrFail()
587
+
544
588
  return {
545
- id: socket.user!.id,
589
+ id: user.id,
546
590
  data: {
547
- name: socket.user!.name,
591
+ name: user.name,
548
592
  },
549
593
  }
550
594
  }
@@ -583,21 +627,23 @@ an exception from a later hook can be retried safely.
583
627
 
584
628
  ## Channel Messages
585
629
 
586
- Incoming client messages are handled through the `handlers` map, the `@onMessage` decorator, or the `onMessage` fallback.
630
+ Incoming client messages are handled through the `@onMessage` decorator.
631
+ Unknown events receive a negative acknowledgement.
587
632
 
588
633
  ```ts
589
634
  import { BaseChannel } from '@rlanz/socket'
635
+ import { onMessage } from '@rlanz/socket/decorators'
590
636
  import type { AuthenticatedSocket } from '@rlanz/socket/types'
591
637
 
592
638
  type User = { id: string; name: string }
639
+ type ServerEvents = {
640
+ 'chat:message': { user: User | undefined; body: string }
641
+ }
593
642
 
594
- export default class ChatChannel extends BaseChannel<User> {
643
+ export default class ChatChannel extends BaseChannel<User, ServerEvents> {
595
644
  static pattern = 'chat/:roomId'
596
645
 
597
- protected handlers = {
598
- 'chat:send': this.sendMessage,
599
- }
600
-
646
+ @onMessage('chat:send')
601
647
  async sendMessage(socket: AuthenticatedSocket<User>, data: { body: string }) {
602
648
  this.broadcast('chat:message', {
603
649
  user: socket.user,
@@ -606,10 +652,6 @@ export default class ChatChannel extends BaseChannel<User> {
606
652
 
607
653
  return { delivered: true }
608
654
  }
609
-
610
- async onMessage(socket: AuthenticatedSocket<User>, event: string, data: unknown) {
611
- console.log('Unhandled channel message', event, data)
612
- }
613
655
  }
614
656
  ```
615
657
 
@@ -643,8 +685,9 @@ Import the browser client from `@rlanz/socket/client`.
643
685
 
644
686
  ```ts
645
687
  import { Socket } from '@rlanz/socket/client'
688
+ import type { AppSocket } from '#generated/socket'
646
689
 
647
- const socket = new Socket({
690
+ const socket = new Socket<AppSocket>({
648
691
  url: 'http://localhost:3333',
649
692
  path: '/socket',
650
693
  autoReconnect: true,
@@ -666,7 +709,7 @@ await socket.connect()
666
709
  ### Subscribe, Listen, Send
667
710
 
668
711
  ```ts
669
- const channel = socket.channel('chat/general')
712
+ const channel = socket.channel('chat/:roomId', { roomId: 'general' })
670
713
 
671
714
  channel
672
715
  .here((users) => console.log('present users', users))
@@ -692,7 +735,7 @@ console.log(ack)
692
735
  channel.stopListening('chat:message')
693
736
  channel.stopListeningForWhisper('typing')
694
737
  await channel.unsubscribe()
695
- await socket.leave('chat/general')
738
+ await socket.leave(channel.name)
696
739
  socket.disconnect()
697
740
  ```
698
741
 
@@ -702,7 +745,7 @@ The client reconnects automatically by default. `reconnectDelay` is the initial
702
745
  milliseconds, and each failed retry doubles the delay until `reconnectMaxDelay` is reached.
703
746
  Set `autoReconnect: false` to disable reconnect attempts.
704
747
 
705
- ### React, Vue, and Svelte
748
+ ### React and Vue
706
749
 
707
750
  Framework adapters keep the generated `AppSocket` type at one application-level factory. Channel
708
751
  names, client methods, server events, and payloads then remain inferred without repeating the
@@ -726,8 +769,13 @@ export const { SocketProvider, useChannel, useChannelEvent, useSocketState } =
726
769
  </SocketProvider>
727
770
 
728
771
  function Chat() {
729
- const channel = useChannel('chat/general')
730
- useChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
772
+ const channel = useChannel('chat/:roomId', { roomId: 'general' })
773
+ useChannelEvent(
774
+ 'chat/:roomId',
775
+ { roomId: 'general' },
776
+ 'chat:message',
777
+ (message) => console.log(message.id)
778
+ )
731
779
 
732
780
  return <button onClick={() => channel?.send('chat:send', { text: 'Hello' })}>Send</button>
733
781
  }
@@ -749,63 +797,31 @@ export const { provideSocket, useChannel, useChannelEvent, useSocketState } =
749
797
  provideSocket(socket, { owned: true })
750
798
 
751
799
  // In a descendant component setup
752
- const channel = useChannel('chat/general')
753
- useChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
800
+ const channel = useChannel('chat/:roomId', { roomId: 'general' })
801
+ useChannelEvent('chat/:roomId', { roomId: 'general' }, 'chat:message', (message) =>
802
+ console.log(message.id)
803
+ )
754
804
 
755
805
  function send() {
756
806
  channel.value?.send('chat:send', { text: 'Hello' })
757
807
  }
758
808
  ```
759
809
 
760
- Svelte:
761
-
762
- ```ts
763
- // src/socket.ts
764
- import { Socket } from '@rlanz/socket/client'
765
- import { createSocketContext } from '@rlanz/socket/client/svelte'
766
- import type { AppSocket } from '#generated/socket'
767
-
768
- export const socket = new Socket<AppSocket>()
769
- export const { setSocket, useSocketState, channel, onChannelEvent } =
770
- createSocketContext<AppSocket>()
771
- ```
772
-
773
- ```svelte
774
- <!-- In the root component -->
775
- <script lang="ts">
776
- import { setSocket, socket } from './socket'
777
-
778
- setSocket(socket, { owned: true })
779
- </script>
780
- ```
781
-
782
- ```svelte
783
- <!-- In a descendant component -->
784
- <script lang="ts">
785
- import { channel, onChannelEvent } from './socket'
786
-
787
- const chat = channel('chat/general')
788
- onChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
789
- </script>
790
-
791
- <button on:click={() => $chat?.send('chat:send', { text: 'Hello' })}>Send</button>
792
- ```
793
-
794
810
  Adapters acquire and share channel subscriptions, attach listeners before subscribing, and release
795
- them when the component, scope, or store consumer is disposed. React returns `null` until its passive
796
- effect acquires the channel; Vue exposes a nullable shallow ref and Svelte exposes a nullable readable
797
- store. Multiple consumers of the same channel share one subscription, including across development
811
+ them when the component or scope is disposed. React returns `null` until its passive effect acquires
812
+ the channel; Vue exposes a nullable shallow ref. Multiple consumers of the same channel share one
813
+ subscription, including across development
798
814
  remounts, without removing listeners registered through the direct client API.
799
815
 
800
816
  Sockets are borrowed by default: the adapter neither connects nor disconnects them. Pass `owned` to
801
- `SocketProvider`, `{ owned: true }` to `provideSocket`, or `{ owned: true }` to `setSocket` only when
802
- that framework root exclusively owns the socket lifecycle. Otherwise call `socket.connect()` and
803
- `socket.disconnect()` in application-owned lifecycle code.
817
+ `SocketProvider` or `{ owned: true }` to `provideSocket` only when that framework root exclusively
818
+ owns the socket lifecycle. Otherwise call `socket.connect()` and `socket.disconnect()` in
819
+ application-owned lifecycle code.
804
820
 
805
821
  ## Package Exports
806
822
 
807
823
  ```ts
808
- import { BaseChannel, ChannelRouter, PresenceManager } from '@rlanz/socket'
824
+ import { BaseChannel, SocketResponseError, defineConfig } from '@rlanz/socket'
809
825
  import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
810
826
  import SocketProvider from '@rlanz/socket/provider'
811
827
  import socket from '@rlanz/socket/services/main'
@@ -813,11 +829,10 @@ import { onMessage } from '@rlanz/socket/decorators'
813
829
  import { SocketHealthCheck } from '@rlanz/socket/health_check'
814
830
  import { SocketInstrumentation } from '@rlanz/socket/otel'
815
831
  import { SocketFake } from '@rlanz/socket/testing'
816
- import { Socket, Channel } from '@rlanz/socket/client'
832
+ import { Socket } from '@rlanz/socket/client'
817
833
  import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
818
834
  import { createSocketHooks } from '@rlanz/socket/client/react'
819
835
  import { createSocketComposables } from '@rlanz/socket/client/vue'
820
- import { createSocketContext } from '@rlanz/socket/client/svelte'
821
836
  import type { SocketOptions, PresenceData } from '@rlanz/socket/client/types'
822
837
  ```
823
838
 
@@ -837,4 +852,3 @@ Available exports:
837
852
  - `@rlanz/socket/client/types`
838
853
  - `@rlanz/socket/client/react`
839
854
  - `@rlanz/socket/client/vue`
840
- - `@rlanz/socket/client/svelte`