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