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