@fluxstack/live 0.1.0 → 0.2.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 +298 -30
- package/dist/index.js +1355 -495
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
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,138 @@ 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 sendImmediate(ws, data) {
|
|
335
|
+
if (ws && ws.readyState === 1) {
|
|
336
|
+
ws.send(data);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
175
340
|
// src/debug/LiveLogger.ts
|
|
176
341
|
var componentConfigs = /* @__PURE__ */ new Map();
|
|
177
342
|
var globalConfigParsed = false;
|
|
@@ -244,19 +409,18 @@ function liveWarn(category, componentId, message, ...args) {
|
|
|
244
409
|
}
|
|
245
410
|
}
|
|
246
411
|
|
|
247
|
-
// src/protocol/constants.ts
|
|
248
|
-
var PROTOCOL_VERSION = 1;
|
|
249
|
-
var DEFAULT_WS_PATH = "/api/live/ws";
|
|
250
|
-
var DEFAULT_CHUNK_SIZE = 64 * 1024;
|
|
251
|
-
var DEFAULT_RATE_LIMIT_MAX_TOKENS = 100;
|
|
252
|
-
var DEFAULT_RATE_LIMIT_REFILL_RATE = 50;
|
|
253
|
-
var MAX_ROOM_STATE_SIZE = 10 * 1024 * 1024;
|
|
254
|
-
|
|
255
412
|
// src/rooms/LiveRoomManager.ts
|
|
256
413
|
var LiveRoomManager = class {
|
|
257
414
|
// componentId -> roomIds
|
|
258
|
-
|
|
415
|
+
/**
|
|
416
|
+
* @param roomEvents - Local server-side event bus
|
|
417
|
+
* @param pubsub - Optional cross-instance pub/sub adapter (e.g. Redis).
|
|
418
|
+
* When provided, room events/state/membership are propagated
|
|
419
|
+
* to other server instances in the background.
|
|
420
|
+
*/
|
|
421
|
+
constructor(roomEvents, pubsub) {
|
|
259
422
|
this.roomEvents = roomEvents;
|
|
423
|
+
this.pubsub = pubsub;
|
|
260
424
|
}
|
|
261
425
|
rooms = /* @__PURE__ */ new Map();
|
|
262
426
|
componentRooms = /* @__PURE__ */ new Map();
|
|
@@ -264,31 +428,36 @@ var LiveRoomManager = class {
|
|
|
264
428
|
* Component joins a room
|
|
265
429
|
*/
|
|
266
430
|
joinRoom(componentId, roomId, ws, initialState) {
|
|
267
|
-
if (!roomId ||
|
|
431
|
+
if (!roomId || !ROOM_NAME_REGEX.test(roomId)) {
|
|
268
432
|
throw new Error("Invalid room name. Must be 1-64 alphanumeric characters, hyphens, underscores, dots, or colons.");
|
|
269
433
|
}
|
|
270
|
-
|
|
271
|
-
|
|
434
|
+
const now = Date.now();
|
|
435
|
+
let room = this.rooms.get(roomId);
|
|
436
|
+
if (!room) {
|
|
437
|
+
room = {
|
|
272
438
|
id: roomId,
|
|
273
439
|
state: initialState || {},
|
|
274
440
|
members: /* @__PURE__ */ new Map(),
|
|
275
|
-
createdAt:
|
|
276
|
-
lastActivity:
|
|
277
|
-
}
|
|
441
|
+
createdAt: now,
|
|
442
|
+
lastActivity: now
|
|
443
|
+
};
|
|
444
|
+
this.rooms.set(roomId, room);
|
|
278
445
|
liveLog("rooms", componentId, `Room '${roomId}' created`);
|
|
279
446
|
}
|
|
280
|
-
const room = this.rooms.get(roomId);
|
|
281
447
|
room.members.set(componentId, {
|
|
282
448
|
componentId,
|
|
283
449
|
ws,
|
|
284
|
-
joinedAt:
|
|
450
|
+
joinedAt: now
|
|
285
451
|
});
|
|
286
|
-
room.lastActivity =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
452
|
+
room.lastActivity = now;
|
|
453
|
+
let compRooms = this.componentRooms.get(componentId);
|
|
454
|
+
if (!compRooms) {
|
|
455
|
+
compRooms = /* @__PURE__ */ new Set();
|
|
456
|
+
this.componentRooms.set(componentId, compRooms);
|
|
457
|
+
}
|
|
458
|
+
compRooms.add(roomId);
|
|
459
|
+
const memberCount = room.members.size;
|
|
460
|
+
liveLog("rooms", componentId, `Component '${componentId}' joined room '${roomId}' (${memberCount} members)`);
|
|
292
461
|
this.broadcastToRoom(roomId, {
|
|
293
462
|
type: "ROOM_SYSTEM",
|
|
294
463
|
componentId,
|
|
@@ -296,10 +465,12 @@ var LiveRoomManager = class {
|
|
|
296
465
|
event: "$sub:join",
|
|
297
466
|
data: {
|
|
298
467
|
subscriberId: componentId,
|
|
299
|
-
count:
|
|
468
|
+
count: memberCount
|
|
300
469
|
},
|
|
301
|
-
timestamp:
|
|
470
|
+
timestamp: now
|
|
302
471
|
}, componentId);
|
|
472
|
+
this.pubsub?.publishMembership(roomId, "join", componentId)?.catch(() => {
|
|
473
|
+
});
|
|
303
474
|
return { state: room.state };
|
|
304
475
|
}
|
|
305
476
|
/**
|
|
@@ -309,9 +480,11 @@ var LiveRoomManager = class {
|
|
|
309
480
|
const room = this.rooms.get(roomId);
|
|
310
481
|
if (!room) return;
|
|
311
482
|
room.members.delete(componentId);
|
|
312
|
-
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
room.lastActivity = now;
|
|
313
485
|
this.componentRooms.get(componentId)?.delete(roomId);
|
|
314
|
-
|
|
486
|
+
const memberCount = room.members.size;
|
|
487
|
+
liveLog("rooms", componentId, `Component '${componentId}' left room '${roomId}' (${memberCount} members)`);
|
|
315
488
|
this.broadcastToRoom(roomId, {
|
|
316
489
|
type: "ROOM_SYSTEM",
|
|
317
490
|
componentId,
|
|
@@ -319,11 +492,13 @@ var LiveRoomManager = class {
|
|
|
319
492
|
event: "$sub:leave",
|
|
320
493
|
data: {
|
|
321
494
|
subscriberId: componentId,
|
|
322
|
-
count:
|
|
495
|
+
count: memberCount
|
|
323
496
|
},
|
|
324
|
-
timestamp:
|
|
497
|
+
timestamp: now
|
|
325
498
|
});
|
|
326
|
-
|
|
499
|
+
this.pubsub?.publishMembership(roomId, "leave", componentId)?.catch(() => {
|
|
500
|
+
});
|
|
501
|
+
if (memberCount === 0) {
|
|
327
502
|
setTimeout(() => {
|
|
328
503
|
const currentRoom = this.rooms.get(roomId);
|
|
329
504
|
if (currentRoom && currentRoom.members.size === 0) {
|
|
@@ -334,13 +509,44 @@ var LiveRoomManager = class {
|
|
|
334
509
|
}
|
|
335
510
|
}
|
|
336
511
|
/**
|
|
337
|
-
* Component disconnects - leave all rooms
|
|
512
|
+
* Component disconnects - leave all rooms.
|
|
513
|
+
* Batches removals: removes member from all rooms first,
|
|
514
|
+
* then sends leave notifications in bulk.
|
|
338
515
|
*/
|
|
339
516
|
cleanupComponent(componentId) {
|
|
340
|
-
const
|
|
341
|
-
if (!
|
|
342
|
-
|
|
343
|
-
|
|
517
|
+
const roomIds = this.componentRooms.get(componentId);
|
|
518
|
+
if (!roomIds || roomIds.size === 0) return;
|
|
519
|
+
const now = Date.now();
|
|
520
|
+
const notifications = [];
|
|
521
|
+
for (const roomId of roomIds) {
|
|
522
|
+
const room = this.rooms.get(roomId);
|
|
523
|
+
if (!room) continue;
|
|
524
|
+
room.members.delete(componentId);
|
|
525
|
+
room.lastActivity = now;
|
|
526
|
+
const memberCount = room.members.size;
|
|
527
|
+
if (memberCount > 0) {
|
|
528
|
+
notifications.push({ roomId, count: memberCount });
|
|
529
|
+
} else {
|
|
530
|
+
setTimeout(() => {
|
|
531
|
+
const currentRoom = this.rooms.get(roomId);
|
|
532
|
+
if (currentRoom && currentRoom.members.size === 0) {
|
|
533
|
+
this.rooms.delete(roomId);
|
|
534
|
+
}
|
|
535
|
+
}, 5 * 60 * 1e3);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const { roomId, count } of notifications) {
|
|
539
|
+
this.broadcastToRoom(roomId, {
|
|
540
|
+
type: "ROOM_SYSTEM",
|
|
541
|
+
componentId,
|
|
542
|
+
roomId,
|
|
543
|
+
event: "$sub:leave",
|
|
544
|
+
data: {
|
|
545
|
+
subscriberId: componentId,
|
|
546
|
+
count
|
|
547
|
+
},
|
|
548
|
+
timestamp: now
|
|
549
|
+
});
|
|
344
550
|
}
|
|
345
551
|
this.componentRooms.delete(componentId);
|
|
346
552
|
}
|
|
@@ -350,37 +556,56 @@ var LiveRoomManager = class {
|
|
|
350
556
|
emitToRoom(roomId, event, data, excludeComponentId) {
|
|
351
557
|
const room = this.rooms.get(roomId);
|
|
352
558
|
if (!room) return 0;
|
|
353
|
-
|
|
559
|
+
const now = Date.now();
|
|
560
|
+
room.lastActivity = now;
|
|
354
561
|
this.roomEvents.emit("room", roomId, event, data, excludeComponentId);
|
|
562
|
+
this.pubsub?.publish(roomId, event, data)?.catch(() => {
|
|
563
|
+
});
|
|
355
564
|
return this.broadcastToRoom(roomId, {
|
|
356
565
|
type: "ROOM_EVENT",
|
|
357
566
|
componentId: "",
|
|
358
567
|
roomId,
|
|
359
568
|
event,
|
|
360
569
|
data,
|
|
361
|
-
timestamp:
|
|
570
|
+
timestamp: now
|
|
362
571
|
}, excludeComponentId);
|
|
363
572
|
}
|
|
364
573
|
/**
|
|
365
|
-
* Update room state
|
|
574
|
+
* Update room state.
|
|
575
|
+
* Mutates state in-place with Object.assign to avoid full-object spread.
|
|
366
576
|
*/
|
|
367
577
|
setRoomState(roomId, updates, excludeComponentId) {
|
|
368
578
|
const room = this.rooms.get(roomId);
|
|
369
579
|
if (!room) return;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
580
|
+
Object.assign(room.state, updates);
|
|
581
|
+
if (room.stateSize === void 0) {
|
|
582
|
+
const fullJson = JSON.stringify(room.state);
|
|
583
|
+
room.stateSize = fullJson.length;
|
|
584
|
+
if (room.stateSize > MAX_ROOM_STATE_SIZE) {
|
|
585
|
+
throw new Error("Room state exceeds maximum size limit");
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
const deltaSize = JSON.stringify(updates).length;
|
|
589
|
+
room.stateSize += deltaSize;
|
|
590
|
+
if (room.stateSize > MAX_ROOM_STATE_SIZE) {
|
|
591
|
+
const precise = JSON.stringify(room.state).length;
|
|
592
|
+
room.stateSize = precise;
|
|
593
|
+
if (precise > MAX_ROOM_STATE_SIZE) {
|
|
594
|
+
throw new Error("Room state exceeds maximum size limit");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
374
597
|
}
|
|
375
|
-
|
|
376
|
-
room.lastActivity =
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
room.lastActivity = now;
|
|
600
|
+
this.pubsub?.publishStateChange(roomId, updates)?.catch(() => {
|
|
601
|
+
});
|
|
377
602
|
this.broadcastToRoom(roomId, {
|
|
378
603
|
type: "ROOM_STATE",
|
|
379
604
|
componentId: "",
|
|
380
605
|
roomId,
|
|
381
606
|
event: "$state:update",
|
|
382
607
|
data: { state: updates },
|
|
383
|
-
timestamp:
|
|
608
|
+
timestamp: now
|
|
384
609
|
}, excludeComponentId);
|
|
385
610
|
}
|
|
386
611
|
/**
|
|
@@ -390,24 +615,28 @@ var LiveRoomManager = class {
|
|
|
390
615
|
return this.rooms.get(roomId)?.state || {};
|
|
391
616
|
}
|
|
392
617
|
/**
|
|
393
|
-
* Broadcast to all members in a room
|
|
618
|
+
* Broadcast to all members in a room.
|
|
619
|
+
* Serializes the message ONCE and sends the same string to all members.
|
|
394
620
|
*/
|
|
395
621
|
broadcastToRoom(roomId, message, excludeComponentId) {
|
|
396
622
|
const room = this.rooms.get(roomId);
|
|
397
|
-
if (!room) return 0;
|
|
623
|
+
if (!room || room.members.size === 0) return 0;
|
|
624
|
+
const serialized = JSON.stringify(message);
|
|
398
625
|
let sent = 0;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (member.ws
|
|
403
|
-
member.ws
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
626
|
+
if (excludeComponentId) {
|
|
627
|
+
for (const [componentId, member] of room.members) {
|
|
628
|
+
if (componentId === excludeComponentId) continue;
|
|
629
|
+
if (member.ws.readyState === 1) {
|
|
630
|
+
queuePreSerialized(member.ws, serialized);
|
|
631
|
+
sent++;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
for (const member of room.members.values()) {
|
|
636
|
+
if (member.ws.readyState === 1) {
|
|
637
|
+
queuePreSerialized(member.ws, serialized);
|
|
407
638
|
sent++;
|
|
408
639
|
}
|
|
409
|
-
} catch (error) {
|
|
410
|
-
console.error(`Failed to send to ${componentId}:`, error);
|
|
411
640
|
}
|
|
412
641
|
}
|
|
413
642
|
return sent;
|
|
@@ -853,15 +1082,21 @@ var LiveAuthManager = class {
|
|
|
853
1082
|
providersToTry.push(provider);
|
|
854
1083
|
}
|
|
855
1084
|
}
|
|
1085
|
+
const errors = [];
|
|
856
1086
|
for (const provider of providersToTry) {
|
|
857
1087
|
try {
|
|
858
1088
|
const context = await provider.authenticate(credentials);
|
|
859
1089
|
if (context && context.authenticated) {
|
|
860
1090
|
return context;
|
|
861
1091
|
}
|
|
862
|
-
} catch {
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
console.warn(`[Auth] Provider '${provider.name}' threw during authentication:`, error.message);
|
|
1094
|
+
errors.push({ provider: provider.name, error });
|
|
863
1095
|
}
|
|
864
1096
|
}
|
|
1097
|
+
if (errors.length > 0) {
|
|
1098
|
+
console.warn(`[Auth] All ${providersToTry.length} provider(s) failed. Errors: ${errors.map((e) => `${e.provider}: ${e.error.message}`).join("; ")}`);
|
|
1099
|
+
}
|
|
865
1100
|
return ANONYMOUS_CONTEXT;
|
|
866
1101
|
}
|
|
867
1102
|
/**
|
|
@@ -959,10 +1194,13 @@ var StateSignatureManager = class {
|
|
|
959
1194
|
secret;
|
|
960
1195
|
previousSecrets = [];
|
|
961
1196
|
rotationTimer;
|
|
962
|
-
usedNonces = /* @__PURE__ */ new Set();
|
|
963
|
-
nonceCleanupTimer;
|
|
964
1197
|
stateBackups = /* @__PURE__ */ new Map();
|
|
965
1198
|
config;
|
|
1199
|
+
encryptionSalt;
|
|
1200
|
+
cachedEncryptionKey = null;
|
|
1201
|
+
/** Replay detection: nonce → timestamp when it was first seen. Cleaned every 60s. */
|
|
1202
|
+
usedNonces = /* @__PURE__ */ new Map();
|
|
1203
|
+
nonceCleanupTimer;
|
|
966
1204
|
constructor(config = {}) {
|
|
967
1205
|
const defaultSecret = typeof process !== "undefined" ? process.env?.LIVE_STATE_SECRET : void 0;
|
|
968
1206
|
this.config = {
|
|
@@ -972,23 +1210,65 @@ var StateSignatureManager = class {
|
|
|
972
1210
|
compressionEnabled: config.compressionEnabled ?? true,
|
|
973
1211
|
encryptionEnabled: config.encryptionEnabled ?? false,
|
|
974
1212
|
nonceEnabled: config.nonceEnabled ?? false,
|
|
975
|
-
maxStateAge: config.maxStateAge ??
|
|
1213
|
+
maxStateAge: config.maxStateAge ?? 30 * 60 * 1e3,
|
|
976
1214
|
backupEnabled: config.backupEnabled ?? true,
|
|
977
|
-
maxBackups: config.maxBackups ?? 3
|
|
1215
|
+
maxBackups: config.maxBackups ?? 3,
|
|
1216
|
+
nonceTTL: config.nonceTTL ?? 5 * 60 * 1e3
|
|
978
1217
|
};
|
|
979
1218
|
if (!this.config.secret) {
|
|
980
1219
|
this.config.secret = randomBytes(32).toString("hex");
|
|
981
1220
|
liveWarn("state", null, "No LIVE_STATE_SECRET provided. Using random key (state will not persist across restarts).");
|
|
982
1221
|
}
|
|
983
1222
|
this.secret = Buffer.from(this.config.secret, "utf-8");
|
|
1223
|
+
this.encryptionSalt = randomBytes(16);
|
|
984
1224
|
if (this.config.rotationEnabled) {
|
|
985
1225
|
this.setupKeyRotation();
|
|
986
1226
|
}
|
|
987
1227
|
if (this.config.nonceEnabled) {
|
|
988
|
-
this.nonceCleanupTimer = setInterval(() => this.cleanupNonces(),
|
|
1228
|
+
this.nonceCleanupTimer = setInterval(() => this.cleanupNonces(), this.config.nonceTTL + 10 * 1e3);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Generate a hybrid nonce: `timestamp:random:HMAC(timestamp:random, secret)`
|
|
1233
|
+
* Self-validating via HMAC, unique via random bytes, replay-tracked via Map.
|
|
1234
|
+
*/
|
|
1235
|
+
generateNonce() {
|
|
1236
|
+
const ts = Date.now().toString();
|
|
1237
|
+
const rand = randomBytes(8).toString("hex");
|
|
1238
|
+
const payload = `${ts}:${rand}`;
|
|
1239
|
+
const mac = createHmac("sha256", this.secret).update(payload).digest("hex").slice(0, 16);
|
|
1240
|
+
return `${ts}:${rand}:${mac}`;
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Validate a hybrid nonce: check format, HMAC, and TTL.
|
|
1244
|
+
*/
|
|
1245
|
+
validateNonce(nonce) {
|
|
1246
|
+
const parts = nonce.split(":");
|
|
1247
|
+
if (parts.length !== 3) return { valid: false, error: "Malformed nonce" };
|
|
1248
|
+
const [ts, rand, mac] = parts;
|
|
1249
|
+
const timestamp = Number(ts);
|
|
1250
|
+
if (isNaN(timestamp)) return { valid: false, error: "Malformed nonce timestamp" };
|
|
1251
|
+
const age = Date.now() - timestamp;
|
|
1252
|
+
if (age > this.config.nonceTTL) {
|
|
1253
|
+
return { valid: false, error: "Nonce expired" };
|
|
1254
|
+
}
|
|
1255
|
+
if (age < -3e4) {
|
|
1256
|
+
return { valid: false, error: "Nonce timestamp in the future" };
|
|
1257
|
+
}
|
|
1258
|
+
const payload = `${ts}:${rand}`;
|
|
1259
|
+
const expectedMac = createHmac("sha256", this.secret).update(payload).digest("hex").slice(0, 16);
|
|
1260
|
+
if (this.timingSafeEqual(mac, expectedMac)) {
|
|
1261
|
+
return { valid: true };
|
|
1262
|
+
}
|
|
1263
|
+
for (const prevSecret of this.previousSecrets) {
|
|
1264
|
+
const prevMac = createHmac("sha256", prevSecret).update(payload).digest("hex").slice(0, 16);
|
|
1265
|
+
if (this.timingSafeEqual(mac, prevMac)) {
|
|
1266
|
+
return { valid: true };
|
|
1267
|
+
}
|
|
989
1268
|
}
|
|
1269
|
+
return { valid: false, error: "Invalid nonce signature" };
|
|
990
1270
|
}
|
|
991
|
-
|
|
1271
|
+
signState(componentId, state, version, options) {
|
|
992
1272
|
let dataStr = JSON.stringify(state);
|
|
993
1273
|
let compressed = false;
|
|
994
1274
|
let encrypted = false;
|
|
@@ -1009,7 +1289,7 @@ var StateSignatureManager = class {
|
|
|
1009
1289
|
dataStr = iv.toString("base64") + ":" + encryptedData;
|
|
1010
1290
|
encrypted = true;
|
|
1011
1291
|
}
|
|
1012
|
-
const nonce = this.config.nonceEnabled ?
|
|
1292
|
+
const nonce = this.config.nonceEnabled ? this.generateNonce() : void 0;
|
|
1013
1293
|
const signedState = {
|
|
1014
1294
|
data: dataStr,
|
|
1015
1295
|
signature: "",
|
|
@@ -1026,26 +1306,34 @@ var StateSignatureManager = class {
|
|
|
1026
1306
|
}
|
|
1027
1307
|
return signedState;
|
|
1028
1308
|
}
|
|
1029
|
-
|
|
1309
|
+
validateState(signedState, options) {
|
|
1030
1310
|
try {
|
|
1031
1311
|
const age = Date.now() - signedState.timestamp;
|
|
1032
1312
|
if (age > this.config.maxStateAge) {
|
|
1033
1313
|
return { valid: false, error: "State expired" };
|
|
1034
1314
|
}
|
|
1035
|
-
if (signedState.nonce && this.config.nonceEnabled) {
|
|
1315
|
+
if (signedState.nonce && this.config.nonceEnabled && !options?.skipNonce) {
|
|
1316
|
+
const nonceResult = this.validateNonce(signedState.nonce);
|
|
1317
|
+
if (!nonceResult.valid) {
|
|
1318
|
+
return { valid: false, error: nonceResult.error };
|
|
1319
|
+
}
|
|
1036
1320
|
if (this.usedNonces.has(signedState.nonce)) {
|
|
1037
|
-
return { valid: false, error: "Nonce already used
|
|
1321
|
+
return { valid: false, error: "Nonce already used" };
|
|
1038
1322
|
}
|
|
1039
1323
|
}
|
|
1040
1324
|
const expectedSig = this.computeSignature(signedState);
|
|
1041
1325
|
if (this.timingSafeEqual(signedState.signature, expectedSig)) {
|
|
1042
|
-
if (signedState.nonce
|
|
1326
|
+
if (signedState.nonce && this.config.nonceEnabled) {
|
|
1327
|
+
this.usedNonces.set(signedState.nonce, Date.now());
|
|
1328
|
+
}
|
|
1043
1329
|
return { valid: true };
|
|
1044
1330
|
}
|
|
1045
1331
|
for (const prevSecret of this.previousSecrets) {
|
|
1046
1332
|
const prevSig = this.computeSignatureWithKey(signedState, prevSecret);
|
|
1047
1333
|
if (this.timingSafeEqual(signedState.signature, prevSig)) {
|
|
1048
|
-
if (signedState.nonce
|
|
1334
|
+
if (signedState.nonce && this.config.nonceEnabled) {
|
|
1335
|
+
this.usedNonces.set(signedState.nonce, Date.now());
|
|
1336
|
+
}
|
|
1049
1337
|
return { valid: true };
|
|
1050
1338
|
}
|
|
1051
1339
|
}
|
|
@@ -1054,7 +1342,7 @@ var StateSignatureManager = class {
|
|
|
1054
1342
|
return { valid: false, error: error.message };
|
|
1055
1343
|
}
|
|
1056
1344
|
}
|
|
1057
|
-
|
|
1345
|
+
extractData(signedState) {
|
|
1058
1346
|
let dataStr = signedState.data;
|
|
1059
1347
|
if (signedState.encrypted) {
|
|
1060
1348
|
const [ivB64, encryptedData] = dataStr.split(":");
|
|
@@ -1107,7 +1395,9 @@ var StateSignatureManager = class {
|
|
|
1107
1395
|
}
|
|
1108
1396
|
}
|
|
1109
1397
|
deriveEncryptionKey() {
|
|
1110
|
-
|
|
1398
|
+
if (this.cachedEncryptionKey) return this.cachedEncryptionKey;
|
|
1399
|
+
this.cachedEncryptionKey = scryptSync(this.secret, this.encryptionSalt, 32);
|
|
1400
|
+
return this.cachedEncryptionKey;
|
|
1111
1401
|
}
|
|
1112
1402
|
setupKeyRotation() {
|
|
1113
1403
|
this.rotationTimer = setInterval(() => {
|
|
@@ -1116,12 +1406,15 @@ var StateSignatureManager = class {
|
|
|
1116
1406
|
this.previousSecrets.pop();
|
|
1117
1407
|
}
|
|
1118
1408
|
this.secret = randomBytes(32);
|
|
1409
|
+
this.cachedEncryptionKey = null;
|
|
1119
1410
|
liveLog("state", null, "Key rotation completed");
|
|
1120
1411
|
}, this.config.rotationInterval);
|
|
1121
1412
|
}
|
|
1413
|
+
/** Remove nonces older than nonceTTL + 10s from the replay detection map. */
|
|
1122
1414
|
cleanupNonces() {
|
|
1123
|
-
|
|
1124
|
-
|
|
1415
|
+
const cutoff = Date.now() - (this.config.nonceTTL + 10 * 1e3);
|
|
1416
|
+
for (const [nonce, ts] of this.usedNonces) {
|
|
1417
|
+
if (ts < cutoff) this.usedNonces.delete(nonce);
|
|
1125
1418
|
}
|
|
1126
1419
|
}
|
|
1127
1420
|
shutdown() {
|
|
@@ -1542,6 +1835,8 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1542
1835
|
connections = /* @__PURE__ */ new Map();
|
|
1543
1836
|
connectionMetrics = /* @__PURE__ */ new Map();
|
|
1544
1837
|
connectionPools = /* @__PURE__ */ new Map();
|
|
1838
|
+
/** Reverse index: connectionId -> Set of poolIds for O(1) cleanup */
|
|
1839
|
+
connectionPoolIndex = /* @__PURE__ */ new Map();
|
|
1545
1840
|
messageQueues = /* @__PURE__ */ new Map();
|
|
1546
1841
|
healthCheckTimer;
|
|
1547
1842
|
heartbeatTimer;
|
|
@@ -1596,6 +1891,10 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1596
1891
|
this.connectionPools.set(poolId, /* @__PURE__ */ new Set());
|
|
1597
1892
|
}
|
|
1598
1893
|
this.connectionPools.get(poolId).add(connectionId);
|
|
1894
|
+
if (!this.connectionPoolIndex.has(connectionId)) {
|
|
1895
|
+
this.connectionPoolIndex.set(connectionId, /* @__PURE__ */ new Set());
|
|
1896
|
+
}
|
|
1897
|
+
this.connectionPoolIndex.get(connectionId).add(poolId);
|
|
1599
1898
|
}
|
|
1600
1899
|
removeFromPool(connectionId, poolId) {
|
|
1601
1900
|
const pool = this.connectionPools.get(poolId);
|
|
@@ -1603,15 +1902,22 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1603
1902
|
pool.delete(connectionId);
|
|
1604
1903
|
if (pool.size === 0) this.connectionPools.delete(poolId);
|
|
1605
1904
|
}
|
|
1905
|
+
this.connectionPoolIndex.get(connectionId)?.delete(poolId);
|
|
1606
1906
|
}
|
|
1607
1907
|
cleanupConnection(connectionId) {
|
|
1608
1908
|
this.connections.delete(connectionId);
|
|
1609
1909
|
this.connectionMetrics.delete(connectionId);
|
|
1610
1910
|
this.messageQueues.delete(connectionId);
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1911
|
+
const poolIds = this.connectionPoolIndex.get(connectionId);
|
|
1912
|
+
if (poolIds) {
|
|
1913
|
+
for (const poolId of poolIds) {
|
|
1914
|
+
const pool = this.connectionPools.get(poolId);
|
|
1915
|
+
if (pool) {
|
|
1916
|
+
pool.delete(connectionId);
|
|
1917
|
+
if (pool.size === 0) this.connectionPools.delete(poolId);
|
|
1918
|
+
}
|
|
1614
1919
|
}
|
|
1920
|
+
this.connectionPoolIndex.delete(connectionId);
|
|
1615
1921
|
}
|
|
1616
1922
|
}
|
|
1617
1923
|
getConnectionMetrics(connectionId) {
|
|
@@ -1693,6 +1999,7 @@ var WebSocketConnectionManager = class extends EventEmitter {
|
|
|
1693
1999
|
this.connections.clear();
|
|
1694
2000
|
this.connectionMetrics.clear();
|
|
1695
2001
|
this.connectionPools.clear();
|
|
2002
|
+
this.connectionPoolIndex.clear();
|
|
1696
2003
|
this.messageQueues.clear();
|
|
1697
2004
|
}
|
|
1698
2005
|
};
|
|
@@ -1707,116 +2014,37 @@ function getLiveComponentContext() {
|
|
|
1707
2014
|
return _ctx;
|
|
1708
2015
|
}
|
|
1709
2016
|
|
|
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;
|
|
2017
|
+
// src/component/managers/ComponentStateManager.ts
|
|
2018
|
+
var _forbiddenSetCache = /* @__PURE__ */ new WeakMap();
|
|
2019
|
+
var ComponentStateManager = class {
|
|
1747
2020
|
_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
|
|
2021
|
+
_proxyState;
|
|
1767
2022
|
_inStateChange = false;
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
this.
|
|
1776
|
-
this.
|
|
1777
|
-
this.
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
"ws",
|
|
1793
|
-
"id",
|
|
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
|
-
}
|
|
2023
|
+
_idBytes = null;
|
|
2024
|
+
componentId;
|
|
2025
|
+
ws;
|
|
2026
|
+
emitFn;
|
|
2027
|
+
onStateChangeFn;
|
|
2028
|
+
_debugger;
|
|
2029
|
+
constructor(opts) {
|
|
2030
|
+
this.componentId = opts.componentId;
|
|
2031
|
+
this.ws = opts.ws;
|
|
2032
|
+
this.emitFn = opts.emitFn;
|
|
2033
|
+
this.onStateChangeFn = opts.onStateChangeFn;
|
|
2034
|
+
this._debugger = opts.debugger ?? null;
|
|
2035
|
+
this._state = opts.initialState;
|
|
2036
|
+
this._proxyState = this.createStateProxy(this._state);
|
|
2037
|
+
}
|
|
2038
|
+
get rawState() {
|
|
2039
|
+
return this._state;
|
|
2040
|
+
}
|
|
2041
|
+
get proxyState() {
|
|
2042
|
+
return this._proxyState;
|
|
2043
|
+
}
|
|
2044
|
+
/** Guard flag — prevents infinite recursion in onStateChange */
|
|
2045
|
+
get inStateChange() {
|
|
2046
|
+
return this._inStateChange;
|
|
1818
2047
|
}
|
|
1819
|
-
// Create a Proxy that auto-emits STATE_DELTA on any mutation
|
|
1820
2048
|
createStateProxy(state) {
|
|
1821
2049
|
const self = this;
|
|
1822
2050
|
return new Proxy(state, {
|
|
@@ -1825,19 +2053,19 @@ var LiveComponent = class _LiveComponent {
|
|
|
1825
2053
|
if (oldValue !== value) {
|
|
1826
2054
|
target[prop] = value;
|
|
1827
2055
|
const changes = { [prop]: value };
|
|
1828
|
-
self.
|
|
2056
|
+
self.emitFn("STATE_DELTA", { delta: changes });
|
|
1829
2057
|
if (!self._inStateChange) {
|
|
1830
2058
|
self._inStateChange = true;
|
|
1831
2059
|
try {
|
|
1832
|
-
self.
|
|
2060
|
+
self.onStateChangeFn(changes);
|
|
1833
2061
|
} catch (err) {
|
|
1834
|
-
console.error(`[${self.
|
|
2062
|
+
console.error(`[${self.componentId}] onStateChange error:`, err?.message || err);
|
|
1835
2063
|
} finally {
|
|
1836
2064
|
self._inStateChange = false;
|
|
1837
2065
|
}
|
|
1838
2066
|
}
|
|
1839
|
-
|
|
1840
|
-
self.
|
|
2067
|
+
self._debugger?.trackStateChange(
|
|
2068
|
+
self.componentId,
|
|
1841
2069
|
changes,
|
|
1842
2070
|
target,
|
|
1843
2071
|
"proxy"
|
|
@@ -1850,65 +2078,368 @@ var LiveComponent = class _LiveComponent {
|
|
|
1850
2078
|
}
|
|
1851
2079
|
});
|
|
1852
2080
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2081
|
+
setState(updates) {
|
|
2082
|
+
const newUpdates = typeof updates === "function" ? updates(this._state) : updates;
|
|
2083
|
+
const actualChanges = {};
|
|
2084
|
+
let hasChanges = false;
|
|
2085
|
+
for (const key of Object.keys(newUpdates)) {
|
|
2086
|
+
if (this._state[key] !== newUpdates[key]) {
|
|
2087
|
+
actualChanges[key] = newUpdates[key];
|
|
2088
|
+
hasChanges = true;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
if (!hasChanges) return;
|
|
2092
|
+
Object.assign(this._state, actualChanges);
|
|
2093
|
+
this.emitFn("STATE_DELTA", { delta: actualChanges });
|
|
2094
|
+
if (!this._inStateChange) {
|
|
2095
|
+
this._inStateChange = true;
|
|
2096
|
+
try {
|
|
2097
|
+
this.onStateChangeFn(actualChanges);
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
console.error(`[${this.componentId}] onStateChange error:`, err?.message || err);
|
|
2100
|
+
} finally {
|
|
2101
|
+
this._inStateChange = false;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
this._debugger?.trackStateChange(
|
|
2105
|
+
this.componentId,
|
|
2106
|
+
actualChanges,
|
|
2107
|
+
this._state,
|
|
2108
|
+
"setState"
|
|
2109
|
+
);
|
|
1858
2110
|
}
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
if (this.roomHandles.has(roomId)) {
|
|
1867
|
-
return this.roomHandles.get(roomId);
|
|
2111
|
+
sendBinaryDelta(delta, encoder) {
|
|
2112
|
+
const actualChanges = {};
|
|
2113
|
+
let hasChanges = false;
|
|
2114
|
+
for (const key of Object.keys(delta)) {
|
|
2115
|
+
if (this._state[key] !== delta[key]) {
|
|
2116
|
+
actualChanges[key] = delta[key];
|
|
2117
|
+
hasChanges = true;
|
|
1868
2118
|
}
|
|
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
|
-
|
|
2119
|
+
}
|
|
2120
|
+
if (!hasChanges) return;
|
|
2121
|
+
Object.assign(this._state, actualChanges);
|
|
2122
|
+
const payload = encoder(actualChanges);
|
|
2123
|
+
if (!this._idBytes) {
|
|
2124
|
+
this._idBytes = new TextEncoder().encode(this.componentId);
|
|
2125
|
+
}
|
|
2126
|
+
const idBytes = this._idBytes;
|
|
2127
|
+
const frame = new Uint8Array(1 + 1 + idBytes.length + payload.length);
|
|
2128
|
+
frame[0] = 1;
|
|
2129
|
+
frame[1] = idBytes.length;
|
|
2130
|
+
frame.set(idBytes, 2);
|
|
2131
|
+
frame.set(payload, 2 + idBytes.length);
|
|
2132
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
2133
|
+
this.ws.send(frame);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
setValue(payload) {
|
|
2137
|
+
const { key, value } = payload;
|
|
2138
|
+
const update = { [key]: value };
|
|
2139
|
+
this.setState(update);
|
|
2140
|
+
return { success: true, key, value };
|
|
2141
|
+
}
|
|
2142
|
+
getSerializableState() {
|
|
2143
|
+
return this._proxyState;
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Create getters/setters for each state property directly on `target`.
|
|
2147
|
+
* This allows `this.count` instead of `this.state.count` in subclasses.
|
|
2148
|
+
*/
|
|
2149
|
+
applyDirectAccessors(target, constructorFn) {
|
|
2150
|
+
let forbidden = _forbiddenSetCache.get(constructorFn);
|
|
2151
|
+
if (!forbidden) {
|
|
2152
|
+
forbidden = /* @__PURE__ */ new Set([
|
|
2153
|
+
...Object.keys(target),
|
|
2154
|
+
...Object.getOwnPropertyNames(Object.getPrototypeOf(target)),
|
|
2155
|
+
"state",
|
|
2156
|
+
"_state",
|
|
2157
|
+
"ws",
|
|
2158
|
+
"id",
|
|
2159
|
+
"room",
|
|
2160
|
+
"userId",
|
|
2161
|
+
"broadcastToRoom",
|
|
2162
|
+
"$private",
|
|
2163
|
+
"_privateState",
|
|
2164
|
+
"$room",
|
|
2165
|
+
"$rooms",
|
|
2166
|
+
"roomType",
|
|
2167
|
+
"roomHandles",
|
|
2168
|
+
"joinedRooms",
|
|
2169
|
+
"roomEventUnsubscribers",
|
|
2170
|
+
// Internal manager fields
|
|
2171
|
+
"_stateManager",
|
|
2172
|
+
"_roomProxyManager",
|
|
2173
|
+
"_actionSecurity",
|
|
2174
|
+
"_messaging"
|
|
2175
|
+
]);
|
|
2176
|
+
_forbiddenSetCache.set(constructorFn, forbidden);
|
|
2177
|
+
}
|
|
2178
|
+
for (const key of Object.keys(this._state)) {
|
|
2179
|
+
if (!forbidden.has(key)) {
|
|
2180
|
+
Object.defineProperty(target, key, {
|
|
2181
|
+
get: () => this._state[key],
|
|
2182
|
+
set: (value) => {
|
|
2183
|
+
this._proxyState[key] = value;
|
|
2184
|
+
},
|
|
2185
|
+
enumerable: true,
|
|
2186
|
+
configurable: true
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
/** Release cached resources */
|
|
2192
|
+
cleanup() {
|
|
2193
|
+
this._idBytes = null;
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
// src/component/managers/ComponentMessaging.ts
|
|
2198
|
+
var EMIT_OVERRIDE_KEY = /* @__PURE__ */ Symbol.for("fluxstack:emitOverride");
|
|
2199
|
+
var ComponentMessaging = class {
|
|
2200
|
+
constructor(ctx) {
|
|
2201
|
+
this.ctx = ctx;
|
|
2202
|
+
}
|
|
2203
|
+
emit(type, payload) {
|
|
2204
|
+
const override = this.ctx.getEmitOverride();
|
|
2205
|
+
if (override) {
|
|
2206
|
+
override(type, payload);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
const message = {
|
|
2210
|
+
type,
|
|
2211
|
+
componentId: this.ctx.componentId,
|
|
2212
|
+
payload,
|
|
2213
|
+
timestamp: Date.now(),
|
|
2214
|
+
userId: this.ctx.getUserId(),
|
|
2215
|
+
room: this.ctx.getRoom()
|
|
2216
|
+
};
|
|
2217
|
+
if (this.ctx.ws) {
|
|
2218
|
+
queueWsMessage(this.ctx.ws, message);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
broadcast(type, payload, excludeCurrentUser = false) {
|
|
2222
|
+
const room = this.ctx.getRoom();
|
|
2223
|
+
if (!room) {
|
|
2224
|
+
liveWarn("rooms", this.ctx.componentId, `[${this.ctx.componentId}] Cannot broadcast '${type}' - no room set`);
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
const message = {
|
|
2228
|
+
type,
|
|
2229
|
+
payload,
|
|
2230
|
+
room,
|
|
2231
|
+
excludeUser: excludeCurrentUser ? this.ctx.getUserId() : void 0
|
|
2232
|
+
};
|
|
2233
|
+
liveLog("rooms", this.ctx.componentId, `[${this.ctx.componentId}] Broadcasting '${type}' to room '${room}'`);
|
|
2234
|
+
this.ctx.getBroadcastToRoom()(message);
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
|
|
2238
|
+
// src/component/managers/ActionSecurityManager.ts
|
|
2239
|
+
var BLOCKED_ACTIONS = /* @__PURE__ */ new Set([
|
|
2240
|
+
"constructor",
|
|
2241
|
+
"destroy",
|
|
2242
|
+
"executeAction",
|
|
2243
|
+
"getSerializableState",
|
|
2244
|
+
"onMount",
|
|
2245
|
+
"onDestroy",
|
|
2246
|
+
"onConnect",
|
|
2247
|
+
"onDisconnect",
|
|
2248
|
+
"onStateChange",
|
|
2249
|
+
"onRoomJoin",
|
|
2250
|
+
"onRoomLeave",
|
|
2251
|
+
"onRehydrate",
|
|
2252
|
+
"onAction",
|
|
2253
|
+
"onClientJoin",
|
|
2254
|
+
"onClientLeave",
|
|
2255
|
+
"setState",
|
|
2256
|
+
"sendBinaryDelta",
|
|
2257
|
+
"emit",
|
|
2258
|
+
"broadcast",
|
|
2259
|
+
"broadcastToRoom",
|
|
2260
|
+
"createStateProxy",
|
|
2261
|
+
"createDirectStateAccessors",
|
|
2262
|
+
"generateId",
|
|
2263
|
+
"setAuthContext",
|
|
2264
|
+
"_resetAuthContext",
|
|
2265
|
+
"$auth",
|
|
2266
|
+
"$private",
|
|
2267
|
+
"_privateState",
|
|
2268
|
+
"$persistent",
|
|
2269
|
+
"_inStateChange",
|
|
2270
|
+
"$room",
|
|
2271
|
+
"$rooms",
|
|
2272
|
+
"subscribeToRoom",
|
|
2273
|
+
"unsubscribeFromRoom",
|
|
2274
|
+
"emitRoomEvent",
|
|
2275
|
+
"onRoomEvent",
|
|
2276
|
+
"emitRoomEventWithState"
|
|
2277
|
+
]);
|
|
2278
|
+
var ActionSecurityManager = class {
|
|
2279
|
+
_actionCalls = /* @__PURE__ */ new Map();
|
|
2280
|
+
async validateAndExecute(action, payload, ctx) {
|
|
2281
|
+
const actionStart = Date.now();
|
|
2282
|
+
const { component, componentClass, componentId } = ctx;
|
|
2283
|
+
try {
|
|
2284
|
+
if (BLOCKED_ACTIONS.has(action)) {
|
|
2285
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2286
|
+
}
|
|
2287
|
+
if (action.startsWith("_") || action.startsWith("#")) {
|
|
2288
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2289
|
+
}
|
|
2290
|
+
const publicActions = componentClass.publicActions;
|
|
2291
|
+
if (!publicActions) {
|
|
2292
|
+
console.warn(`[SECURITY] Component '${componentClass.componentName || componentClass.name}' has no publicActions defined. All remote actions are blocked.`);
|
|
2293
|
+
throw new Error(`Action '${action}' is not callable - component has no publicActions defined`);
|
|
2294
|
+
}
|
|
2295
|
+
if (!publicActions.includes(action)) {
|
|
2296
|
+
const methodExists = typeof component[action] === "function";
|
|
2297
|
+
if (methodExists) {
|
|
2298
|
+
const name = componentClass.componentName || componentClass.name;
|
|
2299
|
+
throw new Error(
|
|
2300
|
+
`Action '${action}' exists on '${name}' but is not listed in publicActions. Add it to: static publicActions = [..., '${action}']`
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2304
|
+
}
|
|
2305
|
+
const method = component[action];
|
|
2306
|
+
if (typeof method !== "function") {
|
|
2307
|
+
throw new Error(`Action '${action}' not found on component`);
|
|
2308
|
+
}
|
|
2309
|
+
if (Object.prototype.hasOwnProperty.call(Object.prototype, action)) {
|
|
2310
|
+
throw new Error(`Action '${action}' is not callable`);
|
|
2311
|
+
}
|
|
2312
|
+
const rateLimit = componentClass.actionRateLimit;
|
|
2313
|
+
if (rateLimit) {
|
|
2314
|
+
const now = Date.now();
|
|
2315
|
+
const key = rateLimit.perAction ? action : "*";
|
|
2316
|
+
let entry = this._actionCalls.get(key);
|
|
2317
|
+
if (!entry || now - entry.windowStart >= rateLimit.windowMs) {
|
|
2318
|
+
entry = { count: 0, windowStart: now };
|
|
2319
|
+
this._actionCalls.set(key, entry);
|
|
2320
|
+
}
|
|
2321
|
+
entry.count++;
|
|
2322
|
+
if (entry.count > rateLimit.maxCalls) {
|
|
2323
|
+
throw new Error(`Action rate limit exceeded (max ${rateLimit.maxCalls} calls per ${rateLimit.windowMs}ms)`);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
const schemas = componentClass.actionSchemas;
|
|
2327
|
+
if (schemas && schemas[action]) {
|
|
2328
|
+
const result2 = schemas[action].safeParse(payload);
|
|
2329
|
+
if (!result2.success) {
|
|
2330
|
+
const errorMsg = result2.error?.message || result2.error?.issues?.map((i) => i.message).join(", ") || "Invalid payload";
|
|
2331
|
+
throw new Error(`Action '${action}' payload validation failed: ${errorMsg}`);
|
|
2332
|
+
}
|
|
2333
|
+
payload = result2.data ?? payload;
|
|
2334
|
+
}
|
|
2335
|
+
ctx.debugger?.trackActionCall(componentId, action, payload);
|
|
2336
|
+
let hookResult;
|
|
2337
|
+
try {
|
|
2338
|
+
hookResult = await component.onAction(action, payload);
|
|
2339
|
+
} catch (hookError) {
|
|
2340
|
+
ctx.debugger?.trackActionError(componentId, action, hookError.message, Date.now() - actionStart);
|
|
2341
|
+
ctx.emitFn("ERROR", {
|
|
2342
|
+
action,
|
|
2343
|
+
error: `Action '${action}' failed pre-validation`
|
|
2344
|
+
});
|
|
2345
|
+
throw hookError;
|
|
2346
|
+
}
|
|
2347
|
+
if (hookResult === false) {
|
|
2348
|
+
ctx.debugger?.trackActionError(componentId, action, "Action cancelled", Date.now() - actionStart);
|
|
2349
|
+
throw new Error(`Action '${action}' was cancelled`);
|
|
2350
|
+
}
|
|
2351
|
+
const result = await method.call(component, payload);
|
|
2352
|
+
ctx.debugger?.trackActionResult(componentId, action, result, Date.now() - actionStart);
|
|
2353
|
+
return result;
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
if (!error.message?.includes("was cancelled") && !error.message?.includes("pre-validation")) {
|
|
2356
|
+
ctx.debugger?.trackActionError(componentId, action, error.message, Date.now() - actionStart);
|
|
2357
|
+
ctx.emitFn("ERROR", {
|
|
2358
|
+
action,
|
|
2359
|
+
error: error.message
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
throw error;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
};
|
|
2366
|
+
|
|
2367
|
+
// src/component/managers/ComponentRoomProxy.ts
|
|
2368
|
+
var ComponentRoomProxy = class {
|
|
2369
|
+
roomEventUnsubscribers = [];
|
|
2370
|
+
joinedRooms = /* @__PURE__ */ new Set();
|
|
2371
|
+
roomHandles = /* @__PURE__ */ new Map();
|
|
2372
|
+
_roomProxy = null;
|
|
2373
|
+
_roomsCache = null;
|
|
2374
|
+
_cachedCtx = null;
|
|
2375
|
+
roomType = "default";
|
|
2376
|
+
room;
|
|
2377
|
+
componentId;
|
|
2378
|
+
ws;
|
|
2379
|
+
getCtx;
|
|
2380
|
+
_debugger;
|
|
2381
|
+
setStateFn;
|
|
2382
|
+
constructor(rctx) {
|
|
2383
|
+
this.componentId = rctx.componentId;
|
|
2384
|
+
this.ws = rctx.ws;
|
|
2385
|
+
this.room = rctx.defaultRoom;
|
|
2386
|
+
this.getCtx = rctx.getCtx;
|
|
2387
|
+
this._debugger = rctx.debugger ?? null;
|
|
2388
|
+
this.setStateFn = rctx.setStateFn;
|
|
2389
|
+
if (this.room) {
|
|
2390
|
+
this.joinedRooms.add(this.room);
|
|
2391
|
+
this.ctx.roomManager.joinRoom(this.componentId, this.room, this.ws);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
/** Lazy context resolution — cached after first access */
|
|
2395
|
+
get ctx() {
|
|
2396
|
+
if (!this._cachedCtx) {
|
|
2397
|
+
this._cachedCtx = this.getCtx();
|
|
2398
|
+
}
|
|
2399
|
+
return this._cachedCtx;
|
|
2400
|
+
}
|
|
2401
|
+
get $room() {
|
|
2402
|
+
if (this._roomProxy) return this._roomProxy;
|
|
2403
|
+
const self = this;
|
|
2404
|
+
const createHandle = (roomId) => {
|
|
2405
|
+
if (this.roomHandles.has(roomId)) {
|
|
2406
|
+
return this.roomHandles.get(roomId);
|
|
2407
|
+
}
|
|
2408
|
+
const handle = {
|
|
2409
|
+
get id() {
|
|
2410
|
+
return roomId;
|
|
2411
|
+
},
|
|
2412
|
+
get state() {
|
|
2413
|
+
return self.ctx.roomManager.getRoomState(roomId);
|
|
2414
|
+
},
|
|
2415
|
+
join: (initialState) => {
|
|
2416
|
+
if (self.joinedRooms.has(roomId)) return;
|
|
2417
|
+
self.joinedRooms.add(roomId);
|
|
2418
|
+
self._roomsCache = null;
|
|
2419
|
+
self.ctx.roomManager.joinRoom(self.componentId, roomId, self.ws, initialState);
|
|
2420
|
+
},
|
|
2421
|
+
leave: () => {
|
|
2422
|
+
if (!self.joinedRooms.has(roomId)) return;
|
|
2423
|
+
self.joinedRooms.delete(roomId);
|
|
2424
|
+
self._roomsCache = null;
|
|
2425
|
+
self.ctx.roomManager.leaveRoom(self.componentId, roomId);
|
|
2426
|
+
},
|
|
2427
|
+
emit: (event, data) => {
|
|
2428
|
+
return self.ctx.roomManager.emitToRoom(roomId, event, data, self.componentId);
|
|
2429
|
+
},
|
|
2430
|
+
on: (event, handler) => {
|
|
2431
|
+
const unsubscribe = self.ctx.roomEvents.on(
|
|
2432
|
+
"room",
|
|
2433
|
+
roomId,
|
|
2434
|
+
event,
|
|
2435
|
+
self.componentId,
|
|
2436
|
+
handler
|
|
2437
|
+
);
|
|
2438
|
+
self.roomEventUnsubscribers.push(unsubscribe);
|
|
2439
|
+
return unsubscribe;
|
|
1909
2440
|
},
|
|
1910
2441
|
setState: (updates) => {
|
|
1911
|
-
ctx.roomManager.setRoomState(roomId, updates, self.
|
|
2442
|
+
self.ctx.roomManager.setRoomState(roomId, updates, self.componentId);
|
|
1912
2443
|
}
|
|
1913
2444
|
};
|
|
1914
2445
|
this.roomHandles.set(roomId, handle);
|
|
@@ -1950,13 +2481,199 @@ var LiveComponent = class _LiveComponent {
|
|
|
1950
2481
|
}
|
|
1951
2482
|
}
|
|
1952
2483
|
});
|
|
2484
|
+
this._roomProxy = proxyFn;
|
|
1953
2485
|
return proxyFn;
|
|
1954
2486
|
}
|
|
2487
|
+
get $rooms() {
|
|
2488
|
+
if (this._roomsCache) return this._roomsCache;
|
|
2489
|
+
this._roomsCache = Array.from(this.joinedRooms);
|
|
2490
|
+
return this._roomsCache;
|
|
2491
|
+
}
|
|
2492
|
+
getJoinedRooms() {
|
|
2493
|
+
return this.joinedRooms;
|
|
2494
|
+
}
|
|
2495
|
+
emitRoomEvent(event, data, notifySelf = false) {
|
|
2496
|
+
if (!this.room) {
|
|
2497
|
+
liveWarn("rooms", this.componentId, `[${this.componentId}] Cannot emit room event '${event}' - no room set`);
|
|
2498
|
+
return 0;
|
|
2499
|
+
}
|
|
2500
|
+
const excludeId = notifySelf ? void 0 : this.componentId;
|
|
2501
|
+
const notified = this.ctx.roomEvents.emit(this.roomType, this.room, event, data, excludeId);
|
|
2502
|
+
liveLog("rooms", this.componentId, `[${this.componentId}] Room event '${event}' -> ${notified} components`);
|
|
2503
|
+
this._debugger?.trackRoomEmit(this.componentId, this.room, event, data);
|
|
2504
|
+
return notified;
|
|
2505
|
+
}
|
|
2506
|
+
onRoomEvent(event, handler) {
|
|
2507
|
+
if (!this.room) {
|
|
2508
|
+
liveWarn("rooms", this.componentId, `[${this.componentId}] Cannot subscribe to room event '${event}' - no room set`);
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
const unsubscribe = this.ctx.roomEvents.on(
|
|
2512
|
+
this.roomType,
|
|
2513
|
+
this.room,
|
|
2514
|
+
event,
|
|
2515
|
+
this.componentId,
|
|
2516
|
+
handler
|
|
2517
|
+
);
|
|
2518
|
+
this.roomEventUnsubscribers.push(unsubscribe);
|
|
2519
|
+
liveLog("rooms", this.componentId, `[${this.componentId}] Subscribed to room event '${event}'`);
|
|
2520
|
+
}
|
|
2521
|
+
emitRoomEventWithState(event, data, stateUpdates) {
|
|
2522
|
+
this.setStateFn(stateUpdates);
|
|
2523
|
+
return this.emitRoomEvent(event, data, false);
|
|
2524
|
+
}
|
|
2525
|
+
subscribeToRoom(roomId) {
|
|
2526
|
+
this.room = roomId;
|
|
2527
|
+
}
|
|
2528
|
+
unsubscribeFromRoom() {
|
|
2529
|
+
this.room = void 0;
|
|
2530
|
+
}
|
|
2531
|
+
destroy() {
|
|
2532
|
+
for (const unsubscribe of this.roomEventUnsubscribers) {
|
|
2533
|
+
unsubscribe();
|
|
2534
|
+
}
|
|
2535
|
+
this.roomEventUnsubscribers = [];
|
|
2536
|
+
if (this.joinedRooms.size > 0 && this._cachedCtx) {
|
|
2537
|
+
for (const roomId of this.joinedRooms) {
|
|
2538
|
+
this._cachedCtx.roomManager.leaveRoom(this.componentId, roomId);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
this.joinedRooms.clear();
|
|
2542
|
+
this.roomHandles.clear();
|
|
2543
|
+
this._roomProxy = null;
|
|
2544
|
+
this._roomsCache = null;
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
// src/component/LiveComponent.ts
|
|
2549
|
+
var _liveDebugger = null;
|
|
2550
|
+
function _setLiveDebugger(dbg) {
|
|
2551
|
+
_liveDebugger = dbg;
|
|
2552
|
+
}
|
|
2553
|
+
var LiveComponent = class {
|
|
2554
|
+
/** Component name for registry lookup - must be defined in subclasses */
|
|
2555
|
+
static componentName;
|
|
2556
|
+
/** Default state - must be defined in subclasses */
|
|
2557
|
+
static defaultState;
|
|
2558
|
+
/**
|
|
2559
|
+
* Per-component logging control. Silent by default.
|
|
2560
|
+
*
|
|
2561
|
+
* @example
|
|
2562
|
+
* static logging = true // all categories
|
|
2563
|
+
* static logging = ['lifecycle', 'messages'] // specific categories
|
|
2564
|
+
*/
|
|
2565
|
+
static logging;
|
|
2566
|
+
/**
|
|
2567
|
+
* Component-level auth configuration.
|
|
2568
|
+
*/
|
|
2569
|
+
static auth;
|
|
2570
|
+
/**
|
|
2571
|
+
* Per-action auth configuration.
|
|
2572
|
+
*/
|
|
2573
|
+
static actionAuth;
|
|
1955
2574
|
/**
|
|
1956
|
-
*
|
|
2575
|
+
* Zod schemas for action payload validation.
|
|
2576
|
+
* When defined, payloads are validated before the action method is called.
|
|
2577
|
+
*
|
|
2578
|
+
* @example
|
|
2579
|
+
* static actionSchemas = {
|
|
2580
|
+
* sendMessage: z.object({ text: z.string().max(500) }),
|
|
2581
|
+
* updatePosition: z.object({ x: z.number(), y: z.number() }),
|
|
2582
|
+
* }
|
|
2583
|
+
*/
|
|
2584
|
+
static actionSchemas;
|
|
2585
|
+
/**
|
|
2586
|
+
* Rate limit for action execution.
|
|
2587
|
+
* Prevents clients from spamming expensive operations.
|
|
2588
|
+
*
|
|
2589
|
+
* @example
|
|
2590
|
+
* static actionRateLimit = { maxCalls: 10, windowMs: 1000, perAction: true }
|
|
2591
|
+
*/
|
|
2592
|
+
static actionRateLimit;
|
|
2593
|
+
/**
|
|
2594
|
+
* Data that survives HMR reloads.
|
|
2595
|
+
*/
|
|
2596
|
+
static persistent;
|
|
2597
|
+
/**
|
|
2598
|
+
* When true, only ONE server-side instance exists for this component.
|
|
2599
|
+
* All clients share the same state.
|
|
2600
|
+
*/
|
|
2601
|
+
static singleton;
|
|
2602
|
+
id;
|
|
2603
|
+
state;
|
|
2604
|
+
// Proxy wrapper (getter delegates to _stateManager)
|
|
2605
|
+
ws;
|
|
2606
|
+
room;
|
|
2607
|
+
userId;
|
|
2608
|
+
broadcastToRoom = () => {
|
|
2609
|
+
};
|
|
2610
|
+
// Server-only private state (NEVER sent to client)
|
|
2611
|
+
_privateState = {};
|
|
2612
|
+
// Auth context (injected by registry during mount, immutable after first set)
|
|
2613
|
+
_authContext = ANONYMOUS_CONTEXT;
|
|
2614
|
+
_authContextSet = false;
|
|
2615
|
+
// Room type for typed events (override in subclass)
|
|
2616
|
+
roomType = "default";
|
|
2617
|
+
// Singleton emit override
|
|
2618
|
+
[EMIT_OVERRIDE_KEY] = null;
|
|
2619
|
+
// ===== Internal Managers (composition) =====
|
|
2620
|
+
_stateManager;
|
|
2621
|
+
_messaging;
|
|
2622
|
+
_actionSecurity;
|
|
2623
|
+
_roomProxyManager;
|
|
2624
|
+
static publicActions;
|
|
2625
|
+
constructor(initialState, ws, options) {
|
|
2626
|
+
this.id = this.generateId();
|
|
2627
|
+
const ctor = this.constructor;
|
|
2628
|
+
this.ws = ws;
|
|
2629
|
+
this.room = options?.room;
|
|
2630
|
+
this.userId = options?.userId;
|
|
2631
|
+
this._messaging = new ComponentMessaging({
|
|
2632
|
+
componentId: this.id,
|
|
2633
|
+
ws: this.ws,
|
|
2634
|
+
getUserId: () => this.userId,
|
|
2635
|
+
getRoom: () => this.room,
|
|
2636
|
+
getBroadcastToRoom: () => this.broadcastToRoom,
|
|
2637
|
+
getEmitOverride: () => this[EMIT_OVERRIDE_KEY]
|
|
2638
|
+
});
|
|
2639
|
+
this._stateManager = new ComponentStateManager({
|
|
2640
|
+
componentId: this.id,
|
|
2641
|
+
initialState: { ...ctor.defaultState, ...initialState },
|
|
2642
|
+
ws: this.ws,
|
|
2643
|
+
emitFn: (type, payload) => this._messaging.emit(type, payload),
|
|
2644
|
+
onStateChangeFn: (changes) => this.onStateChange(changes),
|
|
2645
|
+
debugger: _liveDebugger
|
|
2646
|
+
});
|
|
2647
|
+
this.state = this._stateManager.proxyState;
|
|
2648
|
+
this._actionSecurity = new ActionSecurityManager();
|
|
2649
|
+
this._roomProxyManager = new ComponentRoomProxy({
|
|
2650
|
+
componentId: this.id,
|
|
2651
|
+
ws: this.ws,
|
|
2652
|
+
defaultRoom: this.room,
|
|
2653
|
+
getCtx: () => getLiveComponentContext(),
|
|
2654
|
+
debugger: _liveDebugger,
|
|
2655
|
+
setStateFn: (updates) => this.setState(updates)
|
|
2656
|
+
});
|
|
2657
|
+
this._stateManager.applyDirectAccessors(this, this.constructor);
|
|
2658
|
+
}
|
|
2659
|
+
// ========================================
|
|
2660
|
+
// $private - Server-Only State
|
|
2661
|
+
// ========================================
|
|
2662
|
+
get $private() {
|
|
2663
|
+
return this._privateState;
|
|
2664
|
+
}
|
|
2665
|
+
// ========================================
|
|
2666
|
+
// $room - Unified Room System
|
|
2667
|
+
// ========================================
|
|
2668
|
+
get $room() {
|
|
2669
|
+
return this._roomProxyManager.$room;
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* List of room IDs this component is participating in.
|
|
2673
|
+
* Cached — invalidated on join/leave.
|
|
1957
2674
|
*/
|
|
1958
2675
|
get $rooms() {
|
|
1959
|
-
return
|
|
2676
|
+
return this._roomProxyManager.$rooms;
|
|
1960
2677
|
}
|
|
1961
2678
|
// ========================================
|
|
1962
2679
|
// $auth - Authentication Context
|
|
@@ -1964,13 +2681,22 @@ var LiveComponent = class _LiveComponent {
|
|
|
1964
2681
|
get $auth() {
|
|
1965
2682
|
return this._authContext;
|
|
1966
2683
|
}
|
|
1967
|
-
/** @internal */
|
|
2684
|
+
/** @internal - Immutable after first set to prevent privilege escalation */
|
|
1968
2685
|
setAuthContext(context) {
|
|
2686
|
+
if (this._authContextSet) {
|
|
2687
|
+
throw new Error("Auth context is immutable after initial set");
|
|
2688
|
+
}
|
|
1969
2689
|
this._authContext = context;
|
|
2690
|
+
this._authContextSet = true;
|
|
1970
2691
|
if (context.authenticated && context.user?.id && !this.userId) {
|
|
1971
2692
|
this.userId = context.user.id;
|
|
1972
2693
|
}
|
|
1973
2694
|
}
|
|
2695
|
+
/** @internal - Reset auth context (for registry use in reconnection) */
|
|
2696
|
+
_resetAuthContext() {
|
|
2697
|
+
this._authContextSet = false;
|
|
2698
|
+
this._authContext = ANONYMOUS_CONTEXT;
|
|
2699
|
+
}
|
|
1974
2700
|
// ========================================
|
|
1975
2701
|
// $persistent - HMR-Safe State
|
|
1976
2702
|
// ========================================
|
|
@@ -2009,221 +2735,62 @@ var LiveComponent = class _LiveComponent {
|
|
|
2009
2735
|
onClientLeave(connectionId, connectionCount) {
|
|
2010
2736
|
}
|
|
2011
2737
|
// ========================================
|
|
2012
|
-
// State Management
|
|
2738
|
+
// State Management (delegates to _stateManager)
|
|
2013
2739
|
// ========================================
|
|
2014
2740
|
setState(updates) {
|
|
2015
|
-
|
|
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
|
-
);
|
|
2741
|
+
this._stateManager.setState(updates);
|
|
2043
2742
|
}
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2743
|
+
/**
|
|
2744
|
+
* Send a binary-encoded state delta directly over WebSocket.
|
|
2745
|
+
* Updates internal state (same as setState) then sends the encoder's output
|
|
2746
|
+
* as a binary frame: [0x01][idLen:u8][id_bytes:utf8][payload_bytes].
|
|
2747
|
+
* Bypasses the JSON batcher — ideal for high-frequency updates.
|
|
2748
|
+
*/
|
|
2749
|
+
sendBinaryDelta(delta, encoder) {
|
|
2750
|
+
this._stateManager.sendBinaryDelta(delta, encoder);
|
|
2751
|
+
}
|
|
2752
|
+
setValue(payload) {
|
|
2753
|
+
return this._stateManager.setValue(payload);
|
|
2049
2754
|
}
|
|
2050
2755
|
// ========================================
|
|
2051
|
-
// Action
|
|
2756
|
+
// Action Execution (delegates to _actionSecurity)
|
|
2052
2757
|
// ========================================
|
|
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
2758
|
async executeAction(action, payload) {
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
-
}
|
|
2759
|
+
return this._actionSecurity.validateAndExecute(action, payload, {
|
|
2760
|
+
component: this,
|
|
2761
|
+
componentClass: this.constructor,
|
|
2762
|
+
componentId: this.id,
|
|
2763
|
+
emitFn: (type, p) => this.emit(type, p),
|
|
2764
|
+
debugger: _liveDebugger
|
|
2765
|
+
});
|
|
2152
2766
|
}
|
|
2153
2767
|
// ========================================
|
|
2154
|
-
// Messaging
|
|
2768
|
+
// Messaging (delegates to _messaging)
|
|
2155
2769
|
// ========================================
|
|
2156
2770
|
emit(type, payload) {
|
|
2157
|
-
|
|
2158
|
-
if (override) {
|
|
2159
|
-
override(type, payload);
|
|
2160
|
-
return;
|
|
2161
|
-
}
|
|
2162
|
-
const message = {
|
|
2163
|
-
type,
|
|
2164
|
-
componentId: this.id,
|
|
2165
|
-
payload,
|
|
2166
|
-
timestamp: Date.now(),
|
|
2167
|
-
userId: this.userId,
|
|
2168
|
-
room: this.room
|
|
2169
|
-
};
|
|
2170
|
-
if (this.ws && this.ws.send) {
|
|
2171
|
-
this.ws.send(JSON.stringify(message));
|
|
2172
|
-
}
|
|
2771
|
+
this._messaging.emit(type, payload);
|
|
2173
2772
|
}
|
|
2174
2773
|
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);
|
|
2774
|
+
this._messaging.broadcast(type, payload, excludeCurrentUser);
|
|
2187
2775
|
}
|
|
2188
2776
|
// ========================================
|
|
2189
|
-
// Room Events
|
|
2777
|
+
// Room Events (delegates to _roomProxyManager)
|
|
2190
2778
|
// ========================================
|
|
2191
2779
|
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;
|
|
2780
|
+
return this._roomProxyManager.emitRoomEvent(event, data, notifySelf);
|
|
2202
2781
|
}
|
|
2203
2782
|
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}'`);
|
|
2783
|
+
this._roomProxyManager.onRoomEvent(event, handler);
|
|
2218
2784
|
}
|
|
2219
2785
|
emitRoomEventWithState(event, data, stateUpdates) {
|
|
2220
|
-
this.
|
|
2221
|
-
return this.emitRoomEvent(event, data, false);
|
|
2786
|
+
return this._roomProxyManager.emitRoomEventWithState(event, data, stateUpdates);
|
|
2222
2787
|
}
|
|
2223
|
-
|
|
2788
|
+
subscribeToRoom(roomId) {
|
|
2789
|
+
this._roomProxyManager.subscribeToRoom(roomId);
|
|
2224
2790
|
this.room = roomId;
|
|
2225
2791
|
}
|
|
2226
|
-
|
|
2792
|
+
unsubscribeFromRoom() {
|
|
2793
|
+
this._roomProxyManager.unsubscribeFromRoom();
|
|
2227
2794
|
this.room = void 0;
|
|
2228
2795
|
}
|
|
2229
2796
|
// ========================================
|
|
@@ -2238,21 +2805,13 @@ var LiveComponent = class _LiveComponent {
|
|
|
2238
2805
|
} catch (err) {
|
|
2239
2806
|
console.error(`[${this.id}] onDestroy error:`, err?.message || err);
|
|
2240
2807
|
}
|
|
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();
|
|
2808
|
+
this._roomProxyManager.destroy();
|
|
2809
|
+
this._stateManager.cleanup();
|
|
2251
2810
|
this._privateState = {};
|
|
2252
|
-
this.
|
|
2811
|
+
this.room = void 0;
|
|
2253
2812
|
}
|
|
2254
2813
|
getSerializableState() {
|
|
2255
|
-
return this.
|
|
2814
|
+
return this._stateManager.getSerializableState();
|
|
2256
2815
|
}
|
|
2257
2816
|
};
|
|
2258
2817
|
|
|
@@ -2266,6 +2825,8 @@ var ComponentRegistry = class {
|
|
|
2266
2825
|
autoDiscoveredComponents = /* @__PURE__ */ new Map();
|
|
2267
2826
|
healthCheckInterval;
|
|
2268
2827
|
singletons = /* @__PURE__ */ new Map();
|
|
2828
|
+
remoteSingletons = /* @__PURE__ */ new Map();
|
|
2829
|
+
cluster;
|
|
2269
2830
|
authManager;
|
|
2270
2831
|
debugger;
|
|
2271
2832
|
stateSignature;
|
|
@@ -2275,8 +2836,72 @@ var ComponentRegistry = class {
|
|
|
2275
2836
|
this.debugger = deps.debugger;
|
|
2276
2837
|
this.stateSignature = deps.stateSignature;
|
|
2277
2838
|
this.performanceMonitor = deps.performanceMonitor;
|
|
2839
|
+
this.cluster = deps.cluster;
|
|
2278
2840
|
_setLiveDebugger(deps.debugger);
|
|
2279
2841
|
this.setupHealthMonitoring();
|
|
2842
|
+
this.setupClusterHandlers();
|
|
2843
|
+
}
|
|
2844
|
+
/** Set up handlers for incoming cluster messages (deltas, forwarded actions). */
|
|
2845
|
+
setupClusterHandlers() {
|
|
2846
|
+
if (!this.cluster) return;
|
|
2847
|
+
this.cluster.onDelta((componentId, componentName, delta, sourceInstanceId) => {
|
|
2848
|
+
const remote = this.remoteSingletons.get(componentName);
|
|
2849
|
+
if (!remote || remote.componentId !== componentId) return;
|
|
2850
|
+
if (delta && remote.lastState) {
|
|
2851
|
+
Object.assign(remote.lastState, delta);
|
|
2852
|
+
}
|
|
2853
|
+
const message = JSON.stringify({
|
|
2854
|
+
type: "STATE_DELTA",
|
|
2855
|
+
componentId,
|
|
2856
|
+
payload: { delta },
|
|
2857
|
+
timestamp: Date.now()
|
|
2858
|
+
});
|
|
2859
|
+
const dead = [];
|
|
2860
|
+
for (const [connId, ws] of remote.connections) {
|
|
2861
|
+
if (ws.readyState === 1) {
|
|
2862
|
+
try {
|
|
2863
|
+
ws.send(message);
|
|
2864
|
+
} catch {
|
|
2865
|
+
dead.push(connId);
|
|
2866
|
+
}
|
|
2867
|
+
} else {
|
|
2868
|
+
dead.push(connId);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
for (const connId of dead) remote.connections.delete(connId);
|
|
2872
|
+
});
|
|
2873
|
+
this.cluster.onOwnershipLost((componentName) => {
|
|
2874
|
+
const singleton = this.singletons.get(componentName);
|
|
2875
|
+
if (!singleton) return;
|
|
2876
|
+
this.cluster.saveSingletonState(componentName, singleton.instance.getSerializableState()).catch(() => {
|
|
2877
|
+
});
|
|
2878
|
+
const errorMsg = JSON.stringify({
|
|
2879
|
+
type: "ERROR",
|
|
2880
|
+
componentId: singleton.instance.id,
|
|
2881
|
+
payload: { error: "OWNERSHIP_LOST: singleton moved to another server" },
|
|
2882
|
+
timestamp: Date.now()
|
|
2883
|
+
});
|
|
2884
|
+
for (const [, ws] of singleton.connections) {
|
|
2885
|
+
try {
|
|
2886
|
+
ws.send(errorMsg);
|
|
2887
|
+
} catch {
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
this.cleanupComponent(singleton.instance.id);
|
|
2891
|
+
this.singletons.delete(componentName);
|
|
2892
|
+
});
|
|
2893
|
+
this.cluster.onActionForward(async (request) => {
|
|
2894
|
+
try {
|
|
2895
|
+
const stillOwner = await this.cluster.verifySingletonOwnership(request.componentName);
|
|
2896
|
+
if (!stillOwner) {
|
|
2897
|
+
return { success: false, error: "OWNERSHIP_LOST: this instance no longer owns the singleton", requestId: request.requestId };
|
|
2898
|
+
}
|
|
2899
|
+
const result = await this.executeAction(request.componentId, request.action, request.payload);
|
|
2900
|
+
return { success: true, result, requestId: request.requestId };
|
|
2901
|
+
} catch (error) {
|
|
2902
|
+
return { success: false, error: error.message, requestId: request.requestId };
|
|
2903
|
+
}
|
|
2904
|
+
});
|
|
2280
2905
|
}
|
|
2281
2906
|
setupHealthMonitoring() {
|
|
2282
2907
|
this.healthCheckInterval = setInterval(() => this.performHealthChecks(), 3e4);
|
|
@@ -2360,6 +2985,7 @@ var ComponentRegistry = class {
|
|
|
2360
2985
|
const authResult = this.authManager.authorizeComponent(authContext, componentAuth);
|
|
2361
2986
|
if (!authResult.allowed) throw new Error(`AUTH_DENIED: ${authResult.reason}`);
|
|
2362
2987
|
const isSingleton = ComponentClass.singleton === true;
|
|
2988
|
+
let clusterSingletonId = null;
|
|
2363
2989
|
if (isSingleton) {
|
|
2364
2990
|
const existing = this.singletons.get(componentName);
|
|
2365
2991
|
if (existing) {
|
|
@@ -2367,11 +2993,11 @@ var ComponentRegistry = class {
|
|
|
2367
2993
|
existing.connections.set(connId, ws);
|
|
2368
2994
|
this.ensureWsData(ws, options?.userId);
|
|
2369
2995
|
ws.data.components.set(existing.instance.id, existing.instance);
|
|
2370
|
-
const signedState2 =
|
|
2996
|
+
const signedState2 = this.stateSignature.signState(existing.instance.id, {
|
|
2371
2997
|
...existing.instance.getSerializableState(),
|
|
2372
2998
|
__componentName: componentName
|
|
2373
2999
|
}, 1, { compress: true, backup: true });
|
|
2374
|
-
ws
|
|
3000
|
+
sendImmediate(ws, JSON.stringify({
|
|
2375
3001
|
type: "STATE_UPDATE",
|
|
2376
3002
|
componentId: existing.instance.id,
|
|
2377
3003
|
payload: { state: existing.instance.getSerializableState(), signedState: signedState2 },
|
|
@@ -2383,9 +3009,57 @@ var ComponentRegistry = class {
|
|
|
2383
3009
|
}
|
|
2384
3010
|
return { componentId: existing.instance.id, initialState: existing.instance.getSerializableState(), signedState: signedState2 };
|
|
2385
3011
|
}
|
|
3012
|
+
const existingRemote = this.remoteSingletons.get(componentName);
|
|
3013
|
+
if (existingRemote) {
|
|
3014
|
+
const connId = ws.data?.connectionId || `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3015
|
+
this.ensureWsData(ws, options?.userId);
|
|
3016
|
+
existingRemote.connections.set(connId, ws);
|
|
3017
|
+
sendImmediate(ws, JSON.stringify({
|
|
3018
|
+
type: "STATE_UPDATE",
|
|
3019
|
+
componentId: existingRemote.componentId,
|
|
3020
|
+
payload: { state: existingRemote.lastState },
|
|
3021
|
+
timestamp: Date.now()
|
|
3022
|
+
}));
|
|
3023
|
+
return { componentId: existingRemote.componentId, initialState: existingRemote.lastState, signedState: null };
|
|
3024
|
+
}
|
|
3025
|
+
if (this.cluster) {
|
|
3026
|
+
clusterSingletonId = `live-${crypto.randomUUID()}`;
|
|
3027
|
+
const claim = await this.cluster.claimSingleton(componentName, clusterSingletonId);
|
|
3028
|
+
if (!claim.claimed) {
|
|
3029
|
+
clusterSingletonId = null;
|
|
3030
|
+
const owner = await this.cluster.getSingletonOwner(componentName);
|
|
3031
|
+
if (owner) {
|
|
3032
|
+
const stored = await this.cluster.loadState(owner.componentId);
|
|
3033
|
+
const connId = ws.data?.connectionId || `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3034
|
+
this.ensureWsData(ws, options?.userId);
|
|
3035
|
+
const remote = {
|
|
3036
|
+
componentName,
|
|
3037
|
+
componentId: owner.componentId,
|
|
3038
|
+
ownerInstanceId: owner.instanceId,
|
|
3039
|
+
lastState: stored?.state || {},
|
|
3040
|
+
connections: /* @__PURE__ */ new Map([[connId, ws]])
|
|
3041
|
+
};
|
|
3042
|
+
this.remoteSingletons.set(componentName, remote);
|
|
3043
|
+
sendImmediate(ws, JSON.stringify({
|
|
3044
|
+
type: "STATE_UPDATE",
|
|
3045
|
+
componentId: owner.componentId,
|
|
3046
|
+
payload: { state: remote.lastState },
|
|
3047
|
+
timestamp: Date.now()
|
|
3048
|
+
}));
|
|
3049
|
+
return { componentId: owner.componentId, initialState: remote.lastState, signedState: null };
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
if (claim.recoveredState) {
|
|
3053
|
+
props = { ...props, ...claim.recoveredState };
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
2386
3056
|
}
|
|
2387
3057
|
const component = new ComponentClass({ ...initialState, ...props }, ws, options);
|
|
2388
3058
|
component.setAuthContext(authContext);
|
|
3059
|
+
if (clusterSingletonId) {
|
|
3060
|
+
;
|
|
3061
|
+
component.id = clusterSingletonId;
|
|
3062
|
+
}
|
|
2389
3063
|
component.broadcastToRoom = (message) => {
|
|
2390
3064
|
this.broadcastToRoom(message, component.id);
|
|
2391
3065
|
};
|
|
@@ -2401,6 +3075,13 @@ var ComponentRegistry = class {
|
|
|
2401
3075
|
const connections = /* @__PURE__ */ new Map();
|
|
2402
3076
|
connections.set(connId, ws);
|
|
2403
3077
|
this.singletons.set(componentName, { instance: component, connections });
|
|
3078
|
+
if (this.cluster) {
|
|
3079
|
+
this.cluster.saveState(component.id, componentName, component.getSerializableState()).catch(() => {
|
|
3080
|
+
});
|
|
3081
|
+
this.cluster.saveSingletonState(componentName, component.getSerializableState()).catch(() => {
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
;
|
|
2404
3085
|
component[EMIT_OVERRIDE_KEY] = (type, payload) => {
|
|
2405
3086
|
const message = {
|
|
2406
3087
|
type,
|
|
@@ -2423,6 +3104,14 @@ var ComponentRegistry = class {
|
|
|
2423
3104
|
}
|
|
2424
3105
|
for (const cId of dead) singleton.connections.delete(cId);
|
|
2425
3106
|
}
|
|
3107
|
+
if (this.cluster && type === "STATE_DELTA" && payload?.delta) {
|
|
3108
|
+
this.cluster.publishDelta(component.id, componentName, payload.delta).catch(() => {
|
|
3109
|
+
});
|
|
3110
|
+
this.cluster.saveState(component.id, componentName, component.getSerializableState()).catch(() => {
|
|
3111
|
+
});
|
|
3112
|
+
this.cluster.saveSingletonState(componentName, component.getSerializableState()).catch(() => {
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
2426
3115
|
};
|
|
2427
3116
|
try {
|
|
2428
3117
|
component.onClientJoin(connId, 1);
|
|
@@ -2435,7 +3124,7 @@ var ComponentRegistry = class {
|
|
|
2435
3124
|
registerComponentLogging(component.id, ComponentClass.logging);
|
|
2436
3125
|
this.performanceMonitor.initializeComponent(component.id, componentName);
|
|
2437
3126
|
this.performanceMonitor.recordRenderTime(component.id, renderTime);
|
|
2438
|
-
const signedState =
|
|
3127
|
+
const signedState = this.stateSignature.signState(component.id, {
|
|
2439
3128
|
...component.getSerializableState(),
|
|
2440
3129
|
__componentName: componentName
|
|
2441
3130
|
}, 1, { compress: true, backup: true });
|
|
@@ -2468,7 +3157,7 @@ var ComponentRegistry = class {
|
|
|
2468
3157
|
}
|
|
2469
3158
|
async rehydrateComponent(componentId, componentName, signedState, ws, options) {
|
|
2470
3159
|
try {
|
|
2471
|
-
const validation =
|
|
3160
|
+
const validation = this.stateSignature.validateState(signedState, { skipNonce: true });
|
|
2472
3161
|
if (!validation.valid) return { success: false, error: validation.error || "Invalid state signature" };
|
|
2473
3162
|
const definition = this.definitions.get(componentName);
|
|
2474
3163
|
let ComponentClass = null;
|
|
@@ -2491,8 +3180,8 @@ var ComponentRegistry = class {
|
|
|
2491
3180
|
const componentAuth = ComponentClass.auth;
|
|
2492
3181
|
const authResult = this.authManager.authorizeComponent(authContext, componentAuth);
|
|
2493
3182
|
if (!authResult.allowed) return { success: false, error: `AUTH_DENIED: ${authResult.reason}` };
|
|
2494
|
-
const clientState =
|
|
2495
|
-
if (clientState.__componentName
|
|
3183
|
+
const clientState = this.stateSignature.extractData(signedState);
|
|
3184
|
+
if (!clientState.__componentName || clientState.__componentName !== componentName) {
|
|
2496
3185
|
return { success: false, error: "Component class mismatch - state tampering detected" };
|
|
2497
3186
|
}
|
|
2498
3187
|
const { __componentName, ...cleanState } = clientState;
|
|
@@ -2505,7 +3194,7 @@ var ComponentRegistry = class {
|
|
|
2505
3194
|
this.ensureWsData(ws, options?.userId);
|
|
2506
3195
|
ws.data.components.set(component.id, component);
|
|
2507
3196
|
registerComponentLogging(component.id, ComponentClass.logging);
|
|
2508
|
-
const newSignedState =
|
|
3197
|
+
const newSignedState = this.stateSignature.signState(
|
|
2509
3198
|
component.id,
|
|
2510
3199
|
{ ...component.getSerializableState(), __componentName: componentName },
|
|
2511
3200
|
signedState.version + 1
|
|
@@ -2554,20 +3243,39 @@ var ComponentRegistry = class {
|
|
|
2554
3243
|
if (singleton.instance.id !== componentId) continue;
|
|
2555
3244
|
if (connId) singleton.connections.delete(connId);
|
|
2556
3245
|
if (singleton.connections.size === 0) {
|
|
3246
|
+
const finalState = singleton.instance.getSerializableState();
|
|
2557
3247
|
try {
|
|
2558
3248
|
singleton.instance.onDisconnect();
|
|
2559
3249
|
} catch {
|
|
2560
3250
|
}
|
|
2561
3251
|
this.cleanupComponent(componentId);
|
|
2562
3252
|
this.singletons.delete(name);
|
|
3253
|
+
if (this.cluster) {
|
|
3254
|
+
this.cluster.saveSingletonState(name, finalState).then(() => this.cluster.releaseSingleton(name)).then(() => this.cluster.deleteState(componentId)).catch(() => {
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
return true;
|
|
3259
|
+
}
|
|
3260
|
+
for (const [name, remote] of this.remoteSingletons) {
|
|
3261
|
+
if (remote.componentId !== componentId) continue;
|
|
3262
|
+
if (connId) remote.connections.delete(connId);
|
|
3263
|
+
if (remote.connections.size === 0) {
|
|
3264
|
+
this.remoteSingletons.delete(name);
|
|
2563
3265
|
}
|
|
2564
3266
|
return true;
|
|
2565
3267
|
}
|
|
2566
3268
|
return false;
|
|
2567
3269
|
}
|
|
2568
|
-
|
|
3270
|
+
unmountComponent(componentId, ws) {
|
|
2569
3271
|
const component = this.components.get(componentId);
|
|
2570
|
-
if (!component)
|
|
3272
|
+
if (!component) {
|
|
3273
|
+
if (ws) {
|
|
3274
|
+
const connId = ws.data?.connectionId;
|
|
3275
|
+
this.removeSingletonConnection(componentId, connId, "unmount");
|
|
3276
|
+
}
|
|
3277
|
+
return;
|
|
3278
|
+
}
|
|
2571
3279
|
if (ws) {
|
|
2572
3280
|
const connId = ws.data?.connectionId;
|
|
2573
3281
|
ws.data?.components?.delete(componentId);
|
|
@@ -2596,6 +3304,13 @@ var ComponentRegistry = class {
|
|
|
2596
3304
|
}
|
|
2597
3305
|
return null;
|
|
2598
3306
|
}
|
|
3307
|
+
/** Find a remote singleton entry by componentId. */
|
|
3308
|
+
findRemoteSingleton(componentId) {
|
|
3309
|
+
for (const [, entry] of this.remoteSingletons) {
|
|
3310
|
+
if (entry.componentId === componentId) return entry;
|
|
3311
|
+
}
|
|
3312
|
+
return null;
|
|
3313
|
+
}
|
|
2599
3314
|
async executeAction(componentId, action, payload) {
|
|
2600
3315
|
const component = this.components.get(componentId);
|
|
2601
3316
|
if (!component) throw new Error(`COMPONENT_REHYDRATION_REQUIRED:${componentId}`);
|
|
@@ -2646,7 +3361,7 @@ var ComponentRegistry = class {
|
|
|
2646
3361
|
const component = this.components.get(componentId);
|
|
2647
3362
|
if (message.excludeUser && component?.userId === message.excludeUser) continue;
|
|
2648
3363
|
const ws = this.wsConnections.get(componentId);
|
|
2649
|
-
if (ws
|
|
3364
|
+
if (ws) queueWsMessage(ws, broadcastMessage);
|
|
2650
3365
|
}
|
|
2651
3366
|
}
|
|
2652
3367
|
async handleMessage(ws, message) {
|
|
@@ -2661,9 +3376,26 @@ var ComponentRegistry = class {
|
|
|
2661
3376
|
});
|
|
2662
3377
|
return { success: true, result: mountResult };
|
|
2663
3378
|
case "COMPONENT_UNMOUNT":
|
|
2664
|
-
|
|
3379
|
+
this.unmountComponent(message.componentId, ws);
|
|
2665
3380
|
return { success: true };
|
|
2666
|
-
case "CALL_ACTION":
|
|
3381
|
+
case "CALL_ACTION": {
|
|
3382
|
+
const remoteSingleton = this.findRemoteSingleton(message.componentId);
|
|
3383
|
+
if (remoteSingleton && this.cluster) {
|
|
3384
|
+
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
3385
|
+
const request = {
|
|
3386
|
+
sourceInstanceId: this.cluster.instanceId,
|
|
3387
|
+
targetInstanceId: remoteSingleton.ownerInstanceId,
|
|
3388
|
+
componentId: remoteSingleton.componentId,
|
|
3389
|
+
componentName: remoteSingleton.componentName,
|
|
3390
|
+
action: message.action,
|
|
3391
|
+
payload: message.payload,
|
|
3392
|
+
requestId
|
|
3393
|
+
};
|
|
3394
|
+
const response = await this.cluster.forwardAction(request);
|
|
3395
|
+
if (!response.success) throw new Error(response.error || "Remote action failed");
|
|
3396
|
+
if (message.expectResponse) return { success: true, result: response.result };
|
|
3397
|
+
return null;
|
|
3398
|
+
}
|
|
2667
3399
|
this.recordComponentMetrics(message.componentId, void 0, message.action);
|
|
2668
3400
|
const actionStart = Date.now();
|
|
2669
3401
|
try {
|
|
@@ -2675,6 +3407,7 @@ var ComponentRegistry = class {
|
|
|
2675
3407
|
this.performanceMonitor.recordActionTime(message.componentId, message.action, Date.now() - actionStart, error);
|
|
2676
3408
|
throw error;
|
|
2677
3409
|
}
|
|
3410
|
+
}
|
|
2678
3411
|
case "PROPERTY_UPDATE":
|
|
2679
3412
|
this.updateProperty(message.componentId, message.property, message.payload.value);
|
|
2680
3413
|
return { success: true };
|
|
@@ -2702,6 +3435,14 @@ var ComponentRegistry = class {
|
|
|
2702
3435
|
this.cleanupComponent(componentId);
|
|
2703
3436
|
}
|
|
2704
3437
|
}
|
|
3438
|
+
if (connId) {
|
|
3439
|
+
for (const [name, remote] of this.remoteSingletons) {
|
|
3440
|
+
remote.connections.delete(connId);
|
|
3441
|
+
if (remote.connections.size === 0) {
|
|
3442
|
+
this.remoteSingletons.delete(name);
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
2705
3446
|
ws.data.components.clear();
|
|
2706
3447
|
}
|
|
2707
3448
|
getStats() {
|
|
@@ -2713,6 +3454,9 @@ var ComponentRegistry = class {
|
|
|
2713
3454
|
singletons: Object.fromEntries(
|
|
2714
3455
|
Array.from(this.singletons.entries()).map(([name, s]) => [name, { componentId: s.instance.id, connections: s.connections.size }])
|
|
2715
3456
|
),
|
|
3457
|
+
remoteSingletons: Object.fromEntries(
|
|
3458
|
+
Array.from(this.remoteSingletons.entries()).map(([name, r]) => [name, { componentId: r.componentId, ownerInstanceId: r.ownerInstanceId, connections: r.connections.size }])
|
|
3459
|
+
),
|
|
2716
3460
|
roomDetails: Object.fromEntries(
|
|
2717
3461
|
Array.from(this.rooms.entries()).map(([roomId, components]) => [roomId, components.size])
|
|
2718
3462
|
)
|
|
@@ -2770,7 +3514,7 @@ var ComponentRegistry = class {
|
|
|
2770
3514
|
metadata.healthStatus = metadata.metrics.errorCount > 5 ? "unhealthy" : "degraded";
|
|
2771
3515
|
}
|
|
2772
3516
|
}
|
|
2773
|
-
|
|
3517
|
+
performHealthChecks() {
|
|
2774
3518
|
for (const [componentId, metadata] of this.metadata) {
|
|
2775
3519
|
if (!this.components.get(componentId)) continue;
|
|
2776
3520
|
if (metadata.metrics.errorCount > 10) metadata.healthStatus = "unhealthy";
|
|
@@ -2796,6 +3540,7 @@ var ComponentRegistry = class {
|
|
|
2796
3540
|
cleanup() {
|
|
2797
3541
|
if (this.healthCheckInterval) clearInterval(this.healthCheckInterval);
|
|
2798
3542
|
this.singletons.clear();
|
|
3543
|
+
this.remoteSingletons.clear();
|
|
2799
3544
|
for (const [componentId] of this.components) this.cleanupComponent(componentId);
|
|
2800
3545
|
}
|
|
2801
3546
|
};
|
|
@@ -2874,6 +3619,25 @@ function decodeBinaryChunk(raw) {
|
|
|
2874
3619
|
return { header, data };
|
|
2875
3620
|
}
|
|
2876
3621
|
|
|
3622
|
+
// src/security/sanitize.ts
|
|
3623
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
3624
|
+
var MAX_DEPTH = 10;
|
|
3625
|
+
function sanitizePayload(value, depth = 0) {
|
|
3626
|
+
if (depth > MAX_DEPTH) return value;
|
|
3627
|
+
if (Array.isArray(value)) {
|
|
3628
|
+
return value.map((item) => sanitizePayload(item, depth + 1));
|
|
3629
|
+
}
|
|
3630
|
+
if (value !== null && typeof value === "object") {
|
|
3631
|
+
const clean = {};
|
|
3632
|
+
for (const key of Object.keys(value)) {
|
|
3633
|
+
if (DANGEROUS_KEYS.has(key)) continue;
|
|
3634
|
+
clean[key] = sanitizePayload(value[key], depth + 1);
|
|
3635
|
+
}
|
|
3636
|
+
return clean;
|
|
3637
|
+
}
|
|
3638
|
+
return value;
|
|
3639
|
+
}
|
|
3640
|
+
|
|
2877
3641
|
// src/server/LiveServer.ts
|
|
2878
3642
|
var LiveServer = class {
|
|
2879
3643
|
// Public singletons (accessible for transport adapters & advanced usage)
|
|
@@ -2893,7 +3657,7 @@ var LiveServer = class {
|
|
|
2893
3657
|
this.options = options;
|
|
2894
3658
|
this.transport = options.transport;
|
|
2895
3659
|
this.roomEvents = new RoomEventBus();
|
|
2896
|
-
this.roomManager = new LiveRoomManager(this.roomEvents);
|
|
3660
|
+
this.roomManager = new LiveRoomManager(this.roomEvents, options.roomPubSub);
|
|
2897
3661
|
this.debugger = new LiveDebugger(options.debug ?? false);
|
|
2898
3662
|
this.authManager = new LiveAuthManager();
|
|
2899
3663
|
this.stateSignature = new StateSignatureManager(options.stateSignature);
|
|
@@ -2905,7 +3669,8 @@ var LiveServer = class {
|
|
|
2905
3669
|
authManager: this.authManager,
|
|
2906
3670
|
debugger: this.debugger,
|
|
2907
3671
|
stateSignature: this.stateSignature,
|
|
2908
|
-
performanceMonitor: this.performanceMonitor
|
|
3672
|
+
performanceMonitor: this.performanceMonitor,
|
|
3673
|
+
cluster: options.cluster
|
|
2909
3674
|
});
|
|
2910
3675
|
_setLoggerDebugger(this.debugger);
|
|
2911
3676
|
setLiveComponentContext({
|
|
@@ -2940,10 +3705,13 @@ var LiveServer = class {
|
|
|
2940
3705
|
const prefix = this.options.httpPrefix ?? "/api/live";
|
|
2941
3706
|
await this.transport.registerHttpRoutes(this.buildHttpRoutes(prefix));
|
|
2942
3707
|
}
|
|
3708
|
+
if (this.options.cluster) {
|
|
3709
|
+
await this.options.cluster.start();
|
|
3710
|
+
}
|
|
2943
3711
|
if (this.transport.start) {
|
|
2944
3712
|
await this.transport.start();
|
|
2945
3713
|
}
|
|
2946
|
-
liveLog("lifecycle", null, `LiveServer started (ws: ${wsConfig.path})`);
|
|
3714
|
+
liveLog("lifecycle", null, `LiveServer started (ws: ${wsConfig.path}${this.options.cluster ? ", cluster: enabled" : ""})`);
|
|
2947
3715
|
}
|
|
2948
3716
|
/**
|
|
2949
3717
|
* Graceful shutdown.
|
|
@@ -2953,21 +3721,32 @@ var LiveServer = class {
|
|
|
2953
3721
|
this.connectionManager.shutdown();
|
|
2954
3722
|
this.fileUploadManager.shutdown();
|
|
2955
3723
|
this.stateSignature.shutdown();
|
|
3724
|
+
if (this.options.cluster) await this.options.cluster.shutdown();
|
|
2956
3725
|
if (this.transport.shutdown) await this.transport.shutdown();
|
|
2957
3726
|
liveLog("lifecycle", null, "LiveServer shut down");
|
|
2958
3727
|
}
|
|
2959
3728
|
// ===== WebSocket Handlers =====
|
|
2960
3729
|
handleOpen(ws) {
|
|
3730
|
+
const origin = ws.data?.origin;
|
|
3731
|
+
const allowedOrigins = this.options.allowedOrigins;
|
|
3732
|
+
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
3733
|
+
if (!origin || !allowedOrigins.includes(origin)) {
|
|
3734
|
+
liveLog("websocket", null, `Connection rejected: origin '${origin || "none"}' not in allowedOrigins`);
|
|
3735
|
+
ws.close(4003, "Origin not allowed");
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
2961
3739
|
const connectionId = `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2962
3740
|
ws.data = {
|
|
2963
3741
|
connectionId,
|
|
2964
3742
|
components: /* @__PURE__ */ new Map(),
|
|
2965
3743
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
2966
|
-
connectedAt: /* @__PURE__ */ new Date()
|
|
3744
|
+
connectedAt: /* @__PURE__ */ new Date(),
|
|
3745
|
+
origin
|
|
2967
3746
|
};
|
|
2968
3747
|
this.connectionManager.registerConnection(ws, connectionId);
|
|
2969
3748
|
this.debugger.trackConnection(connectionId);
|
|
2970
|
-
ws
|
|
3749
|
+
sendImmediate(ws, JSON.stringify({
|
|
2971
3750
|
type: "CONNECTION_ESTABLISHED",
|
|
2972
3751
|
connectionId,
|
|
2973
3752
|
timestamp: Date.now()
|
|
@@ -2979,7 +3758,7 @@ var LiveServer = class {
|
|
|
2979
3758
|
if (connectionId) {
|
|
2980
3759
|
const limiter = this.rateLimiter.get(connectionId);
|
|
2981
3760
|
if (!limiter.tryConsume()) {
|
|
2982
|
-
ws
|
|
3761
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: "Rate limit exceeded", timestamp: Date.now() }));
|
|
2983
3762
|
return;
|
|
2984
3763
|
}
|
|
2985
3764
|
}
|
|
@@ -2989,26 +3768,33 @@ var LiveServer = class {
|
|
|
2989
3768
|
if (header.type === "FILE_UPLOAD_CHUNK") {
|
|
2990
3769
|
const chunkMessage = { ...header, data: "" };
|
|
2991
3770
|
const progress = await this.fileUploadManager.receiveChunk(chunkMessage, data);
|
|
2992
|
-
if (progress) ws
|
|
3771
|
+
if (progress) sendImmediate(ws, JSON.stringify(progress));
|
|
2993
3772
|
}
|
|
2994
3773
|
} catch (error) {
|
|
2995
|
-
ws
|
|
3774
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: error.message, timestamp: Date.now() }));
|
|
2996
3775
|
}
|
|
2997
3776
|
return;
|
|
2998
3777
|
}
|
|
3778
|
+
const str = typeof rawMessage === "string" ? rawMessage : new TextDecoder().decode(rawMessage);
|
|
3779
|
+
if (str.length > MAX_MESSAGE_SIZE) {
|
|
3780
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: "Message too large", timestamp: Date.now() }));
|
|
3781
|
+
return;
|
|
3782
|
+
}
|
|
2999
3783
|
let message;
|
|
3000
3784
|
try {
|
|
3001
|
-
const str = typeof rawMessage === "string" ? rawMessage : new TextDecoder().decode(rawMessage);
|
|
3002
3785
|
message = JSON.parse(str);
|
|
3003
3786
|
} catch {
|
|
3004
|
-
ws
|
|
3787
|
+
sendImmediate(ws, JSON.stringify({ type: "ERROR", error: "Invalid JSON", timestamp: Date.now() }));
|
|
3005
3788
|
return;
|
|
3006
3789
|
}
|
|
3790
|
+
if (message.payload) {
|
|
3791
|
+
message.payload = sanitizePayload(message.payload);
|
|
3792
|
+
}
|
|
3007
3793
|
try {
|
|
3008
3794
|
if (message.type === "AUTH") {
|
|
3009
3795
|
const authContext = await this.authManager.authenticate(message.payload || {});
|
|
3010
3796
|
if (ws.data) ws.data.authContext = authContext;
|
|
3011
|
-
ws
|
|
3797
|
+
sendImmediate(ws, JSON.stringify({
|
|
3012
3798
|
type: "AUTH_RESPONSE",
|
|
3013
3799
|
success: authContext.authenticated,
|
|
3014
3800
|
payload: authContext.authenticated ? { userId: authContext.user?.id } : { error: "Authentication failed" },
|
|
@@ -3017,12 +3803,12 @@ var LiveServer = class {
|
|
|
3017
3803
|
return;
|
|
3018
3804
|
}
|
|
3019
3805
|
if (message.type === "ROOM_JOIN" || message.type === "ROOM_LEAVE" || message.type === "ROOM_EMIT" || message.type === "ROOM_STATE_SET" || message.type === "ROOM_STATE_GET") {
|
|
3020
|
-
|
|
3806
|
+
this.handleRoomMessage(ws, message);
|
|
3021
3807
|
return;
|
|
3022
3808
|
}
|
|
3023
3809
|
if (message.type === "FILE_UPLOAD_START") {
|
|
3024
3810
|
const result2 = await this.fileUploadManager.startUpload(message, ws.data?.userId);
|
|
3025
|
-
ws
|
|
3811
|
+
sendImmediate(ws, JSON.stringify({
|
|
3026
3812
|
type: "FILE_UPLOAD_START_RESPONSE",
|
|
3027
3813
|
componentId: message.componentId,
|
|
3028
3814
|
uploadId: message.payload?.uploadId,
|
|
@@ -3035,12 +3821,12 @@ var LiveServer = class {
|
|
|
3035
3821
|
}
|
|
3036
3822
|
if (message.type === "FILE_UPLOAD_CHUNK") {
|
|
3037
3823
|
const progress = await this.fileUploadManager.receiveChunk(message);
|
|
3038
|
-
if (progress) ws
|
|
3824
|
+
if (progress) sendImmediate(ws, JSON.stringify(progress));
|
|
3039
3825
|
return;
|
|
3040
3826
|
}
|
|
3041
3827
|
if (message.type === "FILE_UPLOAD_COMPLETE") {
|
|
3042
3828
|
const result2 = await this.fileUploadManager.completeUpload(message);
|
|
3043
|
-
ws
|
|
3829
|
+
sendImmediate(ws, JSON.stringify(result2));
|
|
3044
3830
|
return;
|
|
3045
3831
|
}
|
|
3046
3832
|
if (message.type === "COMPONENT_REHYDRATE") {
|
|
@@ -3051,7 +3837,7 @@ var LiveServer = class {
|
|
|
3051
3837
|
ws,
|
|
3052
3838
|
{ room: message.payload.room, userId: message.userId }
|
|
3053
3839
|
);
|
|
3054
|
-
ws
|
|
3840
|
+
sendImmediate(ws, JSON.stringify({
|
|
3055
3841
|
type: "COMPONENT_REHYDRATED",
|
|
3056
3842
|
componentId: message.componentId,
|
|
3057
3843
|
success: result2.success,
|
|
@@ -3075,10 +3861,10 @@ var LiveServer = class {
|
|
|
3075
3861
|
responseId: message.responseId,
|
|
3076
3862
|
timestamp: Date.now()
|
|
3077
3863
|
};
|
|
3078
|
-
ws
|
|
3864
|
+
sendImmediate(ws, JSON.stringify(response));
|
|
3079
3865
|
}
|
|
3080
3866
|
} catch (error) {
|
|
3081
|
-
ws
|
|
3867
|
+
sendImmediate(ws, JSON.stringify({
|
|
3082
3868
|
type: "ERROR",
|
|
3083
3869
|
componentId: message.componentId,
|
|
3084
3870
|
error: error.message,
|
|
@@ -3103,13 +3889,26 @@ var LiveServer = class {
|
|
|
3103
3889
|
console.error(`[LiveServer] WebSocket error:`, error.message);
|
|
3104
3890
|
}
|
|
3105
3891
|
// ===== Room Message Router =====
|
|
3106
|
-
|
|
3892
|
+
handleRoomMessage(ws, message) {
|
|
3107
3893
|
const { componentId } = message;
|
|
3108
3894
|
const roomId = message.roomId || message.payload?.roomId;
|
|
3109
3895
|
switch (message.type) {
|
|
3110
3896
|
case "ROOM_JOIN": {
|
|
3897
|
+
const connRooms = ws.data?.rooms;
|
|
3898
|
+
if (connRooms && connRooms.size >= MAX_ROOMS_PER_CONNECTION) {
|
|
3899
|
+
sendImmediate(ws, JSON.stringify({
|
|
3900
|
+
type: "ERROR",
|
|
3901
|
+
componentId,
|
|
3902
|
+
error: "Room limit exceeded",
|
|
3903
|
+
requestId: message.requestId,
|
|
3904
|
+
timestamp: Date.now()
|
|
3905
|
+
}));
|
|
3906
|
+
break;
|
|
3907
|
+
}
|
|
3111
3908
|
const result = this.roomManager.joinRoom(componentId, roomId, ws, message.payload?.initialState);
|
|
3112
|
-
ws.
|
|
3909
|
+
if (!ws.data.rooms) ws.data.rooms = /* @__PURE__ */ new Set();
|
|
3910
|
+
ws.data.rooms.add(roomId);
|
|
3911
|
+
sendImmediate(ws, JSON.stringify({
|
|
3113
3912
|
type: "ROOM_JOINED",
|
|
3114
3913
|
componentId,
|
|
3115
3914
|
payload: { roomId, state: result.state },
|
|
@@ -3120,7 +3919,8 @@ var LiveServer = class {
|
|
|
3120
3919
|
}
|
|
3121
3920
|
case "ROOM_LEAVE":
|
|
3122
3921
|
this.roomManager.leaveRoom(componentId, roomId);
|
|
3123
|
-
ws.
|
|
3922
|
+
ws.data?.rooms?.delete(roomId);
|
|
3923
|
+
sendImmediate(ws, JSON.stringify({
|
|
3124
3924
|
type: "ROOM_LEFT",
|
|
3125
3925
|
componentId,
|
|
3126
3926
|
payload: { roomId },
|
|
@@ -3136,7 +3936,7 @@ var LiveServer = class {
|
|
|
3136
3936
|
break;
|
|
3137
3937
|
case "ROOM_STATE_GET": {
|
|
3138
3938
|
const state = this.roomManager.getRoomState(roomId);
|
|
3139
|
-
ws
|
|
3939
|
+
sendImmediate(ws, JSON.stringify({
|
|
3140
3940
|
type: "ROOM_STATE",
|
|
3141
3941
|
componentId,
|
|
3142
3942
|
payload: { roomId, state },
|
|
@@ -3153,7 +3953,7 @@ var LiveServer = class {
|
|
|
3153
3953
|
{
|
|
3154
3954
|
method: "GET",
|
|
3155
3955
|
path: `${prefix}/stats`,
|
|
3156
|
-
handler:
|
|
3956
|
+
handler: () => ({
|
|
3157
3957
|
body: {
|
|
3158
3958
|
components: this.registry.getStats(),
|
|
3159
3959
|
rooms: this.roomManager.getStats(),
|
|
@@ -3167,7 +3967,7 @@ var LiveServer = class {
|
|
|
3167
3967
|
{
|
|
3168
3968
|
method: "GET",
|
|
3169
3969
|
path: `${prefix}/components`,
|
|
3170
|
-
handler:
|
|
3970
|
+
handler: () => ({
|
|
3171
3971
|
body: { names: this.registry.getRegisteredComponentNames() }
|
|
3172
3972
|
}),
|
|
3173
3973
|
metadata: { summary: "List registered component names", tags: ["live"] }
|
|
@@ -3175,7 +3975,7 @@ var LiveServer = class {
|
|
|
3175
3975
|
{
|
|
3176
3976
|
method: "POST",
|
|
3177
3977
|
path: `${prefix}/rooms/:roomId/messages`,
|
|
3178
|
-
handler:
|
|
3978
|
+
handler: (req) => {
|
|
3179
3979
|
const roomId = req.params.roomId;
|
|
3180
3980
|
this.roomManager.emitToRoom(roomId, "message:new", req.body);
|
|
3181
3981
|
return { body: { success: true, roomId } };
|
|
@@ -3185,7 +3985,7 @@ var LiveServer = class {
|
|
|
3185
3985
|
{
|
|
3186
3986
|
method: "POST",
|
|
3187
3987
|
path: `${prefix}/rooms/:roomId/emit`,
|
|
3188
|
-
handler:
|
|
3988
|
+
handler: (req) => {
|
|
3189
3989
|
const roomId = req.params.roomId;
|
|
3190
3990
|
const { event, data } = req.body;
|
|
3191
3991
|
this.roomManager.emitToRoom(roomId, event, data);
|
|
@@ -3329,6 +4129,66 @@ var RoomStateManager = class {
|
|
|
3329
4129
|
}
|
|
3330
4130
|
};
|
|
3331
4131
|
|
|
3332
|
-
|
|
4132
|
+
// src/rooms/InMemoryRoomAdapter.ts
|
|
4133
|
+
var InMemoryRoomAdapter = class {
|
|
4134
|
+
rooms = /* @__PURE__ */ new Map();
|
|
4135
|
+
// ===== IRoomStorageAdapter =====
|
|
4136
|
+
async getOrCreateRoom(roomId, initialState) {
|
|
4137
|
+
const existing = this.rooms.get(roomId);
|
|
4138
|
+
if (existing) {
|
|
4139
|
+
return { state: existing.state, created: false };
|
|
4140
|
+
}
|
|
4141
|
+
const now = Date.now();
|
|
4142
|
+
const data = {
|
|
4143
|
+
state: initialState ?? {},
|
|
4144
|
+
createdAt: now,
|
|
4145
|
+
lastUpdate: now
|
|
4146
|
+
};
|
|
4147
|
+
this.rooms.set(roomId, data);
|
|
4148
|
+
return { state: data.state, created: true };
|
|
4149
|
+
}
|
|
4150
|
+
async getState(roomId) {
|
|
4151
|
+
return this.rooms.get(roomId)?.state ?? {};
|
|
4152
|
+
}
|
|
4153
|
+
async updateState(roomId, updates) {
|
|
4154
|
+
const room = this.rooms.get(roomId);
|
|
4155
|
+
if (room) {
|
|
4156
|
+
Object.assign(room.state, updates);
|
|
4157
|
+
room.lastUpdate = Date.now();
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
async hasRoom(roomId) {
|
|
4161
|
+
return this.rooms.has(roomId);
|
|
4162
|
+
}
|
|
4163
|
+
async deleteRoom(roomId) {
|
|
4164
|
+
return this.rooms.delete(roomId);
|
|
4165
|
+
}
|
|
4166
|
+
async getStats() {
|
|
4167
|
+
const rooms = {};
|
|
4168
|
+
for (const [id, data] of this.rooms) {
|
|
4169
|
+
rooms[id] = {
|
|
4170
|
+
createdAt: data.createdAt,
|
|
4171
|
+
lastUpdate: data.lastUpdate,
|
|
4172
|
+
stateKeys: Object.keys(data.state)
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
return { totalRooms: this.rooms.size, rooms };
|
|
4176
|
+
}
|
|
4177
|
+
// ===== IRoomPubSubAdapter =====
|
|
4178
|
+
// No-ops for single-instance: all events are already propagated locally
|
|
4179
|
+
// by RoomEventBus and LiveRoomManager's broadcastToRoom().
|
|
4180
|
+
async publish(_roomId, _event, _data) {
|
|
4181
|
+
}
|
|
4182
|
+
async subscribe(_roomId, _handler) {
|
|
4183
|
+
return () => {
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
async publishMembership(_roomId, _action, _componentId) {
|
|
4187
|
+
}
|
|
4188
|
+
async publishStateChange(_roomId, _updates) {
|
|
4189
|
+
}
|
|
4190
|
+
};
|
|
4191
|
+
|
|
4192
|
+
export { ANONYMOUS_CONTEXT, AnonymousContext, AuthenticatedContext, ComponentRegistry, ConnectionRateLimiter, DEFAULT_CHUNK_SIZE, DEFAULT_WS_PATH, EMIT_OVERRIDE_KEY, FileUploadManager, InMemoryRoomAdapter, LiveAuthManager, LiveComponent, LiveDebugger, LiveRoomManager, LiveServer, PROTOCOL_VERSION, PerformanceMonitor, RateLimiterRegistry, RoomEventBus, RoomStateManager, StateSignatureManager, WebSocketConnectionManager, createTypedRoomEventBus, createTypedRoomState, decodeBinaryChunk, encodeBinaryChunk, getLiveComponentContext, liveLog, liveWarn, queueWsMessage, registerComponentLogging, sendImmediate, setLiveComponentContext, unregisterComponentLogging };
|
|
3333
4193
|
//# sourceMappingURL=index.js.map
|
|
3334
4194
|
//# sourceMappingURL=index.js.map
|