@rlanz/socket 0.0.1-4 → 0.0.1-6

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 (55) hide show
  1. package/README.md +430 -26
  2. package/build/chunk-B4Y3TNDI.js +159 -0
  3. package/build/chunk-B4Y3TNDI.js.map +1 -0
  4. package/build/{chunk-GKAD2UOA.js → chunk-D3HUBCBW.js} +1 -1
  5. package/build/chunk-D3HUBCBW.js.map +1 -0
  6. package/build/chunk-FVPY6HZW.js +136 -0
  7. package/build/chunk-FVPY6HZW.js.map +1 -0
  8. package/build/chunk-SFAY2ZA4.js +28 -0
  9. package/build/chunk-SFAY2ZA4.js.map +1 -0
  10. package/build/{chunk-XUDFDJME.js → chunk-SHH6U4CI.js} +12 -9
  11. package/build/chunk-SHH6U4CI.js.map +1 -0
  12. package/build/chunk-YAX5EHHB.js +453 -0
  13. package/build/chunk-YAX5EHHB.js.map +1 -0
  14. package/build/framework-DuW6zpPk.d.ts +14 -0
  15. package/build/index.d.ts +7 -13
  16. package/build/index.js +18 -10
  17. package/build/index.js.map +1 -1
  18. package/build/providers/socket_provider.d.ts +4 -3
  19. package/build/providers/socket_provider.js +1780 -57
  20. package/build/providers/socket_provider.js.map +1 -1
  21. package/build/services/socket.d.ts +4 -3
  22. package/build/socket_service-D3jKrleE.d.ts +105 -0
  23. package/build/src/assembler_hook.d.ts +15 -0
  24. package/build/src/assembler_hook.js +261 -0
  25. package/build/src/assembler_hook.js.map +1 -0
  26. package/build/src/client/index.d.ts +46 -12
  27. package/build/src/client/index.js +223 -76
  28. package/build/src/client/index.js.map +1 -1
  29. package/build/src/client/react.d.ts +27 -0
  30. package/build/src/client/react.js +69 -0
  31. package/build/src/client/react.js.map +1 -0
  32. package/build/src/client/svelte.d.ts +23 -0
  33. package/build/src/client/svelte.js +82 -0
  34. package/build/src/client/svelte.js.map +1 -0
  35. package/build/src/client/types.d.ts +69 -5
  36. package/build/src/client/vue.d.ts +26 -0
  37. package/build/src/client/vue.js +81 -0
  38. package/build/src/client/vue.js.map +1 -0
  39. package/build/src/decorators.d.ts +4 -4
  40. package/build/src/decorators.js +1 -1
  41. package/build/src/health_check.d.ts +4 -3
  42. package/build/src/otel.js +4 -5
  43. package/build/src/otel.js.map +1 -1
  44. package/build/src/testing.d.ts +54 -0
  45. package/build/src/testing.js +7 -0
  46. package/build/src/testing.js.map +1 -0
  47. package/build/src/types.d.ts +2 -2
  48. package/build/{types-BBLNfcWk.d.ts → types-DiYaFvgi.d.ts} +112 -63
  49. package/package.json +41 -6
  50. package/build/chunk-GKAD2UOA.js.map +0 -1
  51. package/build/chunk-ILDK672E.js +0 -1921
  52. package/build/chunk-ILDK672E.js.map +0 -1
  53. package/build/chunk-XUDFDJME.js.map +0 -1
  54. package/build/shared_types-Dw9AphfO.d.ts +0 -21
  55. package/build/socket_service-CRmPa74K.d.ts +0 -144
package/README.md CHANGED
@@ -14,9 +14,18 @@ Register the provider in your AdonisJS application:
14
14
  // adonisrc.ts
15
15
  export default defineConfig({
16
16
  providers: [() => import('@rlanz/socket/provider')],
17
+ hooks: {
18
+ init: [() => import('@rlanz/socket/assembler_hook')],
19
+ },
17
20
  })
