@stonyx/sockets 0.1.0 → 0.1.1-alpha.1
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/.claude/api-reference.md +247 -0
- package/.claude/architecture.md +117 -0
- package/.claude/configuration.md +92 -0
- package/.claude/encryption.md +90 -0
- package/.claude/handlers.md +174 -0
- package/.claude/index.md +36 -0
- package/.claude/testing.md +135 -0
- package/.github/workflows/ci.yml +16 -0
- package/.github/workflows/publish.yml +51 -0
- package/README.md +249 -3
- package/package.json +3 -2
- package/pnpm-lock.yaml +387 -0
- package/src/client.js +12 -2
- package/src/server.js +7 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Handlers
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Handlers are the primary extension point for consumers. Each handler is a class that extends `Handler` and defines a `server()` method, a `client()` method, or both. Handler files live in the handler directory (default: `./socket-handlers`).
|
|
6
|
+
|
|
7
|
+
## Base Class
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
// src/handler.js
|
|
11
|
+
export default class Handler {
|
|
12
|
+
static skipAuth = false;
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That's it — 3 lines. The base class exists to provide the `skipAuth` default and a common prototype for `instanceof` checks.
|
|
17
|
+
|
|
18
|
+
## Defining a Handler
|
|
19
|
+
|
|
20
|
+
### Server-only handler
|
|
21
|
+
|
|
22
|
+
```javascript
|
|
23
|
+
import { Handler } from '@stonyx/sockets';
|
|
24
|
+
|
|
25
|
+
export default class ValidateGameHandler extends Handler {
|
|
26
|
+
server(data, client) {
|
|
27
|
+
// data = whatever the client sent in the 'data' field
|
|
28
|
+
// client = the WebSocket client object (with .id, .ip, .meta, .send())
|
|
29
|
+
|
|
30
|
+
// Return a value to send it back as the response
|
|
31
|
+
return { valid: true };
|
|
32
|
+
|
|
33
|
+
// Return undefined/null to send no response
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Client-only handler
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
import { Handler } from '@stonyx/sockets';
|
|
42
|
+
|
|
43
|
+
export default class ScanGamesHandler extends Handler {
|
|
44
|
+
client(response) {
|
|
45
|
+
// response = whatever the server sent back
|
|
46
|
+
// this.client = reference to the SocketClient instance
|
|
47
|
+
|
|
48
|
+
this.client.app.scanGames(response);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Dual handler (both sides)
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
import { Handler } from '@stonyx/sockets';
|
|
57
|
+
|
|
58
|
+
export default class EchoHandler extends Handler {
|
|
59
|
+
server(data) {
|
|
60
|
+
return data; // echo back
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
client(response) {
|
|
64
|
+
console.log('Got echo:', response);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Auth Handler (Required)
|
|
70
|
+
|
|
71
|
+
The `auth` handler is special — `SocketServer.init()` throws if it doesn't find a handler named `auth` with a `server()` method.
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
import { Handler } from '@stonyx/sockets';
|
|
75
|
+
import config from 'stonyx/config';
|
|
76
|
+
|
|
77
|
+
export default class AuthHandler extends Handler {
|
|
78
|
+
static skipAuth = true; // MUST be true — auth runs before authentication
|
|
79
|
+
|
|
80
|
+
server(data, client) {
|
|
81
|
+
if (data.authKey !== config.sockets.authKey) return client.close();
|
|
82
|
+
|
|
83
|
+
// Register client in the server's client map
|
|
84
|
+
this._serverRef.clientMap.set(client.id, client);
|
|
85
|
+
|
|
86
|
+
// Optionally set app-level metadata
|
|
87
|
+
client.meta = { role: 'worker' };
|
|
88
|
+
|
|
89
|
+
// Returning a truthy value triggers the framework to:
|
|
90
|
+
// 1. Set client.__authenticated = true
|
|
91
|
+
// 2. Generate and send a per-session encryption key (if encryption enabled)
|
|
92
|
+
// 3. Send the response back to the client
|
|
93
|
+
return 'success';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
client(response) {
|
|
97
|
+
// this.client = the SocketClient instance
|
|
98
|
+
if (response !== 'success') this.client.promise.reject(response);
|
|
99
|
+
this.client.promise.resolve();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Auth enforcement rules
|
|
105
|
+
|
|
106
|
+
- If a message arrives from an unauthenticated client:
|
|
107
|
+
- And the handler is the `auth` handler → allowed
|
|
108
|
+
- And the handler has `static skipAuth = true` → allowed
|
|
109
|
+
- Otherwise → rejected, connection closed
|
|
110
|
+
- The framework sets `client.__authenticated = true` when the auth handler returns a truthy value
|
|
111
|
+
- If the auth handler returns `undefined`/`null`/falsy, auth fails (no response sent)
|
|
112
|
+
|
|
113
|
+
## Handler Discovery
|
|
114
|
+
|
|
115
|
+
Both `SocketServer` and `SocketClient` call `forEachFileImport` on the handler directory:
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
await forEachFileImport(handlerDir, (HandlerClass, { name }) => {
|
|
119
|
+
const instance = new HandlerClass();
|
|
120
|
+
if (typeof instance.server === 'function') {
|
|
121
|
+
instance._serverRef = this;
|
|
122
|
+
this.handlers[name] = instance;
|
|
123
|
+
}
|
|
124
|
+
}, { ignoreAccessFailure: true });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- **Filename → handler name:** kebab-case to camelCase (`validate-game.js` → `validateGame`)
|
|
128
|
+
- **Exception:** `auth.js` stays as `auth`
|
|
129
|
+
- **ignoreAccessFailure:** If the handler directory doesn't exist, no error is thrown
|
|
130
|
+
|
|
131
|
+
## Handler Context
|
|
132
|
+
|
|
133
|
+
### Inside `server()` hooks
|
|
134
|
+
|
|
135
|
+
- `this._serverRef` — the `SocketServer` instance
|
|
136
|
+
- First argument `data` — the parsed `data` field from the message
|
|
137
|
+
- Second argument `client` — the WebSocket client object
|
|
138
|
+
|
|
139
|
+
### Inside `client()` hooks
|
|
140
|
+
|
|
141
|
+
- `this.client` — the `SocketClient` instance (set via `.call()` binding)
|
|
142
|
+
- First argument `response` — the parsed `response` field from the message
|
|
143
|
+
|
|
144
|
+
## Wire Protocol
|
|
145
|
+
|
|
146
|
+
All messages are JSON objects with a `request` field:
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
// Client → Server (outgoing request)
|
|
150
|
+
{ request: 'handlerName', data: { ... } }
|
|
151
|
+
|
|
152
|
+
// Server → Client (response from handler return value)
|
|
153
|
+
{ request: 'handlerName', response: { ... } }
|
|
154
|
+
|
|
155
|
+
// Server → Client (explicit send via sendTo/broadcast)
|
|
156
|
+
{ request: 'handlerName', data: { ... } }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Built-in Handlers
|
|
160
|
+
|
|
161
|
+
These are handled by the framework — consumers do NOT define handlers for them:
|
|
162
|
+
|
|
163
|
+
### heartBeat
|
|
164
|
+
|
|
165
|
+
- **Server:** Receives `heartBeat` request → responds with `{ request: 'heartBeat', response: true }`
|
|
166
|
+
- **Client:** Receives `heartBeat` response → schedules next heartbeat via `setTimeout`
|
|
167
|
+
- **Lifecycle:** Automatically started after successful auth; interval configured via `heartBeatInterval`
|
|
168
|
+
|
|
169
|
+
### auth (framework-level handling)
|
|
170
|
+
|
|
171
|
+
While consumers define the auth handler logic, the framework wraps it with:
|
|
172
|
+
- Auto-setting `client.__authenticated = true` on truthy return
|
|
173
|
+
- Auto-generating and transmitting per-session encryption keys
|
|
174
|
+
- Auto-starting the heartbeat cycle on the client side
|
package/.claude/index.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @stonyx/sockets — Agent Documentation Index
|
|
2
|
+
|
|
3
|
+
Comprehensive reference for AI agents working on the `@stonyx/sockets` package. Start here, then drill into specific docs as needed.
|
|
4
|
+
|
|
5
|
+
## Quick Orientation
|
|
6
|
+
|
|
7
|
+
`@stonyx/sockets` is a Stonyx framework module providing WebSocket server/client with handler auto-discovery, auth enforcement, AES-256-GCM encryption, and built-in heartbeat. It follows the same conventions as `@stonyx/rest-server` and `stonyx-orm`.
|
|
8
|
+
|
|
9
|
+
## Documentation
|
|
10
|
+
|
|
11
|
+
- [architecture.md](./architecture.md) — Module structure, singleton pattern, Stonyx integration, handler discovery lifecycle
|
|
12
|
+
- [handlers.md](./handlers.md) — Handler class API, server/client hooks, auth flow, skipAuth, wire protocol
|
|
13
|
+
- [encryption.md](./encryption.md) — AES-256-GCM encryption, key derivation, handshake flow, session keys
|
|
14
|
+
- [configuration.md](./configuration.md) — All config options, env vars, defaults, how config loads via Stonyx
|
|
15
|
+
- [testing.md](./testing.md) — Test structure, running tests, sample handlers, writing new tests
|
|
16
|
+
- [api-reference.md](./api-reference.md) — Complete method/property reference for SocketServer, SocketClient, Handler
|
|
17
|
+
|
|
18
|
+
## Key Files
|
|
19
|
+
|
|
20
|
+
| File | Purpose |
|
|
21
|
+
|------|---------|
|
|
22
|
+
| `src/main.js` | Entry point — `Sockets` default class (Stonyx auto-init) + barrel exports |
|
|
23
|
+
| `src/server.js` | `SocketServer` — singleton, handler discovery, auth gate, message dispatch |
|
|
24
|
+
| `src/client.js` | `SocketClient` — singleton, handler discovery, connect/auth/heartbeat |
|
|
25
|
+
| `src/handler.js` | `Handler` base class (3 lines — just `skipAuth` flag) |
|
|
26
|
+
| `src/encryption.js` | AES-256-GCM encrypt/decrypt, key derivation, session key generation |
|
|
27
|
+
| `config/environment.js` | Default config with env var overrides |
|
|
28
|
+
|
|
29
|
+
## Conventions
|
|
30
|
+
|
|
31
|
+
- **Singleton pattern:** `if (Class.instance) return Class.instance;` in constructor
|
|
32
|
+
- **Stonyx module keywords:** `stonyx-async` + `stonyx-module` in package.json
|
|
33
|
+
- **Config namespace:** `config.sockets` (camelCase of package name minus `@stonyx/`)
|
|
34
|
+
- **Logging:** `log.socket()` via `logColor: 'white'` + `logMethod: 'socket'` in config
|
|
35
|
+
- **Handler discovery:** `forEachFileImport` from `@stonyx/utils/file`, kebab-to-camelCase naming
|
|
36
|
+
- **Test runner:** `stonyx test` (not plain `qunit`) — bootstraps Stonyx before running tests
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Running Tests
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
# From the stonyx-sockets directory
|
|
7
|
+
npx stonyx test
|
|
8
|
+
|
|
9
|
+
# Or via pnpm
|
|
10
|
+
pnpm test
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Important:** Use `stonyx test`, not plain `qunit`. The Stonyx test runner bootstraps the framework (config, logging, module init) before running QUnit. Without it, `stonyx/config` and `log.socket()` won't be available.
|
|
14
|
+
|
|
15
|
+
## Test Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
test/
|
|
19
|
+
├── config/
|
|
20
|
+
│ └── environment.js # Test-specific config overrides
|
|
21
|
+
├── sample/
|
|
22
|
+
│ └── socket-handlers/
|
|
23
|
+
│ ├── auth.js # Sample auth handler (server + client hooks)
|
|
24
|
+
│ └── echo.js # Simple echo handler (both hooks)
|
|
25
|
+
├── unit/
|
|
26
|
+
│ ├── handler-test.js # Base Handler class tests
|
|
27
|
+
│ ├── encryption-test.js # AES-256-GCM encrypt/decrypt tests
|
|
28
|
+
│ ├── server-test.js # SocketServer unit tests (no network)
|
|
29
|
+
│ └── client-test.js # SocketClient unit tests (no network)
|
|
30
|
+
└── integration/
|
|
31
|
+
└── socket-test.js # Full server+client round-trip tests
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Test Config
|
|
35
|
+
|
|
36
|
+
```javascript
|
|
37
|
+
// test/config/environment.js
|
|
38
|
+
export default {
|
|
39
|
+
sockets: {
|
|
40
|
+
handlerDir: './test/sample/socket-handlers',
|
|
41
|
+
heartBeatInterval: 60000, // Long interval so timers don't fire during tests
|
|
42
|
+
encryption: 'false', // Disabled for test simplicity
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Sample Handlers
|
|
48
|
+
|
|
49
|
+
### auth.js
|
|
50
|
+
|
|
51
|
+
Validates `authKey` against config, registers client in `clientMap`, resolves the connection promise. Has `static skipAuth = true`.
|
|
52
|
+
|
|
53
|
+
### echo.js
|
|
54
|
+
|
|
55
|
+
Server returns whatever data it receives. Client stores the response on `client._lastEchoResponse` for test assertions.
|
|
56
|
+
|
|
57
|
+
## Writing Unit Tests
|
|
58
|
+
|
|
59
|
+
Unit tests do NOT start a WebSocket server. They test class behavior directly:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
import QUnit from 'qunit';
|
|
63
|
+
import SocketServer from '../../src/server.js';
|
|
64
|
+
|
|
65
|
+
const { module, test } = QUnit;
|
|
66
|
+
|
|
67
|
+
module('[Unit] SocketServer', function (hooks) {
|
|
68
|
+
hooks.afterEach(function () {
|
|
69
|
+
const server = SocketServer.instance;
|
|
70
|
+
if (server) server.reset();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('Singleton pattern', function (assert) {
|
|
74
|
+
const s1 = new SocketServer();
|
|
75
|
+
const s2 = new SocketServer();
|
|
76
|
+
assert.strictEqual(s1, s2);
|
|
77
|
+
s1.reset();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Key patterns:
|
|
83
|
+
- Always call `reset()` in `afterEach` to clear the singleton
|
|
84
|
+
- Use `sinon` for stubs/spies when needed
|
|
85
|
+
- Restore sinon in `afterEach` with `sinon.restore()`
|
|
86
|
+
|
|
87
|
+
## Writing Integration Tests
|
|
88
|
+
|
|
89
|
+
Integration tests start a real server and client:
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
import QUnit from 'qunit';
|
|
93
|
+
import SocketServer from '../../src/server.js';
|
|
94
|
+
import SocketClient from '../../src/client.js';
|
|
95
|
+
import { setupIntegrationTests } from 'stonyx/test-helpers';
|
|
96
|
+
|
|
97
|
+
const { module, test } = QUnit;
|
|
98
|
+
|
|
99
|
+
module('[Integration] Sockets', function (hooks) {
|
|
100
|
+
setupIntegrationTests(hooks); // Waits for Stonyx.ready
|
|
101
|
+
|
|
102
|
+
hooks.afterEach(function () {
|
|
103
|
+
const client = SocketClient.instance;
|
|
104
|
+
const server = SocketServer.instance;
|
|
105
|
+
if (client) client.reset();
|
|
106
|
+
if (server) server.reset();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('Round-trip', async function (assert) {
|
|
110
|
+
const server = new SocketServer();
|
|
111
|
+
await server.init();
|
|
112
|
+
|
|
113
|
+
const client = new SocketClient();
|
|
114
|
+
await client.init();
|
|
115
|
+
|
|
116
|
+
client.send({ request: 'echo', data: { msg: 'hello' } });
|
|
117
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(client._lastEchoResponse, { msg: 'hello' });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Key patterns:
|
|
125
|
+
- `setupIntegrationTests(hooks)` — adds a `hooks.before` that `await Stonyx.ready`
|
|
126
|
+
- Always clean up in `afterEach` — `reset()` terminates connections and clears state
|
|
127
|
+
- Use `setTimeout` + `await` for async message assertions (messages are async)
|
|
128
|
+
- For multiple clients: null out `SocketClient.instance` between creations, track extras for cleanup
|
|
129
|
+
|
|
130
|
+
## Common Gotchas
|
|
131
|
+
|
|
132
|
+
- **Process hangs after tests:** Usually caused by un-cleared heartbeat timers or unclosed WebSocket servers. Ensure `reset()` is called for all instances.
|
|
133
|
+
- **`log.socket is not a function`:** Running `qunit` directly instead of `stonyx test`. The Stonyx bootstrap is required.
|
|
134
|
+
- **`moduleClass is not a constructor`:** The `src/main.js` default export must be a class (not just named exports). The `Sockets` class serves as the Stonyx auto-init entry point.
|
|
135
|
+
- **Port conflicts:** Integration tests use port 2667 by default. If tests run in parallel with other services, override `SOCKET_PORT`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [dev, main]
|
|
6
|
+
|
|
7
|
+
concurrency:
|
|
8
|
+
group: ci-${{ github.head_ref || github.ref }}
|
|
9
|
+
cancel-in-progress: true
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish to NPM
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
repository_dispatch:
|
|
5
|
+
types: [cascade-publish]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
version-type:
|
|
9
|
+
description: 'Version type'
|
|
10
|
+
required: true
|
|
11
|
+
type: choice
|
|
12
|
+
options:
|
|
13
|
+
- patch
|
|
14
|
+
- minor
|
|
15
|
+
- major
|
|
16
|
+
custom-version:
|
|
17
|
+
description: 'Custom version (optional, overrides version-type)'
|
|
18
|
+
required: false
|
|
19
|
+
type: string
|
|
20
|
+
pull_request:
|
|
21
|
+
types: [opened, synchronize, reopened]
|
|
22
|
+
branches: [main]
|
|
23
|
+
push:
|
|
24
|
+
branches: [main]
|
|
25
|
+
|
|
26
|
+
concurrency:
|
|
27
|
+
group: ${{ github.event_name == 'repository_dispatch' && 'cascade-update' || format('publish-{0}', github.ref) }}
|
|
28
|
+
cancel-in-progress: false
|
|
29
|
+
|
|
30
|
+
permissions:
|
|
31
|
+
contents: write
|
|
32
|
+
id-token: write
|
|
33
|
+
pull-requests: write
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
publish:
|
|
37
|
+
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
|
38
|
+
uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
|
|
39
|
+
with:
|
|
40
|
+
version-type: ${{ github.event.inputs.version-type }}
|
|
41
|
+
custom-version: ${{ github.event.inputs.custom-version }}
|
|
42
|
+
cascade-source: ${{ github.event.client_payload.source_package || '' }}
|
|
43
|
+
secrets: inherit
|
|
44
|
+
|
|
45
|
+
cascade:
|
|
46
|
+
needs: publish
|
|
47
|
+
uses: abofs/stonyx-workflows/.github/workflows/cascade.yml@main
|
|
48
|
+
with:
|
|
49
|
+
package-name: ${{ needs.publish.outputs.package-name }}
|
|
50
|
+
published-version: ${{ needs.publish.outputs.published-version }}
|
|
51
|
+
secrets: inherit
|
package/README.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# @stonyx/sockets
|
|
2
2
|
|
|
3
|
-
WebSocket server and client module for the Stonyx framework.
|
|
3
|
+
WebSocket server and client module for the [Stonyx framework](https://github.com/abofs/stonyx), providing plug-and-play handler discovery, built-in authentication enforcement, AES-256-GCM encryption, and automatic heartbeat keep-alive.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
* **Handler auto-discovery:** Drop handler files into a directory and the framework registers them automatically.
|
|
8
|
+
* **Unified handler files:** A single file can define both `server()` and `client()` hooks.
|
|
9
|
+
* **Auth enforcement:** An `auth` handler is required. Unauthenticated requests are rejected by default.
|
|
10
|
+
* **Built-in heartbeat:** Keep-alive is managed by the framework — no handler needed.
|
|
11
|
+
* **Encryption by default:** AES-256-GCM with per-session keys, zero external dependencies.
|
|
12
|
+
* **Singleton pattern:** Matches the conventions of `@stonyx/rest-server` and `stonyx-orm`.
|
|
4
13
|
|
|
5
14
|
## Installation
|
|
6
15
|
|
|
@@ -8,8 +17,245 @@ WebSocket server and client module for the Stonyx framework.
|
|
|
8
17
|
npm install @stonyx/sockets
|
|
9
18
|
```
|
|
10
19
|
|
|
11
|
-
##
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### 1. Create handler files
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
socket-handlers/ # default directory (configurable)
|
|
26
|
+
auth.js # REQUIRED — must have a server() hook
|
|
27
|
+
scan-games.js # app-specific handlers
|
|
28
|
+
validate-game.js
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Write a handler
|
|
32
|
+
|
|
33
|
+
Each handler extends `Handler` and defines a `server()` method, a `client()` method, or both:
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
// socket-handlers/auth.js
|
|
37
|
+
import { Handler } from '@stonyx/sockets';
|
|
38
|
+
import config from 'stonyx/config';
|
|
39
|
+
|
|
40
|
+
export default class AuthHandler extends Handler {
|
|
41
|
+
static skipAuth = true; // auth handler must work before authentication
|
|
42
|
+
|
|
43
|
+
server(data, client) {
|
|
44
|
+
if (data.authKey !== config.sockets.authKey) return client.close();
|
|
45
|
+
|
|
46
|
+
this._serverRef.clientMap.set(client.id, client);
|
|
47
|
+
return 'success';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
client(response) {
|
|
51
|
+
if (response !== 'success') this.client.promise.reject(response);
|
|
52
|
+
this.client.promise.resolve();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
12
56
|
|
|
13
57
|
```javascript
|
|
14
|
-
|
|
58
|
+
// socket-handlers/scan-games.js — client-only handler
|
|
59
|
+
import { Handler } from '@stonyx/sockets';
|
|
60
|
+
|
|
61
|
+
export default class ScanGamesHandler extends Handler {
|
|
62
|
+
client(validGames) {
|
|
63
|
+
this.client.app.scanGames(validGames);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 3. Start the server / client
|
|
69
|
+
|
|
70
|
+
With Stonyx auto-initialization (recommended):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
stonyx serve
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or manually:
|
|
77
|
+
|
|
78
|
+
```javascript
|
|
79
|
+
import { SocketServer, SocketClient } from '@stonyx/sockets';
|
|
80
|
+
|
|
81
|
+
// Server side
|
|
82
|
+
const server = new SocketServer();
|
|
83
|
+
await server.init();
|
|
84
|
+
|
|
85
|
+
// Client side
|
|
86
|
+
const client = new SocketClient();
|
|
87
|
+
await client.init();
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Handler Architecture
|
|
91
|
+
|
|
92
|
+
### How handlers are discovered
|
|
93
|
+
|
|
94
|
+
On `init()`, both `SocketServer` and `SocketClient` scan the handler directory using `forEachFileImport`. Each file's default export is inspected:
|
|
95
|
+
|
|
96
|
+
- Has a `server()` method → registered on `SocketServer`
|
|
97
|
+
- Has a `client()` method → registered on `SocketClient`
|
|
98
|
+
- Has both → registered on both sides
|
|
99
|
+
|
|
100
|
+
Handler filenames are converted from kebab-case to camelCase: `validate-game.js` → `validateGame`.
|
|
101
|
+
|
|
102
|
+
### Handler hooks
|
|
103
|
+
|
|
104
|
+
**Server hook:** `server(data, client)` — receives the request data and the client object. Return a value to send it back as a response.
|
|
105
|
+
|
|
106
|
+
**Client hook:** `client(response)` — receives the server's response. Inside the hook, `this.client` references the `SocketClient` instance.
|
|
107
|
+
|
|
108
|
+
### skipAuth
|
|
109
|
+
|
|
110
|
+
Set `static skipAuth = true` on a handler class to allow it to execute before the client is authenticated. The `auth` handler must always set this.
|
|
111
|
+
|
|
112
|
+
## Sending Messages
|
|
113
|
+
|
|
114
|
+
### Client → Server
|
|
115
|
+
|
|
116
|
+
```javascript
|
|
117
|
+
// From app code
|
|
118
|
+
client.send({ request: 'handlerName', data: { ... } });
|
|
119
|
+
|
|
120
|
+
// From within a client handler
|
|
121
|
+
this.client.send({ request: 'handlerName', data: { ... } });
|
|
15
122
|
```
|
|
123
|
+
|
|
124
|
+
### Server → Client
|
|
125
|
+
|
|
126
|
+
```javascript
|
|
127
|
+
// Auto-reply (return value from server() hook becomes the response)
|
|
128
|
+
server(data, client) {
|
|
129
|
+
return 'success'; // sends { request, response: 'success' } back
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Send to a specific client by ID
|
|
133
|
+
server.sendTo(clientId, 'scanGames', gamesList);
|
|
134
|
+
|
|
135
|
+
// Broadcast to all authenticated clients
|
|
136
|
+
server.broadcast('announcement', { msg: 'shutdown in 5m' });
|
|
137
|
+
|
|
138
|
+
// Filter by metadata using clientMap directly
|
|
139
|
+
for (const [id, client] of server.clientMap) {
|
|
140
|
+
if (client.meta?.role === 'worker') {
|
|
141
|
+
client.send({ request: 'scanGames', data: games });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Wire Protocol
|
|
147
|
+
|
|
148
|
+
All messages are JSON:
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
{ request: 'handlerName', data: { ... } } // outgoing request
|
|
152
|
+
{ request: 'handlerName', response: { ... } } // reply
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Client Object (Server-Side)
|
|
156
|
+
|
|
157
|
+
Each connected WebSocket client is augmented with:
|
|
158
|
+
|
|
159
|
+
| Property | Description |
|
|
160
|
+
|----------|-------------|
|
|
161
|
+
| `client.id` | Auto-assigned numeric ID (incrementing) |
|
|
162
|
+
| `client.ip` | Remote IP address |
|
|
163
|
+
| `client.meta` | App-defined metadata (set in your auth handler) |
|
|
164
|
+
| `client.__authenticated` | Framework-managed auth flag |
|
|
165
|
+
| `client.send(payload)` | Wrapped send that handles JSON + encryption |
|
|
166
|
+
|
|
167
|
+
## Built-in Mechanisms
|
|
168
|
+
|
|
169
|
+
### Heartbeat
|
|
170
|
+
|
|
171
|
+
The framework automatically manages keep-alive. After successful authentication, the client begins sending periodic `heartBeat` requests at the configured interval. The server responds automatically. No handler needed.
|
|
172
|
+
|
|
173
|
+
### Authentication Flow
|
|
174
|
+
|
|
175
|
+
1. Client connects and sends `{ request: 'auth', data: { authKey } }`
|
|
176
|
+
2. Server routes to the `auth` handler's `server()` method
|
|
177
|
+
3. If the handler returns a truthy value, `client.__authenticated` is set to `true`
|
|
178
|
+
4. Response is sent back; if encryption is enabled, a per-session key is included
|
|
179
|
+
5. Client's `auth` handler `client()` method processes the response
|
|
180
|
+
6. Heartbeat cycle begins automatically
|
|
181
|
+
|
|
182
|
+
**Auth enforcement:** Any message from an unauthenticated client is rejected and the connection is closed, unless the handler has `static skipAuth = true`.
|
|
183
|
+
|
|
184
|
+
**Missing auth handler:** `SocketServer.init()` throws if no `auth` handler with a `server()` method exists.
|
|
185
|
+
|
|
186
|
+
## Encryption
|
|
187
|
+
|
|
188
|
+
Enabled by default (`SOCKET_ENCRYPTION=true`). Uses Node.js native `crypto` — zero external dependencies.
|
|
189
|
+
|
|
190
|
+
- **Algorithm:** AES-256-GCM
|
|
191
|
+
- **Key derivation:** `crypto.scryptSync` from the `authKey` string
|
|
192
|
+
- **Handshake:** Auth request/response use the global key; server generates a per-session key for all subsequent messages
|
|
193
|
+
- **Wire format:** `iv (12 bytes) + auth tag (16 bytes) + ciphertext`
|
|
194
|
+
|
|
195
|
+
Disable with `SOCKET_ENCRYPTION=false`.
|
|
196
|
+
|
|
197
|
+
## Configuration
|
|
198
|
+
|
|
199
|
+
Configuration is read from `stonyx/config` under `sockets`:
|
|
200
|
+
|
|
201
|
+
| Option | Env Var | Default | Description |
|
|
202
|
+
|--------|---------|---------|-------------|
|
|
203
|
+
| `port` | `SOCKET_PORT` | `2667` | WebSocket server port |
|
|
204
|
+
| `address` | `SOCKET_ADDRESS` | `ws://localhost:2667` | Client connection address |
|
|
205
|
+
| `authKey` | `SOCKET_AUTH_KEY` | `'AUTH_KEY'` | Shared authentication key |
|
|
206
|
+
| `heartBeatInterval` | `SOCKET_HEARTBEAT_INTERVAL` | `30000` | Heartbeat interval in ms |
|
|
207
|
+
| `handlerDir` | `SOCKET_HANDLER_DIR` | `'./socket-handlers'` | Handler directory path |
|
|
208
|
+
| `encryption` | `SOCKET_ENCRYPTION` | `'true'` | Enable AES-256-GCM encryption |
|
|
209
|
+
|
|
210
|
+
## API Reference
|
|
211
|
+
|
|
212
|
+
### SocketServer
|
|
213
|
+
|
|
214
|
+
| Method | Description |
|
|
215
|
+
|--------|-------------|
|
|
216
|
+
| `new SocketServer()` | Singleton constructor |
|
|
217
|
+
| `async init()` | Discover handlers, validate auth, start WebSocket server |
|
|
218
|
+
| `sendTo(clientId, request, data)` | Send to one client by ID |
|
|
219
|
+
| `broadcast(request, data)` | Send to all authenticated clients |
|
|
220
|
+
| `clientMap` | `Map<id, client>` of connected clients |
|
|
221
|
+
| `close()` | Terminate all connections and stop the server |
|
|
222
|
+
| `reset()` | Close + clear all state (for testing) |
|
|
223
|
+
|
|
224
|
+
### SocketClient
|
|
225
|
+
|
|
226
|
+
| Method | Description |
|
|
227
|
+
|--------|-------------|
|
|
228
|
+
| `new SocketClient()` | Singleton constructor |
|
|
229
|
+
| `async init()` | Discover handlers, connect, authenticate |
|
|
230
|
+
| `send(payload)` | Send a message to the server |
|
|
231
|
+
| `close()` | Close the connection |
|
|
232
|
+
| `reconnect()` | Reconnect (max 5 retries) |
|
|
233
|
+
| `reset()` | Close + clear all state (for testing) |
|
|
234
|
+
|
|
235
|
+
### Handler
|
|
236
|
+
|
|
237
|
+
| Property / Method | Description |
|
|
238
|
+
|-------------------|-------------|
|
|
239
|
+
| `static skipAuth = false` | Set `true` to allow pre-auth access |
|
|
240
|
+
| `server(data, client)` | Server-side hook (optional) |
|
|
241
|
+
| `client(response)` | Client-side hook (optional) |
|
|
242
|
+
| `this._serverRef` | Reference to SocketServer (in server hooks) |
|
|
243
|
+
| `this.client` | Reference to SocketClient (in client hooks) |
|
|
244
|
+
|
|
245
|
+
## Example Project Structure
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
my-app/
|
|
249
|
+
├── config/
|
|
250
|
+
│ └── environment.js
|
|
251
|
+
├── socket-handlers/
|
|
252
|
+
│ ├── auth.js # Required
|
|
253
|
+
│ ├── scan-games.js
|
|
254
|
+
│ └── validate-game.js
|
|
255
|
+
├── package.json
|
|
256
|
+
└── app.js
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
Apache — do what you want, just keep attribution.
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.
|
|
7
|
+
"version": "0.1.1-alpha.1",
|
|
8
8
|
"description": "WebSocket server and client module for the Stonyx framework",
|
|
9
9
|
"main": "src/main.js",
|
|
10
10
|
"type": "module",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"./handler": "./src/handler.js"
|
|
19
19
|
},
|
|
20
20
|
"publishConfig": {
|
|
21
|
-
"access": "public"
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": true
|
|
22
23
|
},
|
|
23
24
|
"repository": {
|
|
24
25
|
"type": "git",
|