@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/index.cjs CHANGED
@@ -89,6 +89,7 @@ __export(src_exports, {
89
89
  createModuleLogger: () => createModuleLogger,
90
90
  createRateLimiter: () => createRateLimiter,
91
91
  defaultRateLimiter: () => defaultRateLimiter,
92
+ defineWssRoute: () => defineWssRoute,
92
93
  generateRequestId: () => generateRequestId,
93
94
  getAppDir: () => getAppDir,
94
95
  getBuildDir: () => getBuildDir,
@@ -462,6 +463,58 @@ var import_path8 = __toESM(require("path"));
462
463
  var import_fs5 = __toESM(require("fs"));
463
464
  var import_path6 = __toESM(require("path"));
464
465
  init_globals();
466
+
467
+ // modules/router/helpers/routes/extract-wss-route.ts
468
+ function extractDefineWssRoute(mod, namespace) {
469
+ if (!mod.default) {
470
+ if (mod.events && Array.isArray(mod.events)) {
471
+ throw new Error(
472
+ `[loly:realtime] BREAKING CHANGE: 'export const events = []' is no longer supported.
473
+ Please use 'export default defineWssRoute({ events: { ... } })' instead.
474
+ See migration guide: https://loly.dev/docs/migration
475
+ File: ${mod.__filename || "unknown"}`
476
+ );
477
+ }
478
+ return null;
479
+ }
480
+ const routeDef = mod.default;
481
+ if (!routeDef || typeof routeDef !== "object" || !routeDef.events) {
482
+ throw new Error(
483
+ `[loly:realtime] Module must export default from defineWssRoute().
484
+ Expected: export default defineWssRoute({ events: { ... } })
485
+ File: ${mod.__filename || "unknown"}`
486
+ );
487
+ }
488
+ const normalizedEvents = /* @__PURE__ */ new Map();
489
+ for (const [eventName, eventDef] of Object.entries(routeDef.events)) {
490
+ if (typeof eventDef === "function") {
491
+ normalizedEvents.set(eventName.toLowerCase(), {
492
+ handler: eventDef
493
+ });
494
+ } else if (eventDef && typeof eventDef === "object" && eventDef.handler) {
495
+ normalizedEvents.set(eventName.toLowerCase(), {
496
+ schema: eventDef.schema,
497
+ rateLimit: eventDef.rateLimit,
498
+ guard: eventDef.guard,
499
+ handler: eventDef.handler
500
+ });
501
+ } else {
502
+ throw new Error(
503
+ `[loly:realtime] Invalid event definition for '${eventName}'. Event must be a handler function or an object with a 'handler' property.
504
+ File: ${mod.__filename || "unknown"}`
505
+ );
506
+ }
507
+ }
508
+ return {
509
+ namespace,
510
+ auth: routeDef.auth,
511
+ onConnect: routeDef.onConnect,
512
+ onDisconnect: routeDef.onDisconnect,
513
+ events: normalizedEvents
514
+ };
515
+ }
516
+
517
+ // modules/router/helpers/routes/index.ts
465
518
  function readManifest(projectRoot) {
466
519
  const manifestPath = import_path6.default.join(
467
520
  projectRoot,
@@ -536,18 +589,6 @@ function extractWssHandlers(mod, events) {
536
589
  }
537
590
  return handlers;
538
591
  }
539
- function extractWssHandlersFromModule(mod) {
540
- const handlers = {};
541
- if (!Array.isArray(mod?.events)) {
542
- return handlers;
543
- }
544
- for (const event of mod.events) {
545
- if (typeof event.handler === "function" && typeof event.name === "string") {
546
- handlers[event.name.toLowerCase()] = event.handler;
547
- }
548
- }
549
- return handlers;
550
- }
551
592
  function loadPageComponent(pageFile, projectRoot) {
552
593
  const fullPath = import_path6.default.join(projectRoot, pageFile);
553
594
  const pageMod = loadModuleSafely(fullPath);
@@ -1024,8 +1065,29 @@ function loadRoutesFromManifest(projectRoot) {
1024
1065
  if (!mod) {
1025
1066
  continue;
1026
1067
  }
1068
+ let namespace = entry.pattern.replace(/^\/wss/, "");
1069
+ if (!namespace.startsWith("/")) {
1070
+ namespace = "/" + namespace;
1071
+ }
1072
+ if (namespace === "") {
1073
+ namespace = "/";
1074
+ }
1075
+ let normalized = null;
1076
+ try {
1077
+ normalized = extractDefineWssRoute(mod, namespace);
1078
+ } catch (error) {
1079
+ console.warn(
1080
+ `[loly:realtime] Failed to extract normalized route from ${filePath}:`,
1081
+ error instanceof Error ? error.message : String(error)
1082
+ );
1083
+ }
1027
1084
  const handlers = extractWssHandlers(mod, entry.events || []);
1028
1085
  const { global: globalMiddlewares, methodSpecific: methodMiddlewares } = extractApiMiddlewares(mod, []);
1086
+ if (normalized) {
1087
+ for (const [eventName, eventDef] of normalized.events.entries()) {
1088
+ handlers[eventName] = eventDef.handler;
1089
+ }
1090
+ }
1029
1091
  wssRoutes.push({
1030
1092
  pattern: entry.pattern,
1031
1093
  regex,
@@ -1033,7 +1095,9 @@ function loadRoutesFromManifest(projectRoot) {
1033
1095
  handlers,
1034
1096
  middlewares: globalMiddlewares,
1035
1097
  methodMiddlewares,
1036
- filePath
1098
+ filePath,
1099
+ normalized: normalized || void 0
1100
+ // Store normalized structure (use undefined instead of null)
1037
1101
  });
1038
1102
  }
1039
1103
  return { routes: pageRoutes, apiRoutes, wssRoutes };
@@ -1139,7 +1203,30 @@ function loadWssRoutes(appDir) {
1139
1203
  if (!mod) {
1140
1204
  continue;
1141
1205
  }
1142
- const handlers = extractWssHandlersFromModule(mod);
1206
+ let namespace = pattern.replace(/^\/wss/, "");
1207
+ if (!namespace.startsWith("/")) {
1208
+ namespace = "/" + namespace;
1209
+ }
1210
+ if (namespace === "") {
1211
+ namespace = "/";
1212
+ }
1213
+ let normalized = null;
1214
+ try {
1215
+ normalized = extractDefineWssRoute(mod, namespace);
1216
+ } catch (error) {
1217
+ console.error(error instanceof Error ? error.message : String(error));
1218
+ continue;
1219
+ }
1220
+ if (!normalized) {
1221
+ console.warn(
1222
+ `[loly:realtime] Skipping route at ${fullPath}: No default export from defineWssRoute() found`
1223
+ );
1224
+ continue;
1225
+ }
1226
+ const handlers = {};
1227
+ for (const [eventName, eventDef] of normalized.events.entries()) {
1228
+ handlers[eventName] = eventDef.handler;
1229
+ }
1143
1230
  const { global: globalMiddlewares, methodSpecific: methodMiddlewares } = extractApiMiddlewares(mod, []);
1144
1231
  routes.push({
1145
1232
  pattern,
@@ -1148,7 +1235,9 @@ function loadWssRoutes(appDir) {
1148
1235
  handlers,
1149
1236
  middlewares: globalMiddlewares,
1150
1237
  methodMiddlewares,
1151
- filePath: fullPath
1238
+ filePath: fullPath,
1239
+ normalized
1240
+ // Store normalized structure
1152
1241
  });
1153
1242
  }
1154
1243
  }
@@ -5756,6 +5845,31 @@ init_globals();
5756
5845
 
5757
5846
  // modules/server/config.ts
5758
5847
  var CONFIG_FILE_NAME = "loly.config";
5848
+ var DEFAULT_REALTIME_CONFIG = {
5849
+ enabled: true,
5850
+ path: "/wss",
5851
+ transports: ["websocket", "polling"],
5852
+ pingIntervalMs: 25e3,
5853
+ pingTimeoutMs: 2e4,
5854
+ maxPayloadBytes: 64 * 1024,
5855
+ allowedOrigins: process.env.NODE_ENV === "production" ? [] : "*",
5856
+ cors: {
5857
+ credentials: true,
5858
+ allowedHeaders: ["content-type", "authorization"]
5859
+ },
5860
+ scale: {
5861
+ mode: "single"
5862
+ },
5863
+ limits: {
5864
+ connectionsPerIp: 20,
5865
+ eventsPerSecond: 30,
5866
+ burst: 60
5867
+ },
5868
+ logging: {
5869
+ level: "info",
5870
+ pretty: process.env.NODE_ENV !== "production"
5871
+ }
5872
+ };
5759
5873
  var DEFAULT_CONFIG2 = {
5760
5874
  bodyLimit: "1mb",
5761
5875
  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,
@@ -5798,7 +5912,8 @@ var DEFAULT_CONFIG2 = {
5798
5912
  // 1 year
5799
5913
  includeSubDomains: true
5800
5914
  }
5801
- }
5915
+ },
5916
+ realtime: DEFAULT_REALTIME_CONFIG
5802
5917
  };
5803
5918
  async function getServerConfig(projectRoot) {
5804
5919
  let mod = await getServerFile(projectRoot, CONFIG_FILE_NAME);
@@ -5814,12 +5929,76 @@ async function getServerConfig(projectRoot) {
5814
5929
  security: {
5815
5930
  ...DEFAULT_CONFIG2.security,
5816
5931
  ...options.security
5932
+ },
5933
+ realtime: {
5934
+ ...DEFAULT_REALTIME_CONFIG,
5935
+ ...options.realtime,
5936
+ cors: {
5937
+ ...DEFAULT_REALTIME_CONFIG.cors,
5938
+ ...options.realtime?.cors
5939
+ },
5940
+ scale: options.realtime?.scale ? {
5941
+ ...DEFAULT_REALTIME_CONFIG.scale,
5942
+ ...options.realtime.scale,
5943
+ adapter: options.realtime.scale.adapter,
5944
+ stateStore: options.realtime.scale.stateStore ? {
5945
+ ...DEFAULT_REALTIME_CONFIG.scale?.stateStore,
5946
+ ...options.realtime.scale.stateStore,
5947
+ // Ensure name is set (user config takes priority, fallback to default or "memory")
5948
+ name: options.realtime.scale.stateStore.name || DEFAULT_REALTIME_CONFIG.scale?.stateStore?.name || "memory"
5949
+ } : DEFAULT_REALTIME_CONFIG.scale?.stateStore
5950
+ } : DEFAULT_REALTIME_CONFIG.scale,
5951
+ limits: {
5952
+ ...DEFAULT_REALTIME_CONFIG.limits,
5953
+ ...options.realtime?.limits
5954
+ },
5955
+ logging: {
5956
+ ...DEFAULT_REALTIME_CONFIG.logging,
5957
+ ...options.realtime?.logging
5958
+ }
5817
5959
  }
5818
5960
  };
5961
+ validateRealtimeConfig(merged.realtime);
5819
5962
  return merged;
5820
5963
  }
5964
+ validateRealtimeConfig(DEFAULT_CONFIG2.realtime);
5821
5965
  return DEFAULT_CONFIG2;
5822
5966
  }
5967
+ function validateRealtimeConfig(config) {
5968
+ if (!config.enabled) {
5969
+ return;
5970
+ }
5971
+ if (config.scale?.mode === "cluster") {
5972
+ if (!config.scale.adapter) {
5973
+ throw new Error(
5974
+ "[loly:realtime] Cluster mode requires a Redis adapter. Please configure realtime.scale.adapter in your loly.config.ts"
5975
+ );
5976
+ }
5977
+ if (config.scale.adapter.name !== "redis") {
5978
+ throw new Error(
5979
+ "[loly:realtime] Only Redis adapter is supported for cluster mode"
5980
+ );
5981
+ }
5982
+ if (!config.scale.adapter.url) {
5983
+ throw new Error(
5984
+ "[loly:realtime] Redis adapter requires a URL. Set realtime.scale.adapter.url or REDIS_URL environment variable"
5985
+ );
5986
+ }
5987
+ if (config.scale.stateStore?.name === "memory") {
5988
+ console.warn(
5989
+ "[loly:realtime] WARNING: Using memory state store in cluster mode. State will diverge across instances. Consider using Redis state store."
5990
+ );
5991
+ }
5992
+ }
5993
+ if (process.env.NODE_ENV === "production") {
5994
+ if (!config.allowedOrigins || Array.isArray(config.allowedOrigins) && config.allowedOrigins.length === 0 || config.allowedOrigins === "*") {
5995
+ config.allowedOrigins = "*";
5996
+ console.warn(
5997
+ "[loly:realtime] No allowedOrigins configured. Auto-allowing localhost for local development. For production deployment, configure realtime.allowedOrigins in loly.config.ts"
5998
+ );
5999
+ }
6000
+ }
6001
+ }
5823
6002
 
5824
6003
  // modules/server/routes.ts
5825
6004
  var import_path24 = __toESM(require("path"));
@@ -5882,44 +6061,841 @@ function setupRoutes(options) {
5882
6061
 
5883
6062
  // modules/server/wss.ts
5884
6063
  var import_socket = require("socket.io");
5885
- var generateActions = (socket, namespace) => {
6064
+
6065
+ // modules/realtime/state/memory-store.ts
6066
+ var MemoryStateStore = class {
6067
+ constructor() {
6068
+ this.store = /* @__PURE__ */ new Map();
6069
+ this.lists = /* @__PURE__ */ new Map();
6070
+ this.sets = /* @__PURE__ */ new Map();
6071
+ this.locks = /* @__PURE__ */ new Map();
6072
+ this.cleanupInterval = setInterval(() => {
6073
+ this.cleanupExpired();
6074
+ }, 6e4);
6075
+ }
6076
+ /**
6077
+ * Cleanup expired entries
6078
+ */
6079
+ cleanupExpired() {
6080
+ const now = Date.now();
6081
+ for (const [key, entry] of this.store.entries()) {
6082
+ if (entry.expiresAt && entry.expiresAt < now) {
6083
+ this.store.delete(key);
6084
+ }
6085
+ }
6086
+ for (const [key, lock] of this.locks.entries()) {
6087
+ if (lock.expiresAt < now) {
6088
+ this.locks.delete(key);
6089
+ }
6090
+ }
6091
+ }
6092
+ /**
6093
+ * Get a value by key
6094
+ */
6095
+ async get(key) {
6096
+ const entry = this.store.get(key);
6097
+ if (!entry) {
6098
+ return null;
6099
+ }
6100
+ if (entry.expiresAt && entry.expiresAt < Date.now()) {
6101
+ this.store.delete(key);
6102
+ return null;
6103
+ }
6104
+ return entry.value;
6105
+ }
6106
+ /**
6107
+ * Set a value with optional TTL
6108
+ */
6109
+ async set(key, value, opts) {
6110
+ const entry = {
6111
+ value
6112
+ };
6113
+ if (opts?.ttlMs) {
6114
+ entry.expiresAt = Date.now() + opts.ttlMs;
6115
+ }
6116
+ this.store.set(key, entry);
6117
+ }
6118
+ /**
6119
+ * Delete a key
6120
+ */
6121
+ async del(key) {
6122
+ this.store.delete(key);
6123
+ this.lists.delete(key);
6124
+ this.sets.delete(key);
6125
+ this.locks.delete(key);
6126
+ }
6127
+ /**
6128
+ * Increment a numeric value
6129
+ */
6130
+ async incr(key, by = 1) {
6131
+ const current = await this.get(key);
6132
+ const newValue = (current ?? 0) + by;
6133
+ await this.set(key, newValue);
6134
+ return newValue;
6135
+ }
6136
+ /**
6137
+ * Decrement a numeric value
6138
+ */
6139
+ async decr(key, by = 1) {
6140
+ return this.incr(key, -by);
6141
+ }
6142
+ /**
6143
+ * Push to a list (left push)
6144
+ */
6145
+ async listPush(key, value, opts) {
6146
+ let list = this.lists.get(key);
6147
+ if (!list) {
6148
+ list = [];
6149
+ this.lists.set(key, list);
6150
+ }
6151
+ list.unshift(value);
6152
+ if (opts?.maxLen && list.length > opts.maxLen) {
6153
+ list.splice(opts.maxLen);
6154
+ }
6155
+ }
6156
+ /**
6157
+ * Get range from a list
6158
+ */
6159
+ async listRange(key, start, end) {
6160
+ const list = this.lists.get(key);
6161
+ if (!list) {
6162
+ return [];
6163
+ }
6164
+ const len = list.length;
6165
+ const actualStart = start < 0 ? Math.max(0, len + start) : Math.min(start, len);
6166
+ const actualEnd = end < 0 ? Math.max(0, len + end + 1) : Math.min(end + 1, len);
6167
+ return list.slice(actualStart, actualEnd);
6168
+ }
6169
+ /**
6170
+ * Add member to a set
6171
+ */
6172
+ async setAdd(key, member) {
6173
+ let set = this.sets.get(key);
6174
+ if (!set) {
6175
+ set = /* @__PURE__ */ new Set();
6176
+ this.sets.set(key, set);
6177
+ }
6178
+ set.add(member);
6179
+ }
6180
+ /**
6181
+ * Remove member from a set
6182
+ */
6183
+ async setRem(key, member) {
6184
+ const set = this.sets.get(key);
6185
+ if (set) {
6186
+ set.delete(member);
6187
+ if (set.size === 0) {
6188
+ this.sets.delete(key);
6189
+ }
6190
+ }
6191
+ }
6192
+ /**
6193
+ * Get all members of a set
6194
+ */
6195
+ async setMembers(key) {
6196
+ const set = this.sets.get(key);
6197
+ if (!set) {
6198
+ return [];
6199
+ }
6200
+ return Array.from(set);
6201
+ }
6202
+ /**
6203
+ * Acquire a distributed lock
6204
+ */
6205
+ async lock(key, ttlMs) {
6206
+ const lockKey = `__lock:${key}`;
6207
+ const now = Date.now();
6208
+ const existingLock = this.locks.get(lockKey);
6209
+ if (existingLock && existingLock.expiresAt > now) {
6210
+ throw new Error(`Lock '${key}' is already held`);
6211
+ }
6212
+ this.locks.set(lockKey, {
6213
+ expiresAt: now + ttlMs
6214
+ });
6215
+ return async () => {
6216
+ const lock = this.locks.get(lockKey);
6217
+ if (lock && lock.expiresAt > Date.now()) {
6218
+ this.locks.delete(lockKey);
6219
+ }
6220
+ };
6221
+ }
6222
+ /**
6223
+ * Cleanup resources (call when shutting down)
6224
+ */
6225
+ destroy() {
6226
+ if (this.cleanupInterval) {
6227
+ clearInterval(this.cleanupInterval);
6228
+ this.cleanupInterval = void 0;
6229
+ }
6230
+ this.store.clear();
6231
+ this.lists.clear();
6232
+ this.sets.clear();
6233
+ this.locks.clear();
6234
+ }
6235
+ };
6236
+
6237
+ // modules/realtime/state/redis-store.ts
6238
+ var RedisStateStore = class {
6239
+ constructor(client, prefix = "loly:rt:") {
6240
+ this.client = client;
6241
+ this.prefix = prefix;
6242
+ }
6243
+ /**
6244
+ * Add prefix to key
6245
+ */
6246
+ key(key) {
6247
+ return `${this.prefix}${key}`;
6248
+ }
6249
+ /**
6250
+ * Serialize value to JSON string
6251
+ */
6252
+ serialize(value) {
6253
+ return JSON.stringify(value);
6254
+ }
6255
+ /**
6256
+ * Deserialize JSON string to value
6257
+ */
6258
+ deserialize(value) {
6259
+ if (value === null) {
6260
+ return null;
6261
+ }
6262
+ try {
6263
+ return JSON.parse(value);
6264
+ } catch {
6265
+ return null;
6266
+ }
6267
+ }
6268
+ /**
6269
+ * Get a value by key
6270
+ */
6271
+ async get(key) {
6272
+ const value = await this.client.get(this.key(key));
6273
+ return this.deserialize(value);
6274
+ }
6275
+ /**
6276
+ * Set a value with optional TTL
6277
+ */
6278
+ async set(key, value, opts) {
6279
+ const serialized = this.serialize(value);
6280
+ const prefixedKey = this.key(key);
6281
+ if (opts?.ttlMs) {
6282
+ await this.client.psetex(prefixedKey, opts.ttlMs, serialized);
6283
+ } else {
6284
+ await this.client.set(prefixedKey, serialized);
6285
+ }
6286
+ }
6287
+ /**
6288
+ * Delete a key
6289
+ */
6290
+ async del(key) {
6291
+ await this.client.del(this.key(key));
6292
+ }
6293
+ /**
6294
+ * Increment a numeric value
6295
+ */
6296
+ async incr(key, by = 1) {
6297
+ const prefixedKey = this.key(key);
6298
+ if (by === 1) {
6299
+ return await this.client.incr(prefixedKey);
6300
+ } else {
6301
+ return await this.client.incrby(prefixedKey, by);
6302
+ }
6303
+ }
6304
+ /**
6305
+ * Decrement a numeric value
6306
+ */
6307
+ async decr(key, by = 1) {
6308
+ const prefixedKey = this.key(key);
6309
+ if (by === 1) {
6310
+ return await this.client.decr(prefixedKey);
6311
+ } else {
6312
+ return await this.client.decrby(prefixedKey, by);
6313
+ }
6314
+ }
6315
+ /**
6316
+ * Push to a list (left push)
6317
+ */
6318
+ async listPush(key, value, opts) {
6319
+ const serialized = this.serialize(value);
6320
+ const prefixedKey = this.key(key);
6321
+ await this.client.lpush(prefixedKey, serialized);
6322
+ if (opts?.maxLen) {
6323
+ await this.client.eval(
6324
+ `redis.call('ltrim', KEYS[1], 0, ARGV[1])`,
6325
+ 1,
6326
+ prefixedKey,
6327
+ String(opts.maxLen - 1)
6328
+ );
6329
+ }
6330
+ }
6331
+ /**
6332
+ * Get range from a list
6333
+ */
6334
+ async listRange(key, start, end) {
6335
+ const prefixedKey = this.key(key);
6336
+ const values = await this.client.lrange(prefixedKey, start, end);
6337
+ return values.map((v) => this.deserialize(v)).filter((v) => v !== null);
6338
+ }
6339
+ /**
6340
+ * Add member to a set
6341
+ */
6342
+ async setAdd(key, member) {
6343
+ const prefixedKey = this.key(key);
6344
+ await this.client.sadd(prefixedKey, member);
6345
+ }
6346
+ /**
6347
+ * Remove member from a set
6348
+ */
6349
+ async setRem(key, member) {
6350
+ const prefixedKey = this.key(key);
6351
+ await this.client.srem(prefixedKey, member);
6352
+ }
6353
+ /**
6354
+ * Get all members of a set
6355
+ */
6356
+ async setMembers(key) {
6357
+ const prefixedKey = this.key(key);
6358
+ return await this.client.smembers(prefixedKey);
6359
+ }
6360
+ /**
6361
+ * Acquire a distributed lock using Redis SET NX EX
6362
+ */
6363
+ async lock(key, ttlMs) {
6364
+ const lockKey = this.key(`__lock:${key}`);
6365
+ const lockValue = `${Date.now()}-${Math.random()}`;
6366
+ const ttlSeconds = Math.ceil(ttlMs / 1e3);
6367
+ const result = await this.client.set(
6368
+ lockKey,
6369
+ lockValue,
6370
+ "NX",
6371
+ // Only set if not exists
6372
+ "EX",
6373
+ // Expire in seconds
6374
+ ttlSeconds
6375
+ );
6376
+ if (result === null) {
6377
+ throw new Error(`Lock '${key}' is already held`);
6378
+ }
6379
+ return async () => {
6380
+ const unlockScript = `
6381
+ if redis.call("get", KEYS[1]) == ARGV[1] then
6382
+ return redis.call("del", KEYS[1])
6383
+ else
6384
+ return 0
6385
+ end
6386
+ `;
6387
+ await this.client.eval(unlockScript, 1, lockKey, lockValue);
6388
+ };
6389
+ }
6390
+ };
6391
+
6392
+ // modules/realtime/state/index.ts
6393
+ async function createStateStore(config) {
6394
+ if (!config.enabled) {
6395
+ return createNoOpStore();
6396
+ }
6397
+ const storeType = config.scale?.stateStore?.name || "memory";
6398
+ const prefix = config.scale?.stateStore?.prefix || "loly:rt:";
6399
+ if (storeType === "memory") {
6400
+ return new MemoryStateStore();
6401
+ }
6402
+ if (storeType === "redis") {
6403
+ const url = config.scale?.stateStore?.url || process.env.REDIS_URL;
6404
+ if (!url) {
6405
+ throw new Error(
6406
+ "[loly:realtime] Redis state store requires a URL. Set realtime.scale.stateStore.url or REDIS_URL environment variable"
6407
+ );
6408
+ }
6409
+ const client = await createRedisClient(url);
6410
+ return new RedisStateStore(client, prefix);
6411
+ }
6412
+ throw new Error(
6413
+ `[loly:realtime] Unknown state store type: ${storeType}. Supported types: 'memory', 'redis'`
6414
+ );
6415
+ }
6416
+ async function createRedisClient(url) {
6417
+ try {
6418
+ const Redis = await import("ioredis");
6419
+ return new Redis.default(url);
6420
+ } catch {
6421
+ try {
6422
+ const { createClient } = await import("redis");
6423
+ const client = createClient({ url });
6424
+ await client.connect();
6425
+ return client;
6426
+ } catch (err) {
6427
+ throw new Error(
6428
+ `[loly:realtime] Failed to create Redis client. Please install 'ioredis' or 'redis' package. Error: ${err instanceof Error ? err.message : String(err)}`
6429
+ );
6430
+ }
6431
+ }
6432
+ }
6433
+ function createNoOpStore() {
6434
+ const noOp = async () => {
6435
+ };
6436
+ const noOpReturn = async () => null;
6437
+ const noOpNumber = async () => 0;
6438
+ const noOpArray = async () => [];
6439
+ const noOpUnlock = async () => {
6440
+ };
5886
6441
  return {
6442
+ get: noOpReturn,
6443
+ set: noOp,
6444
+ del: noOp,
6445
+ incr: noOpNumber,
6446
+ decr: noOpNumber,
6447
+ listPush: noOp,
6448
+ listRange: noOpArray,
6449
+ setAdd: noOp,
6450
+ setRem: noOp,
6451
+ setMembers: noOpArray,
6452
+ lock: async () => noOpUnlock
6453
+ };
6454
+ }
6455
+
6456
+ // modules/realtime/presence/index.ts
6457
+ var PresenceManager = class {
6458
+ constructor(stateStore, prefix = "loly:rt:") {
6459
+ this.stateStore = stateStore;
6460
+ this.prefix = prefix;
6461
+ }
6462
+ /**
6463
+ * Add a socket for a user
6464
+ */
6465
+ async addSocketForUser(userId, socketId) {
6466
+ const key = this.key(`userSockets:${userId}`);
6467
+ await this.stateStore.setAdd(key, socketId);
6468
+ const socketKey = this.key(`socketUser:${socketId}`);
6469
+ await this.stateStore.set(socketKey, userId);
6470
+ }
6471
+ /**
6472
+ * Remove a socket for a user
6473
+ */
6474
+ async removeSocketForUser(userId, socketId) {
6475
+ const key = this.key(`userSockets:${userId}`);
6476
+ await this.stateStore.setRem(key, socketId);
6477
+ const socketKey = this.key(`socketUser:${socketId}`);
6478
+ await this.stateStore.del(socketKey);
6479
+ const sockets = await this.stateStore.setMembers(key);
6480
+ if (sockets.length === 0) {
6481
+ await this.stateStore.del(key);
6482
+ }
6483
+ }
6484
+ /**
6485
+ * Get all socket IDs for a user
6486
+ */
6487
+ async getSocketsForUser(userId) {
6488
+ const key = this.key(`userSockets:${userId}`);
6489
+ return await this.stateStore.setMembers(key);
6490
+ }
6491
+ /**
6492
+ * Get user ID for a socket
6493
+ */
6494
+ async getUserForSocket(socketId) {
6495
+ const socketKey = this.key(`socketUser:${socketId}`);
6496
+ return await this.stateStore.get(socketKey);
6497
+ }
6498
+ /**
6499
+ * Add user to a room's presence (optional feature)
6500
+ */
6501
+ async addUserToRoom(namespace, room, userId) {
6502
+ const key = this.key(`presence:${namespace}:${room}`);
6503
+ await this.stateStore.setAdd(key, userId);
6504
+ }
6505
+ /**
6506
+ * Remove user from a room's presence
6507
+ */
6508
+ async removeUserFromRoom(namespace, room, userId) {
6509
+ const key = this.key(`presence:${namespace}:${room}`);
6510
+ await this.stateStore.setRem(key, userId);
6511
+ const members = await this.stateStore.setMembers(key);
6512
+ if (members.length === 0) {
6513
+ await this.stateStore.del(key);
6514
+ }
6515
+ }
6516
+ /**
6517
+ * Get all users in a room
6518
+ */
6519
+ async getUsersInRoom(namespace, room) {
6520
+ const key = this.key(`presence:${namespace}:${room}`);
6521
+ return await this.stateStore.setMembers(key);
6522
+ }
6523
+ /**
6524
+ * Add prefix to key
6525
+ */
6526
+ key(key) {
6527
+ return `${this.prefix}${key}`;
6528
+ }
6529
+ };
6530
+
6531
+ // modules/realtime/rate-limit/token-bucket.ts
6532
+ var TokenBucket = class {
6533
+ // tokens per second
6534
+ constructor(capacity, refillRate) {
6535
+ this.capacity = capacity;
6536
+ this.refillRate = refillRate;
6537
+ this.tokens = capacity;
6538
+ this.lastRefill = Date.now();
6539
+ }
6540
+ /**
6541
+ * Try to consume tokens. Returns true if successful, false if rate limited.
6542
+ */
6543
+ consume(tokens = 1) {
6544
+ this.refill();
6545
+ if (this.tokens >= tokens) {
6546
+ this.tokens -= tokens;
6547
+ return true;
6548
+ }
6549
+ return false;
6550
+ }
6551
+ /**
6552
+ * Get current available tokens
6553
+ */
6554
+ getAvailable() {
6555
+ this.refill();
6556
+ return this.tokens;
6557
+ }
6558
+ /**
6559
+ * Refill tokens based on elapsed time
6560
+ */
6561
+ refill() {
6562
+ const now = Date.now();
6563
+ const elapsed = (now - this.lastRefill) / 1e3;
6564
+ const tokensToAdd = elapsed * this.refillRate;
6565
+ this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
6566
+ this.lastRefill = now;
6567
+ }
6568
+ /**
6569
+ * Reset bucket to full capacity
6570
+ */
6571
+ reset() {
6572
+ this.tokens = this.capacity;
6573
+ this.lastRefill = Date.now();
6574
+ }
6575
+ };
6576
+
6577
+ // modules/realtime/rate-limit/index.ts
6578
+ var RateLimiter = class {
6579
+ constructor(stateStore, prefix = "loly:rt:rate:") {
6580
+ this.memoryBuckets = /* @__PURE__ */ new Map();
6581
+ this.stateStore = stateStore;
6582
+ this.prefix = prefix;
6583
+ }
6584
+ /**
6585
+ * Check if a request should be rate limited.
6586
+ *
6587
+ * @param key - Unique key for the rate limit (e.g., socketId or socketId:eventName)
6588
+ * @param config - Rate limit configuration
6589
+ * @returns true if allowed, false if rate limited
6590
+ */
6591
+ async checkLimit(key, config) {
6592
+ const fullKey = `${this.prefix}${key}`;
6593
+ const burst = config.burst || config.eventsPerSecond * 2;
6594
+ if (this.stateStore) {
6595
+ return this.checkLimitWithStore(fullKey, config.eventsPerSecond, burst);
6596
+ } else {
6597
+ return this.checkLimitInMemory(fullKey, config.eventsPerSecond, burst);
6598
+ }
6599
+ }
6600
+ /**
6601
+ * Check rate limit using state store (for cluster mode)
6602
+ */
6603
+ async checkLimitWithStore(key, ratePerSecond, burst) {
6604
+ const count = await this.stateStore.get(key) || 0;
6605
+ if (count >= burst) {
6606
+ return false;
6607
+ }
6608
+ await this.stateStore.incr(key, 1);
6609
+ await this.stateStore.set(key, count + 1, { ttlMs: 1e3 });
6610
+ return true;
6611
+ }
6612
+ /**
6613
+ * Check rate limit using in-memory token bucket
6614
+ */
6615
+ checkLimitInMemory(key, ratePerSecond, burst) {
6616
+ let bucket = this.memoryBuckets.get(key);
6617
+ if (!bucket) {
6618
+ bucket = new TokenBucket(burst, ratePerSecond);
6619
+ this.memoryBuckets.set(key, bucket);
6620
+ }
6621
+ return bucket.consume(1);
6622
+ }
6623
+ /**
6624
+ * Cleanup old buckets (call periodically)
6625
+ */
6626
+ cleanup() {
6627
+ }
6628
+ };
6629
+
6630
+ // modules/realtime/auth/index.ts
6631
+ async function executeAuth(authFn, socket, namespace) {
6632
+ if (!authFn) {
6633
+ return null;
6634
+ }
6635
+ const authCtx = {
6636
+ req: {
6637
+ headers: socket.handshake.headers,
6638
+ ip: socket.handshake.address,
6639
+ url: socket.handshake.url,
6640
+ cookies: socket.handshake.headers.cookie ? parseCookies(socket.handshake.headers.cookie) : void 0
6641
+ },
6642
+ socket,
6643
+ namespace
6644
+ };
6645
+ const user = await authFn(authCtx);
6646
+ if (user) {
6647
+ socket.data = socket.data || {};
6648
+ socket.data.user = user;
6649
+ }
6650
+ return user;
6651
+ }
6652
+ function parseCookies(cookieString) {
6653
+ const cookies = {};
6654
+ cookieString.split(";").forEach((cookie) => {
6655
+ const [name, value] = cookie.trim().split("=");
6656
+ if (name && value) {
6657
+ cookies[name] = decodeURIComponent(value);
6658
+ }
6659
+ });
6660
+ return cookies;
6661
+ }
6662
+
6663
+ // modules/realtime/guards/index.ts
6664
+ async function executeGuard(guardFn, ctx) {
6665
+ if (!guardFn) {
6666
+ return true;
6667
+ }
6668
+ const guardCtx = {
6669
+ user: ctx.user,
6670
+ req: ctx.req,
6671
+ socket: ctx.socket,
6672
+ namespace: ctx.pathname
6673
+ };
6674
+ const result = await guardFn(guardCtx);
6675
+ return result === true;
6676
+ }
6677
+
6678
+ // modules/realtime/validation/index.ts
6679
+ function validateSchema(schema, data) {
6680
+ if (!schema) {
6681
+ return { success: true, data };
6682
+ }
6683
+ if (typeof schema.safeParse === "function") {
6684
+ const result = schema.safeParse(data);
6685
+ if (result.success) {
6686
+ return { success: true, data: result.data };
6687
+ } else {
6688
+ return { success: false, error: result.error };
6689
+ }
6690
+ }
6691
+ if (typeof schema.parse === "function") {
6692
+ try {
6693
+ const parsed = schema.parse(data);
6694
+ return { success: true, data: parsed };
6695
+ } catch (error) {
6696
+ return { success: false, error };
6697
+ }
6698
+ }
6699
+ return {
6700
+ success: false,
6701
+ error: new Error("Schema must have 'parse' or 'safeParse' method")
6702
+ };
6703
+ }
6704
+
6705
+ // modules/realtime/logging/index.ts
6706
+ function createWssLogger(namespace, socket, baseLogger) {
6707
+ const context = {
6708
+ namespace,
6709
+ socketId: socket.id,
6710
+ userId: socket.data?.user?.id || null
6711
+ };
6712
+ const log = (level, message, meta) => {
6713
+ const fullMeta = {
6714
+ ...context,
6715
+ ...meta,
6716
+ requestId: socket.requestId || generateRequestId2()
6717
+ };
6718
+ if (baseLogger) {
6719
+ baseLogger[level](message, fullMeta);
6720
+ } else {
6721
+ console[level === "error" ? "error" : "log"](
6722
+ `[${level.toUpperCase()}] [${namespace}] ${message}`,
6723
+ fullMeta
6724
+ );
6725
+ }
6726
+ };
6727
+ return {
6728
+ debug: (message, meta) => log("debug", message, meta),
6729
+ info: (message, meta) => log("info", message, meta),
6730
+ warn: (message, meta) => log("warn", message, meta),
6731
+ error: (message, meta) => log("error", message, meta)
6732
+ };
6733
+ }
6734
+ function generateRequestId2() {
6735
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
6736
+ }
6737
+
6738
+ // modules/server/wss.ts
6739
+ var generateActions = (socket, namespace, presence) => {
6740
+ return {
6741
+ // Emit to current socket only (reply)
6742
+ reply: (event, payload) => {
6743
+ socket.emit(event, payload);
6744
+ },
5887
6745
  // Emit to all clients in the namespace
5888
- emit: (event, ...args) => {
5889
- socket.nsp.emit(event, ...args);
6746
+ emit: (event, payload) => {
6747
+ socket.nsp.emit(event, payload);
5890
6748
  },
5891
- // Emit to a specific socket by Socket.IO socket ID
6749
+ // Emit to everyone except current socket
6750
+ broadcast: (event, payload, opts) => {
6751
+ if (opts?.excludeSelf === false) {
6752
+ socket.nsp.emit(event, payload);
6753
+ } else {
6754
+ socket.broadcast.emit(event, payload);
6755
+ }
6756
+ },
6757
+ // Join a room
6758
+ join: async (room) => {
6759
+ await socket.join(room);
6760
+ },
6761
+ // Leave a room
6762
+ leave: async (room) => {
6763
+ await socket.leave(room);
6764
+ },
6765
+ // Emit to a specific room
6766
+ toRoom: (room) => {
6767
+ return {
6768
+ emit: (event, payload) => {
6769
+ namespace.to(room).emit(event, payload);
6770
+ }
6771
+ };
6772
+ },
6773
+ // Emit to a specific user (by userId)
6774
+ toUser: (userId) => {
6775
+ return {
6776
+ emit: async (event, payload) => {
6777
+ if (!presence) {
6778
+ console.warn(
6779
+ "[loly:realtime] toUser() requires presence manager. Make sure realtime is properly configured."
6780
+ );
6781
+ return;
6782
+ }
6783
+ const socketIds = await presence.getSocketsForUser(userId);
6784
+ for (const socketId of socketIds) {
6785
+ const targetSocket = namespace.sockets.get(socketId);
6786
+ if (targetSocket) {
6787
+ targetSocket.emit(event, payload);
6788
+ }
6789
+ }
6790
+ }
6791
+ };
6792
+ },
6793
+ // Emit error event (reserved event: __loly:error)
6794
+ error: (code, message, details) => {
6795
+ socket.emit("__loly:error", {
6796
+ code,
6797
+ message,
6798
+ details,
6799
+ requestId: socket.requestId || void 0
6800
+ });
6801
+ },
6802
+ // Legacy: Emit to a specific socket by Socket.IO socket ID
5892
6803
  emitTo: (socketId, event, ...args) => {
5893
6804
  const targetSocket = namespace.sockets.get(socketId);
5894
6805
  if (targetSocket) {
5895
6806
  targetSocket.emit(event, ...args);
5896
6807
  }
5897
6808
  },
5898
- // Emit to a specific client by custom clientId
5899
- // Requires clientId to be stored in socket.data.clientId during connection
6809
+ // Legacy: Emit to a specific client by custom clientId
5900
6810
  emitToClient: (clientId, event, ...args) => {
5901
6811
  namespace.sockets.forEach((s) => {
5902
6812
  if (s.data?.clientId === clientId) {
5903
6813
  s.emit(event, ...args);
5904
6814
  }
5905
6815
  });
5906
- },
5907
- // Broadcast to all clients except the sender
5908
- broadcast: (event, ...args) => {
5909
- socket.broadcast.emit(event, ...args);
5910
6816
  }
5911
6817
  };
5912
6818
  };
5913
- function setupWssEvents(options) {
5914
- const { httpServer, wssRoutes } = options;
6819
+ async function setupWssEvents(options) {
6820
+ const { httpServer, wssRoutes, projectRoot } = options;
5915
6821
  if (wssRoutes.length === 0) {
5916
6822
  return;
5917
6823
  }
6824
+ const serverConfig = await getServerConfig(projectRoot);
6825
+ const realtimeConfig = serverConfig.realtime;
6826
+ if (!realtimeConfig || !realtimeConfig.enabled) {
6827
+ return;
6828
+ }
6829
+ const stateStore = await createStateStore(realtimeConfig);
6830
+ const stateStorePrefix = realtimeConfig.scale?.stateStore?.prefix || "loly:rt:";
6831
+ const presence = new PresenceManager(stateStore, stateStorePrefix);
6832
+ const rateLimiter = new RateLimiter(
6833
+ realtimeConfig.scale?.mode === "cluster" ? stateStore : void 0,
6834
+ `${stateStorePrefix}rate:`
6835
+ );
6836
+ const allowedOrigins = realtimeConfig.allowedOrigins;
6837
+ const corsOrigin = !allowedOrigins || Array.isArray(allowedOrigins) && allowedOrigins.length === 0 || allowedOrigins === "*" ? (
6838
+ // Auto-allow localhost on any port for simplicity
6839
+ (origin, callback) => {
6840
+ if (!origin) {
6841
+ callback(null, true);
6842
+ return;
6843
+ }
6844
+ if (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:") || origin.startsWith("https://localhost:") || origin.startsWith("https://127.0.0.1:")) {
6845
+ callback(null, true);
6846
+ } else {
6847
+ callback(new Error("Not allowed by CORS"));
6848
+ }
6849
+ }
6850
+ ) : allowedOrigins;
6851
+ const corsOptions = {
6852
+ origin: corsOrigin,
6853
+ credentials: realtimeConfig.cors?.credentials ?? true,
6854
+ methods: ["GET", "POST"],
6855
+ allowedHeaders: realtimeConfig.cors?.allowedHeaders || [
6856
+ "content-type",
6857
+ "authorization"
6858
+ ]
6859
+ };
5918
6860
  const io = new import_socket.Server(httpServer, {
5919
- path: "/wss"
6861
+ path: realtimeConfig.path || "/wss",
6862
+ transports: realtimeConfig.transports || ["websocket", "polling"],
6863
+ pingInterval: realtimeConfig.pingIntervalMs || 25e3,
6864
+ pingTimeout: realtimeConfig.pingTimeoutMs || 2e4,
6865
+ maxHttpBufferSize: realtimeConfig.maxPayloadBytes || 64 * 1024,
6866
+ cors: corsOptions
5920
6867
  });
6868
+ if (realtimeConfig.scale?.mode === "cluster" && realtimeConfig.scale.adapter) {
6869
+ try {
6870
+ const redisAdapterModule = await import("@socket.io/redis-adapter").catch(() => null);
6871
+ const ioredisModule = await import("ioredis").catch(() => null);
6872
+ if (!redisAdapterModule || !ioredisModule) {
6873
+ throw new Error(
6874
+ "[loly:realtime] Redis adapter dependencies not found. Install @socket.io/redis-adapter and ioredis for cluster mode: pnpm add @socket.io/redis-adapter ioredis"
6875
+ );
6876
+ }
6877
+ const { createAdapter } = redisAdapterModule;
6878
+ const Redis = ioredisModule.default || ioredisModule;
6879
+ const pubClient = new Redis(realtimeConfig.scale.adapter.url);
6880
+ const subClient = pubClient.duplicate();
6881
+ io.adapter(createAdapter(pubClient, subClient));
6882
+ } catch (error) {
6883
+ console.error(
6884
+ "[loly:realtime] Failed to setup Redis adapter:",
6885
+ error instanceof Error ? error.message : String(error)
6886
+ );
6887
+ throw error;
6888
+ }
6889
+ }
5921
6890
  for (const wssRoute of wssRoutes) {
5922
- let namespacePath = wssRoute.pattern.replace(/^\/wss/, "");
6891
+ const normalized = wssRoute.normalized;
6892
+ if (!normalized) {
6893
+ console.warn(
6894
+ `[loly:realtime] Skipping route ${wssRoute.pattern}: No normalized route definition`
6895
+ );
6896
+ continue;
6897
+ }
6898
+ let namespacePath = normalized.namespace || wssRoute.pattern.replace(/^\/wss/, "");
5923
6899
  if (!namespacePath.startsWith("/")) {
5924
6900
  namespacePath = "/" + namespacePath;
5925
6901
  }
@@ -5927,34 +6903,158 @@ function setupWssEvents(options) {
5927
6903
  namespacePath = "/";
5928
6904
  }
5929
6905
  const namespace = io.of(namespacePath);
5930
- namespace.on("connection", (socket) => {
5931
- Object.entries(wssRoute.handlers).forEach(([event, handler]) => {
5932
- if (event.toLowerCase() === "connection") {
5933
- const ctx = {
5934
- socket,
5935
- io: namespace.server,
5936
- params: {},
5937
- pathname: wssRoute.pattern,
5938
- actions: generateActions(socket, namespace)
5939
- };
5940
- handler(ctx);
5941
- } else {
5942
- socket.on(event, (data) => {
5943
- const ctx = {
5944
- socket,
5945
- io: namespace.server,
5946
- actions: generateActions(socket, namespace),
5947
- params: {},
5948
- pathname: wssRoute.pattern,
5949
- data
5950
- };
5951
- handler(ctx);
6906
+ console.log(`[loly:realtime] Registered namespace: ${namespacePath} (from pattern: ${wssRoute.pattern})`);
6907
+ namespace.on("connection", async (socket) => {
6908
+ console.log(`[loly:realtime] Client connected to namespace ${namespacePath}, socket: ${socket.id}`);
6909
+ const requestId = generateRequestId();
6910
+ socket.requestId = requestId;
6911
+ const log = createWssLogger(namespacePath, socket);
6912
+ try {
6913
+ const user = await executeAuth(normalized.auth, socket, namespacePath);
6914
+ socket.data = socket.data || {};
6915
+ socket.data.user = user;
6916
+ if (user && user.id) {
6917
+ await presence.addSocketForUser(String(user.id), socket.id);
6918
+ }
6919
+ const baseCtx = {
6920
+ socket,
6921
+ io: namespace.server,
6922
+ req: {
6923
+ headers: socket.handshake.headers,
6924
+ ip: socket.handshake.address,
6925
+ url: socket.handshake.url,
6926
+ cookies: socket.handshake.headers.cookie ? parseCookies2(socket.handshake.headers.cookie) : void 0
6927
+ },
6928
+ user: user || null,
6929
+ params: {},
6930
+ pathname: wssRoute.pattern,
6931
+ actions: generateActions(socket, namespace, presence),
6932
+ state: stateStore,
6933
+ log
6934
+ };
6935
+ if (normalized.onConnect) {
6936
+ try {
6937
+ await normalized.onConnect(baseCtx);
6938
+ } catch (error) {
6939
+ log.error("Error in onConnect hook", {
6940
+ error: error instanceof Error ? error.message : String(error)
6941
+ });
6942
+ }
6943
+ }
6944
+ for (const [eventName, eventDef] of normalized.events.entries()) {
6945
+ socket.on(eventName, async (data) => {
6946
+ const eventRequestId = generateRequestId();
6947
+ socket.requestId = eventRequestId;
6948
+ const eventLog = createWssLogger(namespacePath, socket);
6949
+ eventLog.debug(`Event received: ${eventName}`, { data });
6950
+ try {
6951
+ const ctx = {
6952
+ ...baseCtx,
6953
+ data,
6954
+ log: eventLog
6955
+ };
6956
+ if (eventDef.schema) {
6957
+ const validation = validateSchema(eventDef.schema, data);
6958
+ if (!validation.success) {
6959
+ ctx.actions.error("BAD_PAYLOAD", "Invalid payload", {
6960
+ error: validation.error
6961
+ });
6962
+ eventLog.warn("Schema validation failed", {
6963
+ error: validation.error
6964
+ });
6965
+ return;
6966
+ }
6967
+ ctx.data = validation.data;
6968
+ }
6969
+ if (eventDef.guard) {
6970
+ const allowed = await executeGuard(eventDef.guard, ctx);
6971
+ if (!allowed) {
6972
+ ctx.actions.error("FORBIDDEN", "Access denied");
6973
+ eventLog.warn("Guard check failed");
6974
+ return;
6975
+ }
6976
+ }
6977
+ const globalLimit = realtimeConfig.limits;
6978
+ if (globalLimit) {
6979
+ const globalAllowed = await rateLimiter.checkLimit(
6980
+ socket.id,
6981
+ {
6982
+ eventsPerSecond: globalLimit.eventsPerSecond || 30,
6983
+ burst: globalLimit.burst || 60
6984
+ }
6985
+ );
6986
+ if (!globalAllowed) {
6987
+ ctx.actions.error("RATE_LIMIT", "Rate limit exceeded");
6988
+ eventLog.warn("Global rate limit exceeded");
6989
+ return;
6990
+ }
6991
+ }
6992
+ if (eventDef.rateLimit) {
6993
+ const eventAllowed = await rateLimiter.checkLimit(
6994
+ `${socket.id}:${eventName}`,
6995
+ eventDef.rateLimit
6996
+ );
6997
+ if (!eventAllowed) {
6998
+ ctx.actions.error("RATE_LIMIT", "Event rate limit exceeded");
6999
+ eventLog.warn("Event rate limit exceeded");
7000
+ return;
7001
+ }
7002
+ }
7003
+ await eventDef.handler(ctx);
7004
+ eventLog.debug(`Event handled: ${eventName}`);
7005
+ } catch (error) {
7006
+ const errorLog = createWssLogger(namespacePath, socket);
7007
+ errorLog.error(`Error handling event ${eventName}`, {
7008
+ error: error instanceof Error ? error.message : String(error),
7009
+ stack: error instanceof Error ? error.stack : void 0
7010
+ });
7011
+ socket.emit("__loly:error", {
7012
+ code: "INTERNAL_ERROR",
7013
+ message: "An error occurred while processing your request",
7014
+ requestId: eventRequestId
7015
+ });
7016
+ }
5952
7017
  });
5953
7018
  }
5954
- });
7019
+ socket.on("disconnect", async (reason) => {
7020
+ const userId = socket.data?.user?.id;
7021
+ if (userId) {
7022
+ await presence.removeSocketForUser(String(userId), socket.id);
7023
+ }
7024
+ if (normalized.onDisconnect) {
7025
+ try {
7026
+ const disconnectCtx = {
7027
+ ...baseCtx,
7028
+ log: createWssLogger(namespacePath, socket)
7029
+ };
7030
+ await normalized.onDisconnect(disconnectCtx, reason);
7031
+ } catch (error) {
7032
+ log.error("Error in onDisconnect hook", {
7033
+ error: error instanceof Error ? error.message : String(error)
7034
+ });
7035
+ }
7036
+ }
7037
+ log.info("Socket disconnected", { reason });
7038
+ });
7039
+ } catch (error) {
7040
+ log.error("Error during connection setup", {
7041
+ error: error instanceof Error ? error.message : String(error)
7042
+ });
7043
+ socket.disconnect();
7044
+ }
5955
7045
  });
5956
7046
  }
5957
7047
  }
7048
+ function parseCookies2(cookieString) {
7049
+ const cookies = {};
7050
+ cookieString.split(";").forEach((cookie) => {
7051
+ const [name, value] = cookie.trim().split("=");
7052
+ if (name && value) {
7053
+ cookies[name] = decodeURIComponent(value);
7054
+ }
7055
+ });
7056
+ return cookies;
7057
+ }
5958
7058
 
5959
7059
  // modules/server/application.ts
5960
7060
  var import_http = __toESM(require("http"));
@@ -6185,9 +7285,10 @@ async function startServer(options = {}) {
6185
7285
  config
6186
7286
  });
6187
7287
  const routeLoader = isDev ? new FilesystemRouteLoader(appDir, projectRoot) : new ManifestRouteLoader(projectRoot);
6188
- setupWssEvents({
7288
+ await setupWssEvents({
6189
7289
  httpServer,
6190
- wssRoutes
7290
+ wssRoutes,
7291
+ projectRoot
6191
7292
  });
6192
7293
  setupRoutes({
6193
7294
  app,
@@ -6576,6 +7677,90 @@ async function buildApp(options = {}) {
6576
7677
  console.log(`[framework][build] Build completed successfully`);
6577
7678
  }
6578
7679
 
7680
+ // modules/realtime/define-wss-route.ts
7681
+ function defineWssRoute(definition) {
7682
+ if (process.env.NODE_ENV !== "production") {
7683
+ validateRouteDefinition(definition);
7684
+ }
7685
+ return definition;
7686
+ }
7687
+ function validateRouteDefinition(definition) {
7688
+ if (!definition) {
7689
+ throw new Error(
7690
+ "[loly:realtime] Route definition is required. Use defineWssRoute({ events: { ... } })"
7691
+ );
7692
+ }
7693
+ if (!definition.events || typeof definition.events !== "object") {
7694
+ throw new Error(
7695
+ "[loly:realtime] Route definition must have an 'events' object. Example: defineWssRoute({ events: { message: { handler: ... } } })"
7696
+ );
7697
+ }
7698
+ if (Object.keys(definition.events).length === 0) {
7699
+ throw new Error(
7700
+ "[loly:realtime] Route definition must have at least one event handler"
7701
+ );
7702
+ }
7703
+ for (const [eventName, eventDef] of Object.entries(definition.events)) {
7704
+ if (typeof eventName !== "string" || eventName.trim() === "") {
7705
+ throw new Error(
7706
+ "[loly:realtime] Event names must be non-empty strings"
7707
+ );
7708
+ }
7709
+ if (eventName === "__loly:error") {
7710
+ throw new Error(
7711
+ "[loly:realtime] '__loly:error' is a reserved event name"
7712
+ );
7713
+ }
7714
+ if (typeof eventDef === "function") {
7715
+ continue;
7716
+ }
7717
+ if (typeof eventDef !== "object" || eventDef === null) {
7718
+ throw new Error(
7719
+ `[loly:realtime] Event '${eventName}' must be a handler function or an object with a 'handler' property`
7720
+ );
7721
+ }
7722
+ if (typeof eventDef.handler !== "function") {
7723
+ throw new Error(
7724
+ `[loly:realtime] Event '${eventName}' must have a 'handler' function`
7725
+ );
7726
+ }
7727
+ if (eventDef.schema !== void 0) {
7728
+ if (typeof eventDef.schema !== "object" || eventDef.schema === null || typeof eventDef.schema.parse !== "function" && typeof eventDef.schema.safeParse !== "function") {
7729
+ throw new Error(
7730
+ `[loly:realtime] Event '${eventName}' schema must be a Zod or Valibot schema (must have 'parse' or 'safeParse' method)`
7731
+ );
7732
+ }
7733
+ }
7734
+ if (eventDef.rateLimit !== void 0) {
7735
+ if (typeof eventDef.rateLimit !== "object" || eventDef.rateLimit === null || typeof eventDef.rateLimit.eventsPerSecond !== "number" || eventDef.rateLimit.eventsPerSecond <= 0) {
7736
+ throw new Error(
7737
+ `[loly:realtime] Event '${eventName}' rateLimit must have a positive 'eventsPerSecond' number`
7738
+ );
7739
+ }
7740
+ }
7741
+ if (eventDef.guard !== void 0 && typeof eventDef.guard !== "function") {
7742
+ throw new Error(
7743
+ `[loly:realtime] Event '${eventName}' guard must be a function`
7744
+ );
7745
+ }
7746
+ }
7747
+ if (definition.auth !== void 0 && typeof definition.auth !== "function") {
7748
+ throw new Error(
7749
+ "[loly:realtime] 'auth' must be a function"
7750
+ );
7751
+ }
7752
+ if (definition.onConnect !== void 0 && typeof definition.onConnect !== "function") {
7753
+ throw new Error(
7754
+ "[loly:realtime] 'onConnect' must be a function"
7755
+ );
7756
+ }
7757
+ if (definition.onDisconnect !== void 0 && typeof definition.onDisconnect !== "function") {
7758
+ throw new Error(
7759
+ "[loly:realtime] 'onDisconnect' must be a function"
7760
+ );
7761
+ }
7762
+ }
7763
+
6579
7764
  // modules/runtime/client/bootstrap.tsx
6580
7765
  var import_client5 = require("react-dom/client");
6581
7766
 
@@ -7710,6 +8895,7 @@ var commonSchemas = {
7710
8895
  createModuleLogger,
7711
8896
  createRateLimiter,
7712
8897
  defaultRateLimiter,
8898
+ defineWssRoute,
7713
8899
  generateRequestId,
7714
8900
  getAppDir,
7715
8901
  getBuildDir,