18
21
  ```
19
22
 
23
+ 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.
28
+
20
29
  ## Configuration
21
30
 
22
31
  Create a socket config file and tune the WebSocket path or heartbeat.
@@ -28,14 +37,21 @@ import { defineConfig } from '@rlanz/socket'
28
37
  export default defineConfig({
29
38
  websocket: {
30
39
  path: '/socket',
40
+ allowedOrigins: ['https://app.example.com'],
31
41
  pingInterval: '25s',
32
42
  pingTimeout: '5s',
33
43
  maxBufferedAmount: 16 * 1024 * 1024,
44
+ maxOutboundPayload: 1024 * 1024,
45
+ maxSubscriptionsPerSocket: 100,
46
+ maxChannelNameLength: 255,
34
47
  },
35
48
  })
36
49
  ```
37
50
 
38
51
  - `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.
39
55
  - `websocket.middleware` runs AdonisJS HTTP middleware for the WebSocket upgrade request.
40
56
  - `websocket.pingInterval` sends a WebSocket ping every configured duration.
41
57
  - `websocket.pingTimeout` closes connections that do not answer in time.
@@ -45,13 +61,27 @@ export default defineConfig({
45
61
  to `100`.
46
62
  - `websocket.maxMessagesPerInterval` limits inbound protocol messages per
47
63
  `websocket.messageRateInterval`; they default to `1000` messages per `1s`.
48
- - `websocket.maxBufferedAmount` closes slow outbound sockets whose WebSocket buffer is already above
49
- the configured byte limit before an event is sent; it defaults to 16 MiB.
64
+ - `websocket.maxBufferedAmount` closes slow outbound sockets when the buffered bytes plus the next
65
+ protocol message would exceed the configured limit; it defaults to 16 MiB.
66
+ - `websocket.maxOutboundPayload` limits every serialized outbound event, ACK, pong, and protocol
67
+ error; it defaults to 1 MiB.
68
+ - `websocket.maxSubscriptionsPerSocket` limits active channel subscriptions retained by one socket;
69
+ it defaults to `100`.
70
+ - `websocket.maxChannelNameLength` limits channel names before routing; it defaults to `255`
71
+ characters.
50
72
 
51
73
  `websocket.middleware` is different from channel middleware. Use it to prepare request-scoped
52
74
  services such as sessions or auth during the initial HTTP upgrade; use `static middlewares` on a
53
75
  channel class to authorize or enrich individual channel subscriptions.
54
76
 
77
+ AdonisJS CORS middleware does not authorize the Node.js `upgrade` event and browsers do not apply
78
+ Fetch CORS response checks to WebSockets. The origin check therefore runs before upgrade middleware
79
+ and authentication, which protects cookie- or session-authenticated sockets from cross-site
80
+ WebSocket hijacking. A reverse proxy must preserve the public `Host` and overwrite
81
+ `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
83
+ detection.
84
+
55
85
  ### Authentication
56
86
 
57
87
  Authenticate sockets during the HTTP upgrade with `websocket.authenticate`. The hook receives an
@@ -67,38 +97,44 @@ channel itself through `static middlewares`.
67
97
 
68
98
  ```ts
69
99
  // config/socket.ts
70
- import type { HttpContext } from '@adonisjs/core/http'
71
100
  import { authenticateWithAdonisAuth, defineConfig } from '@rlanz/socket'
72
101
 
73
102
  type User = { id: string | number; name: string }
74
103
 
75
- export default defineConfig<User, HttpContext>({
104
+ export default defineConfig<User>({
76
105
  websocket: {
77
106
  middleware: [
78
107
  () => import('@adonisjs/session/session_middleware'),
79
108
  () => import('@adonisjs/auth/initialize_auth_middleware'),
80
109
  ],
81
110
 
82
- authenticate: authenticateWithAdonisAuth<User, HttpContext>(),
111
+ authenticate: authenticateWithAdonisAuth<User>(),
83
112
  },
84
113
  })
85
114
  ```
86
115
 
87
116
  Return `false`, `null`, or throw to reject the upgrade with `401 Unauthorized`. The returned user is
88
- available as `socket.user`, and the HTTP context remains available as `socket.raw.httpContext`.
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.
119
+
120
+ The retained HTTP context describes the initial upgrade handshake. After the `101` response, it is
121
+ not an active HTTP request: do not write through `httpContext.response` or expect it to remain
122
+ available through AdonisJS async-local storage. The upgrade pipeline receives the context explicitly
123
+ and does not install it in async-local storage during the handshake either. Copy durable values into
124
+ `socket.user` or `socket.raw.data` instead of keeping response-bound resources alive.
89
125
 
90
126
  Override `websocket.authenticate` when you need custom behavior:
91
127
 
