@lolyjs/core 0.2.0-alpha.16 → 0.2.0-alpha.17

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/cli.cjs CHANGED
@@ -385,6 +385,58 @@ var import_path7 = __toESM(require("path"));
385
385
  var import_fs4 = __toESM(require("fs"));
386
386
  var import_path5 = __toESM(require("path"));
387
387
  init_globals();
388
+
389
+ // modules/router/helpers/routes/extract-wss-route.ts
390
+ function extractDefineWssRoute(mod, namespace) {
391
+ if (!mod.default) {
392
+ if (mod.events && Array.isArray(mod.events)) {
393
+ throw new Error(
394
+ `[loly:realtime] BREAKING CHANGE: 'export const events = []' is no longer supported.
395
+ Please use 'export default defineWssRoute({ events: { ... } })' instead.
396
+ See migration guide: https://loly.dev/docs/migration
397
+ File: ${mod.__filename || "unknown"}`
398
+ );
399
+ }
400
+ return null;
401
+ }
402
+ const routeDef = mod.default;
403
+ if (!routeDef || typeof routeDef !== "object" || !routeDef.events) {
404
+ throw new Error(
405
+ `[loly:realtime] Module must export default from defineWssRoute().
406
+ Expected: export default defineWssRoute({ events: { ... } })
407
+ File: ${mod.__filename || "unknown"}`
408
+ );
409
+ }
410
+ const normalizedEvents = /* @__PURE__ */ new Map();
411
+ for (const [eventName, eventDef] of Object.entries(routeDef.events)) {
412
+ if (typeof eventDef === "function") {
413
+ normalizedEvents.set(eventName.toLowerCase(), {
414
+ handler: eventDef
415
+ });
416
+ } else if (eventDef && typeof eventDef === "object" && eventDef.handler) {
417
+ normalizedEvents.set(eventName.toLowerCase(), {
418
+ schema: eventDef.schema,
419
+ rateLimit: eventDef.rateLimit,
420
+ guard: eventDef.guard,
421
+ handler: eventDef.handler
422
+ });
423
+ } else {
424
+ throw new Error(
425
+ `[loly:realtime] Invalid event definition for '${eventName}'. Event must be a handler function or an object with a 'handler' property.
426
+ File: ${mod.__filename || "unknown"}`
427
+ );
428
+ }
429
+ }
430
+ return {
431
+ namespace,
432
+ auth: routeDef.auth,
433
+ onConnect: routeDef.onConnect,
434
+ onDisconnect: routeDef.onDisconnect,
435
+ events: normalizedEvents
436
+ };
437
+ }
438
+
439
+ // modules/router/helpers/routes/index.ts
388
440
  function readManifest(projectRoot) {
389
441
  const manifestPath = import_path5.default.join(
390
442
  projectRoot,
@@ -459,18 +511,6 @@ function extractWssHandlers(mod, events) {
459
511
  }
460
512
  return handlers;
461
513
  }
462
- function extractWssHandlersFromModule(mod) {
463
- const handlers = {};
464
- if (!Array.isArray(mod?.events)) {
465
- return handlers;
466
- }
467
- for (const event of mod.events) {
468
- if (typeof event.handler === "function" && typeof event.name === "string") {
469
- handlers[event.name.toLowerCase()] = event.handler;
470
- }
471
- }
472
- return handlers;
473
- }
474
514
  function loadPageComponent(pageFile, projectRoot) {
475
515
  const fullPath = import_path5.default.join(projectRoot, pageFile);
476
516
  const pageMod = loadModuleSafely(fullPath);
@@ -947,8 +987,29 @@ function loadRoutesFromManifest(projectRoot) {
947
987
  if (!mod) {
948
988
  continue;
949
989
  }
990
+ let namespace = entry.pattern.replace(/^\/wss/, "");
991
+ if (!namespace.startsWith("/")) {
992
+ namespace = "/" + namespace;
993
+ }
994
+ if (namespace === "") {
995
+ namespace = "/";
996
+ }
997
+ let normalized = null;
998
+ try {
999
+ normalized = extractDefineWssRoute(mod, namespace);
1000
+ } catch (error) {
1001
+ console.warn(
1002
+ `[loly:realtime] Failed to extract normalized route from ${filePath}:`,
1003
+ error instanceof Error ? error.message : String(error)
1004
+ );
1005
+ }
950
1006
  const handlers = extractWssHandlers(mod, entry.events || []);
951
1007
  const { global: globalMiddlewares, methodSpecific: methodMiddlewares } = extractApiMiddlewares(mod, []);
1008
+ if (normalized) {
1009
+ for (const [eventName, eventDef] of normalized.events.entries()) {
1010
+ handlers[eventName] = eventDef.handler;
1011
+ }
1012
+ }
952
1013
  wssRoutes.push({
953
1014
  pattern: entry.pattern,
954
1015
  regex,
@@ -956,7 +1017,9 @@ function loadRoutesFromManifest(projectRoot) {
956
1017
  handlers,
957
1018
  middlewares: globalMiddlewares,
958
1019
  methodMiddlewares,
959
- filePath
1020
+ filePath,
1021
+ normalized: normalized || void 0
1022
+ // Store normalized structure (use undefined instead of null)
960
1023
  });
961
1024
  }
962
1025
  return { routes: pageRoutes, apiRoutes, wssRoutes };
@@ -1062,7 +1125,30 @@ function loadWssRoutes(appDir) {
1062
1125
  if (!mod) {
1063
1126
  continue;
1064
1127
  }
1065
- const handlers = extractWssHandlersFromModule(mod);
1128
+ let namespace = pattern.replace(/^\/wss/, "");
1129
+ if (!namespace.startsWith("/")) {
1130
+ namespace = "/" + namespace;
1131
+ }
1132
+ if (namespace === "") {
1133
+ namespace = "/";
1134
+ }
1135
+ let normalized = null;
1136
+ try {
1137
+ normalized = extractDefineWssRoute(mod, namespace);
1138
+ } catch (error) {
1139
+ console.error(error instanceof Error ? error.message : String(error));
1140
+ continue;
1141
+ }
1142
+ if (!normalized) {
1143
+ console.warn(
1144
+ `[loly:realtime] Skipping route at ${fullPath}: No default export from defineWssRoute() found`
1145
+ );
1146
+ continue;
1147
+ }
1148
+ const handlers = {};
1149
+ for (const [eventName, eventDef] of normalized.events.entries()) {
1150
+ handlers[eventName] = eventDef.handler;
1151
+ }
1066
1152
  const { global: globalMiddlewares, methodSpecific: methodMiddlewares } = extractApiMiddlewares(mod, []);
1067
1153
  routes.push({
1068
1154
  pattern,
@@ -1071,7 +1157,9 @@ function loadWssRoutes(appDir) {
1071
1157
  handlers,
1072
1158
  middlewares: globalMiddlewares,
1073
1159
  methodMiddlewares,
1074
- filePath: fullPath
1160
+ filePath: fullPath,
1161
+ normalized
1162
+ // Store normalized structure
1075
1163
  });
1076
1164
  }
1077
1165
  }
@@ -3547,6 +3635,31 @@ async function runInitIfExists(projectRoot, serverData) {
3547
3635
 
3548
3636
  // modules/server/config.ts
3549
3637
  var CONFIG_FILE_NAME = "loly.config";
3638
+ var DEFAULT_REALTIME_CONFIG = {
3639
+ enabled: true,
3640
+ path: "/wss",
3641
+ transports: ["websocket", "polling"],
3642
+ pingIntervalMs: 25e3,
3643
+ pingTimeoutMs: 2e4,
3644
+ maxPayloadBytes: 64 * 1024,
3645
+ allowedOrigins: process.env.NODE_ENV === "production" ? [] : "*",
3646
+ cors: {
3647
+ credentials: true,
3648
+ allowedHeaders: ["content-type", "authorization"]
3649
+ },
3650
+ scale: {
3651
+ mode: "single"
3652
+ },
3653
+ limits: {
3654
+ connectionsPerIp: 20,
3655
+ eventsPerSecond: 30,
3656
+ burst: 60
3657
+ },
3658
+ logging: {
3659
+ level: "info",
3660
+ pretty: process.env.NODE_ENV !== "production"
3661
+ }
3662
+ };
3550
3663
  var DEFAULT_CONFIG = {
3551
3664
  bodyLimit: "1mb",
3552
3665
  corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.includes(",") ? process.env.CORS_ORIGIN.split(",").map((s) => s.trim()) : process.env.CORS_ORIGIN : process.env.NODE_ENV === "production" ? [] : true,
@@ -3589,7 +3702,8 @@ var DEFAULT_CONFIG = {
3589
3702
  // 1 year
3590
3703
  includeSubDomains: true
3591
3704
  }
3592
- }
3705
+ },
3706
+ realtime: DEFAULT_REALTIME_CONFIG
3593
3707
  };
3594
3708
  async function getServerConfig(projectRoot) {
3595
3709
  let mod = await getServerFile(projectRoot, CONFIG_FILE_NAME);
@@ -3605,12 +3719,76 @@ async function getServerConfig(projectRoot) {
3605
3719
  security: {
3606
3720
  ...DEFAULT_CONFIG.security,
3607
3721
  ...options.security
3722
+ },
3723
+ realtime: {
3724
+ ...DEFAULT_REALTIME_CONFIG,
3725
+ ...options.realtime,
3726
+ cors: {
3727
+ ...DEFAULT_REALTIME_CONFIG.cors,
3728
+ ...options.realtime?.cors
3729
+ },
3730
+ scale: options.realtime?.scale ? {
3731
+ ...DEFAULT_REALTIME_CONFIG.scale,
3732
+ ...options.realtime.scale,
3733
+ adapter: options.realtime.scale.adapter,
3734
+ stateStore: options.realtime.scale.stateStore ? {
3735
+ ...DEFAULT_REALTIME_CONFIG.scale?.stateStore,
3736
+ ...options.realtime.scale.stateStore,
3737
+ // Ensure name is set (user config takes priority, fallback to default or "memory")
3738
+ name: options.realtime.scale.stateStore.name || DEFAULT_REALTIME_CONFIG.scale?.stateStore?.name || "memory"
3739
+ } : DEFAULT_REALTIME_CONFIG.scale?.stateStore
3740
+ } : DEFAULT_REALTIME_CONFIG.scale,
3741
+ limits: {
3742
+ ...DEFAULT_REALTIME_CONFIG.limits,
3743
+ ...options.realtime?.limits
3744
+ },
3745
+ logging: {
3746
+ ...DEFAULT_REALTIME_CONFIG.logging,
3747
+ ...options.realtime?.logging
3748
+ }
3608
3749
  }
3609
3750
  };
3751
+ validateRealtimeConfig(merged.realtime);
3610
3752
  return merged;
3611
3753
  }
3754
+ validateRealtimeConfig(DEFAULT_CONFIG.realtime);
3612
3755
  return DEFAULT_CONFIG;
3613
3756
  }
3757
+ function validateRealtimeConfig(config) {
3758
+ if (!config.enabled) {
3759
+ return;
3760
+ }
3761
+ if (config.scale?.mode === "cluster") {
3762
+ if (!config.scale.adapter) {
3763
+ throw new Error(
3764
+ "[loly:realtime] Cluster mode requires a Redis adapter. Please configure realtime.scale.adapter in your loly.config.ts"
3765
+ );
3766
+ }
3767
+ if (config.scale.adapter.name !== "redis") {
3768
+ throw new Error(
3769
+ "[loly:realtime] Only Redis adapter is supported for cluster mode"
3770
+ );
3771
+ }
3772
+ if (!config.scale.adapter.url) {
3773
+ throw new Error(
3774
+ "[loly:realtime] Redis adapter requires a URL. Set realtime.scale.adapter.url or REDIS_URL environment variable"
3775
+ );
3776
+ }
3777
+ if (config.scale.stateStore?.name === "memory") {
3778
+ console.warn(
3779
+ "[loly:realtime] WARNING: Using memory state store in cluster mode. State will diverge across instances. Consider using Redis state store."
3780
+ );
3781
+ }
3782
+ }
3783
+ if (process.env.NODE_ENV === "production") {
3784
+ if (!config.allowedOrigins || Array.isArray(config.allowedOrigins) && config.allowedOrigins.length === 0 || config.allowedOrigins === "*") {
3785
+ config.allowedOrigins = "*";
3786
+ console.warn(
3787
+ "[loly:realtime] No allowedOrigins configured. Auto-allowing localhost for local development. For production deployment, configure realtime.allowedOrigins in loly.config.ts"
3788
+ );
3789
+ }
3790
+ }
3791
+ }
3614
3792
 
3615
3793
  // modules/build/bundler/server.ts
3616
3794
  init_globals();
@@ -6187,44 +6365,841 @@ function setupRoutes(options) {
6187
6365
 
6188
6366
  // modules/server/wss.ts
6189
6367
  var import_socket = require("socket.io");
6190
- var generateActions = (socket, namespace) => {
6368
+
6369
+ // modules/realtime/state/memory-store.ts
6370
+ var MemoryStateStore = class {
6371
+ constructor() {
6372
+ this.store = /* @__PURE__ */ new Map();
6373
+ this.lists = /* @__PURE__ */ new Map();
6374
+ this.sets = /* @__PURE__ */ new Map();
6375
+ this.locks = /* @__PURE__ */ new Map();
6376
+ this.cleanupInterval = setInterval(() => {
6377
+ this.cleanupExpired();
6378
+ }, 6e4);
6379
+ }
6380
+ /**
6381
+ * Cleanup expired entries
6382
+ */
6383
+ cleanupExpired() {
6384
+ const now = Date.now();
6385
+ for (const [key, entry] of this.store.entries()) {
6386
+ if (entry.expiresAt && entry.expiresAt < now) {
6387
+ this.store.delete(key);
6388
+ }
6389
+ }
6390
+ for (const [key, lock] of this.locks.entries()) {
6391
+ if (lock.expiresAt < now) {
6392
+ this.locks.delete(key);
6393
+ }
6394
+ }
6395
+ }
6396
+ /**
6397
+ * Get a value by key
6398
+ */
6399
+ async get(key) {
6400
+ const entry = this.store.get(key);
6401
+ if (!entry) {
6402
+ return null;
6403
+ }
6404
+ if (entry.expiresAt && entry.expiresAt < Date.now()) {
6405
+ this.store.delete(key);
6406
+ return null;
6407
+ }
6408
+ return entry.value;
6409
+ }
6410
+ /**
6411
+ * Set a value with optional TTL
6412
+ */
6413
+ async set(key, value, opts) {
6414
+ const entry = {
6415
+ value
6416
+ };
6417
+ if (opts?.ttlMs) {
6418
+ entry.expiresAt = Date.now() + opts.ttlMs;
6419
+ }
6420
+ this.store.set(key, entry);
6421
+ }
6422
+ /**
6423
+ * Delete a key
6424
+ */
6425
+ async del(key) {
6426
+ this.store.delete(key);
6427
+ this.lists.delete(key);
6428
+ this.sets.delete(key);
6429
+ this.locks.delete(key);
6430
+ }
6431
+ /**
6432
+ * Increment a numeric value
6433
+ */
6434
+ async incr(key, by = 1) {
6435
+ const current = await this.get(key);
6436
+ const newValue = (current ?? 0) + by;
6437
+ await this.set(key, newValue);
6438
+ return newValue;
6439
+ }
6440
+ /**
6441
+ * Decrement a numeric value
6442
+ */
6443
+ async decr(key, by = 1) {
6444
+ return this.incr(key, -by);
6445
+ }
6446
+ /**
6447
+ * Push to a list (left push)
6448
+ */
6449
+ async listPush(key, value, opts) {
6450
+ let list = this.lists.get(key);
6451
+ if (!list) {
6452
+ list = [];
6453
+ this.lists.set(key, list);
6454
+ }
6455
+ list.unshift(value);
6456
+ if (opts?.maxLen && list.length > opts.maxLen) {
6457
+ list.splice(opts.maxLen);
6458
+ }
6459
+ }
6460
+ /**
6461
+ * Get range from a list
6462
+ */
6463
+ async listRange(key, start, end) {
6464
+ const list = this.lists.get(key);
6465
+ if (!list) {
6466
+ return [];
6467
+ }
6468
+ const len = list.length;
6469
+ const actualStart = start < 0 ? Math.max(0, len + start) : Math.min(start, len);
6470
+ const actualEnd = end < 0 ? Math.max(0, len + end + 1) : Math.min(end + 1, len);
6471
+ return list.slice(actualStart, actualEnd);
6472
+ }
6473
+ /**
6474
+ * Add member to a set
6475
+ */
6476
+ async setAdd(key, member) {
6477
+ let set = this.sets.get(key);
6478
+ if (!set) {
6479
+ set = /* @__PURE__ */ new Set();
6480
+ this.sets.set(key, set);
6481
+ }
6482
+ set.add(member);
6483
+ }
6484
+ /**
6485
+ * Remove member from a set
6486
+ */
6487
+ async setRem(key, member) {
6488
+ const set = this.sets.get(key);
6489
+ if (set) {
6490
+ set.delete(member);
6491
+ if (set.size === 0) {
6492
+ this.sets.delete(key);
6493
+ }
6494
+ }
6495
+ }
6496
+ /**
6497
+ * Get all members of a set
6498
+ */
6499
+ async setMembers(key) {
6500
+ const set = this.sets.get(key);
6501
+ if (!set) {
6502
+ return [];
6503
+ }
6504
+ return Array.from(set);
6505
+ }
6506
+ /**
6507
+ * Acquire a distributed lock
6508
+ */
6509
+ async lock(key, ttlMs) {
6510
+ const lockKey = `__lock:${key}`;
6511
+ const now = Date.now();
6512
+ const existingLock = this.locks.get(lockKey);
6513
+ if (existingLock && existingLock.expiresAt > now) {
6514
+ throw new Error(`Lock '${key}' is already held`);
6515
+ }
6516
+ this.locks.set(lockKey, {
6517
+ expiresAt: now + ttlMs
6518
+ });
6519
+ return async () => {
6520
+ const lock = this.locks.get(lockKey);
6521
+ if (lock && lock.expiresAt > Date.now()) {
6522
+ this.locks.delete(lockKey);
6523
+ }
6524
+ };
6525
+ }
6526
+ /**
6527
+ * Cleanup resources (call when shutting down)
6528
+ */
6529
+ destroy() {
6530
+ if (this.cleanupInterval) {
6531
+ clearInterval(this.cleanupInterval);
6532
+ this.cleanupInterval = void 0;
6533
+ }
6534
+ this.store.clear();
6535
+ this.lists.clear();
6536
+ this.sets.clear();
6537
+ this.locks.clear();
6538
+ }
6539
+ };
6540
+
6541
+ // modules/realtime/state/redis-store.ts
6542
+ var RedisStateStore = class {
6543
+ constructor(client, prefix = "loly:rt:") {
6544
+ this.client = client;
6545
+ this.prefix = prefix;
6546
+ }
6547
+ /**
6548
+ * Add prefix to key
6549
+ */
6550
+ key(key) {
6551
+ return `${this.prefix}${key}`;
6552
+ }
6553
+ /**
6554
+ * Serialize value to JSON string
6555
+ */
6556
+ serialize(value) {
6557
+ return JSON.stringify(value);
6558
+ }
6559
+ /**
6560
+ * Deserialize JSON string to value
6561
+ */
6562
+ deserialize(value) {
6563
+ if (value === null) {
6564
+ return null;
6565
+ }
6566
+ try {
6567
+ return JSON.parse(value);
6568
+ } catch {
6569
+ return null;
6570
+ }
6571
+ }
6572
+ /**
6573
+ * Get a value by key
6574
+ */
6575
+ async get(key) {
6576
+ const value = await this.client.get(this.key(key));
6577
+ return this.deserialize(value);
6578
+ }
6579
+ /**
6580
+ * Set a value with optional TTL
6581
+ */
6582
+ async set(key, value, opts) {
6583
+ const serialized = this.serialize(value);
6584
+ const prefixedKey = this.key(key);
6585
+ if (opts?.ttlMs) {
6586
+ await this.client.psetex(prefixedKey, opts.ttlMs, serialized);
6587
+ } else {
6588
+ await this.client.set(prefixedKey, serialized);
6589
+ }
6590
+ }
6591
+ /**
6592
+ * Delete a key
6593
+ */
6594
+ async del(key) {
6595
+ await this.client.del(this.key(key));
6596
+ }
6597
+ /**
6598
+ * Increment a numeric value
6599
+ */
6600
+ async incr(key, by = 1) {
6601
+ const prefixedKey = this.key(key);
6602
+ if (by === 1) {
6603
+ return await this.client.incr(prefixedKey);
6604
+ } else {
6605
+ return await this.client.incrby(prefixedKey, by);
6606
+ }
6607
+ }
6608
+ /**
6609
+ * Decrement a numeric value
6610
+ */
6611
+ async decr(key, by = 1) {
6612
+ const prefixedKey = this.key(key);
6613
+ if (by === 1) {
6614
+ return await this.client.decr(prefixedKey);
6615
+ } else {
6616
+ return await this.client.decrby(prefixedKey, by);
6617
+ }
6618
+ }
6619
+ /**
6620
+ * Push to a list (left push)
6621
+ */
6622
+ async listPush(key, value, opts) {
6623
+ const serialized = this.serialize(value);
6624
+ const prefixedKey = this.key(key);
6625
+ await this.client.lpush(prefixedKey, serialized);
6626
+ if (opts?.maxLen) {
6627
+ await this.client.eval(
6628
+ `redis.call('ltrim', KEYS[1], 0, ARGV[1])`,
6629
+ 1,
6630
+ prefixedKey,
6631
+ String(opts.maxLen - 1)
6632
+ );
6633
+ }
6634
+ }
6635
+ /**
6636
+ * Get range from a list
6637
+ */
6638
+ async listRange(key, start, end) {
6639
+ const prefixedKey = this.key(key);
6640
+ const values = await this.client.lrange(prefixedKey, start, end);
6641
+ return values.map((v) => this.deserialize(v)).filter((v) => v !== null);
6642
+ }
6643
+ /**
6644
+ * Add member to a set
6645
+ */
6646
+ async setAdd(key, member) {
6647
+ const prefixedKey = this.key(key);
6648
+ await this.client.sadd(prefixedKey, member);
6649
+ }
6650
+ /**
6651
+ * Remove member from a set
6652
+ */
6653
+ async setRem(key, member) {
6654
+ const prefixedKey = this.key(key);
6655
+ await this.client.srem(prefixedKey, member);
6656
+ }
6657
+ /**
6658
+ * Get all members of a set
6659
+ */
6660
+ async setMembers(key) {
6661
+ const prefixedKey = this.key(key);
6662
+ return await this.client.smembers(prefixedKey);
6663
+ }
6664
+ /**
6665
+ * Acquire a distributed lock using Redis SET NX EX
6666
+ */
6667
+ async lock(key, ttlMs) {
6668
+ const lockKey = this.key(`__lock:${key}`);
6669
+ const lockValue = `${Date.now()}-${Math.random()}`;
6670
+ const ttlSeconds = Math.ceil(ttlMs / 1e3);
6671
+ const result = await this.client.set(
6672
+ lockKey,
6673
+ lockValue,
6674
+ "NX",
6675
+ // Only set if not exists
6676
+ "EX",
6677
+ // Expire in seconds
6678
+ ttlSeconds
6679
+ );
6680
+ if (result === null) {
6681
+ throw new Error(`Lock '${key}' is already held`);
6682
+ }
6683
+ return async () => {
6684
+ const unlockScript = `
6685
+ if redis.call("get", KEYS[1]) == ARGV[1] then
6686
+ return redis.call("del", KEYS[1])
6687
+ else
6688
+ return 0
6689
+ end
6690
+ `;
6691
+ await this.client.eval(unlockScript, 1, lockKey, lockValue);
6692
+ };
6693
+ }
6694
+ };
6695
+
6696
+ // modules/realtime/state/index.ts
6697
+ async function createStateStore(config) {
6698
+ if (!config.enabled) {
6699
+ return createNoOpStore();
6700
+ }
6701
+ const storeType = config.scale?.stateStore?.name || "memory";
6702
+ const prefix = config.scale?.stateStore?.prefix || "loly:rt:";
6703
+ if (storeType === "memory") {
6704
+ return new MemoryStateStore();
6705
+ }
6706
+ if (storeType === "redis") {
6707
+ const url = config.scale?.stateStore?.url || process.env.REDIS_URL;
6708
+ if (!url) {
6709
+ throw new Error(
6710
+ "[loly:realtime] Redis state store requires a URL. Set realtime.scale.stateStore.url or REDIS_URL environment variable"
6711
+ );
6712
+ }
6713
+ const client = await createRedisClient(url);
6714
+ return new RedisStateStore(client, prefix);
6715
+ }
6716
+ throw new Error(
6717
+ `[loly:realtime] Unknown state store type: ${storeType}. Supported types: 'memory', 'redis'`
6718
+ );
6719
+ }
6720
+ async function createRedisClient(url) {
6721
+ try {
6722
+ const Redis = await import("ioredis");
6723
+ return new Redis.default(url);
6724
+ } catch {
6725
+ try {
6726
+ const { createClient } = await import("redis");
6727
+ const client = createClient({ url });
6728
+ await client.connect();
6729
+ return client;
6730
+ } catch (err) {
6731
+ throw new Error(
6732
+ `[loly:realtime] Failed to create Redis client. Please install 'ioredis' or 'redis' package. Error: ${err instanceof Error ? err.message : String(err)}`
6733
+ );
6734
+ }
6735
+ }
6736
+ }
6737
+ function createNoOpStore() {
6738
+ const noOp = async () => {
6739
+ };
6740
+ const noOpReturn = async () => null;
6741
+ const noOpNumber = async () => 0;
6742
+ const noOpArray = async () => [];
6743
+ const noOpUnlock = async () => {
6744
+ };
6745
+ return {
6746
+ get: noOpReturn,
6747
+ set: noOp,
6748
+ del: noOp,
6749
+ incr: noOpNumber,
6750
+ decr: noOpNumber,
6751
+ listPush: noOp,
6752
+ listRange: noOpArray,
6753
+ setAdd: noOp,
6754
+ setRem: noOp,
6755
+ setMembers: noOpArray,
6756
+ lock: async () => noOpUnlock
6757
+ };
6758
+ }
6759
+
6760
+ // modules/realtime/presence/index.ts
6761
+ var PresenceManager = class {
6762
+ constructor(stateStore, prefix = "loly:rt:") {
6763
+ this.stateStore = stateStore;
6764
+ this.prefix = prefix;
6765
+ }
6766
+ /**
6767
+ * Add a socket for a user
6768
+ */
6769
+ async addSocketForUser(userId, socketId) {
6770
+ const key = this.key(`userSockets:${userId}`);
6771
+ await this.stateStore.setAdd(key, socketId);
6772
+ const socketKey = this.key(`socketUser:${socketId}`);
6773
+ await this.stateStore.set(socketKey, userId);
6774
+ }
6775
+ /**
6776
+ * Remove a socket for a user
6777
+ */
6778
+ async removeSocketForUser(userId, socketId) {
6779
+ const key = this.key(`userSockets:${userId}`);
6780
+ await this.stateStore.setRem(key, socketId);
6781
+ const socketKey = this.key(`socketUser:${socketId}`);
6782
+ await this.stateStore.del(socketKey);
6783
+ const sockets = await this.stateStore.setMembers(key);
6784
+ if (sockets.length === 0) {
6785
+ await this.stateStore.del(key);
6786
+ }
6787
+ }
6788
+ /**
6789
+ * Get all socket IDs for a user
6790
+ */
6791
+ async getSocketsForUser(userId) {
6792
+ const key = this.key(`userSockets:${userId}`);
6793
+ return await this.stateStore.setMembers(key);
6794
+ }
6795
+ /**
6796
+ * Get user ID for a socket
6797
+ */
6798
+ async getUserForSocket(socketId) {
6799
+ const socketKey = this.key(`socketUser:${socketId}`);
6800
+ return await this.stateStore.get(socketKey);
6801
+ }
6802
+ /**
6803
+ * Add user to a room's presence (optional feature)
6804
+ */
6805
+ async addUserToRoom(namespace, room, userId) {
6806
+ const key = this.key(`presence:${namespace}:${room}`);
6807
+ await this.stateStore.setAdd(key, userId);
6808
+ }
6809
+ /**
6810
+ * Remove user from a room's presence
6811
+ */
6812
+ async removeUserFromRoom(namespace, room, userId) {
6813
+ const key = this.key(`presence:${namespace}:${room}`);
6814
+ await this.stateStore.setRem(key, userId);
6815
+ const members = await this.stateStore.setMembers(key);
6816
+ if (members.length === 0) {
6817
+ await this.stateStore.del(key);
6818
+ }
6819
+ }
6820
+ /**
6821
+ * Get all users in a room
6822
+ */
6823
+ async getUsersInRoom(namespace, room) {
6824
+ const key = this.key(`presence:${namespace}:${room}`);
6825
+ return await this.stateStore.setMembers(key);
6826
+ }
6827
+ /**
6828
+ * Add prefix to key
6829
+ */
6830
+ key(key) {
6831
+ return `${this.prefix}${key}`;
6832
+ }
6833
+ };
6834
+
6835
+ // modules/realtime/rate-limit/token-bucket.ts
6836
+ var TokenBucket = class {
6837
+ // tokens per second
6838
+ constructor(capacity, refillRate) {
6839
+ this.capacity = capacity;
6840
+ this.refillRate = refillRate;
6841
+ this.tokens = capacity;
6842
+ this.lastRefill = Date.now();
6843
+ }
6844
+ /**
6845
+ * Try to consume tokens. Returns true if successful, false if rate limited.
6846
+ */
6847
+ consume(tokens = 1) {
6848
+ this.refill();
6849
+ if (this.tokens >= tokens) {
6850
+ this.tokens -= tokens;
6851
+ return true;
6852
+ }
6853
+ return false;
6854
+ }
6855
+ /**
6856
+ * Get current available tokens
6857
+ */
6858
+ getAvailable() {
6859
+ this.refill();
6860
+ return this.tokens;
6861
+ }
6862
+ /**
6863
+ * Refill tokens based on elapsed time
6864
+ */
6865
+ refill() {
6866
+ const now = Date.now();
6867
+ const elapsed = (now - this.lastRefill) / 1e3;
6868
+ const tokensToAdd = elapsed * this.refillRate;
6869
+ this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
6870
+ this.lastRefill = now;
6871
+ }
6872
+ /**
6873
+ * Reset bucket to full capacity
6874
+ */
6875
+ reset() {
6876
+ this.tokens = this.capacity;
6877
+ this.lastRefill = Date.now();
6878
+ }
6879
+ };
6880
+
6881
+ // modules/realtime/rate-limit/index.ts
6882
+ var RateLimiter = class {
6883
+ constructor(stateStore, prefix = "loly:rt:rate:") {
6884
+ this.memoryBuckets = /* @__PURE__ */ new Map();
6885
+ this.stateStore = stateStore;
6886
+ this.prefix = prefix;
6887
+ }
6888
+ /**
6889
+ * Check if a request should be rate limited.
6890
+ *
6891
+ * @param key - Unique key for the rate limit (e.g., socketId or socketId:eventName)
6892
+ * @param config - Rate limit configuration
6893
+ * @returns true if allowed, false if rate limited
6894
+ */
6895
+ async checkLimit(key, config) {
6896
+ const fullKey = `${this.prefix}${key}`;
6897
+ const burst = config.burst || config.eventsPerSecond * 2;
6898
+ if (this.stateStore) {
6899
+ return this.checkLimitWithStore(fullKey, config.eventsPerSecond, burst);
6900
+ } else {
6901
+ return this.checkLimitInMemory(fullKey, config.eventsPerSecond, burst);
6902
+ }
6903
+ }
6904
+ /**
6905
+ * Check rate limit using state store (for cluster mode)
6906
+ */
6907
+ async checkLimitWithStore(key, ratePerSecond, burst) {
6908
+ const count = await this.stateStore.get(key) || 0;
6909
+ if (count >= burst) {
6910
+ return false;
6911
+ }
6912
+ await this.stateStore.incr(key, 1);
6913
+ await this.stateStore.set(key, count + 1, { ttlMs: 1e3 });
6914
+ return true;
6915
+ }
6916
+ /**
6917
+ * Check rate limit using in-memory token bucket
6918
+ */
6919
+ checkLimitInMemory(key, ratePerSecond, burst) {
6920
+ let bucket = this.memoryBuckets.get(key);
6921
+ if (!bucket) {
6922
+ bucket = new TokenBucket(burst, ratePerSecond);
6923
+ this.memoryBuckets.set(key, bucket);
6924
+ }
6925
+ return bucket.consume(1);
6926
+ }
6927
+ /**
6928
+ * Cleanup old buckets (call periodically)
6929
+ */
6930
+ cleanup() {
6931
+ }
6932
+ };
6933
+
6934
+ // modules/realtime/auth/index.ts
6935
+ async function executeAuth(authFn, socket, namespace) {
6936
+ if (!authFn) {
6937
+ return null;
6938
+ }
6939
+ const authCtx = {
6940
+ req: {
6941
+ headers: socket.handshake.headers,
6942
+ ip: socket.handshake.address,
6943
+ url: socket.handshake.url,
6944
+ cookies: socket.handshake.headers.cookie ? parseCookies(socket.handshake.headers.cookie) : void 0
6945
+ },
6946
+ socket,
6947
+ namespace
6948
+ };
6949
+ const user = await authFn(authCtx);
6950
+ if (user) {
6951
+ socket.data = socket.data || {};
6952
+ socket.data.user = user;
6953
+ }
6954
+ return user;
6955
+ }
6956
+ function parseCookies(cookieString) {
6957
+ const cookies = {};
6958
+ cookieString.split(";").forEach((cookie) => {
6959
+ const [name, value] = cookie.trim().split("=");
6960
+ if (name && value) {
6961
+ cookies[name] = decodeURIComponent(value);
6962
+ }
6963
+ });
6964
+ return cookies;
6965
+ }
6966
+
6967
+ // modules/realtime/guards/index.ts
6968
+ async function executeGuard(guardFn, ctx) {
6969
+ if (!guardFn) {
6970
+ return true;
6971
+ }
6972
+ const guardCtx = {
6973
+ user: ctx.user,
6974
+ req: ctx.req,
6975
+ socket: ctx.socket,
6976
+ namespace: ctx.pathname
6977
+ };
6978
+ const result = await guardFn(guardCtx);
6979
+ return result === true;
6980
+ }
6981
+
6982
+ // modules/realtime/validation/index.ts
6983
+ function validateSchema(schema, data) {
6984
+ if (!schema) {
6985
+ return { success: true, data };
6986
+ }
6987
+ if (typeof schema.safeParse === "function") {
6988
+ const result = schema.safeParse(data);
6989
+ if (result.success) {
6990
+ return { success: true, data: result.data };
6991
+ } else {
6992
+ return { success: false, error: result.error };
6993
+ }
6994
+ }
6995
+ if (typeof schema.parse === "function") {
6996
+ try {
6997
+ const parsed = schema.parse(data);
6998
+ return { success: true, data: parsed };
6999
+ } catch (error) {
7000
+ return { success: false, error };
7001
+ }
7002
+ }
7003
+ return {
7004
+ success: false,
7005
+ error: new Error("Schema must have 'parse' or 'safeParse' method")
7006
+ };
7007
+ }
7008
+
7009
+ // modules/realtime/logging/index.ts
7010
+ function createWssLogger(namespace, socket, baseLogger) {
7011
+ const context = {
7012
+ namespace,
7013
+ socketId: socket.id,
7014
+ userId: socket.data?.user?.id || null
7015
+ };
7016
+ const log = (level, message, meta) => {
7017
+ const fullMeta = {
7018
+ ...context,
7019
+ ...meta,
7020
+ requestId: socket.requestId || generateRequestId2()
7021
+ };
7022
+ if (baseLogger) {
7023
+ baseLogger[level](message, fullMeta);
7024
+ } else {
7025
+ console[level === "error" ? "error" : "log"](
7026
+ `[${level.toUpperCase()}] [${namespace}] ${message}`,
7027
+ fullMeta
7028
+ );
7029
+ }
7030
+ };
6191
7031
  return {
7032
+ debug: (message, meta) => log("debug", message, meta),
7033
+ info: (message, meta) => log("info", message, meta),
7034
+ warn: (message, meta) => log("warn", message, meta),
7035
+ error: (message, meta) => log("error", message, meta)
7036
+ };
7037
+ }
7038
+ function generateRequestId2() {
7039
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
7040
+ }
7041
+
7042
+ // modules/server/wss.ts
7043
+ var generateActions = (socket, namespace, presence) => {
7044
+ return {
7045
+ // Emit to current socket only (reply)
7046
+ reply: (event, payload) => {
7047
+ socket.emit(event, payload);
7048
+ },
6192
7049
  // Emit to all clients in the namespace
6193
- emit: (event, ...args) => {
6194
- socket.nsp.emit(event, ...args);
7050
+ emit: (event, payload) => {
7051
+ socket.nsp.emit(event, payload);
7052
+ },
7053
+ // Emit to everyone except current socket
7054
+ broadcast: (event, payload, opts) => {
7055
+ if (opts?.excludeSelf === false) {
7056
+ socket.nsp.emit(event, payload);
7057
+ } else {
7058
+ socket.broadcast.emit(event, payload);
7059
+ }
7060
+ },
7061
+ // Join a room
7062
+ join: async (room) => {
7063
+ await socket.join(room);
7064
+ },
7065
+ // Leave a room
7066
+ leave: async (room) => {
7067
+ await socket.leave(room);
7068
+ },
7069
+ // Emit to a specific room
7070
+ toRoom: (room) => {
7071
+ return {
7072
+ emit: (event, payload) => {
7073
+ namespace.to(room).emit(event, payload);
7074
+ }
7075
+ };
6195
7076
  },
6196
- // Emit to a specific socket by Socket.IO socket ID
7077
+ // Emit to a specific user (by userId)
7078
+ toUser: (userId) => {
7079
+ return {
7080
+ emit: async (event, payload) => {
7081
+ if (!presence) {
7082
+ console.warn(
7083
+ "[loly:realtime] toUser() requires presence manager. Make sure realtime is properly configured."
7084
+ );
7085
+ return;
7086
+ }
7087
+ const socketIds = await presence.getSocketsForUser(userId);
7088
+ for (const socketId of socketIds) {
7089
+ const targetSocket = namespace.sockets.get(socketId);
7090
+ if (targetSocket) {
7091
+ targetSocket.emit(event, payload);
7092
+ }
7093
+ }
7094
+ }
7095
+ };
7096
+ },
7097
+ // Emit error event (reserved event: __loly:error)
7098
+ error: (code, message, details) => {
7099
+ socket.emit("__loly:error", {
7100
+ code,
7101
+ message,
7102
+ details,
7103
+ requestId: socket.requestId || void 0
7104
+ });
7105
+ },
7106
+ // Legacy: Emit to a specific socket by Socket.IO socket ID
6197
7107
  emitTo: (socketId, event, ...args) => {
6198
7108
  const targetSocket = namespace.sockets.get(socketId);
6199
7109
  if (targetSocket) {
6200
7110
  targetSocket.emit(event, ...args);
6201
7111
  }
6202
7112
  },
6203
- // Emit to a specific client by custom clientId
6204
- // Requires clientId to be stored in socket.data.clientId during connection
7113
+ // Legacy: Emit to a specific client by custom clientId
6205
7114
  emitToClient: (clientId, event, ...args) => {
6206
7115
  namespace.sockets.forEach((s) => {
6207
7116
  if (s.data?.clientId === clientId) {
6208
7117
  s.emit(event, ...args);
6209
7118
  }
6210
7119
  });
6211
- },
6212
- // Broadcast to all clients except the sender
6213
- broadcast: (event, ...args) => {
6214
- socket.broadcast.emit(event, ...args);
6215
7120
  }
6216
7121
  };
6217
7122
  };
6218
- function setupWssEvents(options) {
6219
- const { httpServer, wssRoutes } = options;
7123
+ async function setupWssEvents(options) {
7124
+ const { httpServer, wssRoutes, projectRoot } = options;
6220
7125
  if (wssRoutes.length === 0) {
6221
7126
  return;
6222
7127
  }
7128
+ const serverConfig = await getServerConfig(projectRoot);
7129
+ const realtimeConfig = serverConfig.realtime;
7130
+ if (!realtimeConfig || !realtimeConfig.enabled) {
7131
+ return;
7132
+ }
7133
+ const stateStore = await createStateStore(realtimeConfig);
7134
+ const stateStorePrefix = realtimeConfig.scale?.stateStore?.prefix || "loly:rt:";
7135
+ const presence = new PresenceManager(stateStore, stateStorePrefix);
7136
+ const rateLimiter = new RateLimiter(
7137
+ realtimeConfig.scale?.mode === "cluster" ? stateStore : void 0,
7138
+ `${stateStorePrefix}rate:`
7139
+ );
7140
+ const allowedOrigins = realtimeConfig.allowedOrigins;
7141
+ const corsOrigin = !allowedOrigins || Array.isArray(allowedOrigins) && allowedOrigins.length === 0 || allowedOrigins === "*" ? (
7142
+ // Auto-allow localhost on any port for simplicity
7143
+ (origin, callback) => {
7144
+ if (!origin) {
7145
+ callback(null, true);
7146
+ return;
7147
+ }
7148
+ if (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:") || origin.startsWith("https://localhost:") || origin.startsWith("https://127.0.0.1:")) {
7149
+ callback(null, true);
7150
+ } else {
7151
+ callback(new Error("Not allowed by CORS"));
7152
+ }
7153
+ }
7154
+ ) : allowedOrigins;
7155
+ const corsOptions = {
7156
+ origin: corsOrigin,
7157
+ credentials: realtimeConfig.cors?.credentials ?? true,
7158
+ methods: ["GET", "POST"],
7159
+ allowedHeaders: realtimeConfig.cors?.allowedHeaders || [
7160
+ "content-type",
7161
+ "authorization"
7162
+ ]
7163
+ };
6223
7164
  const io = new import_socket.Server(httpServer, {
6224
- path: "/wss"
7165
+ path: realtimeConfig.path || "/wss",
7166
+ transports: realtimeConfig.transports || ["websocket", "polling"],
7167
+ pingInterval: realtimeConfig.pingIntervalMs || 25e3,
7168
+ pingTimeout: realtimeConfig.pingTimeoutMs || 2e4,
7169
+ maxHttpBufferSize: realtimeConfig.maxPayloadBytes || 64 * 1024,
7170
+ cors: corsOptions
6225
7171
  });
7172
+ if (realtimeConfig.scale?.mode === "cluster" && realtimeConfig.scale.adapter) {
7173
+ try {
7174
+ const redisAdapterModule = await import("@socket.io/redis-adapter").catch(() => null);
7175
+ const ioredisModule = await import("ioredis").catch(() => null);
7176
+ if (!redisAdapterModule || !ioredisModule) {
7177
+ throw new Error(
7178
+ "[loly:realtime] Redis adapter dependencies not found. Install @socket.io/redis-adapter and ioredis for cluster mode: pnpm add @socket.io/redis-adapter ioredis"
7179
+ );
7180
+ }
7181
+ const { createAdapter } = redisAdapterModule;
7182
+ const Redis = ioredisModule.default || ioredisModule;
7183
+ const pubClient = new Redis(realtimeConfig.scale.adapter.url);
7184
+ const subClient = pubClient.duplicate();
7185
+ io.adapter(createAdapter(pubClient, subClient));
7186
+ } catch (error) {
7187
+ console.error(
7188
+ "[loly:realtime] Failed to setup Redis adapter:",
7189
+ error instanceof Error ? error.message : String(error)
7190
+ );
7191
+ throw error;
7192
+ }
7193
+ }
6226
7194
  for (const wssRoute of wssRoutes) {
6227
- let namespacePath = wssRoute.pattern.replace(/^\/wss/, "");
7195
+ const normalized = wssRoute.normalized;
7196
+ if (!normalized) {
7197
+ console.warn(
7198
+ `[loly:realtime] Skipping route ${wssRoute.pattern}: No normalized route definition`
7199
+ );
7200
+ continue;
7201
+ }
7202
+ let namespacePath = normalized.namespace || wssRoute.pattern.replace(/^\/wss/, "");
6228
7203
  if (!namespacePath.startsWith("/")) {
6229
7204
  namespacePath = "/" + namespacePath;
6230
7205
  }
@@ -6232,34 +7207,158 @@ function setupWssEvents(options) {
6232
7207
  namespacePath = "/";
6233
7208
  }
6234
7209
  const namespace = io.of(namespacePath);
6235
- namespace.on("connection", (socket) => {
6236
- Object.entries(wssRoute.handlers).forEach(([event, handler]) => {
6237
- if (event.toLowerCase() === "connection") {
6238
- const ctx = {
6239
- socket,
6240
- io: namespace.server,
6241
- params: {},
6242
- pathname: wssRoute.pattern,
6243
- actions: generateActions(socket, namespace)
6244
- };
6245
- handler(ctx);
6246
- } else {
6247
- socket.on(event, (data) => {
6248
- const ctx = {
6249
- socket,
6250
- io: namespace.server,
6251
- actions: generateActions(socket, namespace),
6252
- params: {},
6253
- pathname: wssRoute.pattern,
6254
- data
6255
- };
6256
- handler(ctx);
7210
+ console.log(`[loly:realtime] Registered namespace: ${namespacePath} (from pattern: ${wssRoute.pattern})`);
7211
+ namespace.on("connection", async (socket) => {
7212
+ console.log(`[loly:realtime] Client connected to namespace ${namespacePath}, socket: ${socket.id}`);
7213
+ const requestId = generateRequestId();
7214
+ socket.requestId = requestId;
7215
+ const log = createWssLogger(namespacePath, socket);
7216
+ try {
7217
+ const user = await executeAuth(normalized.auth, socket, namespacePath);
7218
+ socket.data = socket.data || {};
7219
+ socket.data.user = user;
7220
+ if (user && user.id) {
7221
+ await presence.addSocketForUser(String(user.id), socket.id);
7222
+ }
7223
+ const baseCtx = {
7224
+ socket,
7225
+ io: namespace.server,
7226
+ req: {
7227
+ headers: socket.handshake.headers,
7228
+ ip: socket.handshake.address,
7229
+ url: socket.handshake.url,
7230
+ cookies: socket.handshake.headers.cookie ? parseCookies2(socket.handshake.headers.cookie) : void 0
7231
+ },
7232
+ user: user || null,
7233
+ params: {},
7234
+ pathname: wssRoute.pattern,
7235
+ actions: generateActions(socket, namespace, presence),
7236
+ state: stateStore,
7237
+ log
7238
+ };
7239
+ if (normalized.onConnect) {
7240
+ try {
7241
+ await normalized.onConnect(baseCtx);
7242
+ } catch (error) {
7243
+ log.error("Error in onConnect hook", {
7244
+ error: error instanceof Error ? error.message : String(error)
7245
+ });
7246
+ }
7247
+ }
7248
+ for (const [eventName, eventDef] of normalized.events.entries()) {
7249
+ socket.on(eventName, async (data) => {
7250
+ const eventRequestId = generateRequestId();
7251
+ socket.requestId = eventRequestId;
7252
+ const eventLog = createWssLogger(namespacePath, socket);
7253
+ eventLog.debug(`Event received: ${eventName}`, { data });
7254
+ try {
7255
+ const ctx = {
7256
+ ...baseCtx,
7257
+ data,
7258
+ log: eventLog
7259
+ };
7260
+ if (eventDef.schema) {
7261
+ const validation = validateSchema(eventDef.schema, data);
7262
+ if (!validation.success) {
7263
+ ctx.actions.error("BAD_PAYLOAD", "Invalid payload", {
7264
+ error: validation.error
7265
+ });
7266
+ eventLog.warn("Schema validation failed", {
7267
+ error: validation.error
7268
+ });
7269
+ return;
7270
+ }
7271
+ ctx.data = validation.data;
7272
+ }
7273
+ if (eventDef.guard) {
7274
+ const allowed = await executeGuard(eventDef.guard, ctx);
7275
+ if (!allowed) {
7276
+ ctx.actions.error("FORBIDDEN", "Access denied");
7277
+ eventLog.warn("Guard check failed");
7278
+ return;
7279
+ }
7280
+ }
7281
+ const globalLimit = realtimeConfig.limits;
7282
+ if (globalLimit) {
7283
+ const globalAllowed = await rateLimiter.checkLimit(
7284
+ socket.id,
7285
+ {
7286
+ eventsPerSecond: globalLimit.eventsPerSecond || 30,
7287
+ burst: globalLimit.burst || 60
7288
+ }
7289
+ );
7290
+ if (!globalAllowed) {
7291
+ ctx.actions.error("RATE_LIMIT", "Rate limit exceeded");
7292
+ eventLog.warn("Global rate limit exceeded");
7293
+ return;
7294
+ }
7295
+ }
7296
+ if (eventDef.rateLimit) {
7297
+ const eventAllowed = await rateLimiter.checkLimit(
7298
+ `${socket.id}:${eventName}`,
7299
+ eventDef.rateLimit
7300
+ );
7301
+ if (!eventAllowed) {
7302
+ ctx.actions.error("RATE_LIMIT", "Event rate limit exceeded");
7303
+ eventLog.warn("Event rate limit exceeded");
7304
+ return;
7305
+ }
7306
+ }
7307
+ await eventDef.handler(ctx);
7308
+ eventLog.debug(`Event handled: ${eventName}`);
7309
+ } catch (error) {
7310
+ const errorLog = createWssLogger(namespacePath, socket);
7311
+ errorLog.error(`Error handling event ${eventName}`, {
7312
+ error: error instanceof Error ? error.message : String(error),
7313
+ stack: error instanceof Error ? error.stack : void 0
7314
+ });
7315
+ socket.emit("__loly:error", {
7316
+ code: "INTERNAL_ERROR",
7317
+ message: "An error occurred while processing your request",
7318
+ requestId: eventRequestId
7319
+ });
7320
+ }
6257
7321
  });
6258
7322
  }
6259
- });
7323
+ socket.on("disconnect", async (reason) => {
7324
+ const userId = socket.data?.user?.id;
7325
+ if (userId) {
7326
+ await presence.removeSocketForUser(String(userId), socket.id);
7327
+ }
7328
+ if (normalized.onDisconnect) {
7329
+ try {
7330
+ const disconnectCtx = {
7331
+ ...baseCtx,
7332
+ log: createWssLogger(namespacePath, socket)
7333
+ };
7334
+ await normalized.onDisconnect(disconnectCtx, reason);
7335
+ } catch (error) {
7336
+ log.error("Error in onDisconnect hook", {
7337
+ error: error instanceof Error ? error.message : String(error)
7338
+ });
7339
+ }
7340
+ }
7341
+ log.info("Socket disconnected", { reason });
7342
+ });
7343
+ } catch (error) {
7344
+ log.error("Error during connection setup", {
7345
+ error: error instanceof Error ? error.message : String(error)
7346
+ });
7347
+ socket.disconnect();
7348
+ }
6260
7349
  });
6261
7350
  }
6262
7351
  }
7352
+ function parseCookies2(cookieString) {
7353
+ const cookies = {};
7354
+ cookieString.split(";").forEach((cookie) => {
7355
+ const [name, value] = cookie.trim().split("=");
7356
+ if (name && value) {
7357
+ cookies[name] = decodeURIComponent(value);
7358
+ }
7359
+ });
7360
+ return cookies;
7361
+ }
6263
7362
 
6264
7363
  // modules/server/application.ts
6265
7364
  var import_http = __toESM(require("http"));
@@ -6490,9 +7589,10 @@ async function startServer(options = {}) {
6490
7589
  config
6491
7590
  });
6492
7591
  const routeLoader = isDev ? new FilesystemRouteLoader(appDir, projectRoot) : new ManifestRouteLoader(projectRoot);
6493
- setupWssEvents({
7592
+ await setupWssEvents({
6494
7593
  httpServer,
6495
- wssRoutes
7594
+ wssRoutes,
7595
+ projectRoot
6496
7596
  });
6497
7597
  setupRoutes({
6498
7598
  app,