reflex-sync 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 +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +256 -0
- package/package.json +38 -0
- package/src/core/Reflex.ts +164 -0
- package/src/index.ts +7 -0
- package/src/security/Authenticator.ts +54 -0
- package/src/state/ProxyManager.ts +69 -0
- package/src/types/index.ts +36 -0
- package/tsconfig.json +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrei
|
|
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,80 @@
|
|
|
1
|
+
# REFLEX
|
|
2
|
+
|
|
3
|
+
Transparent reactive state synchronization for distributed Node.js environments.
|
|
4
|
+
|
|
5
|
+
## ⚙️ Configuration Reference
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
interface ReflexOptions {
|
|
9
|
+
mode: 'master' | 'client';
|
|
10
|
+
host?: string; // Remote address (required for client)
|
|
11
|
+
port: number; // Port for TCP/TLS tunnel
|
|
12
|
+
key: string; // Shared secret for HMAC handshake
|
|
13
|
+
secure?: boolean; // Enable TLS 1.3 encryption
|
|
14
|
+
tls?: { // Standard Node.js TLS options
|
|
15
|
+
cert?: string;
|
|
16
|
+
key?: string;
|
|
17
|
+
ca?: string;
|
|
18
|
+
rejectUnauthorized?: boolean;
|
|
19
|
+
};
|
|
20
|
+
limits?: {
|
|
21
|
+
maxClients?: number; // Maximum inbound connections
|
|
22
|
+
maxPayloadSize?: number; // Inbound packet size cap (default 1MB)
|
|
23
|
+
opsPerSecond?: number; // Sliding-window update threshold (default 500)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 🛠️ API Reference
|
|
29
|
+
|
|
30
|
+
### `reflex.boot(): Promise<void>`
|
|
31
|
+
Initializes the instance. On `master`, starts the TCP/TLS listener. On `client`, initiates the handshake and state hydration.
|
|
32
|
+
|
|
33
|
+
### `reflex.state: T`
|
|
34
|
+
A reactive `Proxy` representing the synchronized state. Changes made to this object are automatically propagated across the network using path-based delta updates. Supports deep nesting and property deletion.
|
|
35
|
+
|
|
36
|
+
### 📡 Events
|
|
37
|
+
|
|
38
|
+
| Event | Origin | Payload | Description |
|
|
39
|
+
|-------|--------|---------|-------------|
|
|
40
|
+
| `ready` | Master | `string` | Listener is active and awaiting connections. |
|
|
41
|
+
| `synced` | Client | `void` | Initial state hydration from master is complete. |
|
|
42
|
+
| `update` | Any | `Packet` | Fired when a property change is received from the network. |
|
|
43
|
+
| `clientConnect` | Master | `string` | Fired when a client successfully authenticates (returns IP). |
|
|
44
|
+
| `error` | Any | `Error` | Fired on connection drops or socket failures. |
|
|
45
|
+
| `warn` | Any | `string` | Fired for non-fatal security violations (Rate limits, Over-sized payloads). |
|
|
46
|
+
|
|
47
|
+
## 🕹️ Examples
|
|
48
|
+
|
|
49
|
+
### Synchronization Patterns
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// Master: Authoritative Node
|
|
53
|
+
const bot = new Reflex({ mode: 'master', port: 8080, key: 'secret' }, { stats: { uptime: 0 } });
|
|
54
|
+
await bot.boot();
|
|
55
|
+
|
|
56
|
+
// Client: Remote Dashboard
|
|
57
|
+
const web = new Reflex({ mode: 'client', host: 'bot.server.com', port: 8080, key: 'secret' });
|
|
58
|
+
await web.boot();
|
|
59
|
+
|
|
60
|
+
// Cross-application reaction
|
|
61
|
+
web.on('update', (pkg) => {
|
|
62
|
+
if (pkg.p.includes('uptime')) renderUI(pkg.v);
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 🔐 Protocol Implementation Details
|
|
67
|
+
|
|
68
|
+
### Conflict Resolution
|
|
69
|
+
Reflex implements a **Last Write Wins (LWW)** strategy using UTC timestamps (`ts`) generated at the point of origin. Updates arriving at a node with a timestamp older than the current property metadata are discarded to prevent out-of-order state transitions.
|
|
70
|
+
|
|
71
|
+
### Security Handshake
|
|
72
|
+
1. Client initiates TCP/TLS connection.
|
|
73
|
+
2. Client sends `AUTH` packet with the `key`.
|
|
74
|
+
3. Master validates the `key`.
|
|
75
|
+
4. Master transmits the full current state and metadata mapping (`SYNC`).
|
|
76
|
+
5. Connection is promoted to the active sync set.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
MIT © 2026 Andrei
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @copyright 2026 Andrei
|
|
5
|
+
* @license MIT
|
|
6
|
+
* @package REFLEX
|
|
7
|
+
*/
|
|
8
|
+
type Operation = 'SET' | 'DELETE' | 'AUTH' | 'SYNC' | 'PONG' | 'PING';
|
|
9
|
+
interface Packet {
|
|
10
|
+
t: Operation;
|
|
11
|
+
p?: string[];
|
|
12
|
+
v?: any;
|
|
13
|
+
ts?: number;
|
|
14
|
+
m?: Record<string, number>;
|
|
15
|
+
k?: string;
|
|
16
|
+
s?: string;
|
|
17
|
+
}
|
|
18
|
+
interface ReflexOptions {
|
|
19
|
+
host?: string;
|
|
20
|
+
port: number;
|
|
21
|
+
key: string;
|
|
22
|
+
mode: 'master' | 'client';
|
|
23
|
+
secure?: boolean;
|
|
24
|
+
tls?: {
|
|
25
|
+
cert?: string;
|
|
26
|
+
key?: string;
|
|
27
|
+
ca?: string;
|
|
28
|
+
rejectUnauthorized?: boolean;
|
|
29
|
+
};
|
|
30
|
+
limits?: {
|
|
31
|
+
maxClients?: number;
|
|
32
|
+
maxPayloadSize?: number;
|
|
33
|
+
opsPerSecond?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @copyright 2026 Andrei
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
declare class Reflex<T extends object> extends EventEmitter {
|
|
42
|
+
private opts;
|
|
43
|
+
private _state;
|
|
44
|
+
private _proxy;
|
|
45
|
+
private _server;
|
|
46
|
+
private _socket;
|
|
47
|
+
private _clients;
|
|
48
|
+
private readonly _auth;
|
|
49
|
+
private _timestamps;
|
|
50
|
+
constructor(opts: ReflexOptions, initial?: T);
|
|
51
|
+
get state(): T;
|
|
52
|
+
boot(): Promise<void>;
|
|
53
|
+
private _serve;
|
|
54
|
+
private _join;
|
|
55
|
+
private _handleInbound;
|
|
56
|
+
private _process;
|
|
57
|
+
private _propagate;
|
|
58
|
+
private _transmit;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { type Operation, type Packet, Reflex, type ReflexOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// src/core/Reflex.ts
|
|
2
|
+
import net from "net";
|
|
3
|
+
import tls from "tls";
|
|
4
|
+
import { EventEmitter } from "events";
|
|
5
|
+
|
|
6
|
+
// src/security/Authenticator.ts
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
var Authenticator = class {
|
|
9
|
+
static ALGORITHM = "sha256";
|
|
10
|
+
clientOps = /* @__PURE__ */ new Map();
|
|
11
|
+
lastReset = Date.now();
|
|
12
|
+
/**
|
|
13
|
+
* @params {string} key
|
|
14
|
+
* @params {string} salt
|
|
15
|
+
* @returns {string} HMAC signature
|
|
16
|
+
*/
|
|
17
|
+
static sign(key, salt) {
|
|
18
|
+
return crypto.createHmac(this.ALGORITHM, key).update(salt).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @params {string} challenge
|
|
22
|
+
* @params {string} signature
|
|
23
|
+
* @params {string} key
|
|
24
|
+
* @returns {boolean} Valid/Invalid
|
|
25
|
+
*/
|
|
26
|
+
static verify(challenge, signature, key) {
|
|
27
|
+
const expected = this.sign(key, challenge);
|
|
28
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* @params {string} id
|
|
32
|
+
* @params {number} limit
|
|
33
|
+
* @returns {boolean} Within limit/Exceeded
|
|
34
|
+
*/
|
|
35
|
+
rateLimit(id, limit) {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (now - this.lastReset > 1e3) {
|
|
38
|
+
this.clientOps.clear();
|
|
39
|
+
this.lastReset = now;
|
|
40
|
+
}
|
|
41
|
+
const count = (this.clientOps.get(id) || 0) + 1;
|
|
42
|
+
if (count > limit) return false;
|
|
43
|
+
this.clientOps.set(id, count);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/state/ProxyManager.ts
|
|
49
|
+
var ProxyManager = class {
|
|
50
|
+
_onChange;
|
|
51
|
+
/**
|
|
52
|
+
* @params {(path: string[], value: any) => void} listener
|
|
53
|
+
*/
|
|
54
|
+
constructor(listener) {
|
|
55
|
+
this._onChange = listener;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @params {T} source
|
|
59
|
+
* @params {string[]} path
|
|
60
|
+
* @returns {T} Observability Proxy
|
|
61
|
+
*/
|
|
62
|
+
observe(source, path = []) {
|
|
63
|
+
const self = this;
|
|
64
|
+
return new Proxy(source, {
|
|
65
|
+
set(target, prop, value) {
|
|
66
|
+
const fullPath = [...path, String(prop)];
|
|
67
|
+
if (target[prop] === value) return true;
|
|
68
|
+
target[prop] = value;
|
|
69
|
+
self._onChange(fullPath, value);
|
|
70
|
+
return true;
|
|
71
|
+
},
|
|
72
|
+
get(target, prop) {
|
|
73
|
+
const value = target[prop];
|
|
74
|
+
if (typeof value === "object" && value !== null) {
|
|
75
|
+
return self.observe(value, [...path, String(prop)]);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
},
|
|
79
|
+
deleteProperty(target, prop) {
|
|
80
|
+
delete target[prop];
|
|
81
|
+
self._onChange([...path, String(prop)], void 0);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* @params {any} root
|
|
88
|
+
* @params {string[]} path
|
|
89
|
+
* @params {any} value
|
|
90
|
+
*/
|
|
91
|
+
patch(root, path, value) {
|
|
92
|
+
const last = path.pop();
|
|
93
|
+
if (!last) return;
|
|
94
|
+
let target = root;
|
|
95
|
+
for (const segment of path) {
|
|
96
|
+
if (!target[segment]) target[segment] = {};
|
|
97
|
+
target = target[segment];
|
|
98
|
+
}
|
|
99
|
+
if (value === void 0) {
|
|
100
|
+
delete target[last];
|
|
101
|
+
} else {
|
|
102
|
+
target[last] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// src/core/Reflex.ts
|
|
108
|
+
var Reflex = class extends EventEmitter {
|
|
109
|
+
constructor(opts, initial = {}) {
|
|
110
|
+
super();
|
|
111
|
+
this.opts = opts;
|
|
112
|
+
this._auth = new Authenticator();
|
|
113
|
+
this._proxy = new ProxyManager((p, v) => this._propagate(p, v));
|
|
114
|
+
this._state = this._proxy.observe(initial);
|
|
115
|
+
}
|
|
116
|
+
_state;
|
|
117
|
+
_proxy;
|
|
118
|
+
_server = null;
|
|
119
|
+
_socket = null;
|
|
120
|
+
_clients = /* @__PURE__ */ new Set();
|
|
121
|
+
_auth;
|
|
122
|
+
_timestamps = /* @__PURE__ */ new Map();
|
|
123
|
+
get state() {
|
|
124
|
+
return this._state;
|
|
125
|
+
}
|
|
126
|
+
async boot() {
|
|
127
|
+
if (this.opts.mode === "master") {
|
|
128
|
+
return this._serve();
|
|
129
|
+
} else {
|
|
130
|
+
return this._join();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async _serve() {
|
|
134
|
+
const handler = (s) => this._handleInbound(s);
|
|
135
|
+
if (this.opts.secure && this.opts.tls) {
|
|
136
|
+
this._server = tls.createServer(this.opts.tls, handler);
|
|
137
|
+
} else {
|
|
138
|
+
this._server = net.createServer(handler);
|
|
139
|
+
}
|
|
140
|
+
return new Promise((r) => {
|
|
141
|
+
this._server?.listen(this.opts.port, this.opts.host || "0.0.0.0", () => {
|
|
142
|
+
this.emit("ready", `REFLEX Master initialized on ${this.opts.port}`);
|
|
143
|
+
r();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async _join() {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const connect = () => {
|
|
150
|
+
const port = this.opts.port;
|
|
151
|
+
const host = this.opts.host || "localhost";
|
|
152
|
+
if (this.opts.secure) {
|
|
153
|
+
this._socket = tls.connect({ port, host, ...this.opts.tls }, () => {
|
|
154
|
+
this._transmit({ t: "AUTH", k: this.opts.key });
|
|
155
|
+
resolve();
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
this._socket = net.connect({ port, host }, () => {
|
|
159
|
+
this._transmit({ t: "AUTH", k: this.opts.key });
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
this._socket.on("data", (d) => this._process(d));
|
|
164
|
+
this._socket.on("error", (e) => {
|
|
165
|
+
this.emit("error", e);
|
|
166
|
+
setTimeout(connect, 5e3);
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
connect();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
_handleInbound(s) {
|
|
173
|
+
let verified = false;
|
|
174
|
+
const remote = s.remoteAddress || "unknown";
|
|
175
|
+
const heartbeat = setInterval(() => {
|
|
176
|
+
if (s.writable) s.write(JSON.stringify({ t: "PING" }));
|
|
177
|
+
}, 3e4);
|
|
178
|
+
s.on("data", (d) => {
|
|
179
|
+
if (d.length > (this.opts.limits?.maxPayloadSize || 1024 * 1024)) {
|
|
180
|
+
this.emit("warn", `Payload too large from ${remote}`);
|
|
181
|
+
return s.destroy();
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const pkg = JSON.parse(d.toString());
|
|
185
|
+
if (pkg.t === "AUTH" && pkg.k === this.opts.key) {
|
|
186
|
+
verified = true;
|
|
187
|
+
this._clients.add(s);
|
|
188
|
+
s.write(JSON.stringify({ t: "SYNC", v: this.state, m: Object.fromEntries(this._timestamps) }));
|
|
189
|
+
this.emit("clientConnect", remote);
|
|
190
|
+
} else if (verified) {
|
|
191
|
+
if (pkg.t === "PONG") return;
|
|
192
|
+
if (!this._auth.rateLimit(remote, this.opts.limits?.opsPerSecond || 500)) return;
|
|
193
|
+
this._process(d, s);
|
|
194
|
+
} else {
|
|
195
|
+
s.destroy();
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
s.destroy();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
s.on("close", () => {
|
|
202
|
+
clearInterval(heartbeat);
|
|
203
|
+
this._clients.delete(s);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
_process(d, source) {
|
|
207
|
+
try {
|
|
208
|
+
const pkg = JSON.parse(d.toString());
|
|
209
|
+
if (pkg.t === "PING") return this._transmit({ t: "PONG" });
|
|
210
|
+
if (pkg.t === "SYNC" && this.opts.mode === "client") {
|
|
211
|
+
Object.assign(this.state, pkg.v);
|
|
212
|
+
if (pkg.m) this._timestamps = new Map(Object.entries(pkg.m));
|
|
213
|
+
this.emit("synced");
|
|
214
|
+
} else if (pkg.t === "SET") {
|
|
215
|
+
const key = pkg.p.join(".");
|
|
216
|
+
const last = this._timestamps.get(key) || 0;
|
|
217
|
+
if (pkg.ts && pkg.ts < last) return;
|
|
218
|
+
this._timestamps.set(key, pkg.ts || Date.now());
|
|
219
|
+
this._proxy.patch(this.state, pkg.p, pkg.v);
|
|
220
|
+
if (this.opts.mode === "master") {
|
|
221
|
+
const raw = d.toString();
|
|
222
|
+
this._clients.forEach((c) => {
|
|
223
|
+
if (c !== source) c.write(raw);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
this.emit("update", pkg);
|
|
227
|
+
}
|
|
228
|
+
} catch (e) {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
_propagate(p, v) {
|
|
232
|
+
const ts = Date.now();
|
|
233
|
+
this._timestamps.set(p.join("."), ts);
|
|
234
|
+
this._transmit({ t: "SET", p, v, ts });
|
|
235
|
+
}
|
|
236
|
+
_transmit(pkg) {
|
|
237
|
+
const raw = JSON.stringify(pkg);
|
|
238
|
+
if (this.opts.mode === "master") {
|
|
239
|
+
this._clients.forEach((c) => c.write(raw));
|
|
240
|
+
} else if (this._socket && !this._socket.destroyed) {
|
|
241
|
+
this._socket.write(raw);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
export {
|
|
246
|
+
Reflex
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* @copyright 2026 Andrei
|
|
250
|
+
* @license MIT
|
|
251
|
+
*/
|
|
252
|
+
/**
|
|
253
|
+
* @copyright 2026 Andrei
|
|
254
|
+
* @license MIT
|
|
255
|
+
* @package REFLEX
|
|
256
|
+
*/
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reflex-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "High-performance, secure, real-time reactive state synchronization across processes and servers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
16
|
+
"dev": "tsup src/index.ts --format esm --watch --dts",
|
|
17
|
+
"test": "node --test tests/*.test.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"state",
|
|
21
|
+
"sync",
|
|
22
|
+
"reactive",
|
|
23
|
+
"ipc",
|
|
24
|
+
"tls",
|
|
25
|
+
"proxy",
|
|
26
|
+
"reflex"
|
|
27
|
+
],
|
|
28
|
+
"author": "Andrei",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"tsup": "^8.0.2",
|
|
32
|
+
"typescript": "^5.3.3",
|
|
33
|
+
"@types/node": "^20.11.24"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"zod": "^3.22.4"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright 2026 Andrei
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import tls from 'node:tls';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { ReflexOptions, Packet } from '../types/index.js';
|
|
9
|
+
import { Authenticator } from '../security/Authenticator.js';
|
|
10
|
+
import { ProxyManager } from '../state/ProxyManager.js';
|
|
11
|
+
|
|
12
|
+
export class Reflex<T extends object> extends EventEmitter {
|
|
13
|
+
private _state: T;
|
|
14
|
+
private _proxy: ProxyManager;
|
|
15
|
+
private _server: net.Server | tls.Server | null = null;
|
|
16
|
+
private _socket: net.Socket | tls.TLSSocket | null = null;
|
|
17
|
+
private _clients: Set<net.Socket | tls.TLSSocket> = new Set();
|
|
18
|
+
private readonly _auth: Authenticator;
|
|
19
|
+
private _timestamps: Map<string, number> = new Map();
|
|
20
|
+
|
|
21
|
+
constructor(private opts: ReflexOptions, initial: T = {} as T) {
|
|
22
|
+
super();
|
|
23
|
+
this._auth = new Authenticator();
|
|
24
|
+
this._proxy = new ProxyManager((p, v) => this._propagate(p, v));
|
|
25
|
+
this._state = this._proxy.observe(initial);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public get state(): T {
|
|
29
|
+
return this._state;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public async boot(): Promise<void> {
|
|
33
|
+
if (this.opts.mode === 'master') {
|
|
34
|
+
return this._serve();
|
|
35
|
+
} else {
|
|
36
|
+
return this._join();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async _serve(): Promise<void> {
|
|
41
|
+
const handler = (s: net.Socket | tls.TLSSocket) => this._handleInbound(s);
|
|
42
|
+
|
|
43
|
+
if (this.opts.secure && this.opts.tls) {
|
|
44
|
+
this._server = tls.createServer(this.opts.tls, handler);
|
|
45
|
+
} else {
|
|
46
|
+
this._server = net.createServer(handler);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new Promise((r) => {
|
|
50
|
+
this._server?.listen(this.opts.port, this.opts.host || '0.0.0.0', () => {
|
|
51
|
+
this.emit('ready', `REFLEX Master initialized on ${this.opts.port}`);
|
|
52
|
+
r();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private async _join(): Promise<void> {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const connect = () => {
|
|
60
|
+
const port = this.opts.port;
|
|
61
|
+
const host = this.opts.host || 'localhost';
|
|
62
|
+
|
|
63
|
+
if (this.opts.secure) {
|
|
64
|
+
this._socket = tls.connect({ port, host, ...this.opts.tls }, () => {
|
|
65
|
+
this._transmit({ t: 'AUTH', k: this.opts.key });
|
|
66
|
+
resolve();
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
this._socket = net.connect({ port, host }, () => {
|
|
70
|
+
this._transmit({ t: 'AUTH', k: this.opts.key });
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this._socket.on('data', (d: Buffer) => this._process(d));
|
|
76
|
+
this._socket.on('error', (e: Error) => {
|
|
77
|
+
this.emit('error', e);
|
|
78
|
+
setTimeout(connect, 5000);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
connect();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private _handleInbound(s: net.Socket | tls.TLSSocket): void {
|
|
86
|
+
let verified = false;
|
|
87
|
+
const remote = s.remoteAddress || 'unknown';
|
|
88
|
+
|
|
89
|
+
const heartbeat = setInterval(() => {
|
|
90
|
+
if (s.writable) s.write(JSON.stringify({ t: 'PING' }));
|
|
91
|
+
}, 30000);
|
|
92
|
+
|
|
93
|
+
s.on('data', (d: Buffer) => {
|
|
94
|
+
if (d.length > (this.opts.limits?.maxPayloadSize || 1024 * 1024)) {
|
|
95
|
+
this.emit('warn', `Payload too large from ${remote}`);
|
|
96
|
+
return s.destroy();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const pkg: Packet = JSON.parse(d.toString());
|
|
101
|
+
|
|
102
|
+
if (pkg.t === 'AUTH' && pkg.k === this.opts.key) {
|
|
103
|
+
verified = true;
|
|
104
|
+
this._clients.add(s);
|
|
105
|
+
s.write(JSON.stringify({ t: 'SYNC', v: this.state, m: Object.fromEntries(this._timestamps) }));
|
|
106
|
+
this.emit('clientConnect', remote);
|
|
107
|
+
} else if (verified) {
|
|
108
|
+
if (pkg.t === 'PONG') return;
|
|
109
|
+
if (!this._auth.rateLimit(remote, this.opts.limits?.opsPerSecond || 500)) return;
|
|
110
|
+
this._process(d, s);
|
|
111
|
+
} else {
|
|
112
|
+
s.destroy();
|
|
113
|
+
}
|
|
114
|
+
} catch (e) { s.destroy(); }
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
s.on('close', () => {
|
|
118
|
+
clearInterval(heartbeat);
|
|
119
|
+
this._clients.delete(s);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private _process(d: Buffer, source?: net.Socket | tls.TLSSocket): void {
|
|
124
|
+
try {
|
|
125
|
+
const pkg: Packet = JSON.parse(d.toString());
|
|
126
|
+
if (pkg.t === 'PING') return this._transmit({ t: 'PONG' });
|
|
127
|
+
|
|
128
|
+
if (pkg.t === 'SYNC' && this.opts.mode === 'client') {
|
|
129
|
+
Object.assign(this.state, pkg.v);
|
|
130
|
+
if (pkg.m) this._timestamps = new Map(Object.entries(pkg.m));
|
|
131
|
+
this.emit('synced');
|
|
132
|
+
} else if (pkg.t === 'SET') {
|
|
133
|
+
const key = pkg.p!.join('.');
|
|
134
|
+
const last = this._timestamps.get(key) || 0;
|
|
135
|
+
|
|
136
|
+
if (pkg.ts && pkg.ts < last) return; // Conflict resolution: LWW
|
|
137
|
+
|
|
138
|
+
this._timestamps.set(key, pkg.ts || Date.now());
|
|
139
|
+
this._proxy.patch(this.state, pkg.p!, pkg.v);
|
|
140
|
+
|
|
141
|
+
if (this.opts.mode === 'master') {
|
|
142
|
+
const raw = d.toString();
|
|
143
|
+
this._clients.forEach(c => { if (c !== source) c.write(raw); });
|
|
144
|
+
}
|
|
145
|
+
this.emit('update', pkg);
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private _propagate(p: string[], v: any): void {
|
|
151
|
+
const ts = Date.now();
|
|
152
|
+
this._timestamps.set(p.join('.'), ts);
|
|
153
|
+
this._transmit({ t: 'SET', p, v, ts });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private _transmit(pkg: Packet): void {
|
|
157
|
+
const raw = JSON.stringify(pkg);
|
|
158
|
+
if (this.opts.mode === 'master') {
|
|
159
|
+
this._clients.forEach(c => c.write(raw));
|
|
160
|
+
} else if (this._socket && !this._socket.destroyed) {
|
|
161
|
+
this._socket.write(raw);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright 2026 Andrei
|
|
3
|
+
* @license MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export class Authenticator {
|
|
9
|
+
private static readonly ALGORITHM = 'sha256';
|
|
10
|
+
private clientOps: Map<string, number> = new Map();
|
|
11
|
+
private lastReset = Date.now();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @params {string} key
|
|
15
|
+
* @params {string} salt
|
|
16
|
+
* @returns {string} HMAC signature
|
|
17
|
+
*/
|
|
18
|
+
public static sign(key: string, salt: string): string {
|
|
19
|
+
return crypto
|
|
20
|
+
.createHmac(this.ALGORITHM, key)
|
|
21
|
+
.update(salt)
|
|
22
|
+
.digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @params {string} challenge
|
|
27
|
+
* @params {string} signature
|
|
28
|
+
* @params {string} key
|
|
29
|
+
* @returns {boolean} Valid/Invalid
|
|
30
|
+
*/
|
|
31
|
+
public static verify(challenge: string, signature: string, key: string): boolean {
|
|
32
|
+
const expected = this.sign(key, challenge);
|
|
33
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @params {string} id
|
|
38
|
+
* @params {number} limit
|
|
39
|
+
* @returns {boolean} Within limit/Exceeded
|
|
40
|
+
*/
|
|
41
|
+
public rateLimit(id: string, limit: number): boolean {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (now - this.lastReset > 1000) {
|
|
44
|
+
this.clientOps.clear();
|
|
45
|
+
this.lastReset = now;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const count = (this.clientOps.get(id) || 0) + 1;
|
|
49
|
+
if (count > limit) return false;
|
|
50
|
+
|
|
51
|
+
this.clientOps.set(id, count);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright 2026 Andrei
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ProxyManager {
|
|
6
|
+
private _onChange: (path: string[], value: any) => void;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @params {(path: string[], value: any) => void} listener
|
|
10
|
+
*/
|
|
11
|
+
constructor(listener: (path: string[], value: any) => void) {
|
|
12
|
+
this._onChange = listener;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @params {T} source
|
|
17
|
+
* @params {string[]} path
|
|
18
|
+
* @returns {T} Observability Proxy
|
|
19
|
+
*/
|
|
20
|
+
public observe<T extends object>(source: T, path: string[] = []): T {
|
|
21
|
+
const self = this;
|
|
22
|
+
return new Proxy(source, {
|
|
23
|
+
set(target: any, prop: string | symbol, value: any): boolean {
|
|
24
|
+
const fullPath = [...path, String(prop)];
|
|
25
|
+
|
|
26
|
+
// Prevent recursive triggers if same value
|
|
27
|
+
if (target[prop] === value) return true;
|
|
28
|
+
|
|
29
|
+
target[prop] = value;
|
|
30
|
+
self._onChange(fullPath, value);
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
get(target: any, prop: string | symbol): any {
|
|
34
|
+
const value = target[prop];
|
|
35
|
+
if (typeof value === 'object' && value !== null) {
|
|
36
|
+
return self.observe(value, [...path, String(prop)]);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
deleteProperty(target: any, prop: string | symbol): boolean {
|
|
41
|
+
delete target[prop];
|
|
42
|
+
self._onChange([...path, String(prop)], undefined);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @params {any} root
|
|
50
|
+
* @params {string[]} path
|
|
51
|
+
* @params {any} value
|
|
52
|
+
*/
|
|
53
|
+
public patch(root: any, path: string[], value: any): void {
|
|
54
|
+
const last = path.pop();
|
|
55
|
+
if (!last) return;
|
|
56
|
+
|
|
57
|
+
let target = root;
|
|
58
|
+
for (const segment of path) {
|
|
59
|
+
if (!target[segment]) target[segment] = {};
|
|
60
|
+
target = target[segment];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (value === undefined) {
|
|
64
|
+
delete target[last];
|
|
65
|
+
} else {
|
|
66
|
+
target[last] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright 2026 Andrei
|
|
3
|
+
* @license MIT
|
|
4
|
+
* @package REFLEX
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Operation = 'SET' | 'DELETE' | 'AUTH' | 'SYNC' | 'PONG' | 'PING';
|
|
8
|
+
|
|
9
|
+
export interface Packet {
|
|
10
|
+
t: Operation;
|
|
11
|
+
p?: string[]; // Path
|
|
12
|
+
v?: any; // Value
|
|
13
|
+
ts?: number; // Timestamp (Conflict Resolution)
|
|
14
|
+
m?: Record<string, number>; // Full Metadata Sync
|
|
15
|
+
k?: string; // Key/Handshake
|
|
16
|
+
s?: string; // Salt/Signature
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReflexOptions {
|
|
20
|
+
host?: string;
|
|
21
|
+
port: number;
|
|
22
|
+
key: string;
|
|
23
|
+
mode: 'master' | 'client';
|
|
24
|
+
secure?: boolean;
|
|
25
|
+
tls?: {
|
|
26
|
+
cert?: string;
|
|
27
|
+
key?: string;
|
|
28
|
+
ca?: string;
|
|
29
|
+
rejectUnauthorized?: boolean;
|
|
30
|
+
};
|
|
31
|
+
limits?: {
|
|
32
|
+
maxClients?: number;
|
|
33
|
+
maxPayloadSize?: number; // In bytes
|
|
34
|
+
opsPerSecond?: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"outDir": "./dist"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|