@stonyx/sockets 0.1.1-alpha.1 → 0.1.1-beta.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.
@@ -5,7 +5,10 @@ const {
5
5
  SOCKET_HEARTBEAT_INTERVAL,
6
6
  SOCKET_HANDLER_DIR,
7
7
  SOCKET_LOG,
8
- SOCKET_ENCRYPTION
8
+ SOCKET_ENCRYPTION,
9
+ SOCKET_RECONNECT_BASE_DELAY,
10
+ SOCKET_RECONNECT_MAX_DELAY,
11
+ SOCKET_MAX_RECONNECT_ATTEMPTS,
9
12
  } = process.env;
10
13
 
11
14
  const port = SOCKET_PORT ?? 2667;
@@ -14,10 +17,14 @@ export default {
14
17
  port,
15
18
  address: SOCKET_ADDRESS ?? `ws://localhost:${port}`,
16
19
  authKey: SOCKET_AUTH_KEY ?? 'AUTH_KEY',
20
+ authData: {},
17
21
  heartBeatInterval: SOCKET_HEARTBEAT_INTERVAL ?? 30000,
18
22
  handlerDir: SOCKET_HANDLER_DIR ?? './socket-handlers',
19
23
  log: SOCKET_LOG ?? false,
20
24
  logColor: 'white',
21
25
  logMethod: 'socket',
22
26
  encryption: SOCKET_ENCRYPTION ?? 'true',
27
+ reconnectBaseDelay: SOCKET_RECONNECT_BASE_DELAY ?? 1000,
28
+ reconnectMaxDelay: SOCKET_RECONNECT_MAX_DELAY ?? 60000,
29
+ maxReconnectAttempts: SOCKET_MAX_RECONNECT_ATTEMPTS ?? Infinity,
23
30
  };
package/package.json CHANGED
@@ -4,12 +4,15 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.1",
7
+ "version": "0.1.1-beta.0",
8
8
  "description": "WebSocket server and client module for the Stonyx framework",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
11
11
  "files": [
12
- "*"
12
+ "src",
13
+ "config",
14
+ "LICENSE.md",
15
+ "README.md"
13
16
  ],
