@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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes, createCipheriv, createDecipheriv, createHmac } from 'crypto';
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 [key, subs] of subscriptions) {
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
- removed += subscriptions.get(key)?.size ?? 0;
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 [key, subs] of this.subscriptions) {
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
- removed += this.subscriptions.get(key)?.size ?? 0;
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
- constructor(roomEvents) {
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 || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(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
- if (!this.rooms.has(roomId)) {
271
- this.rooms.set(roomId, {
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: Date.now(),
276
- lastActivity: Date.now()
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: Date.now()
450
+ joinedAt: now
285
451
  });
286
- room.lastActivity = Date.now();
287
- if (!this.componentRooms.has(componentId)) {
288
- this.componentRooms.set(componentId, /* @__PURE__ */ new Set());
289
- }
290
- this.componentRooms.get(componentId).add(roomId);
291
- liveLog("rooms", componentId, `Component '${componentId}' joined room '${roomId}' (${room.members.size} members)`);
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: room.members.size
468
+ count: memberCount
300
469
  },
301
- timestamp: Date.now()
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
- room.lastActivity = Date.now();
483
+ const now = Date.now();
484
+ room.lastActivity = now;
313
485
  this.componentRooms.get(componentId)?.delete(roomId);
314
- liveLog("rooms", componentId, `Component '${componentId}' left room '${roomId}' (${room.members.size} members)`);
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: room.members.size
495
+ count: memberCount
323
496
  },
324
- timestamp: Date.now()
497
+ timestamp: now
325
498
  });
