@silasdevs/transport 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Silas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,509 @@
1
+ # @silasdevs/transport
2
+
3
+ > Generic WebSocket transport with injectable protocol schema, unified handler system, and Promise-based messaging.
4
+
5
+ Designed as the communication companion to `@silasdevs/core` (state management). Both libraries work together but are fully decoupled — use either one independently.
6
+
7
+ - **Injectable protocol** — configure wire field names, codes, serialization, and ID generation. No built-in defaults — you define the entire schema.
8
+ - **Channel-optional** — protocols that use named channels (like internal APIs) and channel-less protocols (like WhiteBit, Binance) both work out of the box.
9
+ - **Unified handlers** — persistent (server pushes) and ephemeral (request/response) in a single registry with automatic cleanup.
10
+ - **ID-based routing** — responses are matched to requests by message ID, with a secondary index fallback for protocols that omit channel fields in responses.
11
+ - **Three send modes** — `request()` (Promise), `fire()` (callback), `send()` (fire-and-forget).
12
+ - **Auto-reconnect** — configurable delay, max attempts, and backoff strategy.
13
+ - **Typed events** — lifecycle hooks via a typed event emitter instead of setter functions.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @silasdevs/transport
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```ts
24
+ import { createTransport } from '@silasdevs/transport';
25
+ import type { ProtocolSchema } from '@silasdevs/transport';
26
+
27
+ const protocol: ProtocolSchema = {
28
+ fields: {
29
+ requestChannel: 'action', // wire field for channel on outgoing messages
30
+ responseChannel: 'action', // wire field for channel on incoming messages
31
+ messageId: 'reqId', // wire field for the unique message ID
32
+ code: 'status', // wire field for result code
33
+ description: 'desc', // wire field for human-readable description
34
+ payload: 'payload', // wire field for data on outgoing messages
35
+ body: 'payload', // wire field for data on incoming messages
36
+ },
37
+ codes: {
38
+ success: 'OK',
39
+ interim: 'PENDING',
40
+ error: ['FAIL'],
41
+ },
42
+ generateId: () => Math.floor(Math.random() * 1_000_000_000) + 1,
43
+ encode: (msg) => JSON.stringify(msg),
44
+ decode: (raw) => { try { return JSON.parse(raw); } catch { return null; } },
45
+ flattenOutgoing: true,
46
+ };
47
+
48
+ const transport = createTransport({
49
+ url: 'wss://api.example.com/websocket',
50
+ protocol,
51
+ });
52
+
53
+ transport.connect();
54
+
55
+ // Promise-based request
56
+ const res = await transport.request({
57
+ channel: 'usuario',
58
+ data: { id: 5 },
59
+ });
60
+ console.log(res.data); // { usuario: [{ id: 5, nombre: 'Ana' }] }
61
+
62
+ // Persistent handler for server pushes
63
+ transport.addHandler('entrega', 'sync', (msg) => {
64
+ console.log('Delivery update:', msg.data);
65
+ });
66
+ ```
67
+
68
+ ## Channel-less Protocols
69
+
70
+ Not all WebSocket APIs use channel fields. For protocols like WhiteBit or Binance where routing is done purely by message ID, simply omit `requestChannel` and `responseChannel`:
71
+
72
+ ```ts
73
+ const whitebitProtocol: ProtocolSchema = {
74
+ fields: {
75
+ // No requestChannel or responseChannel — routing is ID-based only.
76
+ messageId: 'id',
77
+ code: 'status', // not used by WhiteBit, but required field
78
+ description: 'error',
79
+ payload: 'params',
80
+ body: 'result',
81
+ },
82
+ // No codes — all responses are treated as success.
83
+ generateId: () => Math.floor(Math.random() * 1_000_000_000) + 1,
84
+ encode: (msg) => JSON.stringify(msg),
85
+ decode: (raw) => { try { return JSON.parse(raw); } catch { return null; } },
86
+ flattenOutgoing: false,
87
+ includeIdInRequest: true, // WhiteBit expects the ID on the wire
88
+ };
89
+
90
+ const transport = createTransport({
91
+ url: 'wss://api.whitebit.com/ws',
92
+ protocol: whitebitProtocol,
93
+ });
94
+
95
+ transport.connect();
96
+
97
+ // No channel needed — just send data
98
+ const res = await transport.request({
99
+ data: { method: 'server.ping', params: [] },
100
+ });
101
+ console.log(res.data); // { result: 'pong' }
102
+ ```
103
+
104
+ Internally, channel-less messages use the wildcard `'*'` for handler routing. You can register persistent handlers on `'*'` to receive spontaneous server pushes:
105
+
106
+ ```ts
107
+ transport.addHandler('*', 'push-listener', (msg) => {
108
+ console.log('Server push:', msg.data);
109
+ });
110
+ ```
111
+
112
+ ## Send Modes
113
+
114
+ ### `request()` — Promise-based
115
+
116
+ Resolves on success, rejects on failure or timeout. Interim responses are handled transparently.
117
+
118
+ ```ts
119
+ import type { TransportError } from '@silasdevs/transport';
120
+
121
+ try {
122
+ const res = await transport.request(
123
+ { channel: 'usuario', data: { id: 5 } },
124
+ { timeout: 10_000 },
125
+ );
126
+ console.log(res.code); // 'OK'
127
+ console.log(res.data); // server payload
128
+ } catch (err) {
129
+ if (err instanceof Error) {
130
+ // Timeout or network error (plain Error).
131
+ console.error('Timeout or connection error:', err.message);
132
+ } else {
133
+ // Protocol-level failure — TransportError shape.
134
+ const e = err as TransportError;
135
+ console.error('Protocol error:', e.code, e.error, e.data);
136
+ }
137
+ }
138
+ ```
139
+
140
+ **Timeout behaviour:**
141
+ - `timeout` defaults to `30_000` ms.
142
+ - Pass `timeout: 0` to disable the timeout entirely (request waits indefinitely).
143
+ - Interim responses (`codes.interim`) do **not** reset the timer. The clock starts when `request()` is called and the same deadline applies throughout.
144
+ - On timeout the promise rejects with a plain `Error` (not a `TransportError`), so `err instanceof Error` reliably identifies timeouts.
145
+ - If `disconnect()` is called while a request is pending the promise rejects when the timeout fires (no early rejection).
146
+
147
+ ### `fire()` — Callback-based
148
+
149
+ Return `false` to keep the handler alive (interim pattern). The callback receives **every** message including interim ones — unlike `request()` it does not skip them.
150
+
151
+ ```ts
152
+ const unsub = transport.fire(
153
+ { channel: 'proceso', data: { id: 1 } },
154
+ (msg) => {
155
+ if (msg.code === 'PENDING') {
156
+ console.log('Still processing...', msg.data);
157
+ return false; // keep listening — handler stays registered
158
+ }
159
+ console.log('Done:', msg.data);
160
+ // return void/true → auto-remove handler
161
+ },
162
+ );
163
+
164
+ // Cancel early if needed
165
+ unsub();
166
+ ```
167
+
168
+ > **Note:** `fire()` has no built-in timeout. Use the returned `unsub()` to cancel if necessary.
169
+
170
+ ### `send()` — Fire-and-forget
171
+
172
+ ```ts
173
+ transport.send({ channel: 'ping' });
174
+ ```
175
+
176
+ ## Handlers
177
+
178
+ Two types, one registry:
179
+
180
+ | Type | Key | Lifetime | Use case |
181
+ |---|---|---|---|
182
+ | **persistent** | string name | Until explicitly removed | Server pushes, entity sync |
183
+ | **ephemeral** | numeric messageId | Auto-removed on definitive response | Request/response pairs |
184
+
185
+ ```ts
186
+ // Persistent — receives all 'entrega' pushes (messageId=0)
187
+ const unsub = transport.addHandler('entrega', 'my-sync', (msg) => {
188
+ console.log('Push:', msg.data);
189
+ });
190
+ unsub(); // or transport.removeHandler('entrega', 'my-sync')
191
+
192
+ // Ephemeral — created automatically by request() and fire()
193
+ ```
194
+
195
+ Ephemeral handlers auto-remove when the callback returns anything other than exactly `false` (including `void`, `true`, `null`, `0`, `""`). Return `false` to keep alive (interim pattern).
196
+
197
+ **Handler execution order:** persistent handlers execute in the order they were registered. Ephemeral handlers take priority over persistent handlers for the same (channel, messageId) pair.
198
+
199
+ ### Handler Routing
200
+
201
+ Messages are routed through a 4-step priority chain:
202
+
203
+ 1. **Ephemeral by (channel, messageId)** — exact match for request/response pairs
204
+ 2. **Persistent by channel** — all matching handlers execute
205
+ 3. **ID-only fallback** — if the message has a `messageId` but no matching channel, a secondary index resolves the original channel (useful when responses omit the channel field)
206
+ 4. **Unhandled** — emits `message:unhandled` event
207
+
208
+ ## Protocol Schema
209
+
210
+ You must provide a `ProtocolSchema` when creating a transport. There are no built-in defaults.
211
+
212
+ ### `ProtocolFields`
213
+
214
+ Maps canonical field names to actual wire field names:
215
+
216
+ | Field | Required | Description |
217
+ |---|---|---|
218
+ | `requestChannel` | No | Wire field for channel on outgoing messages. Omit for channel-less protocols. |
219
+ | `responseChannel` | No | Wire field for channel on incoming messages. Omit for channel-less protocols. |
220
+ | `subscriptionChannel` | No | Fallback channel field for subscription/event messages (e.g. Binance `"e"`). |
221
+ | `messageId` | Yes | Wire field for the unique message ID. |
222
+ | `code` | Yes | Wire field for the result code. |
223
+ | `description` | Yes | Wire field for human-readable description. |
224
+ | `payload` | Yes | Wire field for data on outgoing messages. |
225
+ | `body` | Yes | Wire field for data on incoming messages. |
226
+
227
+ ### `ProtocolCodes`
228
+
229
+ All fields are optional. When the entire `codes` object is omitted, all responses resolve immediately:
230
+
231
+ | Field | Type | Description |
232
+ |---|---|---|
233
+ | `success` | `string` | Value indicating success. When undefined, all non-interim/non-error responses succeed. |
234
+ | `interim` | `string` | Value indicating an interim/partial response (keep listening). |
235
+ | `error` | `string[]` | Value(s) indicating an error. Multiple codes supported. |
236
+
237
+ ### `ProtocolSchema`
238
+
239
+ | Field | Required | Description |
240
+ |---|---|---|
241
+ | `fields` | Yes | Maps canonical field names to wire field names. |
242
+ | `codes` | No | Special result code values for classification. |
243
+ | `generateId` | Yes | Function that generates a unique numeric message ID. |
244
+ | `encode` | Yes | Serialize a message object to a string for the wire. |
245
+ | `decode` | Yes | Deserialize a raw wire string to an object (return `null` on failure). |
246
+ | `flattenOutgoing` | Yes | `true` = spread data onto root; `false` = nest under payload field. |
247
+ | `includeIdInRequest` | No | `true` = include messageId on the wire; `false` (default) = ID used internally only. |
248
+
249
+ ### Example: Channel-based Protocol
250
+
251
+ ```ts
252
+ const protocol: ProtocolSchema = {
253
+ fields: {
254
+ requestChannel: 'action',
255
+ responseChannel: 'action',
256
+ messageId: 'reqId',
257
+ code: 'status',
258
+ description: 'desc',
259
+ payload: 'payload',
260
+ body: 'payload',
261
+ },
262
+ codes: {
263
+ success: 'OK',
264
+ interim: 'PENDING',
265
+ error: ['ERROR'],
266
+ },
267
+ generateId: () => Math.floor(Math.random() * 1_000_000_000) + 1,
268
+ encode: (msg) => JSON.stringify(msg),
269
+ decode: (raw) => { try { return JSON.parse(raw); } catch { return null; } },
270
+ flattenOutgoing: true,
271
+ };
272
+ ```
273
+
274
+ ### Example: Channel-less Protocol (WhiteBit-style)
275
+
276
+ ```ts
277
+ const protocol: ProtocolSchema = {
278
+ fields: {
279
+ messageId: 'id',
280
+ code: 'status',
281
+ description: 'error',
282
+ payload: 'params',
283
+ body: 'result',
284
+ },
285
+ generateId: () => Math.floor(Math.random() * 1_000_000_000) + 1,
286
+ encode: (msg) => JSON.stringify(msg),
287
+ decode: (raw) => { try { return JSON.parse(raw); } catch { return null; } },
288
+ flattenOutgoing: false,
289
+ includeIdInRequest: true,
290
+ };
291
+ ```
292
+
293
+ ### Wire Formats
294
+
295
+ **Outgoing (channel-based, data flattened)**:
296
+ ```json
297
+ { "action": "usuario", "reqId": 742381923, "id": 5, "nombre": "Ana" }
298
+ ```
299
+
300
+ **Outgoing (channel-based, data nested)**:
301
+ ```json
302
+ { "action": "usuario", "reqId": 742381923, "payload": { "id": 5, "nombre": "Ana" } }
303
+ ```
304
+
305
+ **Outgoing (channel-less)**:
306
+ ```json
307
+ { "id": 742381923, "params": { "method": "server.ping" } }
308
+ ```
309
+
310
+ **Incoming (channel-based)**:
311
+ ```json
312
+ {
313
+ "action": "usuario",
314
+ "reqId": 742381923,
315
+ "status": "OK",
316
+ "desc": "Success",
317
+ "payload": { "usuario": [{ "id": 5, "nombre": "Ana" }] }
318
+ }
319
+ ```
320
+
321
+ **Incoming (channel-less)**:
322
+ ```json
323
+ { "id": 742381923, "result": { "pong": true } }
324
+ ```
325
+
326
+ ## Events
327
+
328
+ Typed lifecycle events:
329
+
330
+ ```ts
331
+ transport.on('connected', (evt) => console.log('Connected'));
332
+ transport.on('disconnected', ({ code, reason }) => console.log('Disconnected:', code));
333
+ transport.on('reconnecting', ({ attempt, delayMs }) => console.log(`Retry #${attempt}`));
334
+ transport.on('error', (evt) => console.error('WS error'));
335
+
336
+ transport.on('message:raw', ({ data }) => console.log('Raw:', data));
337
+ transport.on('message:parsed', (msg) => console.log('Parsed:', msg.channel));
338
+ transport.on('message:unhandled', (msg) => console.log('Unhandled:', msg.channel));
339
+
340
+ transport.on('send:before', ({ payload }) => console.log('Sending:', payload));
341
+ transport.on('send:after', ({ payload }) => console.log('Sent:', payload));
342
+ transport.on('send:error', ({ reason }) => console.error('Send failed:', reason));
343
+
344
+ // All .on() calls return an unsubscribe function
345
+ const unsub = transport.on('connected', handler);
346
+ unsub();
347
+ ```
348
+
349
+ ## Reconnection
350
+
351
+ ```ts
352
+ const transport = createTransport({
353
+ url: 'wss://api.example.com/ws',
354
+ protocol,
355
+ reconnect: {
356
+ auto: true, // default: true
357
+ delayMs: 10_000, // default: 10s
358
+ maxAttempts: Infinity, // default: Infinity
359
+ backoff: 'fixed', // 'fixed' | 'exponential' (default: 'fixed')
360
+ },
361
+ });
362
+
363
+ // Disable reconnection
364
+ const transport2 = createTransport({
365
+ url: 'wss://...',
366
+ protocol,
367
+ reconnect: false,
368
+ });
369
+ ```
370
+
371
+ **Backoff strategies:**
372
+ - `'fixed'` — waits exactly `delayMs` between every attempt.
373
+ - `'exponential'` — delay doubles each attempt: `delayMs × 2^attempt`, capped at **60 seconds**.
374
+ - Example with `delayMs: 1000`: 1 s → 2 s → 4 s → 8 s → … → 60 s
375
+
376
+ **`disconnect({ clean: true })`** — setting `clean: true` additionally clears all stale ephemeral handlers (request/response pairs that will never resolve). Without `clean`, persistent handlers remain registered.
377
+
378
+ **Reconnect on send failure:** if `send()` is called while the socket is closed or closing and `reconnect.auto` is `true`, a reconnect attempt is scheduled with a 1 s delay.
379
+
380
+ ## Debug Logging
381
+
382
+ Pass `debug: true` to enable console logging for all connection lifecycle events, sends, receives, and handler routing:
383
+
384
+ ```ts
385
+ const transport = createTransport({
386
+ url: 'wss://api.example.com/ws',
387
+ protocol,
388
+ debug: true, // logs to console.log with '[silas/transport]' prefix
389
+ });
390
+
391
+ // Toggle at runtime
392
+ transport.debug(true);
393
+ transport.debug(false);
394
+ ```
395
+
396
+ Logged events include: connecting, connected, disconnected, reconnecting, send (before/after/error), message received, message decoded, handler matched/unmatched.
397
+
398
+ ## Channel-less Protocol Gotcha
399
+
400
+ If your protocol has no `responseChannel` defined, incoming messages always resolve to channel `'*'`. Persistent handlers registered on a **named** channel will **never** fire for those messages:
401
+
402
+ ```ts
403
+ // ❌ Will never fire — no responseChannel means all messages arrive on '*'.
404
+ transport.addHandler('priceUpdate', 'prices', (msg) => { /* ... */ });
405
+
406
+ // ✅ Register on '*' to receive all channel-less pushes.
407
+ transport.addHandler('*', 'prices', (msg) => { /* ... */ });
408
+ ```
409
+
410
+ ## Integration with @silasdevs/core
411
+
412
+ The bridge lives in the consumer, not in either library:
413
+
414
+ ```ts
415
+ import { createTransport } from '@silasdevs/transport';
416
+ import { createStore, defineSchema } from '@silasdevs/core/store';
417
+
418
+ const store = createStore({
419
+ schema: defineSchema({
420
+ tables: {
421
+ usuario: { key: 'id', version: 'version' },
422
+ entrega: { key: 'id', version: 'version' },
423
+ },
424
+ }),
425
+ });
426
+
427
+ const transport = createTransport({
428
+ url: 'wss://api.example.com/ws',
429
+ protocol,
430
+ });
431
+
432
+ // Bridge: classify incoming data into the store
433
+ transport.on('message:parsed', (msg) => {
434
+ if (msg.data) {
435
+ store.classify(msg.data);
436
+ }
437
+ });
438
+
439
+ transport.connect();
440
+ ```
441
+
442
+ ## API Reference
443
+
444
+ ### Factory
445
+
446
+ | Export | Description |
447
+ |---|---|
448
+ | `createTransport(opts)` | Create a Transport instance |
449
+
450
+ ### Transport Instance
451
+
452
+ | Method | Description |
453
+ |---|---|
454
+ | `connect()` | Open WebSocket (idempotent) |
455
+ | `disconnect({ clean? })` | Close WebSocket |
456
+ | `request(msg, opts?)` | Promise-based send |
457
+ | `fire(msg, cb, opts?)` | Callback-based send |
458
+ | `send(msg)` | Fire-and-forget send |
459
+ | `addHandler(channel, name, cb)` | Register persistent handler |
460
+ | `removeHandler(channel, name)` | Remove persistent handler |
461
+ | `on(event, cb)` | Subscribe to lifecycle event |
462
+ | `once(event, cb)` | Subscribe once |
463
+ | `debug(enabled)` | Toggle debug logging |
464
+ | `destroy()` | Disconnect + cleanup everything |
465
+ | `state` | Current `TransportState` (readonly) |
466
+ | `protocol` | Resolved `ProtocolSchema` (readonly) |
467
+
468
+ ### Protocol
469
+
470
+ | Export | Description |
471
+ |---|---|
472
+ | `normalizeIncoming(raw, schema)` | Wire → `IncomingMessage` |
473
+ | `buildOutgoing(msg, id, schema)` | `OutgoingMessage` → wire |
474
+
475
+ ### Utilities
476
+
477
+ | Export | Description |
478
+ |---|---|
479
+ | `createEmitter<T>()` | Typed event emitter factory |
480
+ | `createHandlerStore()` | Handler registry factory |
481
+
482
+ ### Types
483
+
484
+ All types are exported for consumers:
485
+
486
+ ```ts
487
+ import type {
488
+ ProtocolSchema,
489
+ ProtocolFields,
490
+ ProtocolCodes,
491
+ IncomingMessage,
492
+ OutgoingMessage,
493
+ Handler,
494
+ HandlerCallback,
495
+ Transport,
496
+ TransportOptions,
497
+ TransportState,
498
+ TransportEvents,
499
+ TransportError,
500
+ TransportEmitter,
501
+ ReconnectOptions,
502
+ RequestOptions,
503
+ FireOptions,
504
+ } from '@silasdevs/transport';
505
+ ```
506
+
507
+ ## License
508
+
509
+ MIT © Silas