92
128
  ```ts
93
129
  async authenticate({ httpContext }) {
94
- await httpContext!.auth.authenticateUsing()
95
- return httpContext!.auth.getUserOrFail()
130
+ await httpContext.auth.authenticateUsing()
131
+ return httpContext.auth.getUserOrFail()
96
132
  }
97
133
  ```
98
134
 
99
135
  ### Horizontal Sync
100
136
 
101
- Configure an [`@boringnode/bus`](https://github.com/boringnode/bus) transport to synchronize broadcasts across multiple SocketService instances.
137
+ Configure an [`@boringnode/bus`](https://github.com/boringnode/bus) transport to synchronize broadcasts across multiple application instances.
102
138
 
103
139
  ```ts
104
140
  // config/socket.ts
@@ -200,9 +236,241 @@ instrumentation.manuallyRegister()
200
236
 
201
237
  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.
202
238
 
239
+ ## Production and scaling
240
+
241
+ ### Process and reverse proxy
242
+
243
+ The provider attaches its WebSocket `upgrade` handler to the AdonisJS Node HTTP server after that
244
+ server is ready. WebSockets therefore use the same process, listener, event loop, memory, and failure
245
+ domain as HTTP; there is no separate socket process or port. Size each application instance for its
246
+ combined HTTP and long-lived WebSocket workload, and remember that a process failure drops every
247
+ socket connected to that instance.
248
+
249
+ At the reverse proxy or load balancer:
250
+
251
+ - route `websocket.path` to the AdonisJS HTTP listener and forward WebSocket upgrade requests using
252
+ HTTP/1.1 with the `Upgrade` and `Connection: upgrade` headers;
253
+ - terminate TLS there if desired, use `wss://` publicly, preserve a validated public `Host`, and
254
+ **overwrite** `X-Forwarded-Proto` with the trusted public protocol (`http` or `https`);
255
+ - allow long-lived upgraded connections and set the proxy idle timeout above the configured
256
+ heartbeat cadence; and
257
+ - restrict direct access to the upstream. Same-origin validation trusts `Host` and the first
258
+ `X-Forwarded-Proto` value, so client-supplied versions of those headers must not reach the app.
259
+
260
+ 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
262
+ origin or when the proxy rewrites `Host`. Clients without an `Origin` header are accepted, so use
263
+ upgrade authentication for non-browser clients rather than treating the origin check as
264
+ authentication.
265
+
266
+ ### Multiple instances and reconnects
267
+
268
+ A load balancer may route each new upgrade to any ready instance and must keep that upgraded TCP
269
+ connection attached to that instance. The package neither implements nor guarantees sticky
270
+ sessions. Without a [transport](#horizontal-sync), subscriptions, user rooms, broadcasts, and
271
+ presence are local to one instance; affinity is not a replacement for configuring the transport.
272
+ All instances in one logical deployment must use a compatible shared transport and the same bus
273
+ channel.
274
+
275
+ The bundled client reconnects by default with exponential backoff from `250ms` to `5s` and
276
+ re-subscribes desired channels after establishing a new connection, which may be on another
277
+ instance. A reconnect is a new session: pending acknowledgements are rejected, authentication and
278
+ channel middleware run again, and events sent while disconnected are not replayed. The built-in
279
+ backoff has no jitter; stagger deployments or add reconnect staggering in the application when a
280
+ large fleet could reconnect simultaneously.
281
+
282
+ ### Transport, presence, and delivery semantics
283
+
284
+ 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.
289
+
290
+ Distributed presence is also best-effort and non-durable. A snapshot contains the replies received
291
+ before `transport.presenceTimeout`; partitions, slow instances, concurrent changes, and restarts can
292
+ produce incomplete snapshots or duplicate/omitted member transitions. Presence hooks must be
293
+ idempotent and presence must not be used as an authoritative online-user or membership store.
294
+
295
+ `sendWithAck()` only confirms that the receiving server-side handler completed and returned a
296
+ result. It does not confirm that a broadcast reached any client. Applications that need durable
297
+ notifications, recovery after reconnect, or at-least-once processing must persist events or state
298
+ outside this package and define their own event IDs, cursors, deduplication, acknowledgement, and
299
+ replay protocol.
300
+
301
+ ### Restarts and resource limits
302
+
303
+ On AdonisJS shutdown, the service becomes unready, stops accepting upgrades, terminates existing
304
+ WebSockets, runs their subscription/disconnect finalizers, and then disconnects the bus. It does
305
+ **not** gracefully drain established sockets or wait for in-flight client acknowledgements. For a
306
+ rolling restart, remove the instance from new HTTP/upgrade traffic first when the platform permits,
307
+ expect connected clients to reconnect, and give the process enough termination grace to finish its
308
+ socket finalizers and bus shutdown before sending a forced kill.
309
+
310
+ Review the limits under [Configuration](#configuration) against real payloads and fan-out:
311
+
312
+ - enable heartbeat explicitly; it is disabled when neither `pingInterval` nor `pingTimeout` is set;
313
+ - tune inbound/outbound payload sizes, the per-socket fixed-window message rate, serialized message
314
+ queue depth, subscriptions per socket, and channel-name length;
315
+ - treat rate and queue limits as per-connection safeguards, not perimeter abuse prevention; enforce
316
+ aggregate connection and request limits at the proxy or application boundary; and
317
+ - tune `maxBufferedAmount` for available memory. Slow consumers and oversized outbound frames are
318
+ disconnected rather than buffered without bound or retried.
319
+
320
+ There is no built-in total connection limit. Each connection consumes a file descriptor plus
321
+ application, subscription, queue, heartbeat, and `ws` memory. Set process/container memory limits,
322
+ raise and monitor file-descriptor limits where appropriate, cap connections at the edge, and load
323
+ test representative connection counts, message rates, payloads, and broadcast fan-out before
324
+ production.
325
+
326
+ ### Health and monitoring
327
+
328
+ Expose the application-owned readiness endpoint containing [`SocketHealthCheck`](#health-checks)
329
+ and route new upgrades only to ready instances. The check reports local service startup/shutdown
330
+ state and local connection/channel counts; it does not probe the transport backend, remote
331
+ instances, or end-to-end publish delivery. Monitor those dependencies separately and keep a normal
332
+ process liveness check so readiness failures do not automatically cause restart loops.
333
+
334
+ Enable the optional [OpenTelemetry instrumentation](#opentelemetry) and alert on connection and
335
+ disconnection changes, active connections/subscriptions, message errors, and broadcast volume and
336
+ local delivery counts. Also collect process event-loop delay, memory, CPU, open file descriptors,
337
+ proxy upgrade/rejection metrics, reconnect rates, logs, and transport health. Broadcast delivery
338
+ counters count immediate sends to local `ws` objects, not application-level receipt.
339
+
340
+ ### Production checklist
341
+
342
+ - [ ] Proxy upgrades on the configured path with HTTP/1.1 `Upgrade`/`Connection` headers.
343
+ - [ ] 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
345
+ access.
346
+ - [ ] Set proxy idle timeouts and explicitly configure/test heartbeat behavior.
347
+ - [ ] Configure a shared transport and bus channel for every instance that must exchange broadcasts
348
+ or presence.
349
+ - [ ] Make clients tolerate reconnect, re-authentication, re-subscription, duplicate handling, and
350
+ gaps during deploys or failures.
351
+ - [ ] Persist and replay application-critical events outside the socket bus.
352
+ - [ ] Tune payload, rate, queue, subscription, backpressure, connection, memory, and file-descriptor
353
+ limits using load tests.
354
+ - [ ] Wire readiness, liveness, OpenTelemetry, logs, proxy metrics, and transport monitoring.
355
+ - [ ] Give rolling shutdown enough grace for finalizers and bus disconnect; do not expect socket
356
+ draining.
357
+
203
358
  ## Channels
204
359
 
205
- Channels are discovered from `app/channels/**/*_channel.{ts,js}`. Export a default class extending `BaseChannel` and define a static `pattern`.
360
+ Channels are discovered from `app/channels/**/*_channel.{ts,js}`. Export a default class extending
361
+ `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`.
363
+
364
+ ```ts
365
+ // app/channels/chat_channel.ts
366
+ import { BaseChannel } from '@rlanz/socket'
367
+ import type { AuthenticatedSocket } from '@rlanz/socket/types'
368
+
369
+ type User = { id: string; name: string }
370
+ type Message = { id: string; text: string }
371
+
372
+ type ServerEvents = {
373
+ 'chat:message': Message
374
+ }
375
+
376
+ export default class ChatChannel extends BaseChannel<User, ServerEvents> {
377
+ static pattern = 'chat/:roomId'
378
+
379
+ protected handlers = {
380
+ 'chat:send': this.sendMessage,
381
+ 'chat:ping': this.ping,
382
+ }
383
+
384
+ async sendMessage(
385
+ socket: AuthenticatedSocket<User>,
386
+ payload: { text: string }
387
+ ): Promise<Message> {
388
+ const message = { id: crypto.randomUUID(), text: payload.text }
389
+ this.broadcastExcept(socket.id, 'chat:message', message)
390
+ return message
391
+ }
392
+
393
+ ping(_socket: AuthenticatedSocket<User>, _payload: undefined): void {}
394
+ }
395
+ ```
396
+
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:
402
+
403
+ ```ts
404
+ export default class ChatChannel extends BaseChannel<User> {
405
+ // Server events remain unknown and may be typed by callers when listening.
406
+ }
407
+ ```
408
+
409
+ The Assembler hook generates an explicit `AppSocket` registry. Pass it once to the browser client:
410
+
411
+ ```ts
412
+ import { Socket } from '@rlanz/socket/client'
413
+ import type { AppSocket } from '#generated/socket'
414
+
415
+ const socket = new Socket<AppSocket>()
416
+ const channel = socket.channel('chat/general')
417
+
418
+ channel.listen('chat:message', (message) => console.log(message.id))
419
+ channel.send('chat:ping')
420
+
421
+ const message = await channel.sendWithAck('chat:send', { text: 'Hello' })
422
+ console.log(message.id)
423
+ ```
424
+
425
+ 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
428
+ 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
431
+ 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.
434
+
435
+ The default Assembler hook discovers `app/channels/**/*_channel.{ts,js}` and generates both
436
+ `.adonisjs/client/socket.ts` and `.adonisjs/server/socket_channels.ts`. Configure the frontend
437
+ 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. Channels are discovered in `app/channels`
439
+ by default. Any source inside `app` is supported, and generated imports use the corresponding
440
+ AdonisJS `#app` alias without TypeScript extensions. To customize discovery or client output, replace
441
+ the package hook in `adonisrc.ts` with a local hook:
442
+
443
+ ```ts
444
+ // hooks/socket.ts
445
+ import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
446
+
447
+ export default generateSocketRegistry({
448
+ source: './app/realtime',
449
+ glob: ['**/*.ts'],
450
+ output: '.adonisjs/client/socket.d.ts',
451
+ })
452
+ ```
453
+
454
+ ```ts
455
+ // adonisrc.ts
456
+ export default defineConfig({
457
+ providers: [() => import('@rlanz/socket/provider')],
458
+ hooks: {
459
+ init: [() => import('./hooks/socket.js')],
460
+ },
461
+ })
462
+ ```
463
+
464
+ 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:
206
474
 
207
475
  ```ts
208
476
  // app/channels/chat_channel.ts
@@ -239,11 +507,8 @@ import type { MiddlewareContext } from '@rlanz/socket/types'
239
507
  type User = { id: string; name: string }
240
508
 
241
509
  async function auth(ctx: MiddlewareContext<User>, next: () => Promise<void>) {
242
- if (!ctx.socket.user) {
243
- throw new Error('Unauthorized')
244
- }
245
-
246
- await ctx.socket.joinUserRoom(ctx.socket.user.id)
510
+ const user = ctx.socket.getUserOrFail()
511
+ await ctx.socket.joinUserRoom(user.id)
247
512
  await next()
248
513
  }
249
514
 
@@ -253,13 +518,18 @@ export default class ChatChannel extends BaseChannel<User> {
253
518
  }
254
519
  ```
255
520
 
521
+ Only `SocketResponseError` messages are intentionally exposed to clients. Unexpected middleware,
522
+ join, and message-handler errors are logged server-side and replaced with a generic protocol error,
523
+ so internal exception details do not leak. Use `SocketResponseError` only for safe, user-facing
524
+ messages.
525
+
256
526
  ### Presence
257
527
 
258
528
  Enable presence with `static options = { presence: true }` and implement `getPresenceInfo`.
259
529
 
260
530
  ```ts
261
531
  import { BaseChannel } from '@rlanz/socket'
262
- import type { AuthenticatedSocket, PresenceMember } from '@rlanz/socket/types'
532
+ import type { AuthenticatedSocket, PresenceInfo, PresenceMember } from '@rlanz/socket/types'
263
533
 
264
534
  type User = { id: string; name: string }
265
535
 
@@ -267,10 +537,14 @@ export default class RoomChannel extends BaseChannel<User> {
267
537
  static pattern = 'rooms/:roomId'
268
538
  static options = { presence: true }
269
539
 
270
- getPresenceInfo(socket: AuthenticatedSocket<User>): PresenceMember {
540
+ getPresenceInfo(socket: AuthenticatedSocket<User>): PresenceInfo {
541
+ const user = socket.getUserOrFail()
542
+
271
543
  return {
272
- id: socket.user!.id,
273
- name: socket.user!.name,
544
+ id: user.id,
545
+ data: {
546
+ name: user.name,
547
+ },
274
548
  }
275
549
  }
276
550
 
@@ -284,6 +558,28 @@ export default class RoomChannel extends BaseChannel<User> {
284
558
  }
285
559
  ```
286
560
 
561
+ The `id` identifies one member across multiple sockets or browser tabs. The optional `data` object
562
+ defines the channel-specific public fields exposed on that member. Snapshots and member hooks flatten
563
+ these values into `{ id, ...data, joinedAt }`.
564
+
565
+ Custom presence fields are not normalized or recursively validated. WebSocket and distributed
566
+ transport serialization may transform them, so use JSON-compatible values when local and distributed
567
+ snapshots must have the same shape.
568
+
569
+ Presence is exposed per user ID: multiple sockets or browser tabs for the same ID remain separate
570
+ broadcast destinations but produce one `users` entry and contribute one to `count`. `onMemberJoin`
571
+ runs for the first locally observed connection and `onMemberLeave` for the last; `onJoin` and
572
+ `onLeave` still run for every socket. Across multiple application instances, member hooks are
573
+ best-effort and must be idempotent because concurrent joins, leaves, timeouts, or partitions can
574
+ duplicate or omit a transition. When two connections provide different metadata, the earliest
575
+ connection is the representative. Distributed snapshots are deduplicated across the responses
576
+ received before `transport.presenceTimeout` and therefore remain a best-effort, non-durable view
577
+ during network partitions.
578
+
579
+ Presence snapshots are validated for JSON serialization before join hooks run. Hooks can still
580
+ perform arbitrary external side effects and are not database transactions; keep them idempotent so
581
+ an exception from a later hook can be retried safely.
582
+
287
583
  ## Channel Messages
288
584
 
289
585
  Incoming client messages are handled through the `handlers` map, the `@onMessage` decorator, or the `onMessage` fallback.
@@ -293,18 +589,15 @@ import { BaseChannel } from '@rlanz/socket'
293
589
  import type { AuthenticatedSocket } from '@rlanz/socket/types'
294
590
 
295
591
  type User = { id: string; name: string }
296
- type Events = {
297
- 'chat:send': { body: string }
298
- }
299
592
 
300
- export default class ChatChannel extends BaseChannel<User, Events> {
593
+ export default class ChatChannel extends BaseChannel<User> {
301
594
  static pattern = 'chat/:roomId'
302
595
 
303
596
  protected handlers = {
304
597
  'chat:send': this.sendMessage,
305
598
  }
306
599
 
307
- async sendMessage(socket: AuthenticatedSocket<User>, data: Events['chat:send']) {
600
+ async sendMessage(socket: AuthenticatedSocket<User>, data: { body: string }) {
308
601
  this.broadcast('chat:message', {
309
602
  user: socket.user,
310
603
  body: data.body,
@@ -319,7 +612,10 @@ export default class ChatChannel extends BaseChannel<User, Events> {
319
612
  }
320
613
  ```
321
614
 
322
- Returning a value from a handler resolves `sendWithAck()` on the client.
615
+ Returning a value from a handler resolves `sendWithAck()` on the client. Handler results and
616
+ presence snapshots must be JSON-serializable. A non-serializable handler result receives an
617
+ immediate negative acknowledgement; a non-serializable initial presence snapshot rolls back the
618
+ subscription instead of leaving client and server state out of sync.
323
619
 
324
620
  ### Client-to-client events
325
621
 
@@ -405,10 +701,110 @@ The client reconnects automatically by default. `reconnectDelay` is the initial
405
701
  milliseconds, and each failed retry doubles the delay until `reconnectMaxDelay` is reached.
406
702
  Set `autoReconnect: false` to disable reconnect attempts.
407
703
 
704
+ ### React, Vue, and Svelte
705
+
706
+ Framework adapters keep the generated `AppSocket` type at one application-level factory. Channel
707
+ names, client methods, server events, and payloads then remain inferred without repeating the
708
+ generic in every component.
709
+
710
+ React:
711
+
712
+ ```tsx
713
+ // src/socket.ts
714
+ import { Socket } from '@rlanz/socket/client'
715
+ import { createSocketHooks } from '@rlanz/socket/client/react'
716
+ import type { AppSocket } from '#generated/socket'
717
+
718
+ export const socket = new Socket<AppSocket>()
719
+ export const { SocketProvider, useChannel, useChannelEvent, useSocketState } =
720
+ createSocketHooks<AppSocket>()
721
+
722
+ // app.tsx
723
+ <SocketProvider socket={socket} owned>
724
+ <Chat />
725
+ </SocketProvider>
726
+
727
+ function Chat() {
728
+ const channel = useChannel('chat/general')
729
+ useChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
730
+
731
+ return <button onClick={() => channel?.send('chat:send', { text: 'Hello' })}>Send</button>
732
+ }
733
+ ```
734
+
735
+ Vue:
736
+
737
+ ```ts
738
+ // src/socket.ts
739
+ import { Socket } from '@rlanz/socket/client'
740
+ import { createSocketComposables } from '@rlanz/socket/client/vue'
741
+ import type { AppSocket } from '#generated/socket'
742
+
743
+ export const socket = new Socket<AppSocket>()
744
+ export const { provideSocket, useChannel, useChannelEvent, useSocketState } =
745
+ createSocketComposables<AppSocket>()
746
+
747
+ // In the root component setup
748
+ provideSocket(socket, { owned: true })
749
+
750
+ // In a descendant component setup
751
+ const channel = useChannel('chat/general')
752
+ useChannelEvent('chat/general', 'chat:message', (message) => console.log(message.id))
753
+
754
+ function send() {
755
+ channel.value?.send('chat:send', { text: 'Hello' })
756
+ }
757
+ ```
758
+
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
+ 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
797
+ remounts, without removing listeners registered through the direct client API.
798
+
799
+ 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.
803
+
408
804
  ## Package Exports
409
805
 
410
806
  ```ts
411
- import { BaseChannel, ChannelRouter, PresenceManager, SocketService } from '@rlanz/socket'
807
+ import { BaseChannel, ChannelRouter, PresenceManager } from '@rlanz/socket'
412
808
  import type { AuthenticatedSocket, SocketConfig } from '@rlanz/socket/types'
413
809
  import SocketProvider from '@rlanz/socket/provider'
414
810
  import socket from '@rlanz/socket/services/main'
@@ -417,6 +813,10 @@ import { SocketHealthCheck } from '@rlanz/socket/health_check'
417
813
  import { SocketInstrumentation } from '@rlanz/socket/otel'
418
814
  import { SocketFake } from '@rlanz/socket/testing'
419
815
  import { Socket, Channel } from '@rlanz/socket/client'
816
+ import { generateSocketRegistry } from '@rlanz/socket/assembler_hook'
817
+ import { createSocketHooks } from '@rlanz/socket/client/react'
818
+ import { createSocketComposables } from '@rlanz/socket/client/vue'
819
+ import { createSocketContext } from '@rlanz/socket/client/svelte'
420
820
  import type { SocketOptions, PresenceData } from '@rlanz/socket/client/types'
421
821
  ```
422
822
 
@@ -424,6 +824,7 @@ Available exports:
424
824
 
425
825
  - `@rlanz/socket`
426
826
  - `@rlanz/socket/provider`
827
+ - `@rlanz/socket/assembler_hook`
427
828
  - `@rlanz/socket/services/main`
428
829
  - `@rlanz/socket/decorators`
429
830
  - `@rlanz/socket/health_check`
@@ -433,3 +834,6 @@ Available exports:
433
834
  - `@rlanz/socket/types/tracing_channels`
434
835
  - `@rlanz/socket/client`
435
836
  - `@rlanz/socket/client/types`
837
+ - `@rlanz/socket/client/react`
838
+ - `@rlanz/socket/client/vue`
839
+ - `@rlanz/socket/client/svelte`