@fluxstack/live 0.1.0 → 0.3.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/README.md +145 -0
- package/dist/index.d.ts +608 -151
- package/dist/index.js +2373 -880
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes, createCipheriv, createDecipheriv,
|
|
1
|
+
import { randomBytes, createHmac, createCipheriv, createDecipheriv, scryptSync } from 'crypto';
|
|
2
2
|
import { gzipSync, gunzipSync } from 'zlib';
|
|
3
3
|
import { EventEmitter } from 'events';
|
|
4
4
|
|
|
@@ -12,6 +12,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
12
12
|
// src/rooms/RoomEventBus.ts
|
|
13
13
|
function createTypedRoomEventBus() {
|
|
14
14
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
15
|
+
const componentIndex = /* @__PURE__ */ new Map();
|
|
15
16
|
const getKey = (roomType, roomId, event) => `${roomType}:${roomId}:${event}`;
|
|
16
17
|
const getRoomKey = (roomType, roomId) => `${roomType}:${roomId}`;
|
|
17
18
|
return {
|
|
@@ -28,6 +29,10 @@ function createTypedRoomEventBus() {
|
|
|
28
29
|
componentId
|
|
29
30
|
};
|
|
30
31
|
subscriptions.get(key).add(subscription);
|
|
32
|
+
if (!componentIndex.has(componentId)) {
|
|
33
|
+
componentIndex.set(componentId, /* @__PURE__ */ new Set());
|
|
34
|
+
}
|
|
35
|
+
componentIndex.get(componentId).add(key);
|
|
31
36
|
return () => {
|
|
32
37
|
subscriptions.get(key)?.delete(subscription);
|
|
33
38
|
if (subscriptions.get(key)?.size === 0) {
|
|
@@ -52,8 +57,12 @@ function createTypedRoomEventBus() {
|
|
|
52
57
|
return notified;
|
|
53
58
|
},
|
|
54
59
|
unsubscribeAll(componentId) {
|
|
60
|
+
const keys = componentIndex.get(componentId);
|
|
61
|
+
if (!keys) return 0;
|
|
55
62
|
let removed = 0;
|
|
56
|
-
for (const
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
const subs = subscriptions.get(key);
|
|
65
|
+
if (!subs) continue;
|
|
57
66
|
for (const sub of subs) {
|
|
58
67
|
if (sub.componentId === componentId) {
|
|
59
68
|
subs.delete(sub);
|
|
@@ -64,6 +73,7 @@ function createTypedRoomEventBus() {
|
|
|
64
73
|
subscriptions.delete(key);
|
|
65
74
|
}
|
|
66
75
|
}
|
|
76
|
+
componentIndex.delete(componentId);
|
|
67
77
|
return removed;
|
|
68
78
|
},
|
|
69
79
|
clearRoom(roomType, roomId) {
|
|
@@ -71,7 +81,13 @@ function createTypedRoomEventBus() {
|
|
|
71
81
|
let removed = 0;
|
|
72
82
|
for (const key of subscriptions.keys()) {
|
|
73
83
|
if (key.startsWith(prefix)) {
|
|
74
|
-
|
|
84
|
+
const subs = subscriptions.get(key);
|
|
85
|
+
if (subs) {
|
|
86
|
+
for (const sub of subs) {
|
|
87
|
+
componentIndex.get(sub.componentId)?.delete(key);
|
|
88
|
+
}
|
|
89
|
+
removed += subs.size;
|
|
90
|
+
}
|
|
75
91
|
subscriptions.delete(key);
|
|
76
92
|
}
|
|
77
93
|
}
|
|
@@ -96,6 +112,8 @@ function createTypedRoomEventBus() {
|
|
|
96
112
|
}
|
|
97
113
|
var RoomEventBus = class {
|
|
98
114
|
subscriptions = /* @__PURE__ */ new Map();
|
|
115
|
+
/** Reverse index: componentId -> Set of subscription keys for O(1) unsubscribeAll */
|
|
116
|
+
componentIndex = /* @__PURE__ */ new Map();
|
|
99
117
|
getKey(roomType, roomId, event) {
|
|
100
118
|
return `${roomType}:${roomId}:${event}`;
|
|
101
119
|
}
|
|
@@ -106,6 +124,10 @@ var RoomEventBus = class {
|
|
|
106
124
|
}
|
|
107
125
|
const subscription = { roomType, roomId, event, handler, componentId };
|
|
108
126
|
this.subscriptions.get(key).add(subscription);
|
|
127
|
+
if (!this.componentIndex.has(componentId)) {
|
|
128
|
+
this.componentIndex.set(componentId, /* @__PURE__ */ new Set());
|
|
129
|
+
}
|
|
130
|
+
this.componentIndex.get(componentId).add(key);
|
|
109
131
|
return () => {
|
|
110
132
|
this.subscriptions.get(key)?.delete(subscription);
|
|
111
133
|
if (this.subscriptions.get(key)?.size === 0) {
|
|
@@ -130,8 +152,12 @@ var RoomEventBus = class {
|
|
|
130
152
|
return notified;
|
|
131
153
|
}
|
|
132
154
|
unsubscribeAll(componentId) {
|
|
155
|
+
const keys = this.componentIndex.get(componentId);
|
|
156
|
+
if (!keys) return 0;
|
|
133
157
|
let removed = 0;
|
|
134
|
-
for (const
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
const subs = this.subscriptions.get(key);
|
|
160
|
+
if (!subs) continue;
|
|
135
161
|
for (const sub of subs) {
|
|
136
162
|
if (sub.componentId === componentId) {
|
|
137
163
|
subs.delete(sub);
|
|
@@ -142,6 +168,7 @@ var RoomEventBus = class {
|
|
|
142
168
|
this.subscriptions.delete(key);
|
|
143
169
|
}
|
|
144
170
|
}
|
|
171
|
+
this.componentIndex.delete(componentId);
|
|
145
172
|
return removed;
|
|
146
173
|
}
|
|
147
174
|
clearRoom(roomType, roomId) {
|
|
@@ -149,7 +176,13 @@ var RoomEventBus = class {
|
|
|
149
176
|
let removed = 0;
|
|
150
177
|
for (const key of this.subscriptions.keys()) {
|
|
151
178
|
if (key.startsWith(prefix)) {
|
|
152
|
-
|
|
179
|
+
const subs = this.subscriptions.get(key);
|
|
180
|
+
if (subs) {
|
|
181
|
+
for (const sub of subs) {
|
|
182
|
+
this.componentIndex.get(sub.componentId)?.delete(key);
|
|
183
|
+
}
|
|
184
|
+
removed += subs.size;
|
|
185
|
+
}
|
|
153
186
|
this.subscriptions.delete(key);
|
|
154
187
|
}
|
|
155
188
|
}
|
|
@@ -172,6 +205,143 @@ var RoomEventBus = class {
|
|
|
172
205
|
}
|
|
173
206
|
};
|
|
174
207
|
|
|
208
|
+
// src/protocol/constants.ts
|
|
209
|
+
var PROTOCOL_VERSION = 1;
|
|
210
|
+
var DEFAULT_WS_PATH = "/api/live/ws";
|
|
211
|
+
var DEFAULT_CHUNK_SIZE = 64 * 1024;
|
|
212
|
+
var DEFAULT_RATE_LIMIT_MAX_TOKENS = 100;
|
|
213
|
+
var DEFAULT_RATE_LIMIT_REFILL_RATE = 50;
|
|
214
|
+
var MAX_MESSAGE_SIZE = 100 * 1024 * 1024;
|
|
215
|
+
var MAX_ROOM_STATE_SIZE = 10 * 1024 * 1024;
|
|
216
|
+
var MAX_ROOMS_PER_CONNECTION = 50;
|
|
217
|
+
var ROOM_NAME_REGEX = /^[a-zA-Z0-9_:.-]{1,64}$/;
|
|
218
|
+
var MAX_QUEUE_SIZE = 1e3;
|
|
219
|
+
|
|
220
|
+
// src/transport/WsSendBatcher.ts
|
|
221
|
+
var wsQueues = /* @__PURE__ */ new WeakMap();
|
|
222
|
+
var scheduledFlushes = /* @__PURE__ */ new WeakSet();
|
|
223
|
+
var pendingWs = [];
|
|
224
|
+
var globalFlushScheduled = false;
|
|
225
|
+
function scheduleWs(ws) {
|
|
226
|
+
if (!scheduledFlushes.has(ws)) {
|
|
227
|
+
scheduledFlushes.add(ws);
|
|
228
|
+
pendingWs.push(ws);
|
|
229
|
+
if (!globalFlushScheduled) {
|
|
230
|
+
globalFlushScheduled = true;
|
|
231
|
+
queueMicrotask(flushAll);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function queueWsMessage(ws, message) {
|
|
236
|
+
if (!ws || ws.readyState !== 1) return;
|
|
237
|
+
let queue = wsQueues.get(ws);
|
|
238
|
+
if (!queue) {
|
|
239
|
+
queue = [];
|
|
240
|
+
wsQueues.set(ws, queue);
|
|
241
|
+
}
|
|
242
|
+
if (queue.length >= MAX_QUEUE_SIZE) {
|
|
243
|
+
queue.shift();
|
|
244
|
+
}
|
|
245
|
+
queue.push(message);
|
|
246
|
+
scheduleWs(ws);
|
|
247
|
+
}
|
|
248
|
+
function queuePreSerialized(ws, serialized) {
|
|
249
|
+
if (!ws || ws.readyState !== 1) return;
|
|
250
|
+
let queue = wsQueues.get(ws);
|
|
251
|
+
if (!queue) {
|
|
252
|
+
queue = [];
|
|
253
|
+
wsQueues.set(ws, queue);
|
|
254
|
+
}
|
|
255
|
+
if (queue.length >= MAX_QUEUE_SIZE) {
|
|
256
|
+
queue.shift();
|
|
257
|
+
}
|
|
258
|
+
queue.push(serialized);
|
|
259
|
+
scheduleWs(ws);
|
|
260
|
+
}
|
|
261
|
+
function flushAll() {
|
|
262
|
+
globalFlushScheduled = false;
|
|
263
|
+
const connections = pendingWs;
|
|
264
|
+
pendingWs = [];
|
|
265
|
+
for (const ws of connections) {
|
|
266
|
+
scheduledFlushes.delete(ws);
|
|
267
|
+
const queue = wsQueues.get(ws);
|
|
268
|
+
if (!queue || queue.length === 0) continue;
|
|
269
|
+
wsQueues.set(ws, []);
|
|
270
|
+
if (ws.readyState !== 1) continue;
|
|
271
|
+
try {
|
|
272
|
+
if (queue.length === 1) {
|
|
273
|
+
const item = queue[0];
|
|
274
|
+
if (typeof item === "string") {
|
|
275
|
+
ws.send(item);
|
|
276
|
+
} else {
|
|
277
|
+
ws.send(JSON.stringify(item));
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
const objects = [];
|
|
281
|
+
const preSerialized = [];
|
|
282
|
+
for (const item of queue) {
|
|
283
|
+
if (typeof item === "string") {
|
|
284
|
+
preSerialized.push(item);
|
|
285
|
+
} else {
|
|
286
|
+
objects.push(item);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (objects.length === 0) {
|
|
290
|
+
ws.send("[" + preSerialized.join(",") + "]");
|
|
291
|
+
} else if (preSerialized.length === 0) {
|
|
292
|
+
const deduped = deduplicateDeltas(objects);
|
|
293
|
+
ws.send(JSON.stringify(deduped));
|
|
294
|
+
} else {
|
|
295
|
+
const deduped = deduplicateDeltas(objects);
|
|
296
|
+
let result = "[";
|
|
297
|
+
for (let i = 0; i < deduped.length; i++) {
|
|
298
|
+
if (i > 0) result += ",";
|
|
299
|
+
result += JSON.stringify(deduped[i]);
|
|
300
|
+
}
|
|
301
|
+
for (const ps of preSerialized) {
|
|
302
|
+
result += "," + ps;
|
|
303
|
+
}
|
|
304
|
+
result += "]";
|
|
305
|
+
ws.send(result);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function deduplicateDeltas(messages) {
|
|
313
|
+
const deltaIndices = /* @__PURE__ */ new Map();
|
|
314
|
+
const result = [];
|
|
315
|
+
for (const msg of messages) {
|
|
316
|
+
if (msg.type === "STATE_DELTA" && msg.componentId && msg.payload?.delta) {
|
|
317
|
+
const existing = deltaIndices.get(msg.componentId);
|
|
318
|
+
if (existing !== void 0) {
|
|
319
|
+
const target = result[existing];
|
|
320
|
+
target.payload = {
|
|
321
|
+
delta: { ...target.payload.delta, ...msg.payload.delta }
|
|
322
|
+
};
|
|
323
|
+
target.timestamp = msg.timestamp;
|
|
324
|
+
} else {
|
|
325
|
+
deltaIndices.set(msg.componentId, result.length);
|
|
326
|
+
result.push({ ...msg, payload: { delta: { ...msg.payload.delta } } });
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
result.push(msg);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
function sendBinaryImmediate(ws, data) {
|
|
335
|
+
if (ws && ws.readyState === 1) {
|
|
336
|
+
ws.send(data);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function sendImmediate(ws, data) {
|
|
340
|
+
if (ws && ws.readyState === 1) {
|
|
341
|
+
ws.send(data);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
175
345
|
// src/debug/LiveLogger.ts
|
|
176
346
|
var componentConfigs = /* @__PURE__ */ new Map();
|
|
177
347
|
var globalConfigParsed = false;
|
|
@@ -209,22 +379,7 @@ function shouldLog(componentId, category) {
|
|
|
209
379
|
if (cfg === true) return true;
|
|
210
380
|
return cfg.includes(category);
|
|
211
381
|
}
|
|
212
|
-
var _debugger = null;
|
|
213
|
-
function _setLoggerDebugger(dbg) {
|
|
214
|
-
_debugger = dbg;
|
|
215
|
-
}
|
|
216
|
-
function emitToDebugger(category, level, componentId, message, args) {
|
|
217
|
-
if (!_debugger?.enabled) return;
|
|
218
|
-
const data = { category, level, message };
|
|
219
|
-
if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) {
|
|
220
|
-
data.details = args[0];
|
|
221
|
-
} else if (args.length > 0) {
|
|
222
|
-
data.details = args;
|
|
223
|
-
}
|
|
224
|
-
_debugger.emit("LOG", componentId, null, data);
|
|
225
|
-
}
|
|
226
382
|
function liveLog(category, componentId, message, ...args) {
|
|
227
|
-
emitToDebugger(category, "info", componentId, message, args);
|
|
228
383
|
if (shouldLog(componentId, category)) {
|
|
229
384
|
if (args.length > 0) {
|
|
230
385
|
console.log(message, ...args);
|
|
@@ -234,7 +389,6 @@ function liveLog(category, componentId, message, ...args) {
|
|
|
234
389
|
}
|
|
235
390
|
}
|
|
236
391
|
function liveWarn(category, componentId, message, ...args) {
|
|
237
|
-
emitToDebugger(category, "warn", componentId, message, args);
|
|
238
392
|
if (shouldLog(componentId, category)) {
|
|
239
393
|
if (args.length > 0) {
|
|
240
394
|
console.warn(message, ...args);
|
|
@@ -244,51 +398,482 @@ function liveWarn(category, componentId, message, ...args) {
|
|
|
244
398
|
}
|
|
245
399
|
}
|
|
246
400
|
|
|
247
|
-
// src/
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
401
|
+
// src/utils/deepDiff.ts
|
|
402
|
+
function isPlainObject(v) {
|
|
403
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
404
|
+
}
|
|
405
|
+
function computeDeepDiff(prev, next, depth = 0, maxDepth = 3, seen) {
|
|
406
|
+
if (depth > maxDepth) return prev === next ? null : next;
|
|
407
|
+
if (!seen) seen = /* @__PURE__ */ new Set();
|
|
408
|
+
if (seen.has(next)) return prev === next ? null : next;
|
|
409
|
+
seen.add(next);
|
|
410
|
+
let result = null;
|
|
411
|
+
for (const key of Object.keys(next)) {
|
|
412
|
+
const oldVal = prev[key];
|
|
413
|
+
const newVal = next[key];
|
|
414
|
+
if (oldVal === newVal) continue;
|
|
415
|
+
if (isPlainObject(oldVal) && isPlainObject(newVal)) {
|
|
416
|
+
const nested = computeDeepDiff(oldVal, newVal, depth + 1, maxDepth, seen);
|
|
417
|
+
if (nested !== null) {
|
|
418
|
+
result ??= {};
|
|
419
|
+
result[key] = nested;
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
result ??= {};
|
|
423
|
+
result[key] = newVal;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
function deepAssign(target, source, seen) {
|
|
429
|
+
if (!seen) seen = /* @__PURE__ */ new Set();
|
|
430
|
+
if (seen.has(source)) return;
|
|
431
|
+
seen.add(source);
|
|
432
|
+
for (const key of Object.keys(source)) {
|
|
433
|
+
if (isPlainObject(target[key]) && isPlainObject(source[key])) {
|
|
434
|
+
deepAssign(target[key], source[key], seen);
|
|
435
|
+
} else {
|
|
436
|
+
target[key] = source[key];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/rooms/RoomCodec.ts
|
|
442
|
+
var BINARY_ROOM_EVENT = 2;
|
|
443
|
+
var BINARY_ROOM_STATE = 3;
|
|
444
|
+
var encoder = new TextEncoder();
|
|
445
|
+
var decoder = new TextDecoder();
|
|
446
|
+
function msgpackEncode(value) {
|
|
447
|
+
const parts = [];
|
|
448
|
+
encode(value, parts);
|
|
449
|
+
let totalLen = 0;
|
|
450
|
+
for (const p of parts) totalLen += p.length;
|
|
451
|
+
const result = new Uint8Array(totalLen);
|
|
452
|
+
let offset = 0;
|
|
453
|
+
for (const p of parts) {
|
|
454
|
+
result.set(p, offset);
|
|
455
|
+
offset += p.length;
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
function encode(value, parts) {
|
|
460
|
+
if (value === null || value === void 0) {
|
|
461
|
+
parts.push(new Uint8Array([192]));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (typeof value === "boolean") {
|
|
465
|
+
parts.push(new Uint8Array([value ? 195 : 194]));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (typeof value === "number") {
|
|
469
|
+
if (Number.isInteger(value)) {
|
|
470
|
+
encodeInt(value, parts);
|
|
471
|
+
} else {
|
|
472
|
+
const buf = new Uint8Array(9);
|
|
473
|
+
buf[0] = 203;
|
|
474
|
+
new DataView(buf.buffer).setFloat64(1, value, false);
|
|
475
|
+
parts.push(buf);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (typeof value === "string") {
|
|
480
|
+
const encoded = encoder.encode(value);
|
|
481
|
+
const len = encoded.length;
|
|
482
|
+
if (len < 32) {
|
|
483
|
+
parts.push(new Uint8Array([160 | len]));
|
|
484
|
+
} else if (len < 256) {
|
|
485
|
+
parts.push(new Uint8Array([217, len]));
|
|
486
|
+
} else if (len < 65536) {
|
|
487
|
+
parts.push(new Uint8Array([218, len >> 8, len & 255]));
|
|
488
|
+
} else {
|
|
489
|
+
parts.push(new Uint8Array([219, len >> 24 & 255, len >> 16 & 255, len >> 8 & 255, len & 255]));
|
|
490
|
+
}
|
|
491
|
+
parts.push(encoded);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (value instanceof Uint8Array) {
|
|
495
|
+
const len = value.length;
|
|
496
|
+
if (len < 256) {
|
|
497
|
+
parts.push(new Uint8Array([196, len]));
|
|
498
|
+
} else if (len < 65536) {
|
|
499
|
+
parts.push(new Uint8Array([197, len >> 8, len & 255]));
|
|
500
|
+
} else {
|
|
501
|
+
parts.push(new Uint8Array([198, len >> 24 & 255, len >> 16 & 255, len >> 8 & 255, len & 255]));
|
|
502
|
+
}
|
|
503
|
+
parts.push(value);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (Array.isArray(value)) {
|
|
507
|
+
const len = value.length;
|
|
508
|
+
if (len < 16) {
|
|
509
|
+
parts.push(new Uint8Array([144 | len]));
|
|
510
|
+
} else if (len < 65536) {
|
|
511
|
+
parts.push(new Uint8Array([220, len >> 8, len & 255]));
|
|
512
|
+
} else {
|
|
513
|
+
parts.push(new Uint8Array([221, len >> 24 & 255, len >> 16 & 255, len >> 8 & 255, len & 255]));
|
|
514
|
+
}
|
|
515
|
+
for (const item of value) {
|
|
516
|
+
encode(item, parts);
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (typeof value === "object") {
|
|
521
|
+
const keys = Object.keys(value);
|
|
522
|
+
const len = keys.length;
|
|
523
|
+
if (len < 16) {
|
|
524
|
+
parts.push(new Uint8Array([128 | len]));
|
|
525
|
+
} else if (len < 65536) {
|
|
526
|
+
parts.push(new Uint8Array([222, len >> 8, len & 255]));
|
|
527
|
+
} else {
|
|
528
|
+
parts.push(new Uint8Array([223, len >> 24 & 255, len >> 16 & 255, len >> 8 & 255, len & 255]));
|
|
529
|
+
}
|
|
530
|
+
for (const key of keys) {
|
|
531
|
+
encode(key, parts);
|
|
532
|
+
encode(value[key], parts);
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
parts.push(new Uint8Array([192]));
|
|
537
|
+
}
|
|
538
|
+
function encodeInt(value, parts) {
|
|
539
|
+
if (value >= 0) {
|
|
540
|
+
if (value < 128) {
|
|
541
|
+
parts.push(new Uint8Array([value]));
|
|
542
|
+
} else if (value < 256) {
|
|
543
|
+
parts.push(new Uint8Array([204, value]));
|
|
544
|
+
} else if (value < 65536) {
|
|
545
|
+
parts.push(new Uint8Array([205, value >> 8, value & 255]));
|
|
546
|
+
} else if (value < 4294967296) {
|
|
547
|
+
parts.push(new Uint8Array([206, value >> 24 & 255, value >> 16 & 255, value >> 8 & 255, value & 255]));
|
|
548
|
+
} else {
|
|
549
|
+
const buf = new Uint8Array(9);
|
|
550
|
+
buf[0] = 203;
|
|
551
|
+
new DataView(buf.buffer).setFloat64(1, value, false);
|
|
552
|
+
parts.push(buf);
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
if (value >= -32) {
|
|
556
|
+
parts.push(new Uint8Array([value & 255]));
|
|
557
|
+
} else if (value >= -128) {
|
|
558
|
+
parts.push(new Uint8Array([208, value & 255]));
|
|
559
|
+
} else if (value >= -32768) {
|
|
560
|
+
const buf = new Uint8Array(3);
|
|
561
|
+
buf[0] = 209;
|
|
562
|
+
new DataView(buf.buffer).setInt16(1, value, false);
|
|
563
|
+
parts.push(buf);
|
|
564
|
+
} else if (value >= -2147483648) {
|
|
565
|
+
const buf = new Uint8Array(5);
|
|
566
|
+
buf[0] = 210;
|
|
567
|
+
new DataView(buf.buffer).setInt32(1, value, false);
|
|
568
|
+
parts.push(buf);
|
|
569
|
+
} else {
|
|
570
|
+
const buf = new Uint8Array(9);
|
|
571
|
+
buf[0] = 203;
|
|
572
|
+
new DataView(buf.buffer).setFloat64(1, value, false);
|
|
573
|
+
parts.push(buf);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function msgpackDecode(buf) {
|
|
578
|
+
const result = decodeAt(buf, 0);
|
|
579
|
+
return result.value;
|
|
580
|
+
}
|
|
581
|
+
function decodeAt(buf, offset) {
|
|
582
|
+
if (offset >= buf.length) return { value: null, offset };
|
|
583
|
+
const byte = buf[offset];
|
|
584
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
585
|
+
if (byte < 128) return { value: byte, offset: offset + 1 };
|
|
586
|
+
if (byte >= 128 && byte <= 143) return decodeMap(buf, offset + 1, byte & 15);
|
|
587
|
+
if (byte >= 144 && byte <= 159) return decodeArray(buf, offset + 1, byte & 15);
|
|
588
|
+
if (byte >= 160 && byte <= 191) {
|
|
589
|
+
const len = byte & 31;
|
|
590
|
+
const str = decoder.decode(buf.subarray(offset + 1, offset + 1 + len));
|
|
591
|
+
return { value: str, offset: offset + 1 + len };
|
|
592
|
+
}
|
|
593
|
+
if (byte >= 224) return { value: byte - 256, offset: offset + 1 };
|
|
594
|
+
switch (byte) {
|
|
595
|
+
// nil
|
|
596
|
+
case 192:
|
|
597
|
+
return { value: null, offset: offset + 1 };
|
|
598
|
+
// false
|
|
599
|
+
case 194:
|
|
600
|
+
return { value: false, offset: offset + 1 };
|
|
601
|
+
// true
|
|
602
|
+
case 195:
|
|
603
|
+
return { value: true, offset: offset + 1 };
|
|
604
|
+
// bin 8
|
|
605
|
+
case 196: {
|
|
606
|
+
const len = buf[offset + 1];
|
|
607
|
+
return { value: buf.slice(offset + 2, offset + 2 + len), offset: offset + 2 + len };
|
|
608
|
+
}
|
|
609
|
+
// bin 16
|
|
610
|
+
case 197: {
|
|
611
|
+
const len = view.getUint16(offset + 1, false);
|
|
612
|
+
return { value: buf.slice(offset + 3, offset + 3 + len), offset: offset + 3 + len };
|
|
613
|
+
}
|
|
614
|
+
// bin 32
|
|
615
|
+
case 198: {
|
|
616
|
+
const len = view.getUint32(offset + 1, false);
|
|
617
|
+
return { value: buf.slice(offset + 5, offset + 5 + len), offset: offset + 5 + len };
|
|
618
|
+
}
|
|
619
|
+
// float 64
|
|
620
|
+
case 203:
|
|
621
|
+
return { value: view.getFloat64(offset + 1, false), offset: offset + 9 };
|
|
622
|
+
// uint 8
|
|
623
|
+
case 204:
|
|
624
|
+
return { value: buf[offset + 1], offset: offset + 2 };
|
|
625
|
+
// uint 16
|
|
626
|
+
case 205:
|
|
627
|
+
return { value: view.getUint16(offset + 1, false), offset: offset + 3 };
|
|
628
|
+
// uint 32
|
|
629
|
+
case 206:
|
|
630
|
+
return { value: view.getUint32(offset + 1, false), offset: offset + 5 };
|
|
631
|
+
// int 8
|
|
632
|
+
case 208:
|
|
633
|
+
return { value: view.getInt8(offset + 1), offset: offset + 2 };
|
|
634
|
+
// int 16
|
|
635
|
+
case 209:
|
|
636
|
+
return { value: view.getInt16(offset + 1, false), offset: offset + 3 };
|
|
637
|
+
// int 32
|
|
638
|
+
case 210:
|
|
639
|
+
return { value: view.getInt32(offset + 1, false), offset: offset + 5 };
|
|
640
|
+
// str 8
|
|
641
|
+
case 217: {
|
|
642
|
+
const len = buf[offset + 1];
|
|
643
|
+
return { value: decoder.decode(buf.subarray(offset + 2, offset + 2 + len)), offset: offset + 2 + len };
|
|
644
|
+
}
|
|
645
|
+
// str 16
|
|
646
|
+
case 218: {
|
|
647
|
+
const len = view.getUint16(offset + 1, false);
|
|
648
|
+
return { value: decoder.decode(buf.subarray(offset + 3, offset + 3 + len)), offset: offset + 3 + len };
|
|
649
|
+
}
|
|
650
|
+
// str 32
|
|
651
|
+
case 219: {
|
|
652
|
+
const len = view.getUint32(offset + 1, false);
|
|
653
|
+
return { value: decoder.decode(buf.subarray(offset + 5, offset + 5 + len)), offset: offset + 5 + len };
|
|
654
|
+
}
|
|
655
|
+
// array 16
|
|
656
|
+
case 220:
|
|
657
|
+
return decodeArray(buf, offset + 3, view.getUint16(offset + 1, false));
|
|
658
|
+
// array 32
|
|
659
|
+
case 221:
|
|
660
|
+
return decodeArray(buf, offset + 5, view.getUint32(offset + 1, false));
|
|
661
|
+
// map 16
|
|
662
|
+
case 222:
|
|
663
|
+
return decodeMap(buf, offset + 3, view.getUint16(offset + 1, false));
|
|
664
|
+
// map 32
|
|
665
|
+
case 223:
|
|
666
|
+
return decodeMap(buf, offset + 5, view.getUint32(offset + 1, false));
|
|
667
|
+
}
|
|
668
|
+
return { value: null, offset: offset + 1 };
|
|
669
|
+
}
|
|
670
|
+
function decodeArray(buf, offset, count) {
|
|
671
|
+
const arr = new Array(count);
|
|
672
|
+
for (let i = 0; i < count; i++) {
|
|
673
|
+
const result = decodeAt(buf, offset);
|
|
674
|
+
arr[i] = result.value;
|
|
675
|
+
offset = result.offset;
|
|
676
|
+
}
|
|
677
|
+
return { value: arr, offset };
|
|
678
|
+
}
|
|
679
|
+
function decodeMap(buf, offset, count) {
|
|
680
|
+
const obj = {};
|
|
681
|
+
for (let i = 0; i < count; i++) {
|
|
682
|
+
const keyResult = decodeAt(buf, offset);
|
|
683
|
+
offset = keyResult.offset;
|
|
684
|
+
const valResult = decodeAt(buf, offset);
|
|
685
|
+
offset = valResult.offset;
|
|
686
|
+
obj[String(keyResult.value)] = valResult.value;
|
|
687
|
+
}
|
|
688
|
+
return { value: obj, offset };
|
|
689
|
+
}
|
|
690
|
+
var msgpackCodec = {
|
|
691
|
+
encode: msgpackEncode,
|
|
692
|
+
decode: msgpackDecode
|
|
693
|
+
};
|
|
694
|
+
var jsonCodec = {
|
|
695
|
+
encode(data) {
|
|
696
|
+
return encoder.encode(JSON.stringify(data));
|
|
697
|
+
},
|
|
698
|
+
decode(buf) {
|
|
699
|
+
return JSON.parse(decoder.decode(buf));
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
function resolveCodec(option) {
|
|
703
|
+
if (!option || option === "msgpack") return msgpackCodec;
|
|
704
|
+
if (option === "json") return jsonCodec;
|
|
705
|
+
return option;
|
|
706
|
+
}
|
|
707
|
+
var textEncoder = encoder;
|
|
708
|
+
function buildRoomFrame(frameType, componentId, roomId, event, payload) {
|
|
709
|
+
const compIdBytes = textEncoder.encode(componentId);
|
|
710
|
+
const roomIdBytes = textEncoder.encode(roomId);
|
|
711
|
+
const eventBytes = textEncoder.encode(event);
|
|
712
|
+
const totalLen = 1 + 1 + compIdBytes.length + 1 + roomIdBytes.length + 2 + eventBytes.length + payload.length;
|
|
713
|
+
const frame = new Uint8Array(totalLen);
|
|
714
|
+
let offset = 0;
|
|
715
|
+
frame[offset++] = frameType;
|
|
716
|
+
frame[offset++] = compIdBytes.length;
|
|
717
|
+
frame.set(compIdBytes, offset);
|
|
718
|
+
offset += compIdBytes.length;
|
|
719
|
+
frame[offset++] = roomIdBytes.length;
|
|
720
|
+
frame.set(roomIdBytes, offset);
|
|
721
|
+
offset += roomIdBytes.length;
|
|
722
|
+
frame[offset++] = eventBytes.length >> 8 & 255;
|
|
723
|
+
frame[offset++] = eventBytes.length & 255;
|
|
724
|
+
frame.set(eventBytes, offset);
|
|
725
|
+
offset += eventBytes.length;
|
|
726
|
+
frame.set(payload, offset);
|
|
727
|
+
return frame;
|
|
728
|
+
}
|
|
729
|
+
function buildRoomFrameTail(roomId, event, payload) {
|
|
730
|
+
const roomIdBytes = textEncoder.encode(roomId);
|
|
731
|
+
const eventBytes = textEncoder.encode(event);
|
|
732
|
+
const tailLen = 1 + roomIdBytes.length + 2 + eventBytes.length + payload.length;
|
|
733
|
+
const tail = new Uint8Array(tailLen);
|
|
734
|
+
let offset = 0;
|
|
735
|
+
tail[offset++] = roomIdBytes.length;
|
|
736
|
+
tail.set(roomIdBytes, offset);
|
|
737
|
+
offset += roomIdBytes.length;
|
|
738
|
+
tail[offset++] = eventBytes.length >> 8 & 255;
|
|
739
|
+
tail[offset++] = eventBytes.length & 255;
|
|
740
|
+
tail.set(eventBytes, offset);
|
|
741
|
+
offset += eventBytes.length;
|
|
742
|
+
tail.set(payload, offset);
|
|
743
|
+
return tail;
|
|
744
|
+
}
|
|
745
|
+
function prependMemberHeader(frameType, componentId, tail) {
|
|
746
|
+
const compIdBytes = textEncoder.encode(componentId);
|
|
747
|
+
const frame = new Uint8Array(1 + 1 + compIdBytes.length + tail.length);
|
|
748
|
+
frame[0] = frameType;
|
|
749
|
+
frame[1] = compIdBytes.length;
|
|
750
|
+
frame.set(compIdBytes, 2);
|
|
751
|
+
frame.set(tail, 2 + compIdBytes.length);
|
|
752
|
+
return frame;
|
|
753
|
+
}
|
|
754
|
+
function parseRoomFrame(buf) {
|
|
755
|
+
if (buf.length < 6) return null;
|
|
756
|
+
let offset = 0;
|
|
757
|
+
const frameType = buf[offset++];
|
|
758
|
+
const compIdLen = buf[offset++];
|
|
759
|
+
if (offset + compIdLen > buf.length) return null;
|
|
760
|
+
const componentId = decoder.decode(buf.subarray(offset, offset + compIdLen));
|
|
761
|
+
offset += compIdLen;
|
|
762
|
+
const roomIdLen = buf[offset++];
|
|
763
|
+
if (offset + roomIdLen > buf.length) return null;
|
|
764
|
+
const roomId = decoder.decode(buf.subarray(offset, offset + roomIdLen));
|
|
765
|
+
offset += roomIdLen;
|
|
766
|
+
if (offset + 2 > buf.length) return null;
|
|
767
|
+
const eventLen = buf[offset] << 8 | buf[offset + 1];
|
|
768
|
+
offset += 2;
|
|
769
|
+
if (offset + eventLen > buf.length) return null;
|
|
770
|
+
const event = decoder.decode(buf.subarray(offset, offset + eventLen));
|
|
771
|
+
offset += eventLen;
|
|
772
|
+
const payload = buf.subarray(offset);
|
|
773
|
+
return { frameType, componentId, roomId, event, payload };
|
|
774
|
+
}
|
|
254
775
|
|
|
255
776
|
// src/rooms/LiveRoomManager.ts
|
|
256
777
|
var LiveRoomManager = class {
|
|
257
|
-
|
|
258
|
-
|
|
778
|
+
/**
|
|
779
|
+
* @param roomEvents - Local server-side event bus
|
|
780
|
+
* @param pubsub - Optional cross-instance pub/sub adapter (e.g. Redis).
|
|
781
|
+
* When provided, room events/state/membership are propagated
|
|
782
|
+
* to other server instances in the background.
|
|
783
|
+
*/
|
|
784
|
+
constructor(roomEvents, pubsub) {
|
|
259
785
|
this.roomEvents = roomEvents;
|
|
786
|
+
this.pubsub = pubsub;
|
|
260
787
|
}
|
|
261
788
|
rooms = /* @__PURE__ */ new Map();
|
|
262
789
|
componentRooms = /* @__PURE__ */ new Map();
|
|
790
|
+
// componentId -> roomIds
|
|
791
|
+
/** Room registry for LiveRoom class lookup. Set by LiveServer. */
|
|
792
|
+
roomRegistry;
|
|
263
793
|
/**
|
|
264
|
-
* Component joins a room
|
|
794
|
+
* Component joins a room.
|
|
795
|
+
* @param options.deepDiff - Enable/disable deep diff for this room's state. Default: true
|
|
796
|
+
* @param joinContext - Optional context for LiveRoom lifecycle hooks (userId, payload)
|
|
265
797
|
*/
|
|
266
|
-
joinRoom(componentId, roomId, ws, initialState) {
|
|
267
|
-
if (!roomId ||
|
|
798
|
+
joinRoom(componentId, roomId, ws, initialState, options, joinContext) {
|
|
799
|
+
if (!roomId || !ROOM_NAME_REGEX.test(roomId)) {
|
|
268
800
|
throw new Error("Invalid room name. Must be 1-64 alphanumeric characters, hyphens, underscores, dots, or colons.");
|
|
269
801
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
802
|
+
const now = Date.now();
|
|
803
|
+
let room = this.rooms.get(roomId);
|
|
804
|
+
let isNewRoom = false;
|
|
805
|
+
if (!room) {
|
|
806
|
+
isNewRoom = true;
|
|
807
|
+
const roomClass = this.roomRegistry?.resolveFromId(roomId);
|
|
808
|
+
if (roomClass) {
|
|
809
|
+
const instance = new roomClass(roomId, this);
|
|
810
|
+
const opts = roomClass.$options ?? {};
|
|
811
|
+
room = {
|
|
812
|
+
id: roomId,
|
|
813
|
+
state: instance.state,
|
|
814
|
+
members: /* @__PURE__ */ new Map(),
|
|
815
|
+
createdAt: now,
|
|
816
|
+
lastActivity: now,
|
|
817
|
+
deepDiff: opts.deepDiff ?? true,
|
|
818
|
+
deepDiffDepth: opts.deepDiffDepth ?? 3,
|
|
819
|
+
serverOnlyState: true,
|
|
820
|
+
// LiveRoom-backed rooms are always server-only
|
|
821
|
+
instance,
|
|
822
|
+
codec: resolveCodec(opts.codec)
|
|
823
|
+
};
|
|
824
|
+
} else {
|
|
825
|
+
room = {
|
|
826
|
+
id: roomId,
|
|
827
|
+
state: initialState || {},
|
|
828
|
+
members: /* @__PURE__ */ new Map(),
|
|
829
|
+
createdAt: now,
|
|
830
|
+
lastActivity: now,
|
|
831
|
+
deepDiff: options?.deepDiff ?? true,
|
|
832
|
+
deepDiffDepth: options?.deepDiffDepth ?? 3,
|
|
833
|
+
serverOnlyState: options?.serverOnlyState ?? false,
|
|
834
|
+
codec: null
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
this.rooms.set(roomId, room);
|
|
278
838
|
liveLog("rooms", componentId, `Room '${roomId}' created`);
|
|
279
839
|
}
|
|
280
|
-
|
|
840
|
+
if (room.instance) {
|
|
841
|
+
const ctor = room.instance.constructor;
|
|
842
|
+
const maxMembers = ctor.$options?.maxMembers;
|
|
843
|
+
if (maxMembers && room.members.size >= maxMembers) {
|
|
844
|
+
return { rejected: true, reason: "Room is full" };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (room.instance) {
|
|
848
|
+
const result = room.instance.onJoin({
|
|
849
|
+
componentId,
|
|
850
|
+
userId: joinContext?.userId,
|
|
851
|
+
payload: joinContext?.payload
|
|
852
|
+
});
|
|
853
|
+
if (result === false) {
|
|
854
|
+
if (isNewRoom) {
|
|
855
|
+
this.rooms.delete(roomId);
|
|
856
|
+
}
|
|
857
|
+
return { rejected: true, reason: "Join rejected by room" };
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (isNewRoom && room.instance) {
|
|
861
|
+
room.instance.onCreate();
|
|
862
|
+
}
|
|
281
863
|
room.members.set(componentId, {
|
|
282
864
|
componentId,
|
|
283
865
|
ws,
|
|
284
|
-
joinedAt:
|
|
866
|
+
joinedAt: now
|
|
285
867
|
});
|
|
286
|
-
room.lastActivity =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
868
|
+
room.lastActivity = now;
|
|
869
|
+
let compRooms = this.componentRooms.get(componentId);
|
|
870
|
+
if (!compRooms) {
|
|
871
|
+
compRooms = /* @__PURE__ */ new Set();
|
|
872
|
+
this.componentRooms.set(componentId, compRooms);
|
|
873
|
+
}
|
|
874
|
+
compRooms.add(roomId);
|
|
875
|
+
const memberCount = room.members.size;
|
|
876
|
+
liveLog("rooms", componentId, `Component '${componentId}' joined room '${roomId}' (${memberCount} members)`);
|
|
292
877
|
this.broadcastToRoom(roomId, {
|
|
293
878
|
type: "ROOM_SYSTEM",
|
|
294
879
|
componentId,
|
|
@@ -296,22 +881,33 @@ var LiveRoomManager = class {
|
|
|
296
881
|
event: "$sub:join",
|
|
297
882
|
data: {
|
|
298
883
|
subscriberId: componentId,
|
|
299
|
-
count:
|
|
884
|
+
count: memberCount
|
|
300
885
|
},
|
|
301
|
-
timestamp:
|
|
886
|
+
timestamp: now
|
|
302
887
|
}, componentId);
|
|
888
|
+
this.pubsub?.publishMembership(roomId, "join", componentId)?.catch(() => {
|
|
889
|
+
});
|
|
303
890
|
return { state: room.state };
|
|
304
891
|
}
|
|
305
892
|
/**
|
|
306
893
|
* Component leaves a room
|
|
894
|
+
* @param leaveReason - Why the component is leaving. Default: 'leave'
|
|
307
895
|
*/
|
|
308
|
-
leaveRoom(componentId, roomId) {
|
|
896
|
+
leaveRoom(componentId, roomId, leaveReason = "leave") {
|
|
309
897
|
const room = this.rooms.get(roomId);
|
|
310
898
|
if (!room) return;
|
|
899
|
+
if (room.instance) {
|
|
900
|
+
room.instance.onLeave({
|
|
901
|
+
componentId,
|
|
902
|
+
reason: leaveReason
|
|
903
|
+
});
|
|
904
|
+
}
|
|
311
905
|
room.members.delete(componentId);
|
|
312
|
-
|
|
906
|
+
const now = Date.now();
|
|
907
|
+
room.lastActivity = now;
|
|
313
908
|
this.componentRooms.get(componentId)?.delete(roomId);
|
|
314
|
-
|
|
909
|
+
const memberCount = room.members.size;
|
|
910
|
+
liveLog("rooms", componentId, `Component '${componentId}' left room '${roomId}' (${memberCount} members)`);
|
|
315
911
|
this.broadcastToRoom(roomId, {
|
|
316
912
|
type: "ROOM_SYSTEM",
|
|
317
913
|
componentId,
|
|
@@ -319,14 +915,20 @@ var LiveRoomManager = class {
|
|
|
319
915
|
event: "$sub:leave",
|
|
320
916
|
data: {
|
|
321
917
|
subscriberId: componentId,
|
|
322
|
-
count:
|
|
918
|
+
count: memberCount
|
|
323
919
|
},
|
|
324
|
-
timestamp:
|
|
920
|
+
timestamp: now
|
|
325
921
|
});
|
|
326
|
-
|
|
922
|
+
this.pubsub?.publishMembership(roomId, "leave", componentId)?.catch(() => {
|
|
923
|
+
});
|
|
924
|
+
if (memberCount === 0) {
|
|
327
925
|
setTimeout(() => {
|
|
328
926
|
const currentRoom = this.rooms.get(roomId);
|
|
329
927
|
if (currentRoom && currentRoom.members.size === 0) {
|
|
928
|
+
if (currentRoom.instance) {
|
|
929
|
+
const result = currentRoom.instance.onDestroy();
|
|
930
|
+
if (result === false) return;
|
|
931
|
+
}
|
|
330
932
|
this.rooms.delete(roomId);
|
|
331
933
|
liveLog("rooms", null, `Room '${roomId}' destroyed (empty)`);
|
|
332
934
|
}
|
|
@@ -334,53 +936,143 @@ var LiveRoomManager = class {
|
|
|
334
936
|
}
|
|
335
937
|
}
|
|
336
938
|
/**
|
|
337
|
-
* Component disconnects - leave all rooms
|
|
939
|
+
* Component disconnects - leave all rooms.
|
|
940
|
+
* Batches removals: calls onLeave hooks, removes member from all rooms,
|
|
941
|
+
* then sends leave notifications in bulk.
|
|
338
942
|
*/
|
|
339
943
|
cleanupComponent(componentId) {
|
|
340
|
-
const
|
|
341
|
-
if (!
|
|
342
|
-
|
|
343
|
-
|
|
944
|
+
const roomIds = this.componentRooms.get(componentId);
|
|
945
|
+
if (!roomIds || roomIds.size === 0) return;
|
|
946
|
+
const now = Date.now();
|
|
947
|
+
const notifications = [];
|
|
948
|
+
for (const roomId of roomIds) {
|
|
949
|
+
const room = this.rooms.get(roomId);
|
|
950
|
+
if (!room) continue;
|
|
951
|
+
if (room.instance) {
|
|
952
|
+
room.instance.onLeave({
|
|
953
|
+
componentId,
|
|
954
|
+
reason: "disconnect"
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
room.members.delete(componentId);
|
|
958
|
+
room.lastActivity = now;
|
|
959
|
+
const memberCount = room.members.size;
|
|
960
|
+
if (memberCount > 0) {
|
|
961
|
+
notifications.push({ roomId, count: memberCount });
|
|
962
|
+
} else {
|
|
963
|
+
setTimeout(() => {
|
|
964
|
+
const currentRoom = this.rooms.get(roomId);
|
|
965
|
+
if (currentRoom && currentRoom.members.size === 0) {
|
|
966
|
+
if (currentRoom.instance) {
|
|
967
|
+
const result = currentRoom.instance.onDestroy();
|
|
968
|
+
if (result === false) return;
|
|
969
|
+
}
|
|
970
|
+
this.rooms.delete(roomId);
|
|
971
|
+
liveLog("rooms", null, `Room '${roomId}' destroyed (empty)`);
|
|
972
|
+
}
|
|
973
|
+
}, 5 * 60 * 1e3);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
for (const { roomId, count } of notifications) {
|
|
977
|
+
this.broadcastToRoom(roomId, {
|
|
978
|
+
type: "ROOM_SYSTEM",
|
|
979
|
+
componentId,
|
|
980
|
+
roomId,
|
|
981
|
+
event: "$sub:leave",
|
|
982
|
+
data: {
|
|
983
|
+
subscriberId: componentId,
|
|
984
|
+
count
|
|
985
|
+
},
|
|
986
|
+
timestamp: now
|
|
987
|
+
});
|
|
344
988
|
}
|
|
345
989
|
this.componentRooms.delete(componentId);
|
|
346
990
|
}
|
|
347
991
|
/**
|
|
348
|
-
* Emit event to all members in a room
|
|
992
|
+
* Emit event to all members in a room.
|
|
993
|
+
* For LiveRoom-backed rooms, calls onEvent() hook before broadcasting.
|
|
349
994
|
*/
|
|
350
995
|
emitToRoom(roomId, event, data, excludeComponentId) {
|
|
351
996
|
const room = this.rooms.get(roomId);
|
|
352
997
|
if (!room) return 0;
|
|
353
|
-
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
room.lastActivity = now;
|
|
1000
|
+
if (room.instance) {
|
|
1001
|
+
room.instance.onEvent(event, data, {
|
|
1002
|
+
componentId: excludeComponentId ?? ""
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
354
1005
|
this.roomEvents.emit("room", roomId, event, data, excludeComponentId);
|
|
1006
|
+
this.pubsub?.publish(roomId, event, data)?.catch(() => {
|
|
1007
|
+
});
|
|
355
1008
|
return this.broadcastToRoom(roomId, {
|
|
356
1009
|
type: "ROOM_EVENT",
|
|
357
1010
|
componentId: "",
|
|
358
1011
|
roomId,
|
|
359
1012
|
event,
|
|
360
1013
|
data,
|
|
361
|
-
timestamp:
|
|
1014
|
+
timestamp: now
|
|
362
1015
|
}, excludeComponentId);
|
|
363
1016
|
}
|
|
364
1017
|
/**
|
|
365
|
-
* Update room state
|
|
1018
|
+
* Update room state.
|
|
1019
|
+
* When deepDiff is enabled (default), deep-diffs plain objects to send only changed fields.
|
|
1020
|
+
* When disabled, uses shallow diff (reference equality) like classic behavior.
|
|
366
1021
|
*/
|
|
367
1022
|
setRoomState(roomId, updates, excludeComponentId) {
|
|
368
1023
|
const room = this.rooms.get(roomId);
|
|
369
1024
|
if (!room) return;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
1025
|
+
let actualChanges;
|
|
1026
|
+
if (room.deepDiff) {
|
|
1027
|
+
const diff = computeDeepDiff(
|
|
1028
|
+
room.state,
|
|
1029
|
+
updates,
|
|
1030
|
+
0,
|
|
1031
|
+
room.deepDiffDepth
|
|
1032
|
+
);
|
|
1033
|
+
if (diff === null) return;
|
|
1034
|
+
actualChanges = diff;
|
|
1035
|
+
deepAssign(room.state, actualChanges);
|
|
1036
|
+
} else {
|
|
1037
|
+
actualChanges = {};
|
|
1038
|
+
let hasChanges = false;
|
|
1039
|
+
for (const key of Object.keys(updates)) {
|
|
1040
|
+
if (room.state[key] !== updates[key]) {
|
|
1041
|
+
actualChanges[key] = updates[key];
|
|
1042
|
+
hasChanges = true;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (!hasChanges) return;
|
|
1046
|
+
Object.assign(room.state, actualChanges);
|
|
1047
|
+
}
|
|
1048
|
+
if (room.stateSize === void 0) {
|
|
1049
|
+
const fullJson = JSON.stringify(room.state);
|
|
1050
|
+
room.stateSize = fullJson.length;
|
|
1051
|
+
if (room.stateSize > MAX_ROOM_STATE_SIZE) {
|
|
1052
|
+
throw new Error("Room state exceeds maximum size limit");
|
|
1053
|
+
}
|
|
1054
|
+
} else {
|
|
1055
|
+
const deltaSize = JSON.stringify(actualChanges).length;
|
|
1056
|
+
room.stateSize += deltaSize;
|
|
1057
|
+
if (room.stateSize > MAX_ROOM_STATE_SIZE) {
|
|
1058
|
+
const precise = JSON.stringify(room.state).length;
|
|
1059
|
+
room.stateSize = precise;
|
|
1060
|
+
if (precise > MAX_ROOM_STATE_SIZE) {
|
|
1061
|
+
throw new Error("Room state exceeds maximum size limit");
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
374
1064
|
}
|
|
375
|
-
|
|
376
|
-
room.lastActivity =
|
|
1065
|
+
const now = Date.now();
|
|
1066
|
+
room.lastActivity = now;
|
|
1067
|
+
this.pubsub?.publishStateChange(roomId, actualChanges)?.catch(() => {
|
|
1068
|
+
});
|
|
377
1069
|
this.broadcastToRoom(roomId, {
|
|
378
1070
|
type: "ROOM_STATE",
|
|
379
1071
|
componentId: "",
|
|
380
1072
|
roomId,
|
|
381
1073
|
event: "$state:update",
|
|
382
|
-
data: { state:
|
|
383
|
-
timestamp:
|
|
1074
|
+
data: { state: actualChanges },
|
|
1075
|
+
timestamp: now
|
|
384
1076
|
}, excludeComponentId);
|
|
385
1077
|
}
|
|
386
1078
|
/**
|
|
@@ -390,24 +1082,40 @@ var LiveRoomManager = class {
|
|
|
390
1082
|
return this.rooms.get(roomId)?.state || {};
|
|
391
1083
|
}
|
|
392
1084
|
/**
|
|
393
|
-
* Broadcast to all members in a room
|
|
1085
|
+
* Broadcast to all members in a room.
|
|
1086
|
+
*
|
|
1087
|
+
* When the room has a binary codec (LiveRoom-backed), builds a binary frame
|
|
1088
|
+
* once (encode payload + frame tail), then prepends per-member componentId header.
|
|
1089
|
+
*
|
|
1090
|
+
* When no codec (legacy rooms), uses JSON with serialize-once optimization:
|
|
1091
|
+
* builds the JSON string template once, then inserts each member's componentId.
|
|
394
1092
|
*/
|
|
395
1093
|
broadcastToRoom(roomId, message, excludeComponentId) {
|
|
396
1094
|
const room = this.rooms.get(roomId);
|
|
397
|
-
if (!room) return 0;
|
|
1095
|
+
if (!room || room.members.size === 0) return 0;
|
|
398
1096
|
let sent = 0;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1097
|
+
if (room.codec) {
|
|
1098
|
+
const frameType = message.type === "ROOM_EVENT" || message.type === "ROOM_SYSTEM" ? BINARY_ROOM_EVENT : BINARY_ROOM_STATE;
|
|
1099
|
+
const event = message.event ?? "";
|
|
1100
|
+
const payload = room.codec.encode(message.data);
|
|
1101
|
+
const tail = buildRoomFrameTail(roomId, event, payload);
|
|
1102
|
+
for (const [memberComponentId, member] of room.members) {
|
|
1103
|
+
if (memberComponentId === excludeComponentId) continue;
|
|
1104
|
+
if (member.ws.readyState !== 1) continue;
|
|
1105
|
+
const frame = prependMemberHeader(frameType, memberComponentId, tail);
|
|
1106
|
+
sendBinaryImmediate(member.ws, frame);
|
|
1107
|
+
sent++;
|
|
1108
|
+
}
|
|
1109
|
+
} else {
|
|
1110
|
+
const { componentId: _, ...rest } = message;
|
|
1111
|
+
const jsonBody = JSON.stringify(rest);
|
|
1112
|
+
const prefix = '{"componentId":"';
|
|
1113
|
+
const suffix = '",' + jsonBody.slice(1);
|
|
1114
|
+
for (const [memberComponentId, member] of room.members) {
|
|
1115
|
+
if (memberComponentId === excludeComponentId) continue;
|
|
1116
|
+
if (member.ws.readyState !== 1) continue;
|
|
1117
|
+
queuePreSerialized(member.ws, prefix + memberComponentId + suffix);
|
|
1118
|
+
sent++;
|
|
411
1119
|
}
|
|
412
1120
|
}
|
|
413
1121
|
return sent;
|
|
@@ -418,12 +1126,31 @@ var LiveRoomManager = class {
|
|
|
418
1126
|
isInRoom(componentId, roomId) {
|
|
419
1127
|
return this.rooms.get(roomId)?.members.has(componentId) ?? false;
|
|
420
1128
|
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Check if room state is server-only (no client writes)
|
|
1131
|
+
*/
|
|
1132
|
+
isServerOnlyState(roomId) {
|
|
1133
|
+
return this.rooms.get(roomId)?.serverOnlyState ?? false;
|
|
1134
|
+
}
|
|
421
1135
|
/**
|
|
422
1136
|
* Get rooms for a component
|
|
423
1137
|
*/
|
|
424
1138
|
getComponentRooms(componentId) {
|
|
425
1139
|
return Array.from(this.componentRooms.get(componentId) || []);
|
|
426
1140
|
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Get member count for a room
|
|
1143
|
+
*/
|
|
1144
|
+
getMemberCount(roomId) {
|
|
1145
|
+
return this.rooms.get(roomId)?.members.size ?? 0;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Get the LiveRoom instance for a room (if backed by a typed room class).
|
|
1149
|
+
* Used by ComponentRoomProxy to expose custom methods.
|
|
1150
|
+
*/
|
|
1151
|
+
getRoomInstance(roomId) {
|
|
1152
|
+
return this.rooms.get(roomId)?.instance;
|
|
1153
|
+
}
|
|
427
1154
|
/**
|
|
428
1155
|
* Get statistics
|
|
429
1156
|
*/
|
|
@@ -443,329 +1170,59 @@ var LiveRoomManager = class {
|
|
|
443
1170
|
}
|
|
444
1171
|
};
|
|
445
1172
|
|
|
446
|
-
// src/
|
|
447
|
-
var
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
constructor(enabled = false) {
|
|
457
|
-
this._enabled = enabled;
|
|
458
|
-
}
|
|
459
|
-
get enabled() {
|
|
460
|
-
return this._enabled;
|
|
461
|
-
}
|
|
462
|
-
set enabled(value) {
|
|
463
|
-
this._enabled = value;
|
|
464
|
-
}
|
|
465
|
-
// ===== Event Emission =====
|
|
466
|
-
emit(type, componentId, componentName, data = {}) {
|
|
467
|
-
if (!this._enabled) return;
|
|
468
|
-
const event = {
|
|
469
|
-
id: `dbg-${++this.eventCounter}`,
|
|
470
|
-
timestamp: Date.now(),
|
|
471
|
-
type,
|
|
472
|
-
componentId,
|
|
473
|
-
componentName,
|
|
474
|
-
data: this.sanitizeData(data)
|
|
475
|
-
};
|
|
476
|
-
this.events.push(event);
|
|
477
|
-
if (this.events.length > MAX_EVENTS) {
|
|
478
|
-
this.events.shift();
|
|
479
|
-
}
|
|
480
|
-
if (componentId) {
|
|
481
|
-
this.updateSnapshot(event);
|
|
482
|
-
}
|
|
483
|
-
this.broadcastEvent(event);
|
|
1173
|
+
// src/auth/LiveAuthContext.ts
|
|
1174
|
+
var AuthenticatedContext = class {
|
|
1175
|
+
authenticated = true;
|
|
1176
|
+
user;
|
|
1177
|
+
token;
|
|
1178
|
+
authenticatedAt;
|
|
1179
|
+
constructor(user, token) {
|
|
1180
|
+
this.user = user;
|
|
1181
|
+
this.token = token;
|
|
1182
|
+
this.authenticatedAt = Date.now();
|
|
484
1183
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (!this._enabled) return;
|
|
488
|
-
const snapshot = {
|
|
489
|
-
componentId,
|
|
490
|
-
componentName,
|
|
491
|
-
debugLabel,
|
|
492
|
-
state: this.sanitizeState(initialState),
|
|
493
|
-
rooms: room ? [room] : [],
|
|
494
|
-
mountedAt: Date.now(),
|
|
495
|
-
lastActivity: Date.now(),
|
|
496
|
-
actionCount: 0,
|
|
497
|
-
stateChangeCount: 0,
|
|
498
|
-
errorCount: 0
|
|
499
|
-
};
|
|
500
|
-
this.componentSnapshots.set(componentId, snapshot);
|
|
501
|
-
this.emit("COMPONENT_MOUNT", componentId, componentName, {
|
|
502
|
-
initialState: snapshot.state,
|
|
503
|
-
room: room ?? null,
|
|
504
|
-
debugLabel: debugLabel ?? null
|
|
505
|
-
});
|
|
1184
|
+
hasRole(role) {
|
|
1185
|
+
return this.user.roles?.includes(role) ?? false;
|
|
506
1186
|
}
|
|
507
|
-
|
|
508
|
-
if (!this.
|
|
509
|
-
|
|
510
|
-
const componentName = snapshot?.componentName ?? null;
|
|
511
|
-
this.emit("COMPONENT_UNMOUNT", componentId, componentName, {
|
|
512
|
-
lifetime: snapshot ? Date.now() - snapshot.mountedAt : 0,
|
|
513
|
-
totalActions: snapshot?.actionCount ?? 0,
|
|
514
|
-
totalStateChanges: snapshot?.stateChangeCount ?? 0,
|
|
515
|
-
totalErrors: snapshot?.errorCount ?? 0
|
|
516
|
-
});
|
|
517
|
-
this.componentSnapshots.delete(componentId);
|
|
518
|
-
}
|
|
519
|
-
trackStateChange(componentId, delta, fullState, source = "setState") {
|
|
520
|
-
if (!this._enabled) return;
|
|
521
|
-
const snapshot = this.componentSnapshots.get(componentId);
|
|
522
|
-
if (snapshot) {
|
|
523
|
-
snapshot.state = this.sanitizeState(fullState);
|
|
524
|
-
snapshot.stateChangeCount++;
|
|
525
|
-
snapshot.lastActivity = Date.now();
|
|
526
|
-
}
|
|
527
|
-
this.emit("STATE_CHANGE", componentId, snapshot?.componentName ?? null, {
|
|
528
|
-
delta,
|
|
529
|
-
fullState: this.sanitizeState(fullState),
|
|
530
|
-
source
|
|
531
|
-
});
|
|
1187
|
+
hasAnyRole(roles) {
|
|
1188
|
+
if (!this.user.roles?.length) return false;
|
|
1189
|
+
return roles.some((role) => this.user.roles.includes(role));
|
|
532
1190
|
}
|
|
533
|
-
|
|
534
|
-
if (!this.
|
|
535
|
-
|
|
536
|
-
if (snapshot) {
|
|
537
|
-
snapshot.actionCount++;
|
|
538
|
-
snapshot.lastActivity = Date.now();
|
|
539
|
-
}
|
|
540
|
-
this.emit("ACTION_CALL", componentId, snapshot?.componentName ?? null, {
|
|
541
|
-
action,
|
|
542
|
-
payload: this.sanitizeData({ payload }).payload
|
|
543
|
-
});
|
|
1191
|
+
hasAllRoles(roles) {
|
|
1192
|
+
if (!this.user.roles?.length) return roles.length === 0;
|
|
1193
|
+
return roles.every((role) => this.user.roles.includes(role));
|
|
544
1194
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const snapshot = this.componentSnapshots.get(componentId);
|
|
548
|
-
this.emit("ACTION_RESULT", componentId, snapshot?.componentName ?? null, {
|
|
549
|
-
action,
|
|
550
|
-
result: this.sanitizeData({ result }).result,
|
|
551
|
-
duration
|
|
552
|
-
});
|
|
1195
|
+
hasPermission(permission) {
|
|
1196
|
+
return this.user.permissions?.includes(permission) ?? false;
|
|
553
1197
|
}
|
|
554
|
-
|
|
555
|
-
if (!this.
|
|
556
|
-
|
|
557
|
-
if (snapshot) {
|
|
558
|
-
snapshot.errorCount++;
|
|
559
|
-
}
|
|
560
|
-
this.emit("ACTION_ERROR", componentId, snapshot?.componentName ?? null, {
|
|
561
|
-
action,
|
|
562
|
-
error,
|
|
563
|
-
duration
|
|
564
|
-
});
|
|
1198
|
+
hasAllPermissions(permissions) {
|
|
1199
|
+
if (!this.user.permissions?.length) return permissions.length === 0;
|
|
1200
|
+
return permissions.every((perm) => this.user.permissions.includes(perm));
|
|
565
1201
|
}
|
|
566
|
-
|
|
567
|
-
if (!this.
|
|
568
|
-
|
|
569
|
-
if (snapshot && !snapshot.rooms.includes(roomId)) {
|
|
570
|
-
snapshot.rooms.push(roomId);
|
|
571
|
-
}
|
|
572
|
-
this.emit("ROOM_JOIN", componentId, snapshot?.componentName ?? null, { roomId });
|
|
1202
|
+
hasAnyPermission(permissions) {
|
|
1203
|
+
if (!this.user.permissions?.length) return false;
|
|
1204
|
+
return permissions.some((perm) => this.user.permissions.includes(perm));
|
|
573
1205
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1206
|
+
};
|
|
1207
|
+
var AnonymousContext = class {
|
|
1208
|
+
authenticated = false;
|
|
1209
|
+
user = void 0;
|
|
1210
|
+
token = void 0;
|
|
1211
|
+
authenticatedAt = void 0;
|
|
1212
|
+
hasRole() {
|
|
1213
|
+
return false;
|
|
581
1214
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const snapshot = this.componentSnapshots.get(componentId);
|
|
585
|
-
this.emit("ROOM_EMIT", componentId, snapshot?.componentName ?? null, {
|
|
586
|
-
roomId,
|
|
587
|
-
event,
|
|
588
|
-
data: this.sanitizeData({ data }).data
|
|
589
|
-
});
|
|
1215
|
+
hasAnyRole() {
|
|
1216
|
+
return false;
|
|
590
1217
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
this.emit("WS_CONNECT", null, null, { connectionId });
|
|
1218
|
+
hasAllRoles() {
|
|
1219
|
+
return false;
|
|
594
1220
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
this.emit("WS_DISCONNECT", null, null, { connectionId, componentCount });
|
|
1221
|
+
hasPermission() {
|
|
1222
|
+
return false;
|
|
598
1223
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const snapshot = componentId ? this.componentSnapshots.get(componentId) : null;
|
|
602
|
-
if (snapshot) {
|
|
603
|
-
snapshot.errorCount++;
|
|
604
|
-
}
|
|
605
|
-
this.emit("ERROR", componentId, snapshot?.componentName ?? null, {
|
|
606
|
-
error,
|
|
607
|
-
...context
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
// ===== Debug Client Management =====
|
|
611
|
-
registerDebugClient(ws) {
|
|
612
|
-
if (!this._enabled) {
|
|
613
|
-
const disabled = {
|
|
614
|
-
type: "DEBUG_DISABLED",
|
|
615
|
-
enabled: false,
|
|
616
|
-
timestamp: Date.now()
|
|
617
|
-
};
|
|
618
|
-
ws.send(JSON.stringify(disabled));
|
|
619
|
-
ws.close();
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
this.debugClients.add(ws);
|
|
623
|
-
const welcome = {
|
|
624
|
-
type: "DEBUG_WELCOME",
|
|
625
|
-
enabled: true,
|
|
626
|
-
snapshot: this.getSnapshot(),
|
|
627
|
-
timestamp: Date.now()
|
|
628
|
-
};
|
|
629
|
-
ws.send(JSON.stringify(welcome));
|
|
630
|
-
for (const event of this.events.slice(-100)) {
|
|
631
|
-
const msg = {
|
|
632
|
-
type: "DEBUG_EVENT",
|
|
633
|
-
event,
|
|
634
|
-
timestamp: Date.now()
|
|
635
|
-
};
|
|
636
|
-
ws.send(JSON.stringify(msg));
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
unregisterDebugClient(ws) {
|
|
640
|
-
this.debugClients.delete(ws);
|
|
641
|
-
}
|
|
642
|
-
// ===== Snapshot =====
|
|
643
|
-
getSnapshot() {
|
|
644
|
-
return {
|
|
645
|
-
components: Array.from(this.componentSnapshots.values()),
|
|
646
|
-
connections: this.debugClients.size,
|
|
647
|
-
uptime: Date.now() - this.startTime,
|
|
648
|
-
totalEvents: this.eventCounter
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
getComponentState(componentId) {
|
|
652
|
-
return this.componentSnapshots.get(componentId) ?? null;
|
|
653
|
-
}
|
|
654
|
-
getEvents(filter) {
|
|
655
|
-
let filtered = this.events;
|
|
656
|
-
if (filter?.componentId) {
|
|
657
|
-
filtered = filtered.filter((e) => e.componentId === filter.componentId);
|
|
658
|
-
}
|
|
659
|
-
if (filter?.type) {
|
|
660
|
-
filtered = filtered.filter((e) => e.type === filter.type);
|
|
661
|
-
}
|
|
662
|
-
const limit = filter?.limit ?? 100;
|
|
663
|
-
return filtered.slice(-limit);
|
|
664
|
-
}
|
|
665
|
-
clearEvents() {
|
|
666
|
-
this.events = [];
|
|
667
|
-
}
|
|
668
|
-
// ===== Internal =====
|
|
669
|
-
broadcastEvent(event) {
|
|
670
|
-
if (this.debugClients.size === 0) return;
|
|
671
|
-
const msg = {
|
|
672
|
-
type: "DEBUG_EVENT",
|
|
673
|
-
event,
|
|
674
|
-
timestamp: Date.now()
|
|
675
|
-
};
|
|
676
|
-
const json = JSON.stringify(msg);
|
|
677
|
-
for (const client of this.debugClients) {
|
|
678
|
-
try {
|
|
679
|
-
client.send(json);
|
|
680
|
-
} catch {
|
|
681
|
-
this.debugClients.delete(client);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
sanitizeData(data) {
|
|
686
|
-
try {
|
|
687
|
-
const json = JSON.stringify(data);
|
|
688
|
-
if (json.length > MAX_STATE_SIZE) {
|
|
689
|
-
return { _truncated: true, _size: json.length, _preview: json.slice(0, 500) + "..." };
|
|
690
|
-
}
|
|
691
|
-
return JSON.parse(json);
|
|
692
|
-
} catch {
|
|
693
|
-
return { _serialization_error: true };
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
sanitizeState(state) {
|
|
697
|
-
try {
|
|
698
|
-
const json = JSON.stringify(state);
|
|
699
|
-
if (json.length > MAX_STATE_SIZE) {
|
|
700
|
-
return { _truncated: true, _size: json.length };
|
|
701
|
-
}
|
|
702
|
-
return JSON.parse(json);
|
|
703
|
-
} catch {
|
|
704
|
-
return { _serialization_error: true };
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
updateSnapshot(event) {
|
|
708
|
-
if (!event.componentId) return;
|
|
709
|
-
const snapshot = this.componentSnapshots.get(event.componentId);
|
|
710
|
-
if (snapshot) {
|
|
711
|
-
snapshot.lastActivity = event.timestamp;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
|
|
716
|
-
// src/auth/LiveAuthContext.ts
|
|
717
|
-
var AuthenticatedContext = class {
|
|
718
|
-
authenticated = true;
|
|
719
|
-
user;
|
|
720
|
-
token;
|
|
721
|
-
authenticatedAt;
|
|
722
|
-
constructor(user, token) {
|
|
723
|
-
this.user = user;
|
|
724
|
-
this.token = token;
|
|
725
|
-
this.authenticatedAt = Date.now();
|
|
726
|
-
}
|
|
727
|
-
hasRole(role) {
|
|
728
|
-
return this.user.roles?.includes(role) ?? false;
|
|
729
|
-
}
|
|
730
|
-
hasAnyRole(roles) {
|
|
731
|
-
if (!this.user.roles?.length) return false;
|
|
732
|
-
return roles.some((role) => this.user.roles.includes(role));
|
|
733
|
-
}
|
|
734
|
-
hasAllRoles(roles) {
|
|
735
|
-
if (!this.user.roles?.length) return roles.length === 0;
|
|
736
|
-
return roles.every((role) => this.user.roles.includes(role));
|
|
737
|
-
}
|
|
738
|
-
hasPermission(permission) {
|
|
739
|
-
return this.user.permissions?.includes(permission) ?? false;
|
|
740
|
-
}
|
|
741
|
-
hasAllPermissions(permissions) {
|
|
742
|
-
if (!this.user.permissions?.length) return permissions.length === 0;
|
|
743
|
-
return permissions.every((perm) => this.user.permissions.includes(perm));
|
|
744
|
-
}
|
|
745
|
-
hasAnyPermission(permissions) {
|
|
746
|
-
if (!this.user.permissions?.length) return false;
|
|
747
|
-
return permissions.some((perm) => this.user.permissions.includes(perm));
|
|
748
|
-
}
|
|
749
|
-
};
|
|
750
|
-
var AnonymousContext = class {
|
|
751
|
-
authenticated = false;
|
|
752
|
-
user = void 0;
|
|
753
|
-
token = void 0;
|
|
754
|
-
authenticatedAt = void 0;
|
|
755
|
-
hasRole() {
|
|
756
|
-
return false;
|
|
757
|
-
}
|
|
758
|
-
hasAnyRole() {
|
|
759
|
-
return false;
|
|
760
|
-
}
|
|
761
|
-
hasAllRoles() {
|
|
762
|
-
return false;
|
|
763
|
-
}
|
|
764
|
-
hasPermission() {
|
|
765
|
-
return false;
|
|
766
|
-
}
|
|
767
|
-
hasAllPermissions() {
|
|
768
|
-
return false;
|
|
1224
|
+
hasAllPermissions() {
|
|
1225
|
+
return false;
|
|
769
1226
|
}
|
|
770
1227
|
hasAnyPermission() {
|
|
771
1228
|
return false;
|
|
@@ -853,15 +1310,21 @@ var LiveAuthManager = class {
|
|
|
853
1310
|
providersToTry.push(provider);
|
|
854
1311
|
}
|
|
855
1312
|
}
|
|
1313
|
+
const errors = [];
|
|
856
1314
|
for (const provider of providersToTry) {
|
|
857
1315
|
try {
|
|
858
1316
|
const context = await provider.authenticate(credentials);
|
|
859
1317
|
if (context && context.authenticated) {
|
|
860
1318
|
return context;
|
|
861
1319
|
}
|
|
862
|
-
} catch {
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
console.warn(`[Auth] Provider '${provider.name}' threw during authentication:`, error.message);
|
|
1322
|
+
errors.push({ provider: provider.name, error });
|
|
863
1323
|
}
|
|
864
1324
|
}
|
|
1325
|
+
if (errors.length > 0) {
|
|
1326
|
+
console.warn(`[Auth] All ${providersToTry.length} provider(s) failed. Errors: ${errors.map((e) => `${e.provider}: ${e.error.message}`).join("; ")}`);
|
|
1327
|
+
}
|
|
865
1328
|
return ANONYMOUS_CONTEXT;
|
|
866
1329
|
}
|
|
867
1330
|
/**
|
|
@@ -959,10 +1422,13 @@ var StateSignatureManager = class {
|
|
|
959
1422
|
secret;
|
|
960
1423
|
previousSecrets = [];
|
|
961
1424
|
rotationTimer;
|
|
962
|
-
usedNonces = /* @__PURE__ */ new Set();
|
|
963
|
-
nonceCleanupTimer;
|
|
964
1425
|
stateBackups = /* @__PURE__ */ new Map();
|
|
965
1426
|
config;
|
|
1427
|
+
encryptionSalt;
|
|
1428
|
+
cachedEncryptionKey = null;
|
|
1429
|
+
/** Replay detection: nonce → timestamp when it was first seen. Cleaned every 60s. */
|
|
1430
|
+
usedNonces = /* @__PURE__ */ new Map();
|
|
1431
|
+
nonceCleanupTimer;
|
|
966
1432
|
constructor(config = {}) {
|
|
967
1433
|
const defaultSecret = typeof process !== "undefined" ? process.env?.LIVE_STATE_SECRET : void 0;
|
|
968
1434
|
this.config = {
|
|
@@ -972,23 +1438,65 @@ var StateSignatureManager = class {
|
|
|
972
1438
|
compressionEnabled: config.compressionEnabled ?? true,
|
|
973
1439
|
encryptionEnabled: config.encryptionEnabled ?? false,
|
|
974
1440
|
nonceEnabled: config.nonceEnabled ?? false,
|
|
975
|
-
maxStateAge: config.maxStateAge ??
|
|
1441
|
+
maxStateAge: config.maxStateAge ?? 30 * 60 * 1e3,
|
|
976
1442
|
backupEnabled: config.backupEnabled ?? true,
|
|
977
|
-
maxBackups: config.maxBackups ?? 3
|
|
1443
|
+
maxBackups: config.maxBackups ?? 3,
|
|
1444
|
+
nonceTTL: config.nonceTTL ?? 5 * 60 * 1e3
|
|
978
1445
|
};
|
|
979
1446
|
if (!this.config.secret) {
|
|
980
1447
|
this.config.secret = randomBytes(32).toString("hex");
|
|
981
1448
|
liveWarn("state", null, "No LIVE_STATE_SECRET provided. Using random key (state will not persist across restarts).");
|
|
982
1449
|
}
|
|
983
1450
|
this.secret = Buffer.from(this.config.secret, "utf-8");
|
|
1451
|
+
this.encryptionSalt = randomBytes(16);
|
|
984
1452
|
if (this.config.rotationEnabled) {
|
|
985
1453
|
this.setupKeyRotation();
|
|
986
1454
|
}
|
|
987
1455
|
if (this.config.nonceEnabled) {
|
|
988
|
-
this.nonceCleanupTimer = setInterval(() => this.cleanupNonces(),
|
|
1456
|
+
this.nonceCleanupTimer = setInterval(() => this.cleanupNonces(), this.config.nonceTTL + 10 * 1e3);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Generate a hybrid nonce: `timestamp:random:HMAC(timestamp:random, secret)`
|
|
1461
|
+
* Self-validating via HMAC, unique via random bytes, replay-tracked via Map.
|
|
1462
|
+
*/
|
|
1463
|
+
generateNonce() {
|
|
1464
|
+
const ts = Date.now().toString();
|
|
1465
|
+
const rand = randomBytes(8).toString("hex");
|
|
1466
|
+
const payload = `${ts}:${rand}`;
|
|
1467
|
+
const mac = createHmac("sha256", this.secret).update(payload).digest("hex").slice(0, 16);
|
|
1468
|
+
return `${ts}:${rand}:${mac}`;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Validate a hybrid nonce: check format, HMAC, and TTL.
|
|
1472
|
+
*/
|
|
1473
|
+
validateNonce(nonce) {
|
|
1474
|
+
const parts = nonce.split(":");
|
|
1475
|
+
if (parts.length !== 3) return { valid: false, error: "Malformed nonce" };
|
|
1476
|
+
const [ts, rand, mac] = parts;
|
|
1477
|
+
const timestamp = Number(ts);
|
|
1478
|
+
if (isNaN(timestamp)) return { valid: false, error: "Malformed nonce timestamp" };
|
|
1479
|
+
const age = Date.now() - timestamp;
|
|
1480
|
+
if (age > this.config.nonceTTL) {
|
|
1481
|
+
return { valid: false, error: "Nonce expired" };
|
|
1482
|
+
}
|
|
1483
|
+
if (age < -3e4) {
|
|
1484
|
+
return { valid: false, error: "Nonce timestamp in the future" };
|
|
1485
|
+
}
|
|
1486
|
+
const payload = `${ts}:${rand}`;
|
|
1487
|
+
const expectedMac = createHmac("sha256", this.secret).update(payload).digest("hex").slice(0, 16);
|
|
1488
|
+
if (this.timingSafeEqual(mac, expectedMac)) {
|
|
1489
|
+
return { valid: true };
|
|
1490
|
+
}
|
|
1491
|
+
for (const prevSecret of this.previousSecrets) {
|
|
1492
|
+
const prevMac = createHmac("sha256", prevSecret).update(payload).digest("hex").slice(0, 16);
|
|
1493
|
+
if (this.timingSafeEqual(mac, prevMac)) {
|
|
1494
|
+
return { valid: true };
|
|
1495
|
+
}
|
|
989
1496
|
}
|
|
1497
|
+
return { valid: false, error: "Invalid nonce signature" };
|
|
990
1498
|
}
|
|
991
|
-
|
|
1499
|
+
signState(componentId, state, version, options) {
|
|
992
1500
|
let dataStr = JSON.stringify(state);
|
|
993
1501
|
let compressed = false;
|
|
994
1502
|
let encrypted = false;
|
|
@@ -1009,7 +1517,7 @@ var StateSignatureManager = class {
|
|
|
1009
1517
|
dataStr = iv.toString("base64") + ":" + encryptedData;
|
|
1010
1518
|
encrypted = true;
|
|
1011
1519
|
}
|
|
1012
|
-
const nonce = this.config.nonceEnabled ?
|
|
1520
|
+
const nonce = this.config.nonceEnabled ? this.generateNonce() : void 0;
|
|
1013
1521
|
const signedState = {
|
|
1014
1522
|
data: dataStr,
|
|
1015
1523
|
signature: "",
|
|
@@ -1026,26 +1534,34 @@ var StateSignatureManager = class {
|
|
|
1026
1534
|
}
|
|
1027
1535
|
return signedState;
|
|
1028
1536
|
}
|
|
1029
|
-
|
|
1537
|
+
validateState(signedState, options) {
|
|
1030
1538
|
try {
|
|
1031
1539
|
const age = Date.now() - signedState.timestamp;
|
|
1032
1540
|
if (age > this.config.maxStateAge) {
|
|
1033
1541
|
return { valid: false, error: "State expired" };
|
|
1034
1542
|
}
|
|
1035
|
-
if (signedState.nonce && this.config.nonceEnabled) {
|
|
1543
|
+
if (signedState.nonce && this.config.nonceEnabled && !options?.skipNonce) {
|
|
1544
|
+
const nonceResult = this.validateNonce(signedState.nonce);
|
|
1545
|
+
if (!nonceResult.valid) {
|
|
1546
|
+
return { valid: false, error: nonceResult.error };
|
|
1547
|
+
}
|
|
1036
1548
|
if (this.usedNonces.has(signedState.nonce)) {
|
|
1037
|
-
return { valid: false, error: "Nonce already used
|
|
1549
|
+
return { valid: false, error: "Nonce already used" };
|
|
1038
1550
|
}
|
|
1039
1551
|
}
|
|
1040
1552
|
const expectedSig = this.computeSignature(signedState);
|
|
1041
1553
|
if (this.timingSafeEqual(signedState.signature, expectedSig)) {
|
|
1042
|
-
if (signedState.nonce
|
|
1554
|
+
if (signedState.nonce && this.config.nonceEnabled) {
|
|
1555
|
+
this.usedNonces.set(signedState.nonce, Date.now());
|
|
1556
|
+
}
|
|
1043
1557
|
return { valid: true };
|
|
1044
1558
|
}
|
|
1045
1559
|
for (const prevSecret of this.previousSecrets) {
|
|
1046
1560
|
const prevSig = this.computeSignatureWithKey(signedState, prevSecret);
|
|
1047
1561
|
if (this.timingSafeEqual(signedState.signature, prevSig)) {
|
|
1048
|
-
if (signedState.nonce
|
|
1562
|
+
if (signedState.nonce && this.config.nonceEnabled) {
|
|
1563
|
+
this.usedNonces.set(signedState.nonce, Date.now());
|
|
1564
|
+
}
|
|
1049
1565
|
return { valid: true };
|
|
1050
1566
|
}
|
|
1051
1567
|
}
|
|
@@ -1054,7 +1570,7 @@ var StateSignatureManager = class {
|
|
|
1054
1570
|
return { valid: false, error: error.message };
|
|
1055
1571
|
}
|
|
1056
1572
|
}
|
|
1057
|
-
|
|
1573
|
+
extractData(signedState) {
|
|
1058
1574
|
let dataStr = signedState.data;
|
|
1059
1575
|
if (signedState.encrypted) {
|
|
1060
1576
|
const [ivB64, encryptedData] = dataStr.split(":");
|
|
@@ -1107,7 +1623,9 @@ var StateSignatureManager = class {
|
|
|
1107
1623
|
}
|
|
1108
1624
|
}
|
|
1109
1625
|
deriveEncryptionKey() {
|
|
1110
|
-
|
|
1626
|
+
if (this.cachedEncryptionKey) return this.cachedEncryptionKey;
|
|
1627
|
+
this.cachedEncryptionKey = scryptSync(this.secret, this.encryptionSalt, 32);
|
|
1628
|
+
return this.cachedEncryptionKey;
|
|
1111
1629
|
}
|
|
1112
1630
|
setupKeyRotation() {
|
|
1113
1631
|
this.rotationTimer = setInterval(() => {
|
|
@@ -1116,12 +1634,15 @@ var StateSignatureManager = class {
|
|
|
1116
1634
|
this.previousSecrets.pop();
|
|
1117
1635
|
}
|
|
1118
1636
|
this.secret = randomBytes(32);
|
|
1637
|
+
this.cachedEncryptionKey = null;
|
|
1119
1638
|
liveLog("state", null, "Key rotation completed");
|
|
1120
1639
|
}, this.config.rotationInterval);
|
|
1121
1640
|
}
|
|
1641
|
+
/** Remove nonces older than nonceTTL + 10s from the replay detection map. */
|
|
1122
1642
|
cleanupNonces() {
|
|
1123
|
-
|
|
1124
|
-
|
|
1643
|
+
const cutoff = Date.now() - (this.config.nonceTTL + 10 * 1e3);
|
|
1644
|
+
for (const [nonce, ts] of this.usedNonces) {
|
|
1645
|
+
if (ts < cutoff) this.usedNonces.delete(nonce);
|
|
1125
1646
|
}
|
|
1126
1647
|
}
|
|
1127
1648
|
shutdown() {
|
|
@@ -1542,6 +2063,8 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1542
2063
|
connections = /* @__PURE__ */ new Map();
|
|
1543
2064
|
connectionMetrics = /* @__PURE__ */ new Map();
|
|
1544
2065
|
connectionPools = /* @__PURE__ */ new Map();
|
|
2066
|
+
/** Reverse index: connectionId -> Set of poolIds for O(1) cleanup */
|
|
2067
|
+
connectionPoolIndex = /* @__PURE__ */ new Map();
|
|
1545
2068
|
messageQueues = /* @__PURE__ */ new Map();
|
|
1546
2069
|
healthCheckTimer;
|
|
1547
2070
|
heartbeatTimer;
|
|
@@ -1596,6 +2119,10 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1596
2119
|
this.connectionPools.set(poolId, /* @__PURE__ */ new Set());
|
|
1597
2120
|
}
|
|
1598
2121
|
this.connectionPools.get(poolId).add(connectionId);
|
|
2122
|
+
if (!this.connectionPoolIndex.has(connectionId)) {
|
|
2123
|
+
this.connectionPoolIndex.set(connectionId, /* @__PURE__ */ new Set());
|
|
2124
|
+
}
|
|
2125
|
+
this.connectionPoolIndex.get(connectionId).add(poolId);
|
|
1599
2126
|
}
|
|
1600
2127
|
removeFromPool(connectionId, poolId) {
|
|
1601
2128
|
const pool = this.connectionPools.get(poolId);
|
|
@@ -1603,15 +2130,22 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1603
2130
|
pool.delete(connectionId);
|
|
1604
2131
|
if (pool.size === 0) this.connectionPools.delete(poolId);
|
|
1605
2132
|
}
|
|
2133
|
+
this.connectionPoolIndex.get(connectionId)?.delete(poolId);
|
|
1606
2134
|
}
|
|
1607
2135
|
cleanupConnection(connectionId) {
|
|
1608
2136
|
this.connections.delete(connectionId);
|
|
1609
2137
|
this.connectionMetrics.delete(connectionId);
|
|
1610
2138
|
this.messageQueues.delete(connectionId);
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
2139
|
+
const poolIds = this.connectionPoolIndex.get(connectionId);
|
|
2140
|
+
if (poolIds) {
|
|
2141
|
+
for (const poolId of poolIds) {
|
|
2142
|
+
const pool = this.connectionPools.get(poolId);
|
|
2143
|
+
if (pool) {
|
|
2144
|
+
pool.delete(connectionId);
|
|
2145
|
+
if (pool.size === 0) this.connectionPools.delete(poolId);
|
|
2146
|
+
}
|
|
1614
2147
|
}
|
|
2148
|
+
this.connectionPoolIndex.delete(connectionId);
|
|
1615
2149
|
}
|
|
1616
2150
|
}
|
|
1617
2151
|
getConnectionMetrics(connectionId) {
|
|
@@ -1693,6 +2227,7 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1693
2227
|
this.connections.clear();
|
|
1694
2228
|
this.connectionMetrics.clear();
|
|
1695
2229
|
this.connectionPools.clear();
|
|
2230
|
+
this.connectionPoolIndex.clear();
|
|
1696
2231
|
this.messageQueues.clear();
|
|
1697
2232
|
}
|
|
1698
2233
|
};
|
|
@@ -1707,116 +2242,39 @@ function getLiveComponentContext() {
|
|
|
1707
2242
|
return _ctx;
|
|
1708
2243
|
}
|
|
1709
2244
|
|
|
1710
|
-
// src/component/
|
|
1711
|
-
var
|
|
1712
|
-
var
|
|
1713
|
-
function _setLiveDebugger(dbg) {
|
|
1714
|
-
_liveDebugger = dbg;
|
|
1715
|
-
}
|
|
1716
|
-
var LiveComponent = class _LiveComponent {
|
|
1717
|
-
/** Component name for registry lookup - must be defined in subclasses */
|
|
1718
|
-
static componentName;
|
|
1719
|
-
/** Default state - must be defined in subclasses */
|
|
1720
|
-
static defaultState;
|
|
1721
|
-
/**
|
|
1722
|
-
* Per-component logging control. Silent by default.
|
|
1723
|
-
*
|
|
1724
|
-
* @example
|
|
1725
|
-
* static logging = true // all categories
|
|
1726
|
-
* static logging = ['lifecycle', 'messages'] // specific categories
|
|
1727
|
-
*/
|
|
1728
|
-
static logging;
|
|
1729
|
-
/**
|
|
1730
|
-
* Component-level auth configuration.
|
|
1731
|
-
*/
|
|
1732
|
-
static auth;
|
|
1733
|
-
/**
|
|
1734
|
-
* Per-action auth configuration.
|
|
1735
|
-
*/
|
|
1736
|
-
static actionAuth;
|
|
1737
|
-
/**
|
|
1738
|
-
* Data that survives HMR reloads.
|
|
1739
|
-
*/
|
|
1740
|
-
static persistent;
|
|
1741
|
-
/**
|
|
1742
|
-
* When true, only ONE server-side instance exists for this component.
|
|
1743
|
-
* All clients share the same state.
|
|
1744
|
-
*/
|
|
1745
|
-
static singleton;
|
|
1746
|
-
id;
|
|
2245
|
+
// src/component/managers/ComponentStateManager.ts
|
|
2246
|
+
var _forbiddenSetCache = /* @__PURE__ */ new WeakMap();
|
|
2247
|
+
var ComponentStateManager = class {
|
|
1747
2248
|
_state;
|
|
1748
|
-
|
|
1749
|
-
// Proxy wrapper
|
|
1750
|
-
ws;
|
|
1751
|
-
room;
|
|
1752
|
-
userId;
|
|
1753
|
-
broadcastToRoom = () => {
|
|
1754
|
-
};
|
|
1755
|
-
// Server-only private state (NEVER sent to client)
|
|
1756
|
-
_privateState = {};
|
|
1757
|
-
// Auth context (injected by registry during mount)
|
|
1758
|
-
_authContext = ANONYMOUS_CONTEXT;
|
|
1759
|
-
// Room event subscriptions (cleaned up on destroy)
|
|
1760
|
-
roomEventUnsubscribers = [];
|
|
1761
|
-
joinedRooms = /* @__PURE__ */ new Set();
|
|
1762
|
-
// Room type for typed events (override in subclass)
|
|
1763
|
-
roomType = "default";
|
|
1764
|
-
// Cached room handles
|
|
1765
|
-
roomHandles = /* @__PURE__ */ new Map();
|
|
1766
|
-
// Guard against infinite recursion in onStateChange
|
|
2249
|
+
_proxyState;
|
|
1767
2250
|
_inStateChange = false;
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
this.
|
|
1777
|
-
this.
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
this.
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
"room",
|
|
1795
|
-
"userId",
|
|
1796
|
-
"broadcastToRoom",
|
|
1797
|
-
"$private",
|
|
1798
|
-
"_privateState",
|
|
1799
|
-
"$room",
|
|
1800
|
-
"$rooms",
|
|
1801
|
-
"roomType",
|
|
1802
|
-
"roomHandles",
|
|
1803
|
-
"joinedRooms",
|
|
1804
|
-
"roomEventUnsubscribers"
|
|
1805
|
-
]);
|
|
1806
|
-
for (const key of Object.keys(this._state)) {
|
|
1807
|
-
if (!forbidden.has(key)) {
|
|
1808
|
-
Object.defineProperty(this, key, {
|
|
1809
|
-
get: () => this._state[key],
|
|
1810
|
-
set: (value) => {
|
|
1811
|
-
this.state[key] = value;
|
|
1812
|
-
},
|
|
1813
|
-
enumerable: true,
|
|
1814
|
-
configurable: true
|
|
1815
|
-
});
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
2251
|
+
_idBytes = null;
|
|
2252
|
+
_deepDiff;
|
|
2253
|
+
_deepDiffDepth;
|
|
2254
|
+
componentId;
|
|
2255
|
+
ws;
|
|
2256
|
+
emitFn;
|
|
2257
|
+
onStateChangeFn;
|
|
2258
|
+
constructor(opts) {
|
|
2259
|
+
this.componentId = opts.componentId;
|
|
2260
|
+
this.ws = opts.ws;
|
|
2261
|
+
this.emitFn = opts.emitFn;
|
|
2262
|
+
this.onStateChangeFn = opts.onStateChangeFn;
|
|
2263
|
+
this._deepDiff = opts.deepDiff ?? false;
|
|
2264
|
+
this._deepDiffDepth = opts.deepDiffDepth ?? 3;
|
|
2265
|
+
this._state = this._deepDiff ? structuredClone(opts.initialState) : opts.initialState;
|
|
2266
|
+
this._proxyState = this.createStateProxy(this._state);
|
|
2267
|
+
}
|
|
2268
|
+
get rawState() {
|
|
2269
|
+
return this._state;
|
|
2270
|
+
}
|
|
2271
|
+
get proxyState() {
|
|
2272
|
+
return this._proxyState;
|
|
2273
|
+
}
|
|
2274
|
+
/** Guard flag — prevents infinite recursion in onStateChange */
|
|
2275
|
+
get inStateChange() {
|
|
2276
|
+
return this._inStateChange;
|
|
1818
2277
|
}
|
|
1819
|
-
// Create a Proxy that auto-emits STATE_DELTA on any mutation
|
|
1820
2278
|
createStateProxy(state) {
|
|
1821
2279
|
const self = this;
|
|
1822
2280
|
return new Proxy(state, {
|
|
@@ -1825,23 +2283,17 @@ var LiveComponent = class _LiveComponent {
|
|
|
1825
2283
|
if (oldValue !== value) {
|
|
1826
2284
|
target[prop] = value;
|
|
1827
2285
|
const changes = { [prop]: value };
|
|
1828
|
-
self.
|
|
2286
|
+
self.emitFn("STATE_DELTA", { delta: changes });
|
|
1829
2287
|
if (!self._inStateChange) {
|
|
1830
2288
|
self._inStateChange = true;
|
|
1831
2289
|
try {
|
|
1832
|
-
self.
|
|
2290
|
+
self.onStateChangeFn(changes);
|
|
1833
2291
|
} catch (err) {
|
|
1834
|
-
console.error(`[${self.
|
|
2292
|
+
console.error(`[${self.componentId}] onStateChange error:`, err?.message || err);
|
|
1835
2293
|
} finally {
|
|
1836
2294
|
self._inStateChange = false;
|
|
1837
2295
|
}
|
|
1838
2296
|
}
|
|
1839
|
-
_liveDebugger?.trackStateChange(
|
|
1840
|
-
self.id,
|
|
1841
|
-
changes,
|
|
1842
|
-
target,
|
|
1843
|
-
"proxy"
|
|
1844
|
-
);
|
|
1845
2297
|
}
|
|
1846
2298
|
return true;
|
|
1847
2299
|
},
|
|
@@ -1850,76 +2302,459 @@ var LiveComponent = class _LiveComponent {
|
|
|
1850
2302
|
}
|
|
1851
2303
|
});
|
|
1852
2304
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2305
|
+
setState(updates) {
|
|
2306
|
+
const newUpdates = typeof updates === "function" ? updates(this._state) : updates;
|
|
2307
|
+
let actualChanges;
|
|
2308
|
+
let hasChanges;
|
|
2309
|
+
if (this._deepDiff) {
|
|
2310
|
+
const diff = computeDeepDiff(
|
|
2311
|
+
this._state,
|
|
2312
|
+
newUpdates,
|
|
2313
|
+
0,
|
|
2314
|
+
this._deepDiffDepth
|
|
2315
|
+
);
|
|
2316
|
+
if (diff === null) return;
|
|
2317
|
+
actualChanges = diff;
|
|
2318
|
+
hasChanges = true;
|
|
2319
|
+
} else {
|
|
2320
|
+
actualChanges = {};
|
|
2321
|
+
hasChanges = false;
|
|
2322
|
+
for (const key of Object.keys(newUpdates)) {
|
|
2323
|
+
if (this._state[key] !== newUpdates[key]) {
|
|
2324
|
+
actualChanges[key] = newUpdates[key];
|
|
2325
|
+
hasChanges = true;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
if (!hasChanges) return;
|
|
2330
|
+
if (this._deepDiff) {
|
|
2331
|
+
deepAssign(this._state, actualChanges);
|
|
2332
|
+
} else {
|
|
2333
|
+
Object.assign(this._state, actualChanges);
|
|
2334
|
+
}
|
|
2335
|
+
this.emitFn("STATE_DELTA", { delta: actualChanges });
|
|
2336
|
+
if (!this._inStateChange) {
|
|
2337
|
+
this._inStateChange = true;
|
|
2338
|
+
try {
|
|
2339
|
+
this.onStateChangeFn(actualChanges);
|
|
2340
|
+
} catch (err) {
|
|
2341
|
+
console.error(`[${this.componentId}] onStateChange error:`, err?.message || err);
|
|
2342
|
+
} finally {
|
|
2343
|
+
this._inStateChange = false;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
1858
2346
|
}
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
if (this.roomHandles.has(roomId)) {
|
|
1867
|
-
return this.roomHandles.get(roomId);
|
|
2347
|
+
sendBinaryDelta(delta, encoder2) {
|
|
2348
|
+
const actualChanges = {};
|
|
2349
|
+
let hasChanges = false;
|
|
2350
|
+
for (const key of Object.keys(delta)) {
|
|
2351
|
+
if (this._state[key] !== delta[key]) {
|
|
2352
|
+
actualChanges[key] = delta[key];
|
|
2353
|
+
hasChanges = true;
|
|
1868
2354
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2355
|
+
}
|
|
2356
|
+
if (!hasChanges) return;
|
|
2357
|
+
Object.assign(this._state, actualChanges);
|
|
2358
|
+
const payload = encoder2(actualChanges);
|
|
2359
|
+
if (!this._idBytes) {
|
|
2360
|
+
this._idBytes = new TextEncoder().encode(this.componentId);
|
|
2361
|
+
}
|
|
2362
|
+
const idBytes = this._idBytes;
|
|
2363
|
+
const frame = new Uint8Array(1 + 1 + idBytes.length + payload.length);
|
|
2364
|
+
frame[0] = 1;
|
|
2365
|
+
frame[1] = idBytes.length;
|
|
2366
|
+
frame.set(idBytes, 2);
|
|
2367
|
+
frame.set(payload, 2 + idBytes.length);
|
|
2368
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
2369
|
+
this.ws.send(frame);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
setValue(payload) {
|
|
2373
|
+
const { key, value } = payload;
|
|
2374
|
+
const update = { [key]: value };
|
|
2375
|
+
this.setState(update);
|
|
2376
|
+
return { success: true, key, value };
|
|
2377
|
+
}
|
|
2378
|
+
getSerializableState() {
|
|
2379
|
+
return this._proxyState;
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* Create getters/setters for each state property directly on `target`.
|
|
2383
|
+
* This allows `this.count` instead of `this.state.count` in subclasses.
|
|
2384
|
+
*/
|
|
2385
|
+
applyDirectAccessors(target, constructorFn) {
|
|
2386
|
+
let forbidden = _forbiddenSetCache.get(constructorFn);
|
|
2387
|
+
if (!forbidden) {
|
|
2388
|
+
forbidden = /* @__PURE__ */ new Set([
|
|
2389
|
+
...Object.keys(target),
|
|
2390
|
+
...Object.getOwnPropertyNames(Object.getPrototypeOf(target)),
|
|
2391
|
+
"state",
|
|
2392
|
+
"_state",
|
|
2393
|
+
"ws",
|
|
2394
|
+
"id",
|
|
2395
|
+
"room",
|
|
2396
|
+
"userId",
|
|
2397
|
+
"broadcastToRoom",
|
|
2398
|
+
"$private",
|
|
2399
|
+
"_privateState",
|
|
2400
|
+
"$room",
|
|
2401
|
+
"$rooms",
|
|
2402
|
+
"roomType",
|
|
2403
|
+
"roomHandles",
|
|
2404
|
+
"joinedRooms",
|
|
2405
|
+
"roomEventUnsubscribers",
|
|
2406
|
+
// Internal manager fields
|
|
2407
|
+
"_stateManager",
|
|
2408
|
+
"_roomProxyManager",
|
|
2409
|
+
"_actionSecurity",
|
|
2410
|
+
"_messaging"
|
|
2411
|
+
]);
|
|
2412
|
+
_forbiddenSetCache.set(constructorFn, forbidden);
|
|
2413
|
+
}
|
|
2414
|
+
for (const key of Object.keys(this._state)) {
|
|
2415
|
+
if (!forbidden.has(key)) {
|
|
2416
|
+
Object.defineProperty(target, key, {
|
|
2417
|
+
get: () => this._state[key],
|
|
2418
|
+
set: (value) => {
|
|
2419
|
+
this._proxyState[key] = value;
|
|
2420
|
+
},
|
|
2421
|
+
enumerable: true,
|
|
2422
|
+
configurable: true
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
/** Release cached resources */
|
|
2428
|
+
cleanup() {
|
|
2429
|
+
this._idBytes = null;
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
|
|
2433
|
+
// src/component/managers/ComponentMessaging.ts
|
|
2434
|
+
var EMIT_OVERRIDE_KEY = /* @__PURE__ */ Symbol.for("fluxstack:emitOverride");
|
|
2435
|
+
var ComponentMessaging = class {
|
|
2436
|
+
constructor(ctx) {
|
|
2437
|
+
this.ctx = ctx;
|
|
2438
|
+
}
|
|
2439
|
+
emit(type, payload) {
|
|
2440
|
+
const override = this.ctx.getEmitOverride();
|
|
2441
|
+
if (override) {
|
|
2442
|
+
override(type, payload);
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
const message = {
|
|
2446
|
+
type,
|
|
2447
|
+
componentId: this.ctx.componentId,
|
|
2448
|
+
payload,
|
|
2449
|
+
timestamp: Date.now(),
|
|
2450
|
+
userId: this.ctx.getUserId(),
|
|
2451
|
+
room: this.ctx.getRoom()
|
|
2452
|
+
};
|
|
2453
|
+
if (this.ctx.ws) {
|
|
2454
|
+
queueWsMessage(this.ctx.ws, message);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
broadcast(type, payload, excludeCurrentUser = false) {
|
|
2458
|
+
const room = this.ctx.getRoom();
|
|
2459
|
+
if (!room) {
|
|
2460
|
+
liveWarn("rooms", this.ctx.componentId, `[${this.ctx.componentId}] Cannot broadcast '${type}' - no room set`);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
const message = {
|
|
2464
|
+
type,
|
|
2465
|
+
payload,
|
|
2466
|
+
room,
|
|
2467
|
+
excludeUser: excludeCurrentUser ? this.ctx.getUserId() : void 0
|
|
2468
|
+
};
|
|
2469
|
+
liveLog("rooms", this.ctx.componentId, `[${this.ctx.componentId}] Broadcasting '${type}' to room '${room}'`);
|
|
2470
|
+
this.ctx.getBroadcastToRoom()(message);
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
// src/component/managers/ActionSecurityManager.ts
|
|
2475
|
+
var BLOCKED_ACTIONS = /* @__PURE__ */ new Set([
|
|
2476
|
+
"constructor",
|
|
2477
|
+
"destroy",
|
|
2478
|
+
"executeAction",
|
|
2479
|
+
"getSerializableState",
|
|
2480
|
+
"onMount",
|
|
2481
|
+
"onDestroy",
|
|
2482
|
+
"onConnect",
|
|
2483
|
+
"onDisconnect",
|
|
2484
|
+
"onStateChange",
|
|
2485
|
+
"onRoomJoin",
|
|
2486
|
+
"onRoomLeave",
|
|
2487
|
+
"onRehydrate",
|
|
2488
|
+
"onAction",
|
|
2489
|
+
"onClientJoin",
|
|
2490
|
+
"onClientLeave",
|
|
2491
|
+
"setState",
|
|
2492
|
+
"sendBinaryDelta",
|
|
2493
|
+
"emit",
|
|
2494
|
+
"broadcast",
|
|
2495
|
+
"broadcastToRoom",
|
|
2496
|
+
"createStateProxy",
|
|
2497
|
+
"createDirectStateAccessors",
|
|
2498
|
+
"generateId",
|
|
2499
|
+
"setAuthContext",
|
|
2500
|
+
"_resetAuthContext",
|
|
2501
|
+
"$auth",
|
|
2502
|
+
"$private",
|
|
2503
|
+
"_privateState",
|
|
2504
|
+
"$persistent",
|
|
2505
|
+
"_inStateChange",
|
|
2506
|
+
"$room",
|
|
2507
|
+
"$rooms",
|
|
2508
|
+
"subscribeToRoom",
|
|
2509
|
+
"unsubscribeFromRoom",
|
|
2510
|
+
"emitRoomEvent",
|
|
2511
|
+
"onRoomEvent",
|
|
2512
|
+
"emitRoomEventWithState"
|
|
2513
|
+
]);
|
|
2514
|
+
var ActionSecurityManager = class {
|
|
2515
|
+
_actionCalls = /* @__PURE__ */ new Map();
|
|
2516
|
+
async validateAndExecute(action, payload, ctx) {
|
|
2517
|
+
const { component, componentClass, componentId } = ctx;
|
|
2518
|
+
try {
|
|
2519
|
+
if (BLOCKED_ACTIONS.has(action)) {
|
|
2520
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2521
|
+
}
|
|
2522
|
+
if (action.startsWith("_") || action.startsWith("#")) {
|
|
2523
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2524
|
+
}
|
|
2525
|
+
const publicActions = componentClass.publicActions;
|
|
2526
|
+
if (!publicActions) {
|
|
2527
|
+
console.warn(`[SECURITY] Component '${componentClass.componentName || componentClass.name}' has no publicActions defined. All remote actions are blocked.`);
|
|
2528
|
+
throw new Error(`Action '${action}' is not callable - component has no publicActions defined`);
|
|
2529
|
+
}
|
|
2530
|
+
if (!publicActions.includes(action)) {
|
|
2531
|
+
const methodExists = typeof component[action] === "function";
|
|
2532
|
+
if (methodExists) {
|
|
2533
|
+
const name = componentClass.componentName || componentClass.name;
|
|
2534
|
+
throw new Error(
|
|
2535
|
+
`Action '${action}' exists on '${name}' but is not listed in publicActions. Add it to: static publicActions = [..., '${action}']`
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2539
|
+
}
|
|
2540
|
+
const method = component[action];
|
|
2541
|
+
if (typeof method !== "function") {
|
|
2542
|
+
throw new Error(`Action '${action}' not found on component`);
|
|
2543
|
+
}
|
|
2544
|
+
if (Object.prototype.hasOwnProperty.call(Object.prototype, action)) {
|
|
2545
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2546
|
+
}
|
|
2547
|
+
const rateLimit = componentClass.actionRateLimit;
|
|
2548
|
+
if (rateLimit) {
|
|
2549
|
+
const now = Date.now();
|
|
2550
|
+
const key = rateLimit.perAction ? action : "*";
|
|
2551
|
+
let entry = this._actionCalls.get(key);
|
|
2552
|
+
if (!entry || now - entry.windowStart >= rateLimit.windowMs) {
|
|
2553
|
+
entry = { count: 0, windowStart: now };
|
|
2554
|
+
this._actionCalls.set(key, entry);
|
|
2555
|
+
}
|
|
2556
|
+
entry.count++;
|
|
2557
|
+
if (entry.count > rateLimit.maxCalls) {
|
|
2558
|
+
throw new Error(`Action rate limit exceeded (max ${rateLimit.maxCalls} calls per ${rateLimit.windowMs}ms)`);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
const schemas = componentClass.actionSchemas;
|
|
2562
|
+
if (schemas && schemas[action]) {
|
|
2563
|
+
const result2 = schemas[action].safeParse(payload);
|
|
2564
|
+
if (!result2.success) {
|
|
2565
|
+
const errorMsg = result2.error?.message || result2.error?.issues?.map((i) => i.message).join(", ") || "Invalid payload";
|
|
2566
|
+
throw new Error(`Action '${action}' payload validation failed: ${errorMsg}`);
|
|
2567
|
+
}
|
|
2568
|
+
payload = result2.data ?? payload;
|
|
2569
|
+
}
|
|
2570
|
+
let hookResult;
|
|
2571
|
+
try {
|
|
2572
|
+
hookResult = await component.onAction(action, payload);
|
|
2573
|
+
} catch (hookError) {
|
|
2574
|
+
ctx.emitFn("ERROR", {
|
|
2575
|
+
action,
|
|
2576
|
+
error: `Action '${action}' failed pre-validation`
|
|
2577
|
+
});
|
|
2578
|
+
throw hookError;
|
|
2579
|
+
}
|
|
2580
|
+
if (hookResult === false) {
|
|
2581
|
+
throw new Error(`Action '${action}' was cancelled`);
|
|
2582
|
+
}
|
|
2583
|
+
const result = await method.call(component, payload);
|
|
2584
|
+
return result;
|
|
2585
|
+
} catch (error) {
|
|
2586
|
+
if (!error.message?.includes("was cancelled") && !error.message?.includes("pre-validation")) {
|
|
2587
|
+
ctx.emitFn("ERROR", {
|
|
2588
|
+
action,
|
|
2589
|
+
error: error.message
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
throw error;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
};
|
|
2596
|
+
|
|
2597
|
+
// src/component/managers/ComponentRoomProxy.ts
|
|
2598
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set([
|
|
2599
|
+
"id",
|
|
2600
|
+
"state",
|
|
2601
|
+
"join",
|
|
2602
|
+
"leave",
|
|
2603
|
+
"emit",
|
|
2604
|
+
"on",
|
|
2605
|
+
"setState",
|
|
2606
|
+
// Function internals (proxy wraps a function)
|
|
2607
|
+
"call",
|
|
2608
|
+
"apply",
|
|
2609
|
+
"bind",
|
|
2610
|
+
"prototype",
|
|
2611
|
+
"length",
|
|
2612
|
+
"name",
|
|
2613
|
+
"arguments",
|
|
2614
|
+
"caller",
|
|
2615
|
+
// Symbol keys
|
|
2616
|
+
Symbol.toPrimitive,
|
|
2617
|
+
Symbol.toStringTag,
|
|
2618
|
+
Symbol.hasInstance
|
|
2619
|
+
]);
|
|
2620
|
+
var TYPED_RESERVED_KEYS = /* @__PURE__ */ new Set([
|
|
2621
|
+
"id",
|
|
2622
|
+
"state",
|
|
2623
|
+
"meta",
|
|
2624
|
+
"join",
|
|
2625
|
+
"leave",
|
|
2626
|
+
"emit",
|
|
2627
|
+
"on",
|
|
2628
|
+
"setState",
|
|
2629
|
+
"memberCount",
|
|
2630
|
+
// Proxy internals
|
|
2631
|
+
"then",
|
|
2632
|
+
"toJSON",
|
|
2633
|
+
"valueOf",
|
|
2634
|
+
"toString",
|
|
2635
|
+
Symbol.toPrimitive,
|
|
2636
|
+
Symbol.toStringTag,
|
|
2637
|
+
Symbol.hasInstance
|
|
2638
|
+
]);
|
|
2639
|
+
function wrapWithStateProxy(target, getState, setState) {
|
|
2640
|
+
return new Proxy(target, {
|
|
2641
|
+
get(obj, prop, receiver) {
|
|
2642
|
+
if (RESERVED_KEYS.has(prop) || typeof prop === "symbol") {
|
|
2643
|
+
return Reflect.get(obj, prop, receiver);
|
|
2644
|
+
}
|
|
2645
|
+
const desc = Object.getOwnPropertyDescriptor(obj, prop);
|
|
2646
|
+
if (desc) return Reflect.get(obj, prop, receiver);
|
|
2647
|
+
if (prop in obj) return Reflect.get(obj, prop, receiver);
|
|
2648
|
+
const st = getState();
|
|
2649
|
+
return st?.[prop];
|
|
2650
|
+
},
|
|
2651
|
+
set(_obj, prop, value) {
|
|
2652
|
+
if (typeof prop === "symbol") return false;
|
|
2653
|
+
setState({ [prop]: value });
|
|
2654
|
+
return true;
|
|
2655
|
+
}
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
var ComponentRoomProxy = class {
|
|
2659
|
+
roomEventUnsubscribers = [];
|
|
2660
|
+
joinedRooms = /* @__PURE__ */ new Set();
|
|
2661
|
+
roomHandles = /* @__PURE__ */ new Map();
|
|
2662
|
+
_roomProxy = null;
|
|
2663
|
+
_roomsCache = null;
|
|
2664
|
+
_cachedCtx = null;
|
|
2665
|
+
roomType = "default";
|
|
2666
|
+
room;
|
|
2667
|
+
componentId;
|
|
2668
|
+
ws;
|
|
2669
|
+
getCtx;
|
|
2670
|
+
setStateFn;
|
|
2671
|
+
_deepDiff;
|
|
2672
|
+
_deepDiffDepth;
|
|
2673
|
+
_serverOnlyState;
|
|
2674
|
+
constructor(rctx) {
|
|
2675
|
+
this.componentId = rctx.componentId;
|
|
2676
|
+
this.ws = rctx.ws;
|
|
2677
|
+
this.room = rctx.defaultRoom;
|
|
2678
|
+
this.getCtx = rctx.getCtx;
|
|
2679
|
+
this.setStateFn = rctx.setStateFn;
|
|
2680
|
+
this._deepDiff = rctx.deepDiff ?? true;
|
|
2681
|
+
this._deepDiffDepth = rctx.deepDiffDepth;
|
|
2682
|
+
this._serverOnlyState = rctx.serverOnlyState ?? false;
|
|
2683
|
+
if (this.room) {
|
|
2684
|
+
this.joinedRooms.add(this.room);
|
|
2685
|
+
this.ctx.roomManager.joinRoom(this.componentId, this.room, this.ws, void 0, { deepDiff: this._deepDiff, deepDiffDepth: this._deepDiffDepth, serverOnlyState: this._serverOnlyState });
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
/** Lazy context resolution — cached after first access */
|
|
2689
|
+
get ctx() {
|
|
2690
|
+
if (!this._cachedCtx) {
|
|
2691
|
+
this._cachedCtx = this.getCtx();
|
|
2692
|
+
}
|
|
2693
|
+
return this._cachedCtx;
|
|
2694
|
+
}
|
|
2695
|
+
get $room() {
|
|
2696
|
+
if (this._roomProxy) return this._roomProxy;
|
|
2697
|
+
const self = this;
|
|
2698
|
+
const createHandle = (roomId) => {
|
|
2699
|
+
if (this.roomHandles.has(roomId)) {
|
|
2700
|
+
return this.roomHandles.get(roomId);
|
|
2701
|
+
}
|
|
2702
|
+
const handle = {
|
|
2703
|
+
get id() {
|
|
2704
|
+
return roomId;
|
|
2705
|
+
},
|
|
2706
|
+
get state() {
|
|
2707
|
+
return self.ctx.roomManager.getRoomState(roomId);
|
|
2708
|
+
},
|
|
2709
|
+
join: (initialState) => {
|
|
2710
|
+
if (self.joinedRooms.has(roomId)) return;
|
|
2711
|
+
self.joinedRooms.add(roomId);
|
|
2712
|
+
self._roomsCache = null;
|
|
2713
|
+
self.ctx.roomManager.joinRoom(self.componentId, roomId, self.ws, initialState, { deepDiff: self._deepDiff, deepDiffDepth: self._deepDiffDepth, serverOnlyState: self._serverOnlyState });
|
|
2714
|
+
},
|
|
2715
|
+
leave: () => {
|
|
2716
|
+
if (!self.joinedRooms.has(roomId)) return;
|
|
2717
|
+
self.joinedRooms.delete(roomId);
|
|
2718
|
+
self._roomsCache = null;
|
|
2719
|
+
self.ctx.roomManager.leaveRoom(self.componentId, roomId);
|
|
2720
|
+
},
|
|
2721
|
+
emit: (event, data) => {
|
|
2722
|
+
return self.ctx.roomManager.emitToRoom(roomId, event, data, self.componentId);
|
|
2723
|
+
},
|
|
2724
|
+
on: (event, handler) => {
|
|
2725
|
+
const unsubscribe = self.ctx.roomEvents.on(
|
|
2726
|
+
"room",
|
|
2727
|
+
roomId,
|
|
2728
|
+
event,
|
|
2729
|
+
self.componentId,
|
|
2730
|
+
handler
|
|
2731
|
+
);
|
|
2732
|
+
self.roomEventUnsubscribers.push(unsubscribe);
|
|
2733
|
+
return unsubscribe;
|
|
2734
|
+
},
|
|
2735
|
+
setState: (updates) => {
|
|
2736
|
+
self.ctx.roomManager.setRoomState(roomId, updates, self.componentId);
|
|
2737
|
+
}
|
|
2738
|
+
};
|
|
2739
|
+
const proxied = wrapWithStateProxy(
|
|
2740
|
+
handle,
|
|
2741
|
+
() => self.ctx.roomManager.getRoomState(roomId),
|
|
2742
|
+
(updates) => self.ctx.roomManager.setRoomState(roomId, updates, self.componentId)
|
|
2743
|
+
);
|
|
2744
|
+
this.roomHandles.set(roomId, proxied);
|
|
2745
|
+
return proxied;
|
|
2746
|
+
};
|
|
2747
|
+
const proxyFn = ((roomIdOrClass, instanceId) => {
|
|
2748
|
+
if (typeof roomIdOrClass === "function" && instanceId !== void 0) {
|
|
2749
|
+
return self.$typedRoom(roomIdOrClass, instanceId);
|
|
2750
|
+
}
|
|
2751
|
+
return createHandle(roomIdOrClass);
|
|
2752
|
+
});
|
|
2753
|
+
const defaultHandle = this.room ? createHandle(this.room) : null;
|
|
2754
|
+
Object.defineProperties(proxyFn, {
|
|
2755
|
+
id: { get: () => self.room },
|
|
2756
|
+
state: { get: () => defaultHandle?.state ?? {} },
|
|
2757
|
+
join: {
|
|
1923
2758
|
value: (initialState) => {
|
|
1924
2759
|
if (!defaultHandle) throw new Error("No default room set");
|
|
1925
2760
|
defaultHandle.join(initialState);
|
|
@@ -1950,13 +2785,317 @@ var LiveComponent = class _LiveComponent {
|
|
|
1950
2785
|
}
|
|
1951
2786
|
}
|
|
1952
2787
|
});
|
|
1953
|
-
|
|
2788
|
+
const defaultRoom = this.room;
|
|
2789
|
+
const wrapped = defaultRoom ? wrapWithStateProxy(
|
|
2790
|
+
proxyFn,
|
|
2791
|
+
() => self.ctx.roomManager.getRoomState(defaultRoom),
|
|
2792
|
+
(updates) => self.ctx.roomManager.setRoomState(defaultRoom, updates, self.componentId)
|
|
2793
|
+
) : proxyFn;
|
|
2794
|
+
this._roomProxy = wrapped;
|
|
2795
|
+
return wrapped;
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* Get a typed room handle backed by a LiveRoom class.
|
|
2799
|
+
*
|
|
2800
|
+
* Usage: `this.$room(ChatRoom, 'lobby')` → typed handle with custom methods
|
|
2801
|
+
*
|
|
2802
|
+
* The returned handle exposes:
|
|
2803
|
+
* - `.id`, `.state`, `.meta`, `.memberCount` — framework properties
|
|
2804
|
+
* - `.join(payload?)`, `.leave()`, `.emit()`, `.on()`, `.setState()` — framework API
|
|
2805
|
+
* - Any custom method defined on the LiveRoom subclass (e.g. `.addMessage()`, `.ban()`)
|
|
2806
|
+
*
|
|
2807
|
+
* The compound room ID is `${roomClass.roomName}:${instanceId}`.
|
|
2808
|
+
*/
|
|
2809
|
+
$typedRoom(roomClass, instanceId) {
|
|
2810
|
+
const roomId = `${roomClass.roomName}:${instanceId}`;
|
|
2811
|
+
const self = this;
|
|
2812
|
+
const cached = this.roomHandles.get(roomId);
|
|
2813
|
+
if (cached) return cached;
|
|
2814
|
+
const handle = {
|
|
2815
|
+
get id() {
|
|
2816
|
+
return roomId;
|
|
2817
|
+
},
|
|
2818
|
+
get state() {
|
|
2819
|
+
const instance = self.ctx.roomManager.getRoomInstance?.(roomId);
|
|
2820
|
+
return instance ? instance.state : self.ctx.roomManager.getRoomState(roomId);
|
|
2821
|
+
},
|
|
2822
|
+
get meta() {
|
|
2823
|
+
const instance = self.ctx.roomManager.getRoomInstance?.(roomId);
|
|
2824
|
+
if (!instance) throw new Error(`Room '${roomId}' not found or not backed by a LiveRoom class`);
|
|
2825
|
+
return instance.meta;
|
|
2826
|
+
},
|
|
2827
|
+
get memberCount() {
|
|
2828
|
+
return self.ctx.roomManager.getMemberCount?.(roomId) ?? 0;
|
|
2829
|
+
},
|
|
2830
|
+
join: (payload) => {
|
|
2831
|
+
if (self.joinedRooms.has(roomId)) return {};
|
|
2832
|
+
const result = self.ctx.roomManager.joinRoom(
|
|
2833
|
+
self.componentId,
|
|
2834
|
+
roomId,
|
|
2835
|
+
self.ws,
|
|
2836
|
+
void 0,
|
|
2837
|
+
void 0,
|
|
2838
|
+
{ userId: void 0, payload }
|
|
2839
|
+
);
|
|
2840
|
+
if ("rejected" in result && result.rejected) {
|
|
2841
|
+
return result;
|
|
2842
|
+
}
|
|
2843
|
+
self.joinedRooms.add(roomId);
|
|
2844
|
+
self._roomsCache = null;
|
|
2845
|
+
sendImmediate(self.ws, JSON.stringify({
|
|
2846
|
+
type: "ROOM_JOINED",
|
|
2847
|
+
componentId: self.componentId,
|
|
2848
|
+
roomId,
|
|
2849
|
+
event: "$room:joined",
|
|
2850
|
+
data: { state: result.state },
|
|
2851
|
+
timestamp: Date.now()
|
|
2852
|
+
}));
|
|
2853
|
+
return {};
|
|
2854
|
+
},
|
|
2855
|
+
leave: () => {
|
|
2856
|
+
if (!self.joinedRooms.has(roomId)) return;
|
|
2857
|
+
self.joinedRooms.delete(roomId);
|
|
2858
|
+
self._roomsCache = null;
|
|
2859
|
+
self.ctx.roomManager.leaveRoom(self.componentId, roomId, "leave");
|
|
2860
|
+
},
|
|
2861
|
+
emit: ((event, data) => {
|
|
2862
|
+
return self.ctx.roomManager.emitToRoom(roomId, event, data, self.componentId);
|
|
2863
|
+
}),
|
|
2864
|
+
on: (event, handler) => {
|
|
2865
|
+
const unsubscribe = self.ctx.roomEvents.on(
|
|
2866
|
+
"room",
|
|
2867
|
+
roomId,
|
|
2868
|
+
event,
|
|
2869
|
+
self.componentId,
|
|
2870
|
+
handler
|
|
2871
|
+
);
|
|
2872
|
+
self.roomEventUnsubscribers.push(unsubscribe);
|
|
2873
|
+
return unsubscribe;
|
|
2874
|
+
},
|
|
2875
|
+
setState: (updates) => {
|
|
2876
|
+
self.ctx.roomManager.setRoomState(roomId, updates, self.componentId);
|
|
2877
|
+
}
|
|
2878
|
+
};
|
|
2879
|
+
const proxied = new Proxy(handle, {
|
|
2880
|
+
get(obj, prop, receiver) {
|
|
2881
|
+
if (TYPED_RESERVED_KEYS.has(prop) || typeof prop === "symbol") {
|
|
2882
|
+
return Reflect.get(obj, prop, receiver);
|
|
2883
|
+
}
|
|
2884
|
+
if (prop in obj) return Reflect.get(obj, prop, receiver);
|
|
2885
|
+
const instance = self.ctx.roomManager.getRoomInstance?.(roomId);
|
|
2886
|
+
if (instance && prop in instance) {
|
|
2887
|
+
const val = instance[prop];
|
|
2888
|
+
return typeof val === "function" ? val.bind(instance) : val;
|
|
2889
|
+
}
|
|
2890
|
+
return void 0;
|
|
2891
|
+
}
|
|
2892
|
+
});
|
|
2893
|
+
this.roomHandles.set(roomId, proxied);
|
|
2894
|
+
return proxied;
|
|
2895
|
+
}
|
|
2896
|
+
get $rooms() {
|
|
2897
|
+
if (this._roomsCache) return this._roomsCache;
|
|
2898
|
+
this._roomsCache = Array.from(this.joinedRooms);
|
|
2899
|
+
return this._roomsCache;
|
|
2900
|
+
}
|
|
2901
|
+
getJoinedRooms() {
|
|
2902
|
+
return this.joinedRooms;
|
|
2903
|
+
}
|
|
2904
|
+
emitRoomEvent(event, data, notifySelf = false) {
|
|
2905
|
+
if (!this.room) {
|
|
2906
|
+
liveWarn("rooms", this.componentId, `[${this.componentId}] Cannot emit room event '${event}' - no room set`);
|
|
2907
|
+
return 0;
|
|
2908
|
+
}
|
|
2909
|
+
const excludeId = notifySelf ? void 0 : this.componentId;
|
|
2910
|
+
const notified = this.ctx.roomEvents.emit(this.roomType, this.room, event, data, excludeId);
|
|
2911
|
+
liveLog("rooms", this.componentId, `[${this.componentId}] Room event '${event}' -> ${notified} components`);
|
|
2912
|
+
return notified;
|
|
2913
|
+
}
|
|
2914
|
+
onRoomEvent(event, handler) {
|
|
2915
|
+
if (!this.room) {
|
|
2916
|
+
liveWarn("rooms", this.componentId, `[${this.componentId}] Cannot subscribe to room event '${event}' - no room set`);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const unsubscribe = this.ctx.roomEvents.on(
|
|
2920
|
+
this.roomType,
|
|
2921
|
+
this.room,
|
|
2922
|
+
event,
|
|
2923
|
+
this.componentId,
|
|
2924
|
+
handler
|
|
2925
|
+
);
|
|
2926
|
+
this.roomEventUnsubscribers.push(unsubscribe);
|
|
2927
|
+
liveLog("rooms", this.componentId, `[${this.componentId}] Subscribed to room event '${event}'`);
|
|
2928
|
+
}
|
|
2929
|
+
emitRoomEventWithState(event, data, stateUpdates) {
|
|
2930
|
+
this.setStateFn(stateUpdates);
|
|
2931
|
+
return this.emitRoomEvent(event, data, false);
|
|
2932
|
+
}
|
|
2933
|
+
subscribeToRoom(roomId) {
|
|
2934
|
+
this.room = roomId;
|
|
2935
|
+
}
|
|
2936
|
+
unsubscribeFromRoom() {
|
|
2937
|
+
this.room = void 0;
|
|
2938
|
+
}
|
|
2939
|
+
destroy() {
|
|
2940
|
+
for (const unsubscribe of this.roomEventUnsubscribers) {
|
|
2941
|
+
unsubscribe();
|
|
2942
|
+
}
|
|
2943
|
+
this.roomEventUnsubscribers = [];
|
|
2944
|
+
if (this.joinedRooms.size > 0 && this._cachedCtx) {
|
|
2945
|
+
for (const roomId of this.joinedRooms) {
|
|
2946
|
+
this._cachedCtx.roomManager.leaveRoom(this.componentId, roomId);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
this.joinedRooms.clear();
|
|
2950
|
+
this.roomHandles.clear();
|
|
2951
|
+
this._roomProxy = null;
|
|
2952
|
+
this._roomsCache = null;
|
|
2953
|
+
}
|
|
2954
|
+
};
|
|
2955
|
+
|
|
2956
|
+
// src/component/LiveComponent.ts
|
|
2957
|
+
var LiveComponent = class {
|
|
2958
|
+
/** Component name for registry lookup - must be defined in subclasses */
|
|
2959
|
+
static componentName;
|
|
2960
|
+
/** Default state - must be defined in subclasses */
|
|
2961
|
+
static defaultState;
|
|
2962
|
+
/**
|
|
2963
|
+
* Per-component logging control. Silent by default.
|
|
2964
|
+
*
|
|
2965
|
+
* @example
|
|
2966
|
+
* static logging = true // all categories
|
|
2967
|
+
* static logging = ['lifecycle', 'messages'] // specific categories
|
|
2968
|
+
*/
|
|
2969
|
+
static logging;
|
|
2970
|
+
/**
|
|
2971
|
+
* Component-level auth configuration.
|
|
2972
|
+
*/
|
|
2973
|
+
static auth;
|
|
2974
|
+
/**
|
|
2975
|
+
* Per-action auth configuration.
|
|
2976
|
+
*/
|
|
2977
|
+
static actionAuth;
|
|
2978
|
+
/**
|
|
2979
|
+
* Zod schemas for action payload validation.
|
|
2980
|
+
* When defined, payloads are validated before the action method is called.
|
|
2981
|
+
*
|
|
2982
|
+
* @example
|
|
2983
|
+
* static actionSchemas = {
|
|
2984
|
+
* sendMessage: z.object({ text: z.string().max(500) }),
|
|
2985
|
+
* updatePosition: z.object({ x: z.number(), y: z.number() }),
|
|
2986
|
+
* }
|
|
2987
|
+
*/
|
|
2988
|
+
static actionSchemas;
|
|
2989
|
+
/**
|
|
2990
|
+
* Rate limit for action execution.
|
|
2991
|
+
* Prevents clients from spamming expensive operations.
|
|
2992
|
+
*
|
|
2993
|
+
* @example
|
|
2994
|
+
* static actionRateLimit = { maxCalls: 10, windowMs: 1000, perAction: true }
|
|
2995
|
+
*/
|
|
2996
|
+
static actionRateLimit;
|
|
2997
|
+
/**
|
|
2998
|
+
* Data that survives HMR reloads.
|
|
2999
|
+
*/
|
|
3000
|
+
static persistent;
|
|
3001
|
+
/**
|
|
3002
|
+
* When true, only ONE server-side instance exists for this component.
|
|
3003
|
+
* All clients share the same state.
|
|
3004
|
+
*/
|
|
3005
|
+
static singleton;
|
|
3006
|
+
/**
|
|
3007
|
+
* Component behavior options.
|
|
3008
|
+
*
|
|
3009
|
+
* @example
|
|
3010
|
+
* static $options = { deepDiff: true }
|
|
3011
|
+
*/
|
|
3012
|
+
static $options;
|
|
3013
|
+
id;
|
|
3014
|
+
state;
|
|
3015
|
+
// Proxy wrapper (getter delegates to _stateManager)
|
|
3016
|
+
ws;
|
|
3017
|
+
room;
|
|
3018
|
+
userId;
|
|
3019
|
+
broadcastToRoom = () => {
|
|
3020
|
+
};
|
|
3021
|
+
// Server-only private state (NEVER sent to client)
|
|
3022
|
+
_privateState = {};
|
|
3023
|
+
// Auth context (injected by registry during mount, immutable after first set)
|
|
3024
|
+
_authContext = ANONYMOUS_CONTEXT;
|
|
3025
|
+
_authContextSet = false;
|
|
3026
|
+
// Room type for typed events (override in subclass)
|
|
3027
|
+
roomType = "default";
|
|
3028
|
+
// Singleton emit override
|
|
3029
|
+
[EMIT_OVERRIDE_KEY] = null;
|
|
3030
|
+
// ===== Internal Managers (composition) =====
|
|
3031
|
+
_stateManager;
|
|
3032
|
+
_messaging;
|
|
3033
|
+
_actionSecurity;
|
|
3034
|
+
_roomProxyManager;
|
|
3035
|
+
static publicActions;
|
|
3036
|
+
constructor(initialState, ws, options) {
|
|
3037
|
+
this.id = this.generateId();
|
|
3038
|
+
const ctor = this.constructor;
|
|
3039
|
+
this.ws = ws;
|
|
3040
|
+
this.room = options?.room;
|
|
3041
|
+
this.userId = options?.userId;
|
|
3042
|
+
this._messaging = new ComponentMessaging({
|
|
3043
|
+
componentId: this.id,
|
|
3044
|
+
ws: this.ws,
|
|
3045
|
+
getUserId: () => this.userId,
|
|
3046
|
+
getRoom: () => this.room,
|
|
3047
|
+
getBroadcastToRoom: () => this.broadcastToRoom,
|
|
3048
|
+
getEmitOverride: () => this[EMIT_OVERRIDE_KEY]
|
|
3049
|
+
});
|
|
3050
|
+
this._stateManager = new ComponentStateManager({
|
|
3051
|
+
componentId: this.id,
|
|
3052
|
+
initialState: { ...ctor.defaultState, ...initialState },
|
|
3053
|
+
ws: this.ws,
|
|
3054
|
+
emitFn: (type, payload) => this._messaging.emit(type, payload),
|
|
3055
|
+
onStateChangeFn: (changes) => this.onStateChange(changes),
|
|
3056
|
+
deepDiff: ctor.$options?.deepDiff ?? false,
|
|
3057
|
+
deepDiffDepth: ctor.$options?.deepDiffDepth
|
|
3058
|
+
});
|
|
3059
|
+
this.state = this._stateManager.proxyState;
|
|
3060
|
+
this._actionSecurity = new ActionSecurityManager();
|
|
3061
|
+
this._roomProxyManager = new ComponentRoomProxy({
|
|
3062
|
+
componentId: this.id,
|
|
3063
|
+
ws: this.ws,
|
|
3064
|
+
defaultRoom: this.room,
|
|
3065
|
+
getCtx: () => getLiveComponentContext(),
|
|
3066
|
+
setStateFn: (updates) => this.setState(updates),
|
|
3067
|
+
deepDiff: ctor.$options?.roomDeepDiff,
|
|
3068
|
+
deepDiffDepth: ctor.$options?.deepDiffDepth,
|
|
3069
|
+
serverOnlyState: ctor.$options?.serverOnlyRoomState
|
|
3070
|
+
});
|
|
3071
|
+
this._stateManager.applyDirectAccessors(this, this.constructor);
|
|
3072
|
+
}
|
|
3073
|
+
// ========================================
|
|
3074
|
+
// $private - Server-Only State
|
|
3075
|
+
// ========================================
|
|
3076
|
+
get $private() {
|
|
3077
|
+
return this._privateState;
|
|
3078
|
+
}
|
|
3079
|
+
// ========================================
|
|
3080
|
+
// $room - Unified Room System
|
|
3081
|
+
// ========================================
|
|
3082
|
+
/**
|
|
3083
|
+
* Unified room accessor.
|
|
3084
|
+
*
|
|
3085
|
+
* Usage:
|
|
3086
|
+
* - `this.$room` — default room handle (legacy)
|
|
3087
|
+
* - `this.$room('roomId')` — untyped room handle (legacy)
|
|
3088
|
+
* - `this.$room(ChatRoom, 'lobby')` — typed handle with custom methods
|
|
3089
|
+
*/
|
|
3090
|
+
get $room() {
|
|
3091
|
+
return this._roomProxyManager.$room;
|
|
1954
3092
|
}
|
|
1955
3093
|
/**
|
|
1956
|
-
* List of room IDs this component is participating in
|
|
3094
|
+
* List of room IDs this component is participating in.
|
|
3095
|
+
* Cached — invalidated on join/leave.
|
|
1957
3096
|
*/
|
|
1958
3097
|
get $rooms() {
|
|
1959
|
-
return
|
|
3098
|
+
return this._roomProxyManager.$rooms;
|
|
1960
3099
|
}
|
|
1961
3100
|
// ========================================
|
|
1962
3101
|
// $auth - Authentication Context
|
|
@@ -1964,13 +3103,22 @@ var LiveComponent = class _LiveComponent {
|
|
|
1964
3103
|
get $auth() {
|
|
1965
3104
|
return this._authContext;
|
|
1966
3105
|
}
|
|
1967
|
-
/** @internal */
|
|
3106
|
+
/** @internal - Immutable after first set to prevent privilege escalation */
|
|
1968
3107
|
setAuthContext(context) {
|
|
3108
|
+
if (this._authContextSet) {
|
|
3109
|
+
throw new Error("Auth context is immutable after initial set");
|
|
3110
|
+
}
|
|
1969
3111
|
this._authContext = context;
|
|
3112
|
+
this._authContextSet = true;
|
|
1970
3113
|
if (context.authenticated && context.user?.id && !this.userId) {
|
|
1971
3114
|
this.userId = context.user.id;
|
|
1972
3115
|
}
|
|
1973
3116
|
}
|
|
3117
|
+
/** @internal - Reset auth context (for registry use in reconnection) */
|
|
3118
|
+
_resetAuthContext() {
|
|
3119
|
+
this._authContextSet = false;
|
|
3120
|
+
this._authContext = ANONYMOUS_CONTEXT;
|
|
3121
|
+
}
|
|
1974
3122
|
// ========================================
|
|
1975
3123
|
// $persistent - HMR-Safe State
|
|
1976
3124
|
// ========================================
|
|
@@ -2006,224 +3154,64 @@ var LiveComponent = class _LiveComponent {
|
|
|
2006
3154
|
}
|
|
2007
3155
|
onClientJoin(connectionId, connectionCount) {
|
|
2008
3156
|
}
|
|
2009
|
-
onClientLeave(connectionId, connectionCount) {
|
|
2010
|
-
}
|
|
2011
|
-
// ========================================
|
|
2012
|
-
// State Management
|
|
2013
|
-
// ========================================
|
|
2014
|
-
setState(updates) {
|
|
2015
|
-
const newUpdates = typeof updates === "function" ? updates(this._state) : updates;
|
|
2016
|
-
const actualChanges = {};
|
|
2017
|
-
let hasChanges = false;
|
|
2018
|
-
for (const key of Object.keys(newUpdates)) {
|
|
2019
|
-
if (this._state[key] !== newUpdates[key]) {
|
|
2020
|
-
actualChanges[key] = newUpdates[key];
|
|
2021
|
-
hasChanges = true;
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
if (!hasChanges) return;
|
|
2025
|
-
Object.assign(this._state, actualChanges);
|
|
2026
|
-
this.emit("STATE_DELTA", { delta: actualChanges });
|
|
2027
|
-
if (!this._inStateChange) {
|
|
2028
|
-
this._inStateChange = true;
|
|
2029
|
-
try {
|
|
2030
|
-
this.onStateChange(actualChanges);
|
|
2031
|
-
} catch (err) {
|
|
2032
|
-
console.error(`[${this.id}] onStateChange error:`, err?.message || err);
|
|
2033
|
-
} finally {
|
|
2034
|
-
this._inStateChange = false;
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
_liveDebugger?.trackStateChange(
|
|
2038
|
-
this.id,
|
|
2039
|
-
actualChanges,
|
|
2040
|
-
this._state,
|
|
2041
|
-
"setState"
|
|
2042
|
-
);
|
|
2043
|
-
}
|
|
2044
|
-
async setValue(payload) {
|
|
2045
|
-
const { key, value } = payload;
|
|
2046
|
-
const update = { [key]: value };
|
|
2047
|
-
this.setState(update);
|
|
2048
|
-
return { success: true, key, value };
|
|
2049
|
-
}
|
|
2050
|
-
// ========================================
|
|
2051
|
-
// Action Security
|
|
2052
|
-
// ========================================
|
|
2053
|
-
static publicActions;
|
|
2054
|
-
static BLOCKED_ACTIONS = /* @__PURE__ */ new Set([
|
|
2055
|
-
"constructor",
|
|
2056
|
-
"destroy",
|
|
2057
|
-
"executeAction",
|
|
2058
|
-
"getSerializableState",
|
|
2059
|
-
"onMount",
|
|
2060
|
-
"onDestroy",
|
|
2061
|
-
"onConnect",
|
|
2062
|
-
"onDisconnect",
|
|
2063
|
-
"onStateChange",
|
|
2064
|
-
"onRoomJoin",
|
|
2065
|
-
"onRoomLeave",
|
|
2066
|
-
"onRehydrate",
|
|
2067
|
-
"onAction",
|
|
2068
|
-
"onClientJoin",
|
|
2069
|
-
"onClientLeave",
|
|
2070
|
-
"setState",
|
|
2071
|
-
"emit",
|
|
2072
|
-
"broadcast",
|
|
2073
|
-
"broadcastToRoom",
|
|
2074
|
-
"createStateProxy",
|
|
2075
|
-
"createDirectStateAccessors",
|
|
2076
|
-
"generateId",
|
|
2077
|
-
"setAuthContext",
|
|
2078
|
-
"$auth",
|
|
2079
|
-
"$private",
|
|
2080
|
-
"_privateState",
|
|
2081
|
-
"$persistent",
|
|
2082
|
-
"_inStateChange",
|
|
2083
|
-
"$room",
|
|
2084
|
-
"$rooms",
|
|
2085
|
-
"subscribeToRoom",
|
|
2086
|
-
"unsubscribeFromRoom",
|
|
2087
|
-
"emitRoomEvent",
|
|
2088
|
-
"onRoomEvent",
|
|
2089
|
-
"emitRoomEventWithState"
|
|
2090
|
-
]);
|
|
2091
|
-
async executeAction(action, payload) {
|
|
2092
|
-
const actionStart = Date.now();
|
|
2093
|
-
try {
|
|
2094
|
-
if (_LiveComponent.BLOCKED_ACTIONS.has(action)) {
|
|
2095
|
-
throw new Error(`Action '${action}' is not callable`);
|
|
2096
|
-
}
|
|
2097
|
-
if (action.startsWith("_") || action.startsWith("#")) {
|
|
2098
|
-
throw new Error(`Action '${action}' is not callable`);
|
|
2099
|
-
}
|
|
2100
|
-
const componentClass = this.constructor;
|
|
2101
|
-
const publicActions = componentClass.publicActions;
|
|
2102
|
-
if (!publicActions) {
|
|
2103
|
-
console.warn(`[SECURITY] Component '${componentClass.componentName || componentClass.name}' has no publicActions defined. All remote actions are blocked.`);
|
|
2104
|
-
throw new Error(`Action '${action}' is not callable - component has no publicActions defined`);
|
|
2105
|
-
}
|
|
2106
|
-
if (!publicActions.includes(action)) {
|
|
2107
|
-
const methodExists = typeof this[action] === "function";
|
|
2108
|
-
if (methodExists) {
|
|
2109
|
-
const name = componentClass.componentName || componentClass.name;
|
|
2110
|
-
throw new Error(
|
|
2111
|
-
`Action '${action}' exists on '${name}' but is not listed in publicActions. Add it to: static publicActions = [..., '${action}']`
|
|
2112
|
-
);
|
|
2113
|
-
}
|
|
2114
|
-
throw new Error(`Action '${action}' is not callable`);
|
|
2115
|
-
}
|
|
2116
|
-
const method = this[action];
|
|
2117
|
-
if (typeof method !== "function") {
|
|
2118
|
-
throw new Error(`Action '${action}' not found on component`);
|
|
2119
|
-
}
|
|
2120
|
-
if (Object.prototype.hasOwnProperty.call(Object.prototype, action)) {
|
|
2121
|
-
throw new Error(`Action '${action}' is not callable`);
|
|
2122
|
-
}
|
|
2123
|
-
_liveDebugger?.trackActionCall(this.id, action, payload);
|
|
2124
|
-
let hookResult;
|
|
2125
|
-
try {
|
|
2126
|
-
hookResult = await this.onAction(action, payload);
|
|
2127
|
-
} catch (hookError) {
|
|
2128
|
-
_liveDebugger?.trackActionError(this.id, action, hookError.message, Date.now() - actionStart);
|
|
2129
|
-
this.emit("ERROR", {
|
|
2130
|
-
action,
|
|
2131
|
-
error: `Action '${action}' failed pre-validation`
|
|
2132
|
-
});
|
|
2133
|
-
throw hookError;
|
|
2134
|
-
}
|
|
2135
|
-
if (hookResult === false) {
|
|
2136
|
-
_liveDebugger?.trackActionError(this.id, action, "Action cancelled", Date.now() - actionStart);
|
|
2137
|
-
throw new Error(`Action '${action}' was cancelled`);
|
|
2138
|
-
}
|
|
2139
|
-
const result = await method.call(this, payload);
|
|
2140
|
-
_liveDebugger?.trackActionResult(this.id, action, result, Date.now() - actionStart);
|
|
2141
|
-
return result;
|
|
2142
|
-
} catch (error) {
|
|
2143
|
-
if (!error.message?.includes("was cancelled") && !error.message?.includes("pre-validation")) {
|
|
2144
|
-
_liveDebugger?.trackActionError(this.id, action, error.message, Date.now() - actionStart);
|
|
2145
|
-
this.emit("ERROR", {
|
|
2146
|
-
action,
|
|
2147
|
-
error: error.message
|
|
2148
|
-
});
|
|
2149
|
-
}
|
|
2150
|
-
throw error;
|
|
2151
|
-
}
|
|
3157
|
+
onClientLeave(connectionId, connectionCount) {
|
|
2152
3158
|
}
|
|
2153
3159
|
// ========================================
|
|
2154
|
-
//
|
|
3160
|
+
// State Management (delegates to _stateManager)
|
|
2155
3161
|
// ========================================
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
3162
|
+
setState(updates) {
|
|
3163
|
+
this._stateManager.setState(updates);
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Send a binary-encoded state delta directly over WebSocket.
|
|
3167
|
+
* Updates internal state (same as setState) then sends the encoder's output
|
|
3168
|
+
* as a binary frame: [0x01][idLen:u8][id_bytes:utf8][payload_bytes].
|
|
3169
|
+
* Bypasses the JSON batcher — ideal for high-frequency updates.
|
|
3170
|
+
*/
|
|
3171
|
+
sendBinaryDelta(delta, encoder2) {
|
|
3172
|
+
this._stateManager.sendBinaryDelta(delta, encoder2);
|
|
3173
|
+
}
|
|
3174
|
+
setValue(payload) {
|
|
3175
|
+
return this._stateManager.setValue(payload);
|
|
3176
|
+
}
|
|
3177
|
+
// ========================================
|
|
3178
|
+
// Action Execution (delegates to _actionSecurity)
|
|
3179
|
+
// ========================================
|
|
3180
|
+
async executeAction(action, payload) {
|
|
3181
|
+
return this._actionSecurity.validateAndExecute(action, payload, {
|
|
3182
|
+
component: this,
|
|
3183
|
+
componentClass: this.constructor,
|
|
2164
3184
|
componentId: this.id,
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
3185
|
+
emitFn: (type, p) => this.emit(type, p)
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
// ========================================
|
|
3189
|
+
// Messaging (delegates to _messaging)
|
|
3190
|
+
// ========================================
|
|
3191
|
+
emit(type, payload) {
|
|
3192
|
+
this._messaging.emit(type, payload);
|
|
2173
3193
|
}
|
|
2174
3194
|
broadcast(type, payload, excludeCurrentUser = false) {
|
|
2175
|
-
|
|
2176
|
-
liveWarn("rooms", this.id, `[${this.id}] Cannot broadcast '${type}' - no room set`);
|
|
2177
|
-
return;
|
|
2178
|
-
}
|
|
2179
|
-
const message = {
|
|
2180
|
-
type,
|
|
2181
|
-
payload,
|
|
2182
|
-
room: this.room,
|
|
2183
|
-
excludeUser: excludeCurrentUser ? this.userId : void 0
|
|
2184
|
-
};
|
|
2185
|
-
liveLog("rooms", this.id, `[${this.id}] Broadcasting '${type}' to room '${this.room}'`);
|
|
2186
|
-
this.broadcastToRoom(message);
|
|
3195
|
+
this._messaging.broadcast(type, payload, excludeCurrentUser);
|
|
2187
3196
|
}
|
|
2188
3197
|
// ========================================
|
|
2189
|
-
// Room Events
|
|
3198
|
+
// Room Events (delegates to _roomProxyManager)
|
|
2190
3199
|
// ========================================
|
|
2191
3200
|
emitRoomEvent(event, data, notifySelf = false) {
|
|
2192
|
-
|
|
2193
|
-
liveWarn("rooms", this.id, `[${this.id}] Cannot emit room event '${event}' - no room set`);
|
|
2194
|
-
return 0;
|
|
2195
|
-
}
|
|
2196
|
-
const ctx = getLiveComponentContext();
|
|
2197
|
-
const excludeId = notifySelf ? void 0 : this.id;
|
|
2198
|
-
const notified = ctx.roomEvents.emit(this.roomType, this.room, event, data, excludeId);
|
|
2199
|
-
liveLog("rooms", this.id, `[${this.id}] Room event '${event}' -> ${notified} components`);
|
|
2200
|
-
_liveDebugger?.trackRoomEmit(this.id, this.room, event, data);
|
|
2201
|
-
return notified;
|
|
3201
|
+
return this._roomProxyManager.emitRoomEvent(event, data, notifySelf);
|
|
2202
3202
|
}
|
|
2203
3203
|
onRoomEvent(event, handler) {
|
|
2204
|
-
|
|
2205
|
-
liveWarn("rooms", this.id, `[${this.id}] Cannot subscribe to room event '${event}' - no room set`);
|
|
2206
|
-
return;
|
|
2207
|
-
}
|
|
2208
|
-
const ctx = getLiveComponentContext();
|
|
2209
|
-
const unsubscribe = ctx.roomEvents.on(
|
|
2210
|
-
this.roomType,
|
|
2211
|
-
this.room,
|
|
2212
|
-
event,
|
|
2213
|
-
this.id,
|
|
2214
|
-
handler
|
|
2215
|
-
);
|
|
2216
|
-
this.roomEventUnsubscribers.push(unsubscribe);
|
|
2217
|
-
liveLog("rooms", this.id, `[${this.id}] Subscribed to room event '${event}'`);
|
|
3204
|
+
this._roomProxyManager.onRoomEvent(event, handler);
|
|
2218
3205
|
}
|
|
2219
3206
|
emitRoomEventWithState(event, data, stateUpdates) {
|
|
2220
|
-
this.
|
|
2221
|
-
return this.emitRoomEvent(event, data, false);
|
|
3207
|
+
return this._roomProxyManager.emitRoomEventWithState(event, data, stateUpdates);
|
|
2222
3208
|
}
|
|
2223
|
-
|
|
3209
|
+
subscribeToRoom(roomId) {
|
|
3210
|
+
this._roomProxyManager.subscribeToRoom(roomId);
|
|
2224
3211
|
this.room = roomId;
|
|
2225
3212
|
}
|
|
2226
|
-
|
|
3213
|
+
unsubscribeFromRoom() {
|
|
3214
|
+
this._roomProxyManager.unsubscribeFromRoom();
|
|
2227
3215
|
this.room = void 0;
|
|
2228
3216
|
}
|
|
2229
3217
|
// ========================================
|
|
@@ -2238,21 +3226,13 @@ var LiveComponent = class _LiveComponent {
|
|
|
2238
3226
|
} catch (err) {
|
|
2239
3227
|
console.error(`[${this.id}] onDestroy error:`, err?.message || err);
|
|
2240
3228
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
}
|
|
2244
|
-
this.roomEventUnsubscribers = [];
|
|
2245
|
-
const ctx = getLiveComponentContext();
|
|
2246
|
-
for (const roomId of this.joinedRooms) {
|
|
2247
|
-
ctx.roomManager.leaveRoom(this.id, roomId);
|
|
2248
|
-
}
|
|
2249
|
-
this.joinedRooms.clear();
|
|
2250
|
-
this.roomHandles.clear();
|
|
3229
|
+
this._roomProxyManager.destroy();
|
|
3230
|
+
this._stateManager.cleanup();
|
|
2251
3231
|
this._privateState = {};
|
|
2252
|
-
this.
|
|
3232
|
+
this.room = void 0;
|
|
2253
3233
|
}
|
|
2254
3234
|
getSerializableState() {
|
|
2255
|
-
return this.
|
|
3235
|
+
return this._stateManager.getSerializableState();
|
|
2256
3236
|
}
|
|
2257
3237
|
};
|
|
2258
3238
|
|
|
@@ -2266,17 +3246,80 @@ var ComponentRegistry = class {
|
|
|
2266
3246
|
autoDiscoveredComponents = /* @__PURE__ */ new Map();
|
|
2267
3247
|
healthCheckInterval;
|
|
2268
3248
|
singletons = /* @__PURE__ */ new Map();
|
|
3249
|
+
remoteSingletons = /* @__PURE__ */ new Map();
|
|
3250
|
+
cluster;
|
|
2269
3251
|
authManager;
|
|
2270
|
-
debugger;
|
|
2271
3252
|
stateSignature;
|
|
2272
3253
|
performanceMonitor;
|
|
2273
3254
|
constructor(deps) {
|
|
2274
3255
|
this.authManager = deps.authManager;
|
|
2275
|
-
this.debugger = deps.debugger;
|
|
2276
3256
|
this.stateSignature = deps.stateSignature;
|
|
2277
3257
|
this.performanceMonitor = deps.performanceMonitor;
|
|
2278
|
-
|
|
3258
|
+
this.cluster = deps.cluster;
|
|
2279
3259
|
this.setupHealthMonitoring();
|
|
3260
|
+
this.setupClusterHandlers();
|
|
3261
|
+
}
|
|
3262
|
+
/** Set up handlers for incoming cluster messages (deltas, forwarded actions). */
|
|
3263
|
+
setupClusterHandlers() {
|
|
3264
|
+
if (!this.cluster) return;
|
|
3265
|
+
this.cluster.onDelta((componentId, componentName, delta, sourceInstanceId) => {
|
|
3266
|
+
const remote = this.remoteSingletons.get(componentName);
|
|
3267
|
+
if (!remote || remote.componentId !== componentId) return;
|
|
3268
|
+
if (delta && remote.lastState) {
|
|
3269
|
+
Object.assign(remote.lastState, delta);
|
|
3270
|
+
}
|
|
3271
|
+
const message = JSON.stringify({
|
|
3272
|
+
type: "STATE_DELTA",
|
|
3273
|
+
componentId,
|
|
3274
|
+
payload: { delta },
|
|
3275
|
+
timestamp: Date.now()
|
|
3276
|
+
});
|
|
3277
|
+
const dead = [];
|
|
3278
|
+
for (const [connId, ws] of remote.connections) {
|
|
3279
|
+
if (ws.readyState === 1) {
|
|
3280
|
+
try {
|
|
3281
|
+
ws.send(message);
|
|
3282
|
+
} catch {
|
|
3283
|
+
dead.push(connId);
|
|
3284
|
+
}
|
|
3285
|
+
} else {
|
|
3286
|
+
dead.push(connId);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
for (const connId of dead) remote.connections.delete(connId);
|
|
3290
|
+
});
|
|
3291
|
+
this.cluster.onOwnershipLost((componentName) => {
|
|
3292
|
+
const singleton = this.singletons.get(componentName);
|
|
3293
|
+
if (!singleton) return;
|
|
3294
|
+
this.cluster.saveSingletonState(componentName, singleton.instance.getSerializableState()).catch(() => {
|
|
3295
|
+
});
|
|
3296
|
+
const errorMsg = JSON.stringify({
|
|
3297
|
+
type: "ERROR",
|
|
3298
|
+
componentId: singleton.instance.id,
|
|
3299
|
+
payload: { error: "OWNERSHIP_LOST: singleton moved to another server" },
|
|
3300
|
+
timestamp: Date.now()
|
|
3301
|
+
});
|
|
3302
|
+
for (const [, ws] of singleton.connections) {
|
|
3303
|
+
try {
|
|
3304
|
+
ws.send(errorMsg);
|
|
3305
|
+
} catch {
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
this.cleanupComponent(singleton.instance.id);
|
|
3309
|
+
this.singletons.delete(componentName);
|
|
3310
|
+
});
|
|
3311
|
+
this.cluster.onActionForward(async (request) => {
|
|
3312
|
+
try {
|
|
3313
|
+
const stillOwner = await this.cluster.verifySingletonOwnership(request.componentName);
|
|
3314
|
+
if (!stillOwner) {
|
|
3315
|
+
return { success: false, error: "OWNERSHIP_LOST: this instance no longer owns the singleton", requestId: request.requestId };
|
|
3316
|
+
}
|
|
3317
|
+
const result = await this.executeAction(request.componentId, request.action, request.payload);
|
|
3318
|
+
return { success: true, result, requestId: request.requestId };
|
|
3319
|
+
} catch (error) {
|
|
3320
|
+
return { success: false, error: error.message, requestId: request.requestId };
|
|
3321
|
+
}
|
|
3322
|
+
});
|
|
2280
3323
|
}
|
|
2281
3324
|
setupHealthMonitoring() {
|
|
2282
3325
|
this.healthCheckInterval = setInterval(() => this.performHealthChecks(), 3e4);
|
|
@@ -2360,6 +3403,7 @@ var ComponentRegistry = class {
|
|
|
2360
3403
|
const authResult = this.authManager.authorizeComponent(authContext, componentAuth);
|
|
2361
3404
|
if (!authResult.allowed) throw new Error(`AUTH_DENIED: ${authResult.reason}`);
|
|
2362
3405
|
const isSingleton = ComponentClass.singleton === true;
|
|
3406
|
+
let clusterSingletonId = null;
|
|
2363
3407
|
if (isSingleton) {
|
|
2364
3408
|
const existing = this.singletons.get(componentName);
|
|
2365
3409
|
if (existing) {
|
|
@@ -2367,11 +3411,11 @@ var ComponentRegistry = class {
|
|
|
2367
3411
|
existing.connections.set(connId, ws);
|
|
2368
3412
|
this.ensureWsData(ws, options?.userId);
|
|
2369
3413
|
ws.data.components.set(existing.instance.id, existing.instance);
|
|
2370
|
-
const signedState2 =
|
|
3414
|
+
const signedState2 = this.stateSignature.signState(existing.instance.id, {
|
|
2371
3415
|
...existing.instance.getSerializableState(),
|
|
2372
3416
|
__componentName: componentName
|
|
2373
3417
|
}, 1, { compress: true, backup: true });
|
|
2374
|
-
ws
|
|
3418
|
+
sendImmediate(ws, JSON.stringify({
|
|
2375
3419
|
type: "STATE_UPDATE",
|
|
2376
3420
|
componentId: existing.instance.id,
|
|
2377
3421
|
payload: { state: existing.instance.getSerializableState(), signedState: signedState2 },
|
|
@@ -2383,9 +3427,57 @@ var ComponentRegistry = class {
|
|
|
2383
3427
|
}
|
|
2384
3428
|
return { componentId: existing.instance.id, initialState: existing.instance.getSerializableState(), signedState: signedState2 };
|
|
2385
3429
|
}
|
|
3430
|
+
const existingRemote = this.remoteSingletons.get(componentName);
|
|
3431
|
+
if (existingRemote) {
|
|
3432
|
+
const connId = ws.data?.connectionId || `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3433
|
+
this.ensureWsData(ws, options?.userId);
|
|
3434
|
+
existingRemote.connections.set(connId, ws);
|
|
3435
|
+
sendImmediate(ws, JSON.stringify({
|
|
3436
|
+
type: "STATE_UPDATE",
|
|
3437
|
+
componentId: existingRemote.componentId,
|
|
3438
|
+
payload: { state: existingRemote.lastState },
|
|
3439
|
+
timestamp: Date.now()
|
|
3440
|
+
}));
|
|
3441
|
+
return { componentId: existingRemote.componentId, initialState: existingRemote.lastState, signedState: null };
|
|
3442
|
+
}
|
|
3443
|
+
if (this.cluster) {
|
|
3444
|
+
clusterSingletonId = `live-${crypto.randomUUID()}`;
|
|
3445
|
+
const claim = await this.cluster.claimSingleton(componentName, clusterSingletonId);
|
|
3446
|
+
if (!claim.claimed) {
|
|
3447
|
+
clusterSingletonId = null;
|
|
3448
|
+
const owner = await this.cluster.getSingletonOwner(componentName);
|
|
3449
|
+
if (owner) {
|
|
3450
|
+
const stored = await this.cluster.loadState(owner.componentId);
|
|
3451
|
+
const connId = ws.data?.connectionId || `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3452
|
+
this.ensureWsData(ws, options?.userId);
|
|
3453
|
+
const remote = {
|
|
3454
|
+
componentName,
|
|
3455
|
+
componentId: owner.componentId,
|
|
3456
|
+
ownerInstanceId: owner.instanceId,
|
|
3457
|
+
lastState: stored?.state || {},
|
|
3458
|
+
connections: /* @__PURE__ */ new Map([[connId, ws]])
|
|
3459
|
+
};
|
|
3460
|
+
this.remoteSingletons.set(componentName, remote);
|
|
3461
|
+
sendImmediate(ws, JSON.stringify({
|
|
3462
|
+
type: "STATE_UPDATE",
|
|
3463
|
+
componentId: owner.componentId,
|
|
3464
|
+
payload: { state: remote.lastState },
|
|
3465
|
+
timestamp: Date.now()
|
|
3466
|
+
}));
|
|
3467
|
+
return { componentId: owner.componentId, initialState: remote.lastState, signedState: null };
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
if (claim.recoveredState) {
|
|
3471
|
+
props = { ...props, ...claim.recoveredState };
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
2386
3474
|
}
|
|
2387
3475
|
const component = new ComponentClass({ ...initialState, ...props }, ws, options);
|
|
2388
3476
|
component.setAuthContext(authContext);
|
|
3477
|
+
if (clusterSingletonId) {
|
|
3478
|
+
;
|
|
3479
|
+
component.id = clusterSingletonId;
|
|
3480
|
+
}
|
|
2389
3481
|
component.broadcastToRoom = (message) => {
|
|
2390
3482
|
this.broadcastToRoom(message, component.id);
|
|
2391
3483
|
};
|
|
@@ -2401,6 +3493,13 @@ var ComponentRegistry = class {
|
|
|
2401
3493
|
const connections = /* @__PURE__ */ new Map();
|
|
2402
3494
|
connections.set(connId, ws);
|
|
2403
3495
|
this.singletons.set(componentName, { instance: component, connections });
|
|
3496
|
+
if (this.cluster) {
|
|
3497
|
+
this.cluster.saveState(component.id, componentName, component.getSerializableState()).catch(() => {
|
|
3498
|
+
});
|
|
3499
|
+
this.cluster.saveSingletonState(componentName, component.getSerializableState()).catch(() => {
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
;
|
|
2404
3503
|
component[EMIT_OVERRIDE_KEY] = (type, payload) => {
|
|
2405
3504
|
const message = {
|
|
2406
3505
|
type,
|
|
@@ -2423,6 +3522,14 @@ var ComponentRegistry = class {
|
|
|
2423
3522
|
}
|
|
2424
3523
|
for (const cId of dead) singleton.connections.delete(cId);
|
|
2425
3524
|
}
|
|
3525
|
+
if (this.cluster && type === "STATE_DELTA" && payload?.delta) {
|
|
3526
|
+
this.cluster.publishDelta(component.id, componentName, payload.delta).catch(() => {
|
|
3527
|
+
});
|
|
3528
|
+
this.cluster.saveState(component.id, componentName, component.getSerializableState()).catch(() => {
|
|
3529
|
+
});
|
|
3530
|
+
this.cluster.saveSingletonState(componentName, component.getSerializableState()).catch(() => {
|
|
3531
|
+
});
|
|
3532
|
+
}
|
|
2426
3533
|
};
|
|
2427
3534
|
try {
|
|
2428
3535
|
component.onClientJoin(connId, 1);
|
|
@@ -2435,7 +3542,7 @@ var ComponentRegistry = class {
|
|
|
2435
3542
|
registerComponentLogging(component.id, ComponentClass.logging);
|
|
2436
3543
|
this.performanceMonitor.initializeComponent(component.id, componentName);
|
|
2437
3544
|
this.performanceMonitor.recordRenderTime(component.id, renderTime);
|
|
2438
|
-
const signedState =
|
|
3545
|
+
const signedState = this.stateSignature.signState(component.id, {
|
|
2439
3546
|
...component.getSerializableState(),
|
|
2440
3547
|
__componentName: componentName
|
|
2441
3548
|
}, 1, { compress: true, backup: true });
|
|
@@ -2453,13 +3560,6 @@ var ComponentRegistry = class {
|
|
|
2453
3560
|
;
|
|
2454
3561
|
component.emit("ERROR", { action: "onMount", error: `Mount initialization failed: ${err?.message || err}` });
|
|
2455
3562
|
}
|
|
2456
|
-
this.debugger.trackComponentMount(
|
|
2457
|
-
component.id,
|
|
2458
|
-
componentName,
|
|
2459
|
-
component.getSerializableState(),
|
|
2460
|
-
options?.room,
|
|
2461
|
-
options?.debugLabel
|
|
2462
|
-
);
|
|
2463
3563
|
return { componentId: component.id, initialState: component.getSerializableState(), signedState };
|
|
2464
3564
|
} catch (error) {
|
|
2465
3565
|
console.error(`Failed to mount component ${componentName}:`, error);
|
|
@@ -2468,7 +3568,7 @@ var ComponentRegistry = class {
|
|
|
2468
3568
|
}
|
|
2469
3569
|
async rehydrateComponent(componentId, componentName, signedState, ws, options) {
|
|
2470
3570
|
try {
|
|
2471
|
-
const validation =
|
|
3571
|
+
const validation = this.stateSignature.validateState(signedState, { skipNonce: true });
|
|
2472
3572
|
if (!validation.valid) return { success: false, error: validation.error || "Invalid state signature" };
|
|
2473
3573
|
const definition = this.definitions.get(componentName);
|
|
2474
3574
|
let ComponentClass = null;
|
|
@@ -2491,8 +3591,8 @@ var ComponentRegistry = class {
|
|
|
2491
3591
|
const componentAuth = ComponentClass.auth;
|
|
2492
3592
|
const authResult = this.authManager.authorizeComponent(authContext, componentAuth);
|
|
2493
3593
|
if (!authResult.allowed) return { success: false, error: `AUTH_DENIED: ${authResult.reason}` };
|
|
2494
|
-
const clientState =
|
|
2495
|
-
if (clientState.__componentName
|
|
3594
|
+
const clientState = this.stateSignature.extractData(signedState);
|
|
3595
|
+
if (!clientState.__componentName || clientState.__componentName !== componentName) {
|
|
2496
3596
|
return { success: false, error: "Component class mismatch - state tampering detected" };
|
|
2497
3597
|
}
|
|
2498
3598
|
const { __componentName, ...cleanState } = clientState;
|
|
@@ -2505,7 +3605,7 @@ var ComponentRegistry = class {
|
|
|
2505
3605
|
this.ensureWsData(ws, options?.userId);
|
|
2506
3606
|
ws.data.components.set(component.id, component);
|
|
2507
3607
|
registerComponentLogging(component.id, ComponentClass.logging);
|
|
2508
|
-
const newSignedState =
|
|
3608
|
+
const newSignedState = this.stateSignature.signState(
|
|
2509
3609
|
component.id,
|
|
2510
3610
|
{ ...component.getSerializableState(), __componentName: componentName },
|
|
2511
3611
|
signedState.version + 1
|
|
@@ -2554,20 +3654,39 @@ var ComponentRegistry = class {
|
|
|
2554
3654
|
if (singleton.instance.id !== componentId) continue;
|
|
2555
3655
|
if (connId) singleton.connections.delete(connId);
|
|
2556
3656
|
if (singleton.connections.size === 0) {
|
|
3657
|
+
const finalState = singleton.instance.getSerializableState();
|
|
2557
3658
|
try {
|
|
2558
3659
|
singleton.instance.onDisconnect();
|
|
2559
3660
|
} catch {
|
|
2560
3661
|
}
|
|
2561
3662
|
this.cleanupComponent(componentId);
|
|
2562
3663
|
this.singletons.delete(name);
|
|
3664
|
+
if (this.cluster) {
|
|
3665
|
+
this.cluster.saveSingletonState(name, finalState).then(() => this.cluster.releaseSingleton(name)).then(() => this.cluster.deleteState(componentId)).catch(() => {
|
|
3666
|
+
});
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
return true;
|
|
3670
|
+
}
|
|
3671
|
+
for (const [name, remote] of this.remoteSingletons) {
|
|
3672
|
+
if (remote.componentId !== componentId) continue;
|
|
3673
|
+
if (connId) remote.connections.delete(connId);
|
|
3674
|
+
if (remote.connections.size === 0) {
|
|
3675
|
+
this.remoteSingletons.delete(name);
|
|
2563
3676
|
}
|
|
2564
3677
|
return true;
|
|
2565
3678
|
}
|
|
2566
3679
|
return false;
|
|
2567
3680
|
}
|
|
2568
|
-
|
|
3681
|
+
unmountComponent(componentId, ws) {
|
|
2569
3682
|
const component = this.components.get(componentId);
|
|
2570
|
-
if (!component)
|
|
3683
|
+
if (!component) {
|
|
3684
|
+
if (ws) {
|
|
3685
|
+
const connId = ws.data?.connectionId;
|
|
3686
|
+
this.removeSingletonConnection(componentId, connId, "unmount");
|
|
3687
|
+
}
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
2571
3690
|
if (ws) {
|
|
2572
3691
|
const connId = ws.data?.connectionId;
|
|
2573
3692
|
ws.data?.components?.delete(componentId);
|
|
@@ -2583,7 +3702,6 @@ var ComponentRegistry = class {
|
|
|
2583
3702
|
} else {
|
|
2584
3703
|
if (this.removeSingletonConnection(componentId, void 0, "unmount")) return;
|
|
2585
3704
|
}
|
|
2586
|
-
this.debugger.trackComponentUnmount(componentId);
|
|
2587
3705
|
component.destroy?.();
|
|
2588
3706
|
this.unsubscribeFromAllRooms(componentId);
|
|
2589
3707
|
this.components.delete(componentId);
|
|
@@ -2596,6 +3714,13 @@ var ComponentRegistry = class {
|
|
|
2596
3714
|
}
|
|
2597
3715
|
return null;
|
|
2598
3716
|
}
|
|
3717
|
+
/** Find a remote singleton entry by componentId. */
|
|
3718
|
+
findRemoteSingleton(componentId) {
|
|
3719
|
+
for (const [, entry] of this.remoteSingletons) {
|
|
3720
|
+
if (entry.componentId === componentId) return entry;
|
|
3721
|
+
}
|
|
3722
|
+
return null;
|
|
3723
|
+
}
|
|
2599
3724
|
async executeAction(componentId, action, payload) {
|
|
2600
3725
|
const component = this.components.get(componentId);
|
|
2601
3726
|
if (!component) throw new Error(`COMPONENT_REHYDRATION_REQUIRED:${componentId}`);
|
|
@@ -2646,7 +3771,7 @@ var ComponentRegistry = class {
|
|
|
2646
3771
|
const component = this.components.get(componentId);
|
|
2647
3772
|
if (message.excludeUser && component?.userId === message.excludeUser) continue;
|
|
2648
3773
|
const ws = this.wsConnections.get(componentId);
|
|
2649
|
-
if (ws
|
|
3774
|
+
if (ws) queueWsMessage(ws, broadcastMessage);
|
|
2650
3775
|
}
|
|
2651
3776
|
}
|
|
2652
3777
|
async handleMessage(ws, message) {
|
|
@@ -2661,9 +3786,26 @@ var ComponentRegistry = class {
|
|
|
2661
3786
|
});
|
|
2662
3787
|
return { success: true, result: mountResult };
|
|
2663
3788
|
case "COMPONENT_UNMOUNT":
|
|
2664
|
-
|
|
3789
|
+
this.unmountComponent(message.componentId, ws);
|
|
2665
3790
|
return { success: true };
|
|
2666
|
-
case "CALL_ACTION":
|
|
3791
|
+
case "CALL_ACTION": {
|
|
3792
|
+
const remoteSingleton = this.findRemoteSingleton(message.componentId);
|
|
3793
|
+
if (remoteSingleton && this.cluster) {
|
|
3794
|
+
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3795
|
+
const request = {
|
|
3796
|
+
sourceInstanceId: this.cluster.instanceId,
|
|
3797
|
+
targetInstanceId: remoteSingleton.ownerInstanceId,
|
|
3798
|
+
componentId: remoteSingleton.componentId,
|
|
3799
|
+
componentName: remoteSingleton.componentName,
|
|
3800
|
+
action: message.action,
|
|
3801
|
+
payload: message.payload,
|
|
3802
|
+
requestId
|
|
3803
|
+
};
|
|
3804
|
+
const response = await this.cluster.forwardAction(request);
|
|
3805
|
+
if (!response.success) throw new Error(response.error || "Remote action failed");
|
|
3806
|
+
if (message.expectResponse) return { success: true, result: response.result };
|
|
3807
|
+
return null;
|
|
3808
|
+
}
|
|
2667
3809
|
this.recordComponentMetrics(message.componentId, void 0, message.action);
|
|
2668
3810
|
const actionStart = Date.now();
|
|
2669
3811
|
try {
|
|
@@ -2675,6 +3817,7 @@ var ComponentRegistry = class {
|
|
|
2675
3817
|
this.performanceMonitor.recordActionTime(message.componentId, message.action, Date.now() - actionStart, error);
|
|
2676
3818
|
throw error;
|
|
2677
3819
|
}
|
|
3820
|
+
}
|
|
2678
3821
|
case "PROPERTY_UPDATE":
|
|
2679
3822
|
this.updateProperty(message.componentId, message.property, message.payload.value);
|
|
2680
3823
|
return { success: true };
|
|
@@ -2702,6 +3845,14 @@ var ComponentRegistry = class {
|
|
|
2702
3845
|
this.cleanupComponent(componentId);
|
|
2703
3846
|
}
|
|
2704
3847
|
}
|
|
3848
|
+
if (connId) {
|
|
3849
|
+
for (const [name, remote] of this.remoteSingletons) {
|
|
3850
|
+
remote.connections.delete(connId);
|
|
3851
|
+
if (remote.connections.size === 0) {
|
|
3852
|
+
this.remoteSingletons.delete(name);
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
2705
3856
|
ws.data.components.clear();
|
|
2706
3857
|
}
|
|
2707
3858
|
getStats() {
|
|
@@ -2713,6 +3864,9 @@ var ComponentRegistry = class {
|
|
|
2713
3864
|
singletons: Object.fromEntries(
|
|
2714
3865
|
Array.from(this.singletons.entries()).map(([name, s]) => [name, { componentId: s.instance.id, connections: s.connections.size }])
|
|
2715
3866
|
),
|
|
3867
|
+
remoteSingletons: Object.fromEntries(
|
|
3868
|
+
Array.from(this.remoteSingletons.entries()).map(([name, r]) => [name, { componentId: r.componentId, ownerInstanceId: r.ownerInstanceId, connections: r.connections.size }])
|
|
3869
|
+
),
|
|
2716
3870
|
roomDetails: Object.fromEntries(
|
|
2717
3871
|
Array.from(this.rooms.entries()).map(([roomId, components]) => [roomId, components.size])
|
|
2718
3872
|
)
|
|
@@ -2770,7 +3924,7 @@ var ComponentRegistry = class {
|
|
|
2770
3924
|
metadata.healthStatus = metadata.metrics.errorCount > 5 ? "unhealthy" : "degraded";
|
|
2771
3925
|
}
|
|
2772
3926
|
}
|
|
2773
|
-
|
|
3927
|
+
performHealthChecks() {
|
|
2774
3928
|
for (const [componentId, metadata] of this.metadata) {
|
|
2775
3929
|
if (!this.components.get(componentId)) continue;
|
|
2776
3930
|
if (metadata.metrics.errorCount > 10) metadata.healthStatus = "unhealthy";
|
|
@@ -2796,6 +3950,7 @@ var ComponentRegistry = class {
|
|
|
2796
3950
|
cleanup() {
|
|
2797
3951
|
if (this.healthCheckInterval) clearInterval(this.healthCheckInterval);
|
|
2798
3952
|
this.singletons.clear();
|
|
3953
|
+
this.remoteSingletons.clear();
|
|
2799
3954
|
for (const [componentId] of this.components) this.cleanupComponent(componentId);
|
|
2800
3955
|
}
|
|
2801
3956
|
};
|
|
@@ -2874,12 +4029,165 @@ function decodeBinaryChunk(raw) {
|
|
|
2874
4029
|
return { header, data };
|
|
2875
4030
|
}
|
|
2876
4031
|
|
|
4032
|
+
// src/security/sanitize.ts
|
|
4033
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
4034
|
+
var MAX_DEPTH = 10;
|
|
4035
|
+
function sanitizePayload(value, depth = 0) {
|
|
4036
|
+
if (depth > MAX_DEPTH) return value;
|
|
4037
|
+
if (Array.isArray(value)) {
|
|
4038
|
+
return value.map((item) => sanitizePayload(item, depth + 1));
|
|
4039
|
+
}
|
|
4040
|
+
if (value !== null && typeof value === "object") {
|
|
4041
|
+
const clean = {};
|
|
4042
|
+
for (const key of Object.keys(value)) {
|
|
4043
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
4044
|
+
clean[key] = sanitizePayload(value[key], depth + 1);
|
|
4045
|
+
}
|
|
4046
|
+
return clean;
|
|
4047
|
+
}
|
|
4048
|
+
return value;
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
// src/rooms/LiveRoom.ts
|
|
4052
|
+
var LiveRoom = class {
|
|
4053
|
+
/** Unique room type name. Used as prefix in compound room IDs (e.g. "chat:lobby"). */
|
|
4054
|
+
static roomName;
|
|
4055
|
+
/** Initial public state template. Cloned per room instance. */
|
|
4056
|
+
static defaultState = {};
|
|
4057
|
+
/** Initial private metadata template. Cloned per room instance. */
|
|
4058
|
+
static defaultMeta = {};
|
|
4059
|
+
/** Room-level options */
|
|
4060
|
+
static $options;
|
|
4061
|
+
/** The unique room instance identifier (e.g. "chat:lobby") */
|
|
4062
|
+
id;
|
|
4063
|
+
/** Public state — synced to all connected clients via setState(). */
|
|
4064
|
+
state;
|
|
4065
|
+
/** Private metadata — NEVER leaves the server. Mutate directly. */
|
|
4066
|
+
meta;
|
|
4067
|
+
/** @internal Reference to the room manager for broadcasting */
|
|
4068
|
+
_manager;
|
|
4069
|
+
constructor(id, manager) {
|
|
4070
|
+
const ctor = this.constructor;
|
|
4071
|
+
this.id = id;
|
|
4072
|
+
this._manager = manager;
|
|
4073
|
+
this.state = structuredClone(ctor.defaultState ?? {});
|
|
4074
|
+
this.meta = structuredClone(ctor.defaultMeta ?? {});
|
|
4075
|
+
}
|
|
4076
|
+
// ===== Framework Methods =====
|
|
4077
|
+
/**
|
|
4078
|
+
* Update public state and broadcast changes to all room members.
|
|
4079
|
+
* Uses deep diff by default — only changed fields are sent over the wire.
|
|
4080
|
+
*/
|
|
4081
|
+
setState(updates) {
|
|
4082
|
+
this._manager.setRoomState(this.id, updates);
|
|
4083
|
+
}
|
|
4084
|
+
/**
|
|
4085
|
+
* Emit a typed event to all members in this room.
|
|
4086
|
+
* @returns Number of members notified
|
|
4087
|
+
*/
|
|
4088
|
+
emit(event, data) {
|
|
4089
|
+
return this._manager.emitToRoom(this.id, event, data);
|
|
4090
|
+
}
|
|
4091
|
+
/** Get current member count */
|
|
4092
|
+
get memberCount() {
|
|
4093
|
+
return this._manager.getMemberCount?.(this.id) ?? 0;
|
|
4094
|
+
}
|
|
4095
|
+
// ===== Lifecycle Hooks (override in subclass) =====
|
|
4096
|
+
/**
|
|
4097
|
+
* Called when a component attempts to join this room.
|
|
4098
|
+
* Return false to reject the join.
|
|
4099
|
+
*/
|
|
4100
|
+
onJoin(_ctx2) {
|
|
4101
|
+
}
|
|
4102
|
+
/**
|
|
4103
|
+
* Called after a component leaves this room.
|
|
4104
|
+
*/
|
|
4105
|
+
onLeave(_ctx2) {
|
|
4106
|
+
}
|
|
4107
|
+
/**
|
|
4108
|
+
* Called when an event is emitted to this room.
|
|
4109
|
+
* Can intercept/validate events before broadcasting.
|
|
4110
|
+
*/
|
|
4111
|
+
onEvent(_event, _data, _ctx2) {
|
|
4112
|
+
}
|
|
4113
|
+
/**
|
|
4114
|
+
* Called once when the room is first created (first member joins).
|
|
4115
|
+
*/
|
|
4116
|
+
onCreate() {
|
|
4117
|
+
}
|
|
4118
|
+
/**
|
|
4119
|
+
* Called when the last member leaves and the room is about to be destroyed.
|
|
4120
|
+
* Return false to keep the room alive (e.g., persist state).
|
|
4121
|
+
*/
|
|
4122
|
+
onDestroy() {
|
|
4123
|
+
}
|
|
4124
|
+
};
|
|
4125
|
+
|
|
4126
|
+
// src/rooms/RoomRegistry.ts
|
|
4127
|
+
var RoomRegistry = class {
|
|
4128
|
+
rooms = /* @__PURE__ */ new Map();
|
|
4129
|
+
/**
|
|
4130
|
+
* Register a LiveRoom subclass.
|
|
4131
|
+
* @throws If the class doesn't define a static roomName
|
|
4132
|
+
*/
|
|
4133
|
+
register(roomClass) {
|
|
4134
|
+
const name = roomClass.roomName;
|
|
4135
|
+
if (!name) {
|
|
4136
|
+
throw new Error("LiveRoom subclass must define static roomName");
|
|
4137
|
+
}
|
|
4138
|
+
if (this.rooms.has(name)) {
|
|
4139
|
+
throw new Error(`LiveRoom '${name}' is already registered`);
|
|
4140
|
+
}
|
|
4141
|
+
this.rooms.set(name, roomClass);
|
|
4142
|
+
}
|
|
4143
|
+
/**
|
|
4144
|
+
* Get a registered room class by name.
|
|
4145
|
+
*/
|
|
4146
|
+
get(name) {
|
|
4147
|
+
return this.rooms.get(name);
|
|
4148
|
+
}
|
|
4149
|
+
/**
|
|
4150
|
+
* Check if a room class is registered.
|
|
4151
|
+
*/
|
|
4152
|
+
has(name) {
|
|
4153
|
+
return this.rooms.has(name);
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Resolve a compound room ID (e.g. "chat:lobby") to its registered class.
|
|
4157
|
+
* Returns undefined if the prefix doesn't match any registered room.
|
|
4158
|
+
*/
|
|
4159
|
+
resolveFromId(roomId) {
|
|
4160
|
+
const colonIdx = roomId.indexOf(":");
|
|
4161
|
+
if (colonIdx === -1) return void 0;
|
|
4162
|
+
const prefix = roomId.substring(0, colonIdx);
|
|
4163
|
+
return this.rooms.get(prefix);
|
|
4164
|
+
}
|
|
4165
|
+
/**
|
|
4166
|
+
* Get all registered room names.
|
|
4167
|
+
*/
|
|
4168
|
+
getRegisteredNames() {
|
|
4169
|
+
return Array.from(this.rooms.keys());
|
|
4170
|
+
}
|
|
4171
|
+
/**
|
|
4172
|
+
* Check if a value is a LiveRoom subclass.
|
|
4173
|
+
*/
|
|
4174
|
+
static isLiveRoomClass(cls) {
|
|
4175
|
+
if (typeof cls !== "function" || !cls.prototype) return false;
|
|
4176
|
+
if (typeof cls.roomName !== "string") return false;
|
|
4177
|
+
let proto = Object.getPrototypeOf(cls.prototype);
|
|
4178
|
+
while (proto) {
|
|
4179
|
+
if (proto.constructor === LiveRoom) return true;
|
|
4180
|
+
proto = Object.getPrototypeOf(proto);
|
|
4181
|
+
}
|
|
4182
|
+
return false;
|
|
4183
|
+
}
|
|
4184
|
+
};
|
|
4185
|
+
|
|
2877
4186
|
// src/server/LiveServer.ts
|
|
2878
4187
|
var LiveServer = class {
|
|
2879
4188
|
// Public singletons (accessible for transport adapters & advanced usage)
|
|
2880
4189
|
roomEvents;
|
|
2881
4190
|
roomManager;
|
|
2882
|
-
debugger;
|
|
2883
4191
|
authManager;
|
|
2884
4192
|
stateSignature;
|
|
2885
4193
|
performanceMonitor;
|
|
@@ -2887,31 +4195,36 @@ var LiveServer = class {
|
|
|
2887
4195
|
connectionManager;
|
|
2888
4196
|
registry;
|
|
2889
4197
|
rateLimiter;
|
|
4198
|
+
roomRegistry;
|
|
2890
4199
|
transport;
|
|
2891
4200
|
options;
|
|
2892
4201
|
constructor(options) {
|
|
2893
4202
|
this.options = options;
|
|
2894
4203
|
this.transport = options.transport;
|
|
2895
4204
|
this.roomEvents = new RoomEventBus();
|
|
2896
|
-
this.roomManager = new LiveRoomManager(this.roomEvents);
|
|
2897
|
-
this.debugger = new LiveDebugger(options.debug ?? false);
|
|
4205
|
+
this.roomManager = new LiveRoomManager(this.roomEvents, options.roomPubSub);
|
|
2898
4206
|
this.authManager = new LiveAuthManager();
|
|
2899
4207
|
this.stateSignature = new StateSignatureManager(options.stateSignature);
|
|
2900
4208
|
this.performanceMonitor = new PerformanceMonitor(options.performance);
|
|
2901
4209
|
this.fileUploadManager = new FileUploadManager(options.fileUpload);
|
|
2902
4210
|
this.connectionManager = new WebSocketConnectionManager(options.connection);
|
|
2903
4211
|
this.rateLimiter = new RateLimiterRegistry(options.rateLimitMaxTokens, options.rateLimitRefillRate);
|
|
4212
|
+
this.roomRegistry = new RoomRegistry();
|
|
4213
|
+
this.roomManager.roomRegistry = this.roomRegistry;
|
|
4214
|
+
if (options.rooms) {
|
|
4215
|
+
for (const roomClass of options.rooms) {
|
|
4216
|
+
this.roomRegistry.register(roomClass);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
2904
4219
|
this.registry = new ComponentRegistry({
|
|
2905
4220
|
authManager: this.authManager,
|
|
2906
|
-
debugger: this.debugger,
|
|
2907
4221
|
stateSignature: this.stateSignature,
|
|
2908
|
-
performanceMonitor: this.performanceMonitor
|
|
4222
|
+
performanceMonitor: this.performanceMonitor,
|
|
4223
|
+
cluster: options.cluster
|
|
2909
4224
|
});
|
|
2910
|
-
_setLoggerDebugger(this.debugger);
|
|
2911
4225
|
setLiveComponentContext({
|
|
2912
4226
|
roomEvents: this.roomEvents,
|
|
2913
|
-
roomManager: this.roomManager
|
|
2914
|
-
debugger: this.debugger
|
|
4227
|
+
roomManager: this.roomManager
|
|
2915
4228
|
});
|
|
2916
4229
|
}
|
|
2917
4230
|
/**
|
|
@@ -2921,6 +4234,14 @@ var LiveServer = class {
|
|
|
2921
4234
|
this.authManager.register(provider);
|
|
2922
4235
|
return this;
|
|
2923
4236
|
}
|
|
4237
|
+
/**
|
|
4238
|
+
* Register a LiveRoom class.
|
|
4239
|
+
* Can be called before start() to register room types dynamically.
|
|
4240
|
+
*/
|
|
4241
|
+
useRoom(roomClass) {
|
|
4242
|
+
this.roomRegistry.register(roomClass);
|
|
4243
|
+
return this;
|
|
4244
|
+
}
|
|
2924
4245
|
/**
|
|
2925
4246
|
* Start the LiveServer: register WS + HTTP handlers on the transport.
|
|
2926
4247
|
*/
|
|
@@ -2940,10 +4261,13 @@ var LiveServer = class {
|
|
|
2940
4261
|
const prefix = this.options.httpPrefix ?? "/api/live";
|
|
2941
4262
|
await this.transport.registerHttpRoutes(this.buildHttpRoutes(prefix));
|
|
2942
4263
|
}
|
|
4264
|
+
if (this.options.cluster) {
|
|
4265
|
+
await this.options.cluster.start();
|
|
4266
|
+
}
|
|
2943
4267
|
if (this.transport.start) {
|
|
2944
4268
|
await this.transport.start();
|
|
2945
4269
|
}
|
|
2946
|
-
liveLog("lifecycle", null, `LiveServer started (ws: ${wsConfig.path})`);
|
|
4270
|
+
liveLog("lifecycle", null, `LiveServer started (ws: ${wsConfig.path}${this.options.cluster ? ", cluster: enabled" : ""})`);
|
|
2947
4271
|
}
|
|
2948
4272
|
/**
|
|
2949
4273
|
* Graceful shutdown.
|
|
@@ -2953,21 +4277,31 @@ var LiveServer = class {
|
|
|
2953
4277
|
this.connectionManager.shutdown();
|
|
2954
4278
|
this.fileUploadManager.shutdown();
|
|
2955
4279
|
this.stateSignature.shutdown();
|
|
4280
|
+
if (this.options.cluster) await this.options.cluster.shutdown();
|
|
2956
4281
|
if (this.transport.shutdown) await this.transport.shutdown();
|
|
2957
4282
|
liveLog("lifecycle", null, "LiveServer shut down");
|
|
2958
4283
|
}
|
|
2959
4284
|
// ===== WebSocket Handlers =====
|
|
2960
4285
|
handleOpen(ws) {
|
|
4286
|
+
const origin = ws.data?.origin;
|
|
4287
|
+
const allowedOrigins = this.options.allowedOrigins;
|
|
4288
|
+
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
4289
|
+
if (!origin || !allowedOrigins.includes(origin)) {
|
|
4290
|
+
liveLog("websocket", null, `Connection rejected: origin '${origin || "none"}' not in allowedOrigins`);
|
|
4291
|
+
ws.close(4003, "Origin not allowed");
|
|
4292
|
+
return;
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
2961
4295
|
const connectionId = `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2962
4296
|
ws.data = {
|
|
2963
4297
|
connectionId,
|
|
2964
4298
|
components: /* @__PURE__ */ new Map(),
|
|
2965
4299
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
2966
|
-
connectedAt: /* @__PURE__ */ new Date()
|
|
4300
|
+
connectedAt: /* @__PURE__ */ new Date(),
|
|
4301
|
+
origin
|
|
2967
4302
|
};
|
|
2968
4303
|
this.connectionManager.registerConnection(ws, connectionId);
|
|
2969
|
-
|
|
2970
|
-
ws.send(JSON.stringify({
|
|
4304
|
+
sendImmediate(ws, JSON.stringify({
|
|
2971
4305
|
type: "CONNECTION_ESTABLISHED",
|
|
2972
4306
|
connectionId,
|
|
2973
4307
|
timestamp: Date.now()
|
|
@@ -2979,7 +4313,7 @@ var LiveServer = class {
|
|
|
2979
4313
|
if (connectionId) {
|
|
2980
4314
|
const limiter = this.rateLimiter.get(connectionId);
|
|
2981
4315
|
if (!limiter.tryConsume()) {
|
|
2982
|
-
ws
|
|
4316
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: "Rate limit exceeded", timestamp: Date.now() }));
|
|
2983
4317
|
return;
|
|
2984
4318
|
}
|
|
2985
4319
|
}
|
|
@@ -2989,26 +4323,33 @@ var LiveServer = class {
|
|
|
2989
4323
|
if (header.type === "FILE_UPLOAD_CHUNK") {
|
|
2990
4324
|
const chunkMessage = { ...header, data: "" };
|
|
2991
4325
|
const progress = await this.fileUploadManager.receiveChunk(chunkMessage, data);
|
|
2992
|
-
if (progress) ws
|
|
4326
|
+
if (progress) sendImmediate(ws, JSON.stringify(progress));
|
|
2993
4327
|
}
|
|
2994
4328
|
} catch (error) {
|
|
2995
|
-
ws
|
|
4329
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: error.message, timestamp: Date.now() }));
|
|
2996
4330
|
}
|
|
2997
4331
|
return;
|
|
2998
4332
|
}
|
|
4333
|
+
const str = typeof rawMessage === "string" ? rawMessage : new TextDecoder().decode(rawMessage);
|
|
4334
|
+
if (str.length > MAX_MESSAGE_SIZE) {
|
|
4335
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: "Message too large", timestamp: Date.now() }));
|
|
4336
|
+
return;
|
|
4337
|
+
}
|
|
2999
4338
|
let message;
|
|
3000
4339
|
try {
|
|
3001
|
-
const str = typeof rawMessage === "string" ? rawMessage : new TextDecoder().decode(rawMessage);
|
|
3002
4340
|
message = JSON.parse(str);
|
|
3003
4341
|
} catch {
|
|
3004
|
-
ws
|
|
4342
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: "Invalid JSON", timestamp: Date.now() }));
|
|
3005
4343
|
return;
|
|
3006
4344
|
}
|
|
4345
|
+
if (message.payload) {
|
|
4346
|
+
message.payload = sanitizePayload(message.payload);
|
|
4347
|
+
}
|
|
3007
4348
|
try {
|
|
3008
4349
|
if (message.type === "AUTH") {
|
|
3009
4350
|
const authContext = await this.authManager.authenticate(message.payload || {});
|
|
3010
4351
|
if (ws.data) ws.data.authContext = authContext;
|
|
3011
|
-
ws
|
|
4352
|
+
sendImmediate(ws, JSON.stringify({
|
|
3012
4353
|
type: "AUTH_RESPONSE",
|
|
3013
4354
|
success: authContext.authenticated,
|
|
3014
4355
|
payload: authContext.authenticated ? { userId: authContext.user?.id } : { error: "Authentication failed" },
|
|
@@ -3022,7 +4363,7 @@ var LiveServer = class {
|
|
|
3022
4363
|
}
|
|
3023
4364
|
if (message.type === "FILE_UPLOAD_START") {
|
|
3024
4365
|
const result2 = await this.fileUploadManager.startUpload(message, ws.data?.userId);
|
|
3025
|
-
ws
|
|
4366
|
+
sendImmediate(ws, JSON.stringify({
|
|
3026
4367
|
type: "FILE_UPLOAD_START_RESPONSE",
|
|
3027
4368
|
componentId: message.componentId,
|
|
3028
4369
|
uploadId: message.payload?.uploadId,
|
|
@@ -3035,12 +4376,12 @@ var LiveServer = class {
|
|
|
3035
4376
|
}
|
|
3036
4377
|
if (message.type === "FILE_UPLOAD_CHUNK") {
|
|
3037
4378
|
const progress = await this.fileUploadManager.receiveChunk(message);
|
|
3038
|
-
if (progress) ws
|
|
4379
|
+
if (progress) sendImmediate(ws, JSON.stringify(progress));
|
|
3039
4380
|
return;
|
|
3040
4381
|
}
|
|
3041
4382
|
if (message.type === "FILE_UPLOAD_COMPLETE") {
|
|
3042
4383
|
const result2 = await this.fileUploadManager.completeUpload(message);
|
|
3043
|
-
ws
|
|
4384
|
+
sendImmediate(ws, JSON.stringify(result2));
|
|
3044
4385
|
return;
|
|
3045
4386
|
}
|
|
3046
4387
|
if (message.type === "COMPONENT_REHYDRATE") {
|
|
@@ -3051,7 +4392,7 @@ var LiveServer = class {
|
|
|
3051
4392
|
ws,
|
|
3052
4393
|
{ room: message.payload.room, userId: message.userId }
|
|
3053
4394
|
);
|
|
3054
|
-
ws
|
|
4395
|
+
sendImmediate(ws, JSON.stringify({
|
|
3055
4396
|
type: "COMPONENT_REHYDRATED",
|
|
3056
4397
|
componentId: message.componentId,
|
|
3057
4398
|
success: result2.success,
|
|
@@ -3075,10 +4416,10 @@ var LiveServer = class {
|
|
|
3075
4416
|
responseId: message.responseId,
|
|
3076
4417
|
timestamp: Date.now()
|
|
3077
4418
|
};
|
|
3078
|
-
ws
|
|
4419
|
+
sendImmediate(ws, JSON.stringify(response));
|
|
3079
4420
|
}
|
|
3080
4421
|
} catch (error) {
|
|
3081
|
-
ws
|
|
4422
|
+
sendImmediate(ws, JSON.stringify({
|
|
3082
4423
|
type: "ERROR",
|
|
3083
4424
|
componentId: message.componentId,
|
|
3084
4425
|
error: error.message,
|
|
@@ -3096,7 +4437,6 @@ var LiveServer = class {
|
|
|
3096
4437
|
this.connectionManager.cleanupConnection(connectionId);
|
|
3097
4438
|
this.rateLimiter.remove(connectionId);
|
|
3098
4439
|
}
|
|
3099
|
-
this.debugger.trackDisconnection(connectionId || "", componentCount);
|
|
3100
4440
|
liveLog("websocket", null, `Connection closed: ${connectionId} (${componentCount} components)`);
|
|
3101
4441
|
}
|
|
3102
4442
|
handleError(ws, error) {
|
|
@@ -3108,8 +4448,58 @@ var LiveServer = class {
|
|
|
3108
4448
|
const roomId = message.roomId || message.payload?.roomId;
|
|
3109
4449
|
switch (message.type) {
|
|
3110
4450
|
case "ROOM_JOIN": {
|
|
4451
|
+
if (this.roomRegistry.resolveFromId(roomId)) {
|
|
4452
|
+
sendImmediate(ws, JSON.stringify({
|
|
4453
|
+
type: "ERROR",
|
|
4454
|
+
componentId,
|
|
4455
|
+
error: "Room requires server-side join via component action",
|
|
4456
|
+
requestId: message.requestId,
|
|
4457
|
+
timestamp: Date.now()
|
|
4458
|
+
}));
|
|
4459
|
+
break;
|
|
4460
|
+
}
|
|
4461
|
+
const connRooms = ws.data?.rooms;
|
|
4462
|
+
if (connRooms && connRooms.size >= MAX_ROOMS_PER_CONNECTION) {
|
|
4463
|
+
sendImmediate(ws, JSON.stringify({
|
|
4464
|
+
type: "ERROR",
|
|
4465
|
+
componentId,
|
|
4466
|
+
error: "Room limit exceeded",
|
|
4467
|
+
requestId: message.requestId,
|
|
4468
|
+
timestamp: Date.now()
|
|
4469
|
+
}));
|
|
4470
|
+
break;
|
|
4471
|
+
}
|
|
4472
|
+
if (this.authManager.hasProviders()) {
|
|
4473
|
+
const authContext = ws.data?.authContext;
|
|
4474
|
+
const authResult = await this.authManager.authorizeRoom(
|
|
4475
|
+
authContext || ANONYMOUS_CONTEXT,
|
|
4476
|
+
roomId
|
|
4477
|
+
);
|
|
4478
|
+
if (!authResult.allowed) {
|
|
4479
|
+
sendImmediate(ws, JSON.stringify({
|
|
4480
|
+
type: "ERROR",
|
|
4481
|
+
componentId,
|
|
4482
|
+
error: authResult.reason || "Room access denied",
|
|
4483
|
+
requestId: message.requestId,
|
|
4484
|
+
timestamp: Date.now()
|
|
4485
|
+
}));
|
|
4486
|
+
break;
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
3111
4489
|
const result = this.roomManager.joinRoom(componentId, roomId, ws, message.payload?.initialState);
|
|
3112
|
-
|
|
4490
|
+
if ("rejected" in result && result.rejected) {
|
|
4491
|
+
sendImmediate(ws, JSON.stringify({
|
|
4492
|
+
type: "ERROR",
|
|
4493
|
+
componentId,
|
|
4494
|
+
error: result.reason,
|
|
4495
|
+
requestId: message.requestId,
|
|
4496
|
+
timestamp: Date.now()
|
|
4497
|
+
}));
|
|
4498
|
+
break;
|
|
4499
|
+
}
|
|
4500
|
+
if (!ws.data.rooms) ws.data.rooms = /* @__PURE__ */ new Set();
|
|
4501
|
+
ws.data.rooms.add(roomId);
|
|
4502
|
+
sendImmediate(ws, JSON.stringify({
|
|
3113
4503
|
type: "ROOM_JOINED",
|
|
3114
4504
|
componentId,
|
|
3115
4505
|
payload: { roomId, state: result.state },
|
|
@@ -3120,7 +4510,8 @@ var LiveServer = class {
|
|
|
3120
4510
|
}
|
|
3121
4511
|
case "ROOM_LEAVE":
|
|
3122
4512
|
this.roomManager.leaveRoom(componentId, roomId);
|
|
3123
|
-
ws.
|
|
4513
|
+
ws.data?.rooms?.delete(roomId);
|
|
4514
|
+
sendImmediate(ws, JSON.stringify({
|
|
3124
4515
|
type: "ROOM_LEFT",
|
|
3125
4516
|
componentId,
|
|
3126
4517
|
payload: { roomId },
|
|
@@ -3128,15 +4519,57 @@ var LiveServer = class {
|
|
|
3128
4519
|
timestamp: Date.now()
|
|
3129
4520
|
}));
|
|
3130
4521
|
break;
|
|
3131
|
-
case "ROOM_EMIT":
|
|
4522
|
+
case "ROOM_EMIT": {
|
|
4523
|
+
if (!this.roomManager.isInRoom(componentId, roomId)) {
|
|
4524
|
+
sendImmediate(ws, JSON.stringify({
|
|
4525
|
+
type: "ERROR",
|
|
4526
|
+
componentId,
|
|
4527
|
+
error: "Not a member of this room",
|
|
4528
|
+
requestId: message.requestId,
|
|
4529
|
+
timestamp: Date.now()
|
|
4530
|
+
}));
|
|
4531
|
+
break;
|
|
4532
|
+
}
|
|
3132
4533
|
this.roomManager.emitToRoom(roomId, message.payload?.event, message.payload?.data, componentId);
|
|
3133
4534
|
break;
|
|
3134
|
-
|
|
4535
|
+
}
|
|
4536
|
+
case "ROOM_STATE_SET": {
|
|
4537
|
+
if (!this.roomManager.isInRoom(componentId, roomId)) {
|
|
4538
|
+
sendImmediate(ws, JSON.stringify({
|
|
4539
|
+
type: "ERROR",
|
|
4540
|
+
componentId,
|
|
4541
|
+
error: "Not a member of this room",
|
|
4542
|
+
requestId: message.requestId,
|
|
4543
|
+
timestamp: Date.now()
|
|
4544
|
+
}));
|
|
4545
|
+
break;
|
|
4546
|
+
}
|
|
4547
|
+
if (this.roomManager.isServerOnlyState(roomId)) {
|
|
4548
|
+
sendImmediate(ws, JSON.stringify({
|
|
4549
|
+
type: "ERROR",
|
|
4550
|
+
componentId,
|
|
4551
|
+
error: "Room state is server-only",
|
|
4552
|
+
requestId: message.requestId,
|
|
4553
|
+
timestamp: Date.now()
|
|
4554
|
+
}));
|
|
4555
|
+
break;
|
|
4556
|
+
}
|
|
3135
4557
|
this.roomManager.setRoomState(roomId, message.payload?.state, componentId);
|
|
3136
4558
|
break;
|
|
4559
|
+
}
|
|
3137
4560
|
case "ROOM_STATE_GET": {
|
|
4561
|
+
if (!this.roomManager.isInRoom(componentId, roomId)) {
|
|
4562
|
+
sendImmediate(ws, JSON.stringify({
|
|
4563
|
+
type: "ERROR",
|
|
4564
|
+
componentId,
|
|
4565
|
+
error: "Not a member of this room",
|
|
4566
|
+
requestId: message.requestId,
|
|
4567
|
+
timestamp: Date.now()
|
|
4568
|
+
}));
|
|
4569
|
+
break;
|
|
4570
|
+
}
|
|
3138
4571
|
const state = this.roomManager.getRoomState(roomId);
|
|
3139
|
-
ws
|
|
4572
|
+
sendImmediate(ws, JSON.stringify({
|
|
3140
4573
|
type: "ROOM_STATE",
|
|
3141
4574
|
componentId,
|
|
3142
4575
|
payload: { roomId, state },
|
|
@@ -3153,7 +4586,7 @@ var LiveServer = class {
|
|
|
3153
4586
|
{
|
|
3154
4587
|
method: "GET",
|
|
3155
4588
|
path: `${prefix}/stats`,
|
|
3156
|
-
handler:
|
|
4589
|
+
handler: () => ({
|
|
3157
4590
|
body: {
|
|
3158
4591
|
components: this.registry.getStats(),
|
|
3159
4592
|
rooms: this.roomManager.getStats(),
|
|
@@ -3167,7 +4600,7 @@ var LiveServer = class {
|
|
|
3167
4600
|
{
|
|
3168
4601
|
method: "GET",
|
|
3169
4602
|
path: `${prefix}/components`,
|
|
3170
|
-
handler:
|
|
4603
|
+
handler: () => ({
|
|
3171
4604
|
body: { names: this.registry.getRegisteredComponentNames() }
|
|
3172
4605
|
}),
|
|
3173
4606
|
metadata: { summary: "List registered component names", tags: ["live"] }
|
|
@@ -3175,7 +4608,7 @@ var LiveServer = class {
|
|
|
3175
4608
|
{
|
|
3176
4609
|
method: "POST",
|
|
3177
4610
|
path: `${prefix}/rooms/:roomId/messages`,
|
|
3178
|
-
handler:
|
|
4611
|
+
handler: (req) => {
|
|
3179
4612
|
const roomId = req.params.roomId;
|
|
3180
4613
|
this.roomManager.emitToRoom(roomId, "message:new", req.body);
|
|
3181
4614
|
return { body: { success: true, roomId } };
|
|
@@ -3185,7 +4618,7 @@ var LiveServer = class {
|
|
|
3185
4618
|
{
|
|
3186
4619
|
method: "POST",
|
|
3187
4620
|
path: `${prefix}/rooms/:roomId/emit`,
|
|
3188
|
-
handler:
|
|
4621
|
+
handler: (req) => {
|
|
3189
4622
|
const roomId = req.params.roomId;
|
|
3190
4623
|
const { event, data } = req.body;
|
|
3191
4624
|
this.roomManager.emitToRoom(roomId, event, data);
|
|
@@ -3329,6 +4762,66 @@ var RoomStateManager = class {
|
|
|
3329
4762
|
}
|
|
3330
4763
|
};
|
|
3331
4764
|
|
|
3332
|
-
|
|
4765
|
+
// src/rooms/InMemoryRoomAdapter.ts
|
|
4766
|
+
var InMemoryRoomAdapter = class {
|
|
4767
|
+
rooms = /* @__PURE__ */ new Map();
|
|
4768
|
+
// ===== IRoomStorageAdapter =====
|
|
4769
|
+
async getOrCreateRoom(roomId, initialState) {
|
|
4770
|
+
const existing = this.rooms.get(roomId);
|
|
4771
|
+
if (existing) {
|
|
4772
|
+
return { state: existing.state, created: false };
|
|
4773
|
+
}
|
|
4774
|
+
const now = Date.now();
|
|
4775
|
+
const data = {
|
|
4776
|
+
state: initialState ?? {},
|
|
4777
|
+
createdAt: now,
|
|
4778
|
+
lastUpdate: now
|
|
4779
|
+
};
|
|
4780
|
+
this.rooms.set(roomId, data);
|
|
4781
|
+
return { state: data.state, created: true };
|
|
4782
|
+
}
|
|
4783
|
+
async getState(roomId) {
|
|
4784
|
+
return this.rooms.get(roomId)?.state ?? {};
|
|
4785
|
+
}
|
|
4786
|
+
async updateState(roomId, updates) {
|
|
4787
|
+
const room = this.rooms.get(roomId);
|
|
4788
|
+
if (room) {
|
|
4789
|
+
Object.assign(room.state, updates);
|
|
4790
|
+
room.lastUpdate = Date.now();
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
async hasRoom(roomId) {
|
|
4794
|
+
return this.rooms.has(roomId);
|
|
4795
|
+
}
|
|
4796
|
+
async deleteRoom(roomId) {
|
|
4797
|
+
return this.rooms.delete(roomId);
|
|
4798
|
+
}
|
|
4799
|
+
async getStats() {
|
|
4800
|
+
const rooms = {};
|
|
4801
|
+
for (const [id, data] of this.rooms) {
|
|
4802
|
+
rooms[id] = {
|
|
4803
|
+
createdAt: data.createdAt,
|
|
4804
|
+
lastUpdate: data.lastUpdate,
|
|
4805
|
+
stateKeys: Object.keys(data.state)
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
return { totalRooms: this.rooms.size, rooms };
|
|
4809
|
+
}
|
|
4810
|
+
// ===== IRoomPubSubAdapter =====
|
|
4811
|
+
// No-ops for single-instance: all events are already propagated locally
|
|
4812
|
+
// by RoomEventBus and LiveRoomManager's broadcastToRoom().
|
|
4813
|
+
async publish(_roomId, _event, _data) {
|
|
4814
|
+
}
|
|
4815
|
+
async subscribe(_roomId, _handler) {
|
|
4816
|
+
return () => {
|
|
4817
|
+
};
|
|
4818
|
+
}
|
|
4819
|
+
async publishMembership(_roomId, _action, _componentId) {
|
|
4820
|
+
}
|
|
4821
|
+
async publishStateChange(_roomId, _updates) {
|
|
4822
|
+
}
|
|
4823
|
+
};
|
|
4824
|
+
|
|
4825
|
+
export { ANONYMOUS_CONTEXT, AnonymousContext, AuthenticatedContext, BINARY_ROOM_EVENT, BINARY_ROOM_STATE, ComponentRegistry, ConnectionRateLimiter, DEFAULT_CHUNK_SIZE, DEFAULT_WS_PATH, EMIT_OVERRIDE_KEY, FileUploadManager, InMemoryRoomAdapter, LiveAuthManager, LiveComponent, LiveRoom, LiveRoomManager, LiveServer, PROTOCOL_VERSION, PerformanceMonitor, RateLimiterRegistry, RoomEventBus, RoomRegistry, RoomStateManager, StateSignatureManager, WebSocketConnectionManager, buildRoomFrame, buildRoomFrameTail, createTypedRoomEventBus, createTypedRoomState, decodeBinaryChunk, encodeBinaryChunk, getLiveComponentContext, jsonCodec, liveLog, liveWarn, msgpackCodec, parseRoomFrame, prependMemberHeader, queueWsMessage, registerComponentLogging, resolveCodec, sendBinaryImmediate, sendImmediate, setLiveComponentContext, unregisterComponentLogging };
|
|
3333
4826
|
//# sourceMappingURL=index.js.map
|
|
3334
4827
|
//# sourceMappingURL=index.js.map
|