14
17
  "exports": {
15
18
  ".": "./src/main.js",
package/src/client.js CHANGED
@@ -2,11 +2,18 @@ import { WebSocket } from 'ws';
2
2
  import config from 'stonyx/config';
3
3
  import log from 'stonyx/log';
4
4
  import { forEachFileImport } from '@stonyx/utils/file';
5
+ import { sleep } from '@stonyx/utils/promise';
5
6
  import { encrypt, decrypt, deriveKey } from './encryption.js';
6
7
 
7
8
  export default class SocketClient {
8
9
  handlers = {};
9
10
  reconnectCount = 0;
11
+ _intentionalClose = false;
12
+
13
+ onDisconnect = null;
14
+ onReconnecting = null;
15
+ onReconnected = null;
16
+ onReconnectFailed = null;
10
17
 
11
18
  constructor() {
12
19
  if (SocketClient.instance) return SocketClient.instance;
@@ -41,7 +48,7 @@ export default class SocketClient {
41
48
 
42
49
  async connect() {
43
50
  return new Promise((resolve, reject) => {
44
- const { address, authKey } = config.sockets;
51
+ const { address, authKey, authData } = config.sockets;
45
52
  this.promise = { resolve, reject };
46
53
 
47
54
  log.socket(`Connecting to remote server: ${address}`);
@@ -51,13 +58,14 @@ export default class SocketClient {
51
58
  socket.onmessage = this.onMessage.bind(this);
52
59
  socket.onclose = this.onClose.bind(this);
53
60
  socket.onerror = event => {
54
- console.error(event);
61
+ log.socket(`Error connecting to socket server`);
55
62
  reject('Error connecting to socket server');
56
63
  };
57
64
 
58
65
  socket.onopen = () => {
66
+ this._intentionalClose = false;
59
67
  this.reconnectCount = 0;
60
- this.send({ request: 'auth', data: { authKey } }, true);
68
+ this.send({ request: 'auth', data: { authKey, ...authData } }, true);
61
69
  };
62
70
  });
63
71
  }
@@ -71,12 +79,12 @@ export default class SocketClient {
71
79
  const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
72
80
  parsed = JSON.parse(decrypt(raw, key));
73
81
  } else {
74
- parsed = JSON.parse(payload);
82
+ const raw = Buffer.isBuffer(payload) ? payload.toString() : payload;
83
+ parsed = JSON.parse(raw);
75
84
  }
76
85
 
77
86
  const { request, response, sessionKey } = parsed;
78
87
 
79
- // Built-in auth session key handling + heartbeat kickoff
80
88
  if (request === 'auth') {
81
89
  if (sessionKey && this.encryptionEnabled) {
82
90
  this.sessionKey = Buffer.from(sessionKey, 'base64');
@@ -84,7 +92,6 @@ export default class SocketClient {
84
92
  this.nextHeartBeat();
85
93
  }
86
94
 
87
- // Built-in heartbeat — schedule next beat on response
88
95
  if (request === 'heartBeat') {
89
96
  this.nextHeartBeat();
90
97
  return;
@@ -93,13 +100,12 @@ export default class SocketClient {
93
100
  const handler = this.handlers[request];
94
101
 
95
102
  if (!handler) {
96
- console.error(`Call to invalid handler: ${request}`);
103
+ log.socket(`Call to invalid handler: ${request}`);
97
104
  return;
98
105
  }
99
106
 
100
107
  handler.client.call({ client: this, ...handler }, response);
101
108
  } catch (error) {
102
- console.error(error);
103
109
  log.socket(`Invalid payload received from remote server`);
104
110
  }
105
111
  }
@@ -125,21 +131,55 @@ export default class SocketClient {
125
131
  onClose() {
126
132
  log.socket('Disconnected from remote server');
127
133
  if (this._heartBeatTimer) clearTimeout(this._heartBeatTimer);
134
+
135
+ this.onDisconnect?.();
136
+
137
+ if (!this._intentionalClose) {
138
+ this.reconnect();
139
+ }
128
140
  }
129
141
 
130
142
  close() {
143
+ this._intentionalClose = true;
131
144
  if (this._heartBeatTimer) clearTimeout(this._heartBeatTimer);
132
145
  if (this.socket) this.socket.close();
133
146
  }
134
147
 
135
- reconnect() {
136
- if (this.reconnectCount > 5) {
148
+ getReconnectDelay() {
149
+ const {
150
+ reconnectBaseDelay = 1000,
151
+ reconnectMaxDelay = 60000,
152
+ } = config.sockets;
153
+
154
+ const exponential = reconnectBaseDelay * Math.pow(2, this.reconnectCount - 1);
155
+ const capped = Math.min(exponential, reconnectMaxDelay);
156
+ const jitter = Math.floor(Math.random() * 1000);
157
+ return capped + jitter;
158
+ }
159
+
160
+ async reconnect() {
161
+ const { maxReconnectAttempts = Infinity } = config.sockets;
162
+
163
+ this.reconnectCount++;
164
+
165
+ if (this.reconnectCount > maxReconnectAttempts) {
137
166
  log.socket('Max reconnect attempts exceeded');
167
+ this.onReconnectFailed?.();
138
168
  return;
139
169
  }
140
170
 
141
- this.reconnectCount++;
142
- return this.connect();
171
+ const delay = this.getReconnectDelay();
172
+ this.onReconnecting?.(this.reconnectCount, delay);
173
+ log.socket(`Reconnecting (attempt ${this.reconnectCount}, delay ${delay}ms)`);
174
+
175
+ await sleep(delay / 1000);
176
+
177
+ try {
178
+ await this.connect();
179
+ this.onReconnected?.();
180
+ } catch {
181
+ // onClose will fire and trigger the next reconnect attempt
182
+ }
143
183
  }
144
184
 
145
185
  reset() {
@@ -147,6 +187,11 @@ export default class SocketClient {
147
187
  this.handlers = {};
148
188
  this.sessionKey = null;
149
189
  this.reconnectCount = 0;
190
+ this._intentionalClose = false;
191
+ this.onDisconnect = null;
192
+ this.onReconnecting = null;
193
+ this.onReconnected = null;
194
+ this.onReconnectFailed = null;
150
195
  SocketClient.instance = null;
151
196
  }
152
197
  }
package/src/server.js CHANGED
@@ -63,7 +63,7 @@ export default class SocketServer {
63
63
  }
64
64
  }
65
65
 
66
- onMessage(payload, client) {
66
+ async onMessage(payload, client) {
67
67
  try {
68
68
  let parsed;
69
69
 
@@ -96,7 +96,7 @@ export default class SocketServer {
96
96
  return;
97
97
  }
98
98
 
99
- const response = handler.server(data, client);
99
+ const response = await handler.server(data, client);
100
100
  if (response === undefined || response === null) return;
101
101
 
102
102
  if (request === 'auth' && response) {
@@ -139,18 +139,29 @@ export default class SocketServer {
139
139
  const { ip } = client;
140
140
  log.socket(`[${ip}] Client disconnected`);
141
141
  this.clientMap.delete(client.id);
142
+ this.onClientDisconnect?.(client);
142
143
  }
143
144
 
144
- sendTo(clientId, request, data) {
145
+ sendTo(clientId, request, response) {
145
146
  const client = this.clientMap.get(clientId);
146
147
  if (!client) return;
147
- client.send({ request, data });
148
+ client.send({ request, response });
148
149
  }
149
150
 
150
- broadcast(request, data) {
151
+ sendToByMeta(key, value, request, response) {
152
+ for (const [, client] of this.clientMap) {
153
+ if (client.meta?.[key] === value && client.__authenticated) {
154
+ client.send({ request, response });
155
+ return true;
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
161
+ broadcast(request, response) {
151
162
  for (const [, client] of this.clientMap) {
152
163
  if (client.__authenticated) {
153
- client.send({ request, data });
164
+ client.send({ request, response });
154
165
  }
155
166
  }
156
167
  }
@@ -1,247 +0,0 @@
1
- # API Reference
2
-
3
- ## Exports
4
-
5
- ```javascript
6
- // Main entry (src/main.js)
7
- import { SocketServer, SocketClient, Handler } from '@stonyx/sockets';
8
-
9
- // Sub-path exports
10
- import SocketServer from '@stonyx/sockets/server';
11
- import SocketClient from '@stonyx/sockets/client';
12
- import Handler from '@stonyx/sockets/handler';
13
- ```
14
-
15
- The default export from `@stonyx/sockets` is the `Sockets` class (used by Stonyx for auto-init). Consumer code should use the named exports.
16
-
17
- ---
18
-
19
- ## SocketServer
20
-
21
- **File:** `src/server.js`
22
-
23
- ### Properties
24
-
25
- | Property | Type | Description |
26
- |----------|------|-------------|
27
- | `clientMap` | `Map<number, client>` | Connected clients keyed by auto-assigned ID |
28
- | `handlers` | `Object<string, Handler>` | Discovered server handlers keyed by name |
29
- | `wss` | `WebSocketServer \| null` | The underlying `ws` WebSocketServer instance |
30
- | `encryptionEnabled` | `boolean` | Whether AES-256-GCM encryption is active |
31
- | `globalKey` | `Buffer` | Derived encryption key from authKey (if encryption enabled) |
32
-
33
- ### Static
34
-
35
- | Property | Type | Description |
36
- |----------|------|-------------|
37
- | `SocketServer.instance` | `SocketServer \| null` | Singleton instance |
38
-
39
- ### Methods
40
-
41
- #### `constructor()`
42
-
43
- Returns the existing singleton or creates a new one. Does NOT start the server.
44
-
45
- #### `async init()`
46
-
47
- Full initialization sequence:
48
- 1. Discovers handlers from `config.sockets.handlerDir`
49
- 2. Validates that an `auth` handler exists
50
- 3. Configures encryption if enabled
51
- 4. Starts `WebSocketServer` on `config.sockets.port`
52
- 5. Wires connection/message/close events
53
-
54
- #### `sendTo(clientId, request, data)`
55
-
56
- Send a message to a specific client by numeric ID.
57
-
58
- - `clientId` — number, the client's auto-assigned ID
59
- - `request` — string, the handler name
60
- - `data` — any serializable value
61
-
62
- Does nothing if the client doesn't exist.
63
-
64
- #### `broadcast(request, data)`
65
-
66
- Send a message to all authenticated clients in `clientMap`.
67
-
68
- - `request` — string, the handler name
69
- - `data` — any serializable value
70
-
71
- #### `close()`
72
-
73
- Terminates all connected clients and closes the WebSocket server.
74
-
75
- #### `reset()`
76
-
77
- Calls `close()`, clears `clientMap`, clears `handlers`, resets the client ID counter, sets `SocketServer.instance = null`. Used in tests.
78
-
79
- ### Internal Methods
80
-
81
- #### `async discoverHandlers()`
82
-
83
- Scans handler directory. For each file: instantiates the class, checks for `server()` method, registers it in `this.handlers`.
84
-
85
- #### `validateAuthHandler()`
86
-
87
- Throws `Error` if `this.handlers.auth` doesn't exist.
88
-
89
- #### `onMessage(payload, client)`
90
-
91
- Main message dispatcher. Decrypts (if enabled), parses JSON, routes to handler or built-in heartbeat, enforces auth gate.
92
-
93
- #### `prepareSend(client)`
94
-
95
- Replaces `client.send()` with a wrapper that JSON-stringifies and encrypts (if enabled).
96
-
97
- #### `handleDisconnect(client)`
98
-
99
- Removes client from `clientMap`, logs disconnection.
100
-
101
- ---
102
-
103
- ## SocketClient
104
-
105
- **File:** `src/client.js`
106
-
107
- ### Properties
108
-
109
- | Property | Type | Description |
110
- |----------|------|-------------|
111
- | `handlers` | `Object<string, Handler>` | Discovered client handlers keyed by name |
112
- | `socket` | `WebSocket \| null` | The underlying `ws` WebSocket instance |
113
- | `reconnectCount` | `number` | Current reconnection attempt count |
114
- | `promise` | `{ resolve, reject }` | Connection promise callbacks |
115
- | `encryptionEnabled` | `boolean` | Whether AES-256-GCM encryption is active |
116
- | `globalKey` | `Buffer` | Derived encryption key from authKey (if encryption enabled) |
117
- | `sessionKey` | `Buffer \| null` | Per-session key received from server after auth |
118
-
119
- ### Static
120
-
121
- | Property | Type | Description |
122
- |----------|------|-------------|
123
- | `SocketClient.instance` | `SocketClient \| null` | Singleton instance |
124
-
125
- ### Methods
126
-
127
- #### `constructor()`
128
-
129
- Returns the existing singleton or creates a new one. Does NOT connect.
130
-
131
- #### `async init()`
132
-
133
- Full initialization:
134
- 1. Discovers handlers from `config.sockets.handlerDir`
135
- 2. Configures encryption if enabled
136
- 3. Calls `connect()`
137
-
138
- Returns the Promise from `connect()`.
139
-
140
- #### `send(payload, useGlobalKey = false)`
141
-
142
- Send a message to the server.
143
-
144
- - `payload` — object with `request` and optionally `data` fields
145
- - `useGlobalKey` — boolean, use the global key instead of session key (internal, for auth)
146
-
147
- #### `close()`
148
-
149
- Clears heartbeat timer and closes the WebSocket connection.
150
-
151
- #### `reconnect()`
152
-
153
- Attempts to reconnect. Returns the `connect()` Promise. Fails after 5 consecutive attempts.
154
-
155
- #### `reset()`
156
-
157
- Calls `close()`, clears handlers, clears session key, resets reconnect counter, sets `SocketClient.instance = null`. Used in tests.
158
-
159
- ### Internal Methods
160
-
161
- #### `async discoverHandlers()`
162
-
163
- Scans handler directory. For each file: instantiates the class, checks for `client()` method, registers it.
164
-
165
- #### `async connect()`
166
-
167
- Creates WebSocket, wires events, sends auth on open. Returns Promise that resolves when auth handler resolves it.
168
-
169
- #### `onMessage({ data: payload })`
170
-
171
- Decrypts, parses, handles built-in auth/heartbeat, routes to handler.
172
-
173
- #### `heartBeat()`
174
-
175
- Sends `{ request: 'heartBeat' }` to the server.
176
-
177
- #### `nextHeartBeat()`
178
-
179
- Schedules `heartBeat()` via `setTimeout` at `config.sockets.heartBeatInterval`.
180
-
181
- #### `onClose()`
182
-
183
- Logs disconnection, clears heartbeat timer.
184
-
185
- ---
186
-
187
- ## Handler
188
-
189
- **File:** `src/handler.js`
190
-
191
- ### Static Properties
192
-
193
- | Property | Type | Default | Description |
194
- |----------|------|---------|-------------|
195
- | `skipAuth` | `boolean` | `false` | Set `true` to allow pre-auth access (server side only) |
196
-
197
- ### Instance Methods (defined by subclasses)
198
-
199
- #### `server(data, client)`
200
-
201
- Server-side hook. Called when a message with the matching handler name arrives.
202
-
203
- - `data` — the parsed `data` field from the incoming message
204
- - `client` — the WebSocket client object with `.id`, `.ip`, `.meta`, `.send()`, `.__authenticated`
205
- - **Return value:** sent back as the `response` field. Return `undefined`/`null` to send nothing.
206
-
207
- #### `client(response)`
208
-
209
- Client-side hook. Called when a response with the matching handler name arrives from the server.
210
-
211
- - `response` — the parsed `response` field from the message
212
- - **Context:** `this.client` references the `SocketClient` instance
213
-
214
- ### Instance Properties (set by framework)
215
-
216
- | Property | Set by | Description |
217
- |----------|--------|-------------|
218
- | `_serverRef` | `SocketServer.discoverHandlers()` | Reference to the SocketServer instance |
219
- | `_clientRef` | `SocketClient.discoverHandlers()` | Reference to the SocketClient instance |
220
-
221
- ---
222
-
223
- ## Client Object (Server-Side)
224
-
225
- The raw WebSocket client is augmented by the framework:
226
-
227
- | Property | Type | Set by | Description |
228
- |----------|------|--------|-------------|
229
- | `id` | `number` | Framework (on connect) | Auto-incrementing numeric ID |
230
- | `ip` | `string` | Framework (on connect) | Remote IP address |
231
- | `__authenticated` | `boolean` | Framework (on auth) | Auth state |
232
- | `__sessionKey` | `Buffer` | Framework (on auth, if encryption) | Per-session encryption key |
233
- | `meta` | `any` | Consumer (in auth handler) | App-defined metadata |
234
- | `send(payload)` | `function` | Framework (`prepareSend`) | Sends JSON, auto-encrypts if enabled |
235
-
236
- ---
237
-
238
- ## Encryption Functions
239
-
240
- **File:** `src/encryption.js`
241
-
242
- | Function | Signature | Description |
243
- |----------|-----------|-------------|
244
- | `encrypt` | `(data: string, key: Buffer) → Buffer` | AES-256-GCM encrypt, returns `iv + tag + ciphertext` |
245
- | `decrypt` | `(buffer: Buffer, key: Buffer) → string` | AES-256-GCM decrypt, returns UTF-8 string |
246
- | `generateSessionKey` | `() → Buffer` | `crypto.randomBytes(32)` |
247
- | `deriveKey` | `(authKey: string) → Buffer` | `crypto.scryptSync(authKey, 'stonyx-sockets', 32)` |
@@ -1,117 +0,0 @@
1
- # Architecture
2
-
3
- ## Module Structure
4
-
5
- ```
6
- stonyx-sockets/
7
- ├── config/environment.js # Default config, env var overrides
8
- ├── src/
9
- │ ├── main.js # Sockets class (Stonyx entry) + barrel exports
10
- │ ├── server.js # SocketServer singleton
11
- │ ├── client.js # SocketClient singleton
12
- │ ├── handler.js # Base Handler class
13
- │ └── encryption.js # AES-256-GCM utilities
14
- └── test/
15
- ├── config/environment.js # Test config overrides
16
- ├── sample/socket-handlers # Auth + echo sample handlers
17
- ├── unit/ # Handler, encryption, server, client unit tests
18
- └── integration/ # Full server+client round-trip tests
19
- ```
20
-
21
- ## Stonyx Integration
22
-
23
- ### Auto-Initialization
24
-
25
- The package declares `stonyx-async` + `stonyx-module` keywords. When Stonyx starts:
26
-
27
- 1. Reads `config/environment.js` and merges into `config.sockets`
28
- 2. Registers `log.socket()` via `logColor: 'white'` + `logMethod: 'socket'`
29
- 3. Imports `src/main.js`, instantiates the default `Sockets` class, calls `.init()`
30
-
31
- The `Sockets` default export is a thin entry point — it does NOT auto-start the WebSocket server. The actual server/client initialization is deferred to when the consumer explicitly creates `new SocketServer()` or `new SocketClient()` and calls `.init()`.
32
-
33
- ### Standalone Mode (Testing)
34
-
35
- When running `stonyx test` from within the package directory, Stonyx detects the `stonyx-` prefix in the path and transforms the config:
36
-
37
- ```javascript
38
- // config/environment.js exports { port, address, authKey, ... }
39
- // Stonyx wraps it as: { sockets: { port, address, authKey, ... } }
40
- ```
41
-
42
- Test overrides from `test/config/environment.js` are merged on top.
43
-
44
- ## Singleton Pattern
45
-
46
- Both `SocketServer` and `SocketClient` use the Stonyx singleton convention:
47
-
48
- ```javascript
49
- constructor() {
50
- if (SocketServer.instance) return SocketServer.instance;
51
- SocketServer.instance = this;
52
- }
53
- ```
54
-
55
- `reset()` sets the static instance back to `null` (used in tests).
56
-
57
- ## Initialization Lifecycle
58
-
59
- ### SocketServer.init()
60
-
61
- 1. **discoverHandlers()** — scans `config.sockets.handlerDir` via `forEachFileImport`
62
- - For each file: instantiates the class, checks for `server()` method
63
- - Stores instance in `this.handlers[name]` (kebab-to-camelCase)
64
- - Sets `instance._serverRef = this` for access to the server within handlers
65
- 2. **validateAuthHandler()** — throws if `this.handlers.auth` doesn't exist
66
- 3. **Configure encryption** — if enabled, derives global key from `authKey`
67
- 4. **Start WebSocketServer** on configured port
68
- 5. **Wire connection events** — on each connection: assign ID, set IP, wrap send, bind message/close listeners
69
-
70
- ### SocketClient.init()
71
-
72
- 1. **discoverHandlers()** — same as server, but checks for `client()` method
73
- - Sets `instance._clientRef = this` for access to the client within handlers
74
- 2. **Configure encryption** — if enabled, derives global key from `authKey`
75
- 3. **connect()** — returns Promise that resolves after auth completes
76
- - Creates WebSocket to `config.sockets.address`
77
- - On open: sends auth request
78
- - On auth response: resolves the promise, starts heartbeat
79
-
80
- ## Message Flow
81
-
82
- ### Server onMessage
83
-
84
- ```
85
- payload received
86
- → decrypt (if encryption enabled, using session key or global key for auth)
87
- → JSON.parse
88
- → heartBeat? → respond with true, return
89
- → handler lookup by request name
90
- → auth gate: if not auth handler, not skipAuth, not authenticated → reject + close
91
- → call handler.server(data, client)
92
- → if return value is truthy:
93
- → if auth request: set __authenticated, generate session key, respond
94
- → else: send { request, response } back to client
95
- ```
96
-
97
- ### Client onMessage
98
-
99
- ```
100
- payload received
101
- → decrypt (if encryption enabled, using session key or global key)
102
- → JSON.parse
103
- → auth response? → store session key, start heartbeat
104
- → heartBeat response? → schedule next heartbeat, return
105
- → handler lookup by request name
106
- → call handler.client(response) with this.client bound
107
- ```
108
-
109
- ## Dependencies
110
-
111
- | Package | Purpose |
112
- |---------|---------|
113
- | `ws` | WebSocket server (`WebSocketServer`) and client (`WebSocket`) |
114
- | `stonyx` | Framework core — config, logging, module lifecycle |
115
- | `@stonyx/utils` (dev) | `forEachFileImport` for handler auto-discovery |
116
- | `qunit` (dev) | Test framework |
117
- | `sinon` (dev) | Stubs and spies for unit tests |