326
- if (room.members.size === 0) {
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 rooms = this.componentRooms.get(componentId);
341
- if (!rooms) return;
342
- for (const roomId of rooms) {
343
- this.leaveRoom(componentId, roomId);
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
- room.lastActivity = Date.now();
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: Date.now()
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
- const newState = { ...room.state, ...updates };
371
- const stateSize = Buffer.byteLength(JSON.stringify(newState), "utf8");
372
- if (stateSize > MAX_ROOM_STATE_SIZE) {
373
- throw new Error("Room state exceeds maximum size limit");
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
- room.state = newState;
376
- room.lastActivity = Date.now();
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: Date.now()
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
- for (const [componentId, member] of room.members) {
400
- if (excludeComponentId && componentId === excludeComponentId) continue;
401
- try {
402
- if (member.ws && member.ws.readyState === 1) {
403
- member.ws.send(JSON.stringify({
404
- ...message,
405
- componentId
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 ?? 7 * 24 * 60 * 60 * 1e3,
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(), 60 * 60 * 1e3);
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
- async signState(componentId, state, version, options) {
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 ? randomBytes(16).toString("hex") : void 0;
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
- async validateState(signedState) {
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 (replay attempt)" };
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) this.usedNonces.add(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) this.usedNonces.add(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
- async extractData(signedState) {
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
- return createHmac("sha256", this.secret).update("encryption-key-derivation").digest();
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
- if (this.usedNonces.size > 1e5) {
1124
- this.usedNonces.clear();
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
- for (const [poolId, pool] of this.connectionPools) {
1612
- if (pool.has(connectionId)) {
1613
- this.removeFromPool(connectionId, poolId);
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/LiveComponent.ts
1711
- var EMIT_OVERRIDE_KEY = /* @__PURE__ */ Symbol.for("fluxstack:emitOverride");
1712
- var _liveDebugger = null;
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
- state;
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
- // Singleton emit override
1769
- [EMIT_OVERRIDE_KEY] = null;
1770
- constructor(initialState, ws, options) {
1771
- this.id = this.generateId();
1772
- const ctor = this.constructor;
1773
- this._state = { ...ctor.defaultState, ...initialState };
1774
- this.state = this.createStateProxy(this._state);
1775
- this.ws = ws;
1776
- this.room = options?.room;
1777
- this.userId = options?.userId;
1778
- if (this.room) {
1779
- this.joinedRooms.add(this.room);
1780
- const ctx = getLiveComponentContext();
1781
- ctx.roomManager.joinRoom(this.id, this.room, this.ws);
1782
- }
1783
- this.createDirectStateAccessors();
1784
- }
1785
- // Create getters/setters for each state property directly on `this`
1786
- createDirectStateAccessors() {
1787
- const forbidden = /* @__PURE__ */ new Set([
1788
- ...Object.keys(this),
1789
- ...Object.getOwnPropertyNames(Object.getPrototypeOf(this)),
1790
- "state",
1791
- "_state",
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.emit("STATE_DELTA", { delta: changes });
2056
+ self.emitFn("STATE_DELTA", { delta: changes });
1829
2057
  if (!self._inStateChange) {
1830
2058
  self._inStateChange = true;
1831
2059
  try {
1832
- self.onStateChange(changes);
2060
+ self.onStateChangeFn(changes);
1833
2061
  } catch (err) {
1834
- console.error(`[${self.id}] onStateChange error:`, err?.message || err);
2062
+ console.error(`[${self.componentId}] onStateChange error:`, err?.message || err);
1835
2063
  } finally {
1836
2064
  self._inStateChange = false;
1837
2065
  }
1838
2066
  }
1839
- _liveDebugger?.trackStateChange(
1840
- self.id,
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
- // $private - Server-Only State
1855
- // ========================================
1856
- get $private() {
1857
- return this._privateState;
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
- // $room - Unified Room System
1861
- // ========================================
1862
- get $room() {
1863
- const self = this;
1864
- const ctx = getLiveComponentContext();
1865
- const createHandle = (roomId) => {
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
- const handle = {
1870
- get id() {
1871
- return roomId;
1872
- },
1873
- get state() {
1874
- return ctx.roomManager.getRoomState(roomId);
1875
- },
1876
- join: (initialState) => {
1877
- if (self.joinedRooms.has(roomId)) return;
1878
- self.joinedRooms.add(roomId);
1879
- ctx.roomManager.joinRoom(self.id, roomId, self.ws, initialState);
1880
- try {
1881
- self.onRoomJoin(roomId);
1882
- } catch (err) {
1883
- console.error(`[${self.id}] onRoomJoin error:`, err?.message || err);
1884
- }
1885
- },
1886
- leave: () => {
1887
- if (!self.joinedRooms.has(roomId)) return;
1888
- self.joinedRooms.delete(roomId);
1889
- ctx.roomManager.leaveRoom(self.id, roomId);
1890
- try {
1891
- self.onRoomLeave(roomId);
1892
- } catch (err) {
1893
- console.error(`[${self.id}] onRoomLeave error:`, err?.message || err);
1894
- }
1895
- },
1896
- emit: (event, data) => {
1897
- return ctx.roomManager.emitToRoom(roomId, event, data, self.id);
1898
- },
1899
- on: (event, handler) => {
1900
- const unsubscribe = ctx.roomEvents.on(
1901
- "room",
1902
- roomId,
1903
- event,
1904
- self.id,
1905
- handler
1906
- );
1907
- self.roomEventUnsubscribers.push(unsubscribe);
1908
- return unsubscribe;
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.id);
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
- * List of room IDs this component is participating in
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 Array.from(this.joinedRooms);
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
- const newUpdates = typeof updates === "function" ? updates(this._state) : updates;
2016
- const actualChanges = {};
2017
- let hasChanges = false;
2018
- for (const key of Object.keys(newUpdates)) {
2019
- if (this._state[key] !== newUpdates[key]) {
2020
- actualChanges[key] = newUpdates[key];
2021
- hasChanges = true;
2022
- }
2023
- }
2024
- if (!hasChanges) return;
2025
- Object.assign(this._state, actualChanges);
2026
- this.emit("STATE_DELTA", { delta: actualChanges });
2027
- if (!this._inStateChange) {
2028
- this._inStateChange = true;
2029
- try {
2030
- this.onStateChange(actualChanges);
2031
- } catch (err) {
2032
- console.error(`[${this.id}] onStateChange error:`, err?.message || err);
2033
- } finally {
2034
- this._inStateChange = false;
2035
- }
2036
- }
2037
- _liveDebugger?.trackStateChange(
2038
- this.id,
2039
- actualChanges,
2040
- this._state,
2041
- "setState"
2042
- );
2741
+ this._stateManager.setState(updates);
2043
2742
  }
2044
- async setValue(payload) {
2045
- const { key, value } = payload;
2046
- const update = { [key]: value };
2047
- this.setState(update);
2048
- return { success: true, key, value };
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 Security
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
- const actionStart = Date.now();
2093
- try {
2094
- if (_LiveComponent.BLOCKED_ACTIONS.has(action)) {
2095
- throw new Error(`Action '${action}' is not callable`);
2096
- }
2097
- if (action.startsWith("_") || action.startsWith("#")) {
2098
- throw new Error(`Action '${action}' is not callable`);
2099
- }
2100
- const componentClass = this.constructor;
2101
- const publicActions = componentClass.publicActions;
2102
- if (!publicActions) {
2103
- console.warn(`[SECURITY] Component '${componentClass.componentName || componentClass.name}' has no publicActions defined. All remote actions are blocked.`);
2104
- throw new Error(`Action '${action}' is not callable - component has no publicActions defined`);
2105
- }
2106
- if (!publicActions.includes(action)) {
2107
- const methodExists = typeof this[action] === "function";
2108
- if (methodExists) {
2109
- const name = componentClass.componentName || componentClass.name;
2110
- throw new Error(
2111
- `Action '${action}' exists on '${name}' but is not listed in publicActions. Add it to: static publicActions = [..., '${action}']`
2112
- );
2113
- }
2114
- throw new Error(`Action '${action}' is not callable`);
2115
- }
2116
- const method = this[action];
2117
- if (typeof method !== "function") {
2118
- throw new Error(`Action '${action}' not found on component`);
2119
- }
2120
- if (Object.prototype.hasOwnProperty.call(Object.prototype, action)) {
2121
- throw new Error(`Action '${action}' is not callable`);
2122
- }
2123
- _liveDebugger?.trackActionCall(this.id, action, payload);
2124
- let hookResult;
2125
- try {
2126
- hookResult = await this.onAction(action, payload);
2127
- } catch (hookError) {
2128
- _liveDebugger?.trackActionError(this.id, action, hookError.message, Date.now() - actionStart);
2129
- this.emit("ERROR", {
2130
- action,
2131
- error: `Action '${action}' failed pre-validation`
2132
- });
2133
- throw hookError;
2134
- }
2135
- if (hookResult === false) {
2136
- _liveDebugger?.trackActionError(this.id, action, "Action cancelled", Date.now() - actionStart);
2137
- throw new Error(`Action '${action}' was cancelled`);
2138
- }
2139
- const result = await method.call(this, payload);
2140
- _liveDebugger?.trackActionResult(this.id, action, result, Date.now() - actionStart);
2141
- return result;
2142
- } catch (error) {
2143
- if (!error.message?.includes("was cancelled") && !error.message?.includes("pre-validation")) {
2144
- _liveDebugger?.trackActionError(this.id, action, error.message, Date.now() - actionStart);
2145
- this.emit("ERROR", {
2146
- action,
2147
- error: error.message
2148
- });
2149
- }
2150
- throw error;
2151
- }
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
- const override = this[EMIT_OVERRIDE_KEY];
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
- if (!this.room) {
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 - Internal Server Events
2777
+ // Room Events (delegates to _roomProxyManager)
2190
2778
  // ========================================
2191
2779
  emitRoomEvent(event, data, notifySelf = false) {
2192
- if (!this.room) {
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
- if (!this.room) {
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.setState(stateUpdates);
2221
- return this.emitRoomEvent(event, data, false);
2786
+ return this._roomProxyManager.emitRoomEventWithState(event, data, stateUpdates);
2222
2787
  }
2223
- async subscribeToRoom(roomId) {
2788
+ subscribeToRoom(roomId) {
2789
+ this._roomProxyManager.subscribeToRoom(roomId);
2224
2790
  this.room = roomId;
2225
2791
  }
2226
- async unsubscribeFromRoom() {
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
- for (const unsubscribe of this.roomEventUnsubscribers) {
2242
- unsubscribe();
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.unsubscribeFromRoom();
2811
+ this.room = void 0;
2253
2812
  }
2254
2813
  getSerializableState() {
2255
- return this.state;
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 = await this.stateSignature.signState(existing.instance.id, {
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.send(JSON.stringify({
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 = await this.stateSignature.signState(component.id, {
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 = await this.stateSignature.validateState(signedState);
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 = await this.stateSignature.extractData(signedState);
2495
- if (clientState.__componentName && clientState.__componentName !== 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 = await this.stateSignature.signState(
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
- async unmountComponent(componentId, ws) {
3270
+ unmountComponent(componentId, ws) {
2569
3271
  const component = this.components.get(componentId);
2570
- if (!component) return;
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 && ws.send) ws.send(JSON.stringify(broadcastMessage));
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
- await this.unmountComponent(message.componentId, ws);
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
- async performHealthChecks() {
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.send(JSON.stringify({
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.send(JSON.stringify({ type: "ERROR", error: "Rate limit exceeded", timestamp: Date.now() }));
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.send(JSON.stringify(progress));
3771
+ if (progress) sendImmediate(ws, JSON.stringify(progress));
2993
3772
  }
2994
3773
  } catch (error) {
2995
- ws.send(JSON.stringify({ type: "ERROR", error: error.message, timestamp: Date.now() }));
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.send(JSON.stringify({ type: "ERROR", error: "Invalid JSON", timestamp: Date.now() }));
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.send(JSON.stringify({
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
- await this.handleRoomMessage(ws, message);
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.send(JSON.stringify({
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.send(JSON.stringify(progress));
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.send(JSON.stringify(result2));
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.send(JSON.stringify({
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.send(JSON.stringify(response));
3864
+ sendImmediate(ws, JSON.stringify(response));
3079
3865
  }
3080
3866
  } catch (error) {
3081
- ws.send(JSON.stringify({
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
- async handleRoomMessage(ws, message) {
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.send(JSON.stringify({
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.send(JSON.stringify({
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.send(JSON.stringify({
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: async () => ({
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: async () => ({
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: async (req) => {
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: async (req) => {
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
- export { ANONYMOUS_CONTEXT, AnonymousContext, AuthenticatedContext, ComponentRegistry, ConnectionRateLimiter, DEFAULT_CHUNK_SIZE, DEFAULT_WS_PATH, FileUploadManager, LiveAuthManager, LiveComponent, LiveDebugger, LiveRoomManager, LiveServer, PROTOCOL_VERSION, PerformanceMonitor, RateLimiterRegistry, RoomEventBus, RoomStateManager, StateSignatureManager, WebSocketConnectionManager, createTypedRoomEventBus, createTypedRoomState, decodeBinaryChunk, encodeBinaryChunk, getLiveComponentContext, liveLog, liveWarn, registerComponentLogging, setLiveComponentContext, unregisterComponentLogging };
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