@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.js CHANGED
@@ -420,6 +420,58 @@ import path6 from "path";
420
420
  import fs5 from "fs";
421
421
  import path5 from "path";
422
422
  init_globals();
423
+
424
+ // modules/router/helpers/routes/extract-wss-route.ts
425
+ function extractDefineWssRoute(mod, namespace) {
426
+ if (!mod.default) {
427
+ if (mod.events && Array.isArray(mod.events)) {
428
+ throw new Error(
429
+ `[loly:realtime] BREAKING CHANGE: 'export const events = []' is no longer supported.
430
+ Please use 'export default defineWssRoute({ events: { ... } })' instead.
431
+ See migration guide: https://loly.dev/docs/migration
432
+ File: ${mod.__filename || "unknown"}`
433
+ );
434
+ }
435
+ return null;
436
+ }
437
+ const routeDef = mod.default;
438
+ if (!routeDef || typeof routeDef !== "object" || !routeDef.events) {
439
+ throw new Error(
440
+ `[loly:realtime] Module must export default from defineWssRoute().
441
+ Expected: export default defineWssRoute({ events: { ... } })
442
+ File: ${mod.__filename || "unknown"}`
443
+ );
444
+ }
445
+ const normalizedEvents = /* @__PURE__ */ new Map();
446
+ for (const [eventName, eventDef] of Object.entries(routeDef.events)) {
447
+ if (typeof eventDef === "function") {
448
+ normalizedEvents.set(eventName.toLowerCase(), {
449
+ handler: eventDef
450
+ });
451
+ } else if (eventDef && typeof eventDef === "object" && eventDef.handler) {
452
+ normalizedEvents.set(eventName.toLowerCase(), {
453
+ schema: eventDef.schema,
454
+ rateLimit: eventDef.rateLimit,
455
+ guard: eventDef.guard,
456
+ handler: eventDef.handler
457
+ });
458
+ } else {
459
+ throw new Error(
460
+ `[loly:realtime] Invalid event definition for '${eventName}'. Event must be a handler function or an object with a 'handler' property.
461
+ File: ${mod.__filename || "unknown"}`
462
+ );
463
+ }
464
+ }
465
+ return {
466
+ namespace,
467
+ auth: routeDef.auth,
468
+ onConnect: routeDef.onConnect,
469
+ onDisconnect: routeDef.onDisconnect,
470
+ events: normalizedEvents
471
+ };
472
+ }
473
+
474
+ // modules/router/helpers/routes/index.ts
423
475
  function readManifest(projectRoot) {
424
476
  const manifestPath = path5.join(
425
477
  projectRoot,
@@ -494,18 +546,6 @@ function extractWssHandlers(mod, events) {
494
546
  }
495
547
  return handlers;
496
548
  }
497
- function extractWssHandlersFromModule(mod) {
498
- const handlers = {};
499
- if (!Array.isArray(mod?.events)) {
500
- return handlers;
501
- }
502
- for (const event of mod.events) {
503
- if (typeof event.handler === "function" && typeof event.name === "string") {
504
- handlers[event.name.toLowerCase()] = event.handler;
505
- }
506
- }
507
- return handlers;
508
- }
509
549
  function loadPageComponent(pageFile, projectRoot) {
510
550
  const fullPath = path5.join(projectRoot, pageFile);
511
551
  const pageMod = loadModuleSafely(fullPath);
@@ -982,8 +1022,29 @@ function loadRoutesFromManifest(projectRoot) {
982
1022
  if (!mod) {
983
1023
  continue;
984
1024
  }
1025
+ let namespace = entry.pattern.replace(/^\/wss/, "");
1026
+ if (!namespace.startsWith("/")) {
1027
+ namespace = "/" + namespace;
1028
+ }
1029
+ if (namespace === "") {
1030
+ namespace = "/";
1031
+ }
1032
+ let normalized = null;
1033
+ try {
1034
+ normalized = extractDefineWssRoute(mod, namespace);
1035
+ } catch (error) {
1036
+ console.warn(
1037
+ `[loly:realtime] Failed to extract normalized route from ${filePath}:`,
1038
+ error instanceof Error ? error.message : String(error)
1039
+ );
1040
+ }
985
1041
  const handlers = extractWssHandlers(mod, entry.events || []);
986
1042
  const { global: globalMiddlewares, methodSpecific: methodMiddlewares } = extractApiMiddlewares(mod, []);
1043
+ if (normalized) {
1044
+ for (const [eventName, eventDef] of normalized.events.entries()) {
1045
+ handlers[eventName] = eventDef.handler;
1046
+ }
1047
+ }
987
1048
  wssRoutes.push({
988
1049
  pattern: entry.pattern,
989
1050
  regex,
@@ -991,7 +1052,9 @@ function loadRoutesFromManifest(projectRoot) {
991
1052
  handlers,
992
1053
  middlewares: globalMiddlewares,
993
1054
  methodMiddlewares,
994
- filePath
1055
+ filePath,
1056
+ normalized: normalized || void 0
1057
+ // Store normalized structure (use undefined instead of null)
995
1058
  });
996
1059
  }
997
1060
  return { routes: pageRoutes, apiRoutes, wssRoutes };
@@ -1097,7 +1160,30 @@ function loadWssRoutes(appDir) {
1097
1160
  if (!mod) {
1098
1161
  continue;
1099
1162
  }
1100
- const handlers = extractWssHandlersFromModule(mod);
1163
+ let namespace = pattern.replace(/^\/wss/, "");
1164
+ if (!namespace.startsWith("/")) {
1165
+ namespace = "/" + namespace;
1166
+ }
1167
+ if (namespace === "") {
1168
+ namespace = "/";
1169
+ }
1170
+ let normalized = null;
1171
+ try {
1172
+ normalized = extractDefineWssRoute(mod, namespace);
1173
+ } catch (error) {
1174
+ console.error(error instanceof Error ? error.message : String(error));
1175
+ continue;
1176
+ }
1177
+ if (!normalized) {
1178
+ console.warn(
1179
+ `[loly:realtime] Skipping route at ${fullPath}: No default export from defineWssRoute() found`
1180
+ );
1181
+ continue;
1182
+ }
1183
+ const handlers = {};
1184
+ for (const [eventName, eventDef] of normalized.events.entries()) {
1185
+ handlers[eventName] = eventDef.handler;
1186
+ }
1101
1187
  const { global: globalMiddlewares, methodSpecific: methodMiddlewares } = extractApiMiddlewares(mod, []);
1102
1188
  routes.push({
1103
1189
  pattern,
@@ -1106,7 +1192,9 @@ function loadWssRoutes(appDir) {
1106
1192
  handlers,
1107
1193
  middlewares: globalMiddlewares,
1108
1194
  methodMiddlewares,
1109
- filePath: fullPath
1195
+ filePath: fullPath,
1196
+ normalized
1197
+ // Store normalized structure
1110
1198
  });
1111
1199
  }
1112
1200
  }
@@ -5714,6 +5802,31 @@ init_globals();
5714
5802
 
5715
5803
  // modules/server/config.ts
5716
5804
  var CONFIG_FILE_NAME = "loly.config";
5805
+ var DEFAULT_REALTIME_CONFIG = {
5806
+ enabled: true,
5807
+ path: "/wss",
5808
+ transports: ["websocket", "polling"],
5809
+ pingIntervalMs: 25e3,
5810
+ pingTimeoutMs: 2e4,
5811
+ maxPayloadBytes: 64 * 1024,
5812
+ allowedOrigins: process.env.NODE_ENV === "production" ? [] : "*",
5813
+ cors: {
5814
+ credentials: true,
5815
+ allowedHeaders: ["content-type", "authorization"]
5816
+ },
5817
+ scale: {
5818
+ mode: "single"
5819
+ },
5820
+ limits: {
5821
+ connectionsPerIp: 20,
5822
+ eventsPerSecond: 30,
5823
+ burst: 60
5824
+ },
5825
+ logging: {
5826
+ level: "info",
5827
+ pretty: process.env.NODE_ENV !== "production"
5828
+ }
5829
+ };
5717
5830
  var DEFAULT_CONFIG2 = {
5718
5831
  bodyLimit: "1mb",
5719
5832
  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,
@@ -5756,7 +5869,8 @@ var DEFAULT_CONFIG2 = {
5756
5869
  // 1 year
5757
5870
  includeSubDomains: true
5758
5871
  }
5759
- }
5872
+ },
5873
+ realtime: DEFAULT_REALTIME_CONFIG
5760
5874
  };
5761
5875
  async function getServerConfig(projectRoot) {
5762
5876
  let mod = await getServerFile(projectRoot, CONFIG_FILE_NAME);
@@ -5772,12 +5886,76 @@ async function getServerConfig(projectRoot) {
5772
5886
  security: {
5773
5887
  ...DEFAULT_CONFIG2.security,
5774
5888
  ...options.security
5889
+ },
5890
+ realtime: {
5891
+ ...DEFAULT_REALTIME_CONFIG,
5892
+ ...options.realtime,
5893
+ cors: {
5894
+ ...DEFAULT_REALTIME_CONFIG.cors,
5895
+ ...options.realtime?.cors
5896
+ },
5897
+ scale: options.realtime?.scale ? {
5898
+ ...DEFAULT_REALTIME_CONFIG.scale,
5899
+ ...options.realtime.scale,
5900
+ adapter: options.realtime.scale.adapter,
5901
+ stateStore: options.realtime.scale.stateStore ? {
5902
+ ...DEFAULT_REALTIME_CONFIG.scale?.stateStore,
5903
+ ...options.realtime.scale.stateStore,
5904
+ // Ensure name is set (user config takes priority, fallback to default or "memory")
5905
+ name: options.realtime.scale.stateStore.name || DEFAULT_REALTIME_CONFIG.scale?.stateStore?.name || "memory"
5906
+ } : DEFAULT_REALTIME_CONFIG.scale?.stateStore
5907
+ } : DEFAULT_REALTIME_CONFIG.scale,
5908
+ limits: {
5909
+ ...DEFAULT_REALTIME_CONFIG.limits,
5910
+ ...options.realtime?.limits
5911
+ },
5912
+ logging: {
5913
+ ...DEFAULT_REALTIME_CONFIG.logging,
5914
+ ...options.realtime?.logging
5915
+ }
5775
5916
  }
5776
5917
  };
5918
+ validateRealtimeConfig(merged.realtime);
5777
5919
  return merged;
5778
5920
  }
5921
+ validateRealtimeConfig(DEFAULT_CONFIG2.realtime);
5779
5922
  return DEFAULT_CONFIG2;
5780
5923
  }
5924
+ function validateRealtimeConfig(config) {
5925
+ if (!config.enabled) {
5926
+ return;
5927
+ }
5928
+ if (config.scale?.mode === "cluster") {
5929
+ if (!config.scale.adapter) {
5930
+ throw new Error(
5931
+ "[loly:realtime] Cluster mode requires a Redis adapter. Please configure realtime.scale.adapter in your loly.config.ts"
5932
+ );
5933
+ }
5934
+ if (config.scale.adapter.name !== "redis") {
5935
+ throw new Error(
5936
+ "[loly:realtime] Only Redis adapter is supported for cluster mode"
5937
+ );
5938
+ }
5939
+ if (!config.scale.adapter.url) {
5940
+ throw new Error(
5941
+ "[loly:realtime] Redis adapter requires a URL. Set realtime.scale.adapter.url or REDIS_URL environment variable"
5942
+ );
5943
+ }
5944
+ if (config.scale.stateStore?.name === "memory") {
5945
+ console.warn(
5946
+ "[loly:realtime] WARNING: Using memory state store in cluster mode. State will diverge across instances. Consider using Redis state store."
5947
+ );
5948
+ }
5949
+ }
5950
+ if (process.env.NODE_ENV === "production") {
5951
+ if (!config.allowedOrigins || Array.isArray(config.allowedOrigins) && config.allowedOrigins.length === 0 || config.allowedOrigins === "*") {
5952
+ config.allowedOrigins = "*";
5953
+ console.warn(
5954
+ "[loly:realtime] No allowedOrigins configured. Auto-allowing localhost for local development. For production deployment, configure realtime.allowedOrigins in loly.config.ts"
5955
+ );
5956
+ }
5957
+ }
5958
+ }
5781
5959
 
5782
5960
  // modules/server/routes.ts
5783
5961
  import path22 from "path";
@@ -5840,44 +6018,841 @@ function setupRoutes(options) {
5840
6018
 
5841
6019
  // modules/server/wss.ts
5842
6020
  import { Server } from "socket.io";
5843
- var generateActions = (socket, namespace) => {
6021
+
6022
+ // modules/realtime/state/memory-store.ts
6023
+ var MemoryStateStore = class {
6024
+ constructor() {
6025
+ this.store = /* @__PURE__ */ new Map();
6026
+ this.lists = /* @__PURE__ */ new Map();
6027
+ this.sets = /* @__PURE__ */ new Map();
6028
+ this.locks = /* @__PURE__ */ new Map();
6029
+ this.cleanupInterval = setInterval(() => {
6030
+ this.cleanupExpired();
6031
+ }, 6e4);
6032
+ }
6033
+ /**
6034
+ * Cleanup expired entries
6035
+ */
6036
+ cleanupExpired() {
6037
+ const now = Date.now();
6038
+ for (const [key, entry] of this.store.entries()) {
6039
+ if (entry.expiresAt && entry.expiresAt < now) {
6040
+ this.store.delete(key);
6041
+ }
6042
+ }
6043
+ for (const [key, lock] of this.locks.entries()) {
6044
+ if (lock.expiresAt < now) {
6045
+ this.locks.delete(key);
6046
+ }
6047
+ }
6048
+ }
6049
+ /**
6050
+ * Get a value by key
6051
+ */
6052
+ async get(key) {
6053
+ const entry = this.store.get(key);
6054
+ if (!entry) {
6055
+ return null;
6056
+ }
6057
+ if (entry.expiresAt && entry.expiresAt < Date.now()) {
6058
+ this.store.delete(key);
6059
+ return null;
6060
+ }
6061
+ return entry.value;
6062
+ }
6063
+ /**
6064
+ * Set a value with optional TTL
6065
+ */
6066
+ async set(key, value, opts) {
6067
+ const entry = {
6068
+ value
6069
+ };
6070
+ if (opts?.ttlMs) {
6071
+ entry.expiresAt = Date.now() + opts.ttlMs;
6072
+ }
6073
+ this.store.set(key, entry);
6074
+ }
6075
+ /**
6076
+ * Delete a key
6077
+ */
6078
+ async del(key) {
6079
+ this.store.delete(key);
6080
+ this.lists.delete(key);
6081
+ this.sets.delete(key);
6082
+ this.locks.delete(key);
6083
+ }
6084
+ /**
6085
+ * Increment a numeric value
6086
+ */
6087
+ async incr(key, by = 1) {
6088
+ const current = await this.get(key);
6089
+ const newValue = (current ?? 0) + by;
6090
+ await this.set(key, newValue);
6091
+ return newValue;
6092
+ }
6093
+ /**
6094
+ * Decrement a numeric value
6095
+ */
6096
+ async decr(key, by = 1) {
6097
+ return this.incr(key, -by);
6098
+ }
6099
+ /**
6100
+ * Push to a list (left push)
6101
+ */
6102
+ async listPush(key, value, opts) {
6103
+ let list = this.lists.get(key);
6104
+ if (!list) {
6105
+ list = [];
6106
+ this.lists.set(key, list);
6107
+ }
6108
+ list.unshift(value);
6109
+ if (opts?.maxLen && list.length > opts.maxLen) {
6110
+ list.splice(opts.maxLen);
6111
+ }
6112
+ }
6113
+ /**
6114
+ * Get range from a list
6115
+ */
6116
+ async listRange(key, start, end) {
6117
+ const list = this.lists.get(key);
6118
+ if (!list) {
6119
+ return [];
6120
+ }
6121
+ const len = list.length;
6122
+ const actualStart = start < 0 ? Math.max(0, len + start) : Math.min(start, len);
6123
+ const actualEnd = end < 0 ? Math.max(0, len + end + 1) : Math.min(end + 1, len);
6124
+ return list.slice(actualStart, actualEnd);
6125
+ }
6126
+ /**
6127
+ * Add member to a set
6128
+ */
6129
+ async setAdd(key, member) {
6130
+ let set = this.sets.get(key);
6131
+ if (!set) {
6132
+ set = /* @__PURE__ */ new Set();
6133
+ this.sets.set(key, set);
6134
+ }
6135
+ set.add(member);
6136
+ }
6137
+ /**
6138
+ * Remove member from a set
6139
+ */
6140
+ async setRem(key, member) {
6141
+ const set = this.sets.get(key);
6142
+ if (set) {
6143
+ set.delete(member);
6144
+ if (set.size === 0) {
6145
+ this.sets.delete(key);
6146
+ }
6147
+ }
6148
+ }
6149
+ /**
6150
+ * Get all members of a set
6151
+ */
6152
+ async setMembers(key) {
6153
+ const set = this.sets.get(key);
6154
+ if (!set) {
6155
+ return [];
6156
+ }
6157
+ return Array.from(set);
6158
+ }
6159
+ /**
6160
+ * Acquire a distributed lock
6161
+ */
6162
+ async lock(key, ttlMs) {
6163
+ const lockKey = `__lock:${key}`;
6164
+ const now = Date.now();
6165
+ const existingLock = this.locks.get(lockKey);
6166
+ if (existingLock && existingLock.expiresAt > now) {
6167
+ throw new Error(`Lock '${key}' is already held`);
6168
+ }
6169
+ this.locks.set(lockKey, {
6170
+ expiresAt: now + ttlMs
6171
+ });
6172
+ return async () => {
6173
+ const lock = this.locks.get(lockKey);
6174
+ if (lock && lock.expiresAt > Date.now()) {
6175
+ this.locks.delete(lockKey);
6176
+ }
6177
+ };
6178
+ }
6179
+ /**
6180
+ * Cleanup resources (call when shutting down)
6181
+ */
6182
+ destroy() {
6183
+ if (this.cleanupInterval) {
6184
+ clearInterval(this.cleanupInterval);
6185
+ this.cleanupInterval = void 0;
6186
+ }
6187
+ this.store.clear();
6188
+ this.lists.clear();
6189
+ this.sets.clear();
6190
+ this.locks.clear();
6191
+ }
6192
+ };
6193
+
6194
+ // modules/realtime/state/redis-store.ts
6195
+ var RedisStateStore = class {
6196
+ constructor(client, prefix = "loly:rt:") {
6197
+ this.client = client;
6198
+ this.prefix = prefix;
6199
+ }
6200
+ /**
6201
+ * Add prefix to key
6202
+ */
6203
+ key(key) {
6204
+ return `${this.prefix}${key}`;
6205
+ }
6206
+ /**
6207
+ * Serialize value to JSON string
6208
+ */
6209
+ serialize(value) {
6210
+ return JSON.stringify(value);
6211
+ }
6212
+ /**
6213
+ * Deserialize JSON string to value
6214
+ */
6215
+ deserialize(value) {
6216
+ if (value === null) {
6217
+ return null;
6218
+ }
6219
+ try {
6220
+ return JSON.parse(value);
6221
+ } catch {
6222
+ return null;
6223
+ }
6224
+ }
6225
+ /**
6226
+ * Get a value by key
6227
+ */
6228
+ async get(key) {
6229
+ const value = await this.client.get(this.key(key));
6230
+ return this.deserialize(value);
6231
+ }
6232
+ /**
6233
+ * Set a value with optional TTL
6234
+ */
6235
+ async set(key, value, opts) {
6236
+ const serialized = this.serialize(value);
6237
+ const prefixedKey = this.key(key);
6238
+ if (opts?.ttlMs) {
6239
+ await this.client.psetex(prefixedKey, opts.ttlMs, serialized);
6240
+ } else {
6241
+ await this.client.set(prefixedKey, serialized);
6242
+ }
6243
+ }
6244
+ /**
6245
+ * Delete a key
6246
+ */
6247
+ async del(key) {
6248
+ await this.client.del(this.key(key));
6249
+ }
6250
+ /**
6251
+ * Increment a numeric value
6252
+ */
6253
+ async incr(key, by = 1) {
6254
+ const prefixedKey = this.key(key);
6255
+ if (by === 1) {
6256
+ return await this.client.incr(prefixedKey);
6257
+ } else {
6258
+ return await this.client.incrby(prefixedKey, by);
6259
+ }
6260
+ }
6261
+ /**
6262
+ * Decrement a numeric value
6263
+ */
6264
+ async decr(key, by = 1) {
6265
+ const prefixedKey = this.key(key);
6266
+ if (by === 1) {
6267
+ return await this.client.decr(prefixedKey);
6268
+ } else {
6269
+ return await this.client.decrby(prefixedKey, by);
6270
+ }
6271
+ }
6272
+ /**
6273
+ * Push to a list (left push)
6274
+ */
6275
+ async listPush(key, value, opts) {
6276
+ const serialized = this.serialize(value);
6277
+ const prefixedKey = this.key(key);
6278
+ await this.client.lpush(prefixedKey, serialized);
6279
+ if (opts?.maxLen) {
6280
+ await this.client.eval(
6281
+ `redis.call('ltrim', KEYS[1], 0, ARGV[1])`,
6282
+ 1,
6283
+ prefixedKey,
6284
+ String(opts.maxLen - 1)
6285
+ );
6286
+ }
6287
+ }
6288
+ /**
6289
+ * Get range from a list
6290
+ */
6291
+ async listRange(key, start, end) {
6292
+ const prefixedKey = this.key(key);
6293
+ const values = await this.client.lrange(prefixedKey, start, end);
6294
+ return values.map((v) => this.deserialize(v)).filter((v) => v !== null);
6295
+ }
6296
+ /**
6297
+ * Add member to a set
6298
+ */
6299
+ async setAdd(key, member) {
6300
+ const prefixedKey = this.key(key);
6301
+ await this.client.sadd(prefixedKey, member);
6302
+ }
6303
+ /**
6304
+ * Remove member from a set
6305
+ */
6306
+ async setRem(key, member) {
6307
+ const prefixedKey = this.key(key);
6308
+ await this.client.srem(prefixedKey, member);
6309
+ }
6310
+ /**
6311
+ * Get all members of a set
6312
+ */
6313
+ async setMembers(key) {
6314
+ const prefixedKey = this.key(key);
6315
+ return await this.client.smembers(prefixedKey);
6316
+ }
6317
+ /**
6318
+ * Acquire a distributed lock using Redis SET NX EX
6319
+ */
6320
+ async lock(key, ttlMs) {
6321
+ const lockKey = this.key(`__lock:${key}`);
6322
+ const lockValue = `${Date.now()}-${Math.random()}`;
6323
+ const ttlSeconds = Math.ceil(ttlMs / 1e3);
6324
+ const result = await this.client.set(
6325
+ lockKey,
6326
+ lockValue,
6327
+ "NX",
6328
+ // Only set if not exists
6329
+ "EX",
6330
+ // Expire in seconds
6331
+ ttlSeconds
6332
+ );
6333
+ if (result === null) {
6334
+ throw new Error(`Lock '${key}' is already held`);
6335
+ }
6336
+ return async () => {
6337
+ const unlockScript = `
6338
+ if redis.call("get", KEYS[1]) == ARGV[1] then
6339
+ return redis.call("del", KEYS[1])
6340
+ else
6341
+ return 0
6342
+ end
6343
+ `;
6344
+ await this.client.eval(unlockScript, 1, lockKey, lockValue);
6345
+ };
6346
+ }
6347
+ };
6348
+
6349
+ // modules/realtime/state/index.ts
6350
+ async function createStateStore(config) {
6351
+ if (!config.enabled) {
6352
+ return createNoOpStore();
6353
+ }
6354
+ const storeType = config.scale?.stateStore?.name || "memory";
6355
+ const prefix = config.scale?.stateStore?.prefix || "loly:rt:";
6356
+ if (storeType === "memory") {
6357
+ return new MemoryStateStore();
6358
+ }
6359
+ if (storeType === "redis") {
6360
+ const url = config.scale?.stateStore?.url || process.env.REDIS_URL;
6361
+ if (!url) {
6362
+ throw new Error(
6363
+ "[loly:realtime] Redis state store requires a URL. Set realtime.scale.stateStore.url or REDIS_URL environment variable"
6364
+ );
6365
+ }
6366
+ const client = await createRedisClient(url);
6367
+ return new RedisStateStore(client, prefix);
6368
+ }
6369
+ throw new Error(
6370
+ `[loly:realtime] Unknown state store type: ${storeType}. Supported types: 'memory', 'redis'`
6371
+ );
6372
+ }
6373
+ async function createRedisClient(url) {
6374
+ try {
6375
+ const Redis = await import("ioredis");
6376
+ return new Redis.default(url);
6377
+ } catch {
6378
+ try {
6379
+ const { createClient } = await import("redis");
6380
+ const client = createClient({ url });
6381
+ await client.connect();
6382
+ return client;
6383
+ } catch (err) {
6384
+ throw new Error(
6385
+ `[loly:realtime] Failed to create Redis client. Please install 'ioredis' or 'redis' package. Error: ${err instanceof Error ? err.message : String(err)}`
6386
+ );
6387
+ }
6388
+ }
6389
+ }
6390
+ function createNoOpStore() {
6391
+ const noOp = async () => {
6392
+ };
6393
+ const noOpReturn = async () => null;
6394
+ const noOpNumber = async () => 0;
6395
+ const noOpArray = async () => [];
6396
+ const noOpUnlock = async () => {
6397
+ };
5844
6398
  return {
6399
+ get: noOpReturn,
6400
+ set: noOp,
6401
+ del: noOp,
6402
+ incr: noOpNumber,
6403
+ decr: noOpNumber,
6404
+ listPush: noOp,
6405
+ listRange: noOpArray,
6406
+ setAdd: noOp,
6407
+ setRem: noOp,
6408
+ setMembers: noOpArray,
6409
+ lock: async () => noOpUnlock
6410
+ };
6411
+ }
6412
+
6413
+ // modules/realtime/presence/index.ts
6414
+ var PresenceManager = class {
6415
+ constructor(stateStore, prefix = "loly:rt:") {
6416
+ this.stateStore = stateStore;
6417
+ this.prefix = prefix;
6418
+ }
6419
+ /**
6420
+ * Add a socket for a user
6421
+ */
6422
+ async addSocketForUser(userId, socketId) {
6423
+ const key = this.key(`userSockets:${userId}`);
6424
+ await this.stateStore.setAdd(key, socketId);
6425
+ const socketKey = this.key(`socketUser:${socketId}`);
6426
+ await this.stateStore.set(socketKey, userId);
6427
+ }
6428
+ /**
6429
+ * Remove a socket for a user
6430
+ */
6431
+ async removeSocketForUser(userId, socketId) {
6432
+ const key = this.key(`userSockets:${userId}`);
6433
+ await this.stateStore.setRem(key, socketId);
6434
+ const socketKey = this.key(`socketUser:${socketId}`);
6435
+ await this.stateStore.del(socketKey);
6436
+ const sockets = await this.stateStore.setMembers(key);
6437
+ if (sockets.length === 0) {
6438
+ await this.stateStore.del(key);
6439
+ }
6440
+ }
6441
+ /**
6442
+ * Get all socket IDs for a user
6443
+ */
6444
+ async getSocketsForUser(userId) {
6445
+ const key = this.key(`userSockets:${userId}`);
6446
+ return await this.stateStore.setMembers(key);
6447
+ }
6448
+ /**
6449
+ * Get user ID for a socket
6450
+ */
6451
+ async getUserForSocket(socketId) {
6452
+ const socketKey = this.key(`socketUser:${socketId}`);
6453
+ return await this.stateStore.get(socketKey);
6454
+ }
6455
+ /**
6456
+ * Add user to a room's presence (optional feature)
6457
+ */
6458
+ async addUserToRoom(namespace, room, userId) {
6459
+ const key = this.key(`presence:${namespace}:${room}`);
6460
+ await this.stateStore.setAdd(key, userId);
6461
+ }
6462
+ /**
6463
+ * Remove user from a room's presence
6464
+ */
6465
+ async removeUserFromRoom(namespace, room, userId) {
6466
+ const key = this.key(`presence:${namespace}:${room}`);
6467
+ await this.stateStore.setRem(key, userId);
6468
+ const members = await this.stateStore.setMembers(key);
6469
+ if (members.length === 0) {
6470
+ await this.stateStore.del(key);
6471
+ }
6472
+ }
6473
+ /**
6474
+ * Get all users in a room
6475
+ */
6476
+ async getUsersInRoom(namespace, room) {
6477
+ const key = this.key(`presence:${namespace}:${room}`);
6478
+ return await this.stateStore.setMembers(key);
6479
+ }
6480
+ /**
6481
+ * Add prefix to key
6482
+ */
6483
+ key(key) {
6484
+ return `${this.prefix}${key}`;
6485
+ }
6486
+ };
6487
+
6488
+ // modules/realtime/rate-limit/token-bucket.ts
6489
+ var TokenBucket = class {
6490
+ // tokens per second
6491
+ constructor(capacity, refillRate) {
6492
+ this.capacity = capacity;
6493
+ this.refillRate = refillRate;
6494
+ this.tokens = capacity;
6495
+ this.lastRefill = Date.now();
6496
+ }
6497
+ /**
6498
+ * Try to consume tokens. Returns true if successful, false if rate limited.
6499
+ */
6500
+ consume(tokens = 1) {
6501
+ this.refill();
6502
+ if (this.tokens >= tokens) {
6503
+ this.tokens -= tokens;
6504
+ return true;
6505
+ }
6506
+ return false;
6507
+ }
6508
+ /**
6509
+ * Get current available tokens
6510
+ */
6511
+ getAvailable() {
6512
+ this.refill();
6513
+ return this.tokens;
6514
+ }
6515
+ /**
6516
+ * Refill tokens based on elapsed time
6517
+ */
6518
+ refill() {
6519
+ const now = Date.now();
6520
+ const elapsed = (now - this.lastRefill) / 1e3;
6521
+ const tokensToAdd = elapsed * this.refillRate;
6522
+ this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
6523
+ this.lastRefill = now;
6524
+ }
6525
+ /**
6526
+ * Reset bucket to full capacity
6527
+ */
6528
+ reset() {
6529
+ this.tokens = this.capacity;
6530
+ this.lastRefill = Date.now();
6531
+ }
6532
+ };
6533
+
6534
+ // modules/realtime/rate-limit/index.ts
6535
+ var RateLimiter = class {
6536
+ constructor(stateStore, prefix = "loly:rt:rate:") {
6537
+ this.memoryBuckets = /* @__PURE__ */ new Map();
6538
+ this.stateStore = stateStore;
6539
+ this.prefix = prefix;
6540
+ }
6541
+ /**
6542
+ * Check if a request should be rate limited.
6543
+ *
6544
+ * @param key - Unique key for the rate limit (e.g., socketId or socketId:eventName)
6545
+ * @param config - Rate limit configuration
6546
+ * @returns true if allowed, false if rate limited
6547
+ */
6548
+ async checkLimit(key, config) {
6549
+ const fullKey = `${this.prefix}${key}`;
6550
+ const burst = config.burst || config.eventsPerSecond * 2;
6551
+ if (this.stateStore) {
6552
+ return this.checkLimitWithStore(fullKey, config.eventsPerSecond, burst);
6553
+ } else {
6554
+ return this.checkLimitInMemory(fullKey, config.eventsPerSecond, burst);
6555
+ }
6556
+ }
6557
+ /**
6558
+ * Check rate limit using state store (for cluster mode)
6559
+ */
6560
+ async checkLimitWithStore(key, ratePerSecond, burst) {
6561
+ const count = await this.stateStore.get(key) || 0;
6562
+ if (count >= burst) {
6563
+ return false;
6564
+ }
6565
+ await this.stateStore.incr(key, 1);
6566
+ await this.stateStore.set(key, count + 1, { ttlMs: 1e3 });
6567
+ return true;
6568
+ }
6569
+ /**
6570
+ * Check rate limit using in-memory token bucket
6571
+ */
6572
+ checkLimitInMemory(key, ratePerSecond, burst) {
6573
+ let bucket = this.memoryBuckets.get(key);
6574
+ if (!bucket) {
6575
+ bucket = new TokenBucket(burst, ratePerSecond);
6576
+ this.memoryBuckets.set(key, bucket);
6577
+ }
6578
+ return bucket.consume(1);
6579
+ }
6580
+ /**
6581
+ * Cleanup old buckets (call periodically)
6582
+ */
6583
+ cleanup() {
6584
+ }
6585
+ };
6586
+
6587
+ // modules/realtime/auth/index.ts
6588
+ async function executeAuth(authFn, socket, namespace) {
6589
+ if (!authFn) {
6590
+ return null;
6591
+ }
6592
+ const authCtx = {
6593
+ req: {
6594
+ headers: socket.handshake.headers,
6595
+ ip: socket.handshake.address,
6596
+ url: socket.handshake.url,
6597
+ cookies: socket.handshake.headers.cookie ? parseCookies(socket.handshake.headers.cookie) : void 0
6598
+ },
6599
+ socket,
6600
+ namespace
6601
+ };
6602
+ const user = await authFn(authCtx);
6603
+ if (user) {
6604
+ socket.data = socket.data || {};
6605
+ socket.data.user = user;
6606
+ }
6607
+ return user;
6608
+ }
6609
+ function parseCookies(cookieString) {
6610
+ const cookies = {};
6611
+ cookieString.split(";").forEach((cookie) => {
6612
+ const [name, value] = cookie.trim().split("=");
6613
+ if (name && value) {
6614
+ cookies[name] = decodeURIComponent(value);
6615
+ }
6616
+ });
6617
+ return cookies;
6618
+ }
6619
+
6620
+ // modules/realtime/guards/index.ts
6621
+ async function executeGuard(guardFn, ctx) {
6622
+ if (!guardFn) {
6623
+ return true;
6624
+ }
6625
+ const guardCtx = {
6626
+ user: ctx.user,
6627
+ req: ctx.req,
6628
+ socket: ctx.socket,
6629
+ namespace: ctx.pathname
6630
+ };
6631
+ const result = await guardFn(guardCtx);
6632
+ return result === true;
6633
+ }
6634
+
6635
+ // modules/realtime/validation/index.ts
6636
+ function validateSchema(schema, data) {
6637
+ if (!schema) {
6638
+ return { success: true, data };
6639
+ }
6640
+ if (typeof schema.safeParse === "function") {
6641
+ const result = schema.safeParse(data);
6642
+ if (result.success) {
6643
+ return { success: true, data: result.data };
6644
+ } else {
6645
+ return { success: false, error: result.error };
6646
+ }
6647
+ }
6648
+ if (typeof schema.parse === "function") {
6649
+ try {
6650
+ const parsed = schema.parse(data);
6651
+ return { success: true, data: parsed };
6652
+ } catch (error) {
6653
+ return { success: false, error };
6654
+ }
6655
+ }
6656
+ return {
6657
+ success: false,
6658
+ error: new Error("Schema must have 'parse' or 'safeParse' method")
6659
+ };
6660
+ }
6661
+
6662
+ // modules/realtime/logging/index.ts
6663
+ function createWssLogger(namespace, socket, baseLogger) {
6664
+ const context = {
6665
+ namespace,
6666
+ socketId: socket.id,
6667
+ userId: socket.data?.user?.id || null
6668
+ };
6669
+ const log = (level, message, meta) => {
6670
+ const fullMeta = {
6671
+ ...context,
6672
+ ...meta,
6673
+ requestId: socket.requestId || generateRequestId2()
6674
+ };
6675
+ if (baseLogger) {
6676
+ baseLogger[level](message, fullMeta);
6677
+ } else {
6678
+ console[level === "error" ? "error" : "log"](
6679
+ `[${level.toUpperCase()}] [${namespace}] ${message}`,
6680
+ fullMeta
6681
+ );
6682
+ }
6683
+ };
6684
+ return {
6685
+ debug: (message, meta) => log("debug", message, meta),
6686
+ info: (message, meta) => log("info", message, meta),
6687
+ warn: (message, meta) => log("warn", message, meta),
6688
+ error: (message, meta) => log("error", message, meta)
6689
+ };
6690
+ }
6691
+ function generateRequestId2() {
6692
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
6693
+ }
6694
+
6695
+ // modules/server/wss.ts
6696
+ var generateActions = (socket, namespace, presence) => {
6697
+ return {
6698
+ // Emit to current socket only (reply)
6699
+ reply: (event, payload) => {
6700
+ socket.emit(event, payload);
6701
+ },
5845
6702
  // Emit to all clients in the namespace
5846
- emit: (event, ...args) => {
5847
- socket.nsp.emit(event, ...args);
6703
+ emit: (event, payload) => {
6704
+ socket.nsp.emit(event, payload);
5848
6705
  },
5849
- // Emit to a specific socket by Socket.IO socket ID
6706
+ // Emit to everyone except current socket
6707
+ broadcast: (event, payload, opts) => {
6708
+ if (opts?.excludeSelf === false) {
6709
+ socket.nsp.emit(event, payload);
6710
+ } else {
6711
+ socket.broadcast.emit(event, payload);
6712
+ }
6713
+ },
6714
+ // Join a room
6715
+ join: async (room) => {
6716
+ await socket.join(room);
6717
+ },
6718
+ // Leave a room
6719
+ leave: async (room) => {
6720
+ await socket.leave(room);
6721
+ },
6722
+ // Emit to a specific room
6723
+ toRoom: (room) => {
6724
+ return {
6725
+ emit: (event, payload) => {
6726
+ namespace.to(room).emit(event, payload);
6727
+ }
6728
+ };
6729
+ },
6730
+ // Emit to a specific user (by userId)
6731
+ toUser: (userId) => {
6732
+ return {
6733
+ emit: async (event, payload) => {
6734
+ if (!presence) {
6735
+ console.warn(
6736
+ "[loly:realtime] toUser() requires presence manager. Make sure realtime is properly configured."
6737
+ );
6738
+ return;
6739
+ }
6740
+ const socketIds = await presence.getSocketsForUser(userId);
6741
+ for (const socketId of socketIds) {
6742
+ const targetSocket = namespace.sockets.get(socketId);
6743
+ if (targetSocket) {
6744
+ targetSocket.emit(event, payload);
6745
+ }
6746
+ }
6747
+ }
6748
+ };
6749
+ },
6750
+ // Emit error event (reserved event: __loly:error)
6751
+ error: (code, message, details) => {
6752
+ socket.emit("__loly:error", {
6753
+ code,
6754
+ message,
6755
+ details,
6756
+ requestId: socket.requestId || void 0
6757
+ });
6758
+ },
6759
+ // Legacy: Emit to a specific socket by Socket.IO socket ID
5850
6760
  emitTo: (socketId, event, ...args) => {
5851
6761
  const targetSocket = namespace.sockets.get(socketId);
5852
6762
  if (targetSocket) {
5853
6763
  targetSocket.emit(event, ...args);
5854
6764
  }
5855
6765
  },
5856
- // Emit to a specific client by custom clientId
5857
- // Requires clientId to be stored in socket.data.clientId during connection
6766
+ // Legacy: Emit to a specific client by custom clientId
5858
6767
  emitToClient: (clientId, event, ...args) => {
5859
6768
  namespace.sockets.forEach((s) => {
5860
6769
  if (s.data?.clientId === clientId) {
5861
6770
  s.emit(event, ...args);
5862
6771
  }
5863
6772
  });
5864
- },
5865
- // Broadcast to all clients except the sender
5866
- broadcast: (event, ...args) => {
5867
- socket.broadcast.emit(event, ...args);
5868
6773
  }
5869
6774
  };
5870
6775
  };
5871
- function setupWssEvents(options) {
5872
- const { httpServer, wssRoutes } = options;
6776
+ async function setupWssEvents(options) {
6777
+ const { httpServer, wssRoutes, projectRoot } = options;
5873
6778
  if (wssRoutes.length === 0) {
5874
6779
  return;
5875
6780
  }
6781
+ const serverConfig = await getServerConfig(projectRoot);
6782
+ const realtimeConfig = serverConfig.realtime;
6783
+ if (!realtimeConfig || !realtimeConfig.enabled) {
6784
+ return;
6785
+ }
6786
+ const stateStore = await createStateStore(realtimeConfig);
6787
+ const stateStorePrefix = realtimeConfig.scale?.stateStore?.prefix || "loly:rt:";
6788
+ const presence = new PresenceManager(stateStore, stateStorePrefix);
6789
+ const rateLimiter = new RateLimiter(
6790
+ realtimeConfig.scale?.mode === "cluster" ? stateStore : void 0,
6791
+ `${stateStorePrefix}rate:`
6792
+ );
6793
+ const allowedOrigins = realtimeConfig.allowedOrigins;
6794
+ const corsOrigin = !allowedOrigins || Array.isArray(allowedOrigins) && allowedOrigins.length === 0 || allowedOrigins === "*" ? (
6795
+ // Auto-allow localhost on any port for simplicity
6796
+ (origin, callback) => {
6797
+ if (!origin) {
6798
+ callback(null, true);
6799
+ return;
6800
+ }
6801
+ if (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:") || origin.startsWith("https://localhost:") || origin.startsWith("https://127.0.0.1:")) {
6802
+ callback(null, true);
6803
+ } else {
6804
+ callback(new Error("Not allowed by CORS"));
6805
+ }
6806
+ }
6807
+ ) : allowedOrigins;
6808
+ const corsOptions = {
6809
+ origin: corsOrigin,
6810
+ credentials: realtimeConfig.cors?.credentials ?? true,
6811
+ methods: ["GET", "POST"],
6812
+ allowedHeaders: realtimeConfig.cors?.allowedHeaders || [
6813
+ "content-type",
6814
+ "authorization"
6815
+ ]
6816
+ };
5876
6817
  const io = new Server(httpServer, {
5877
- path: "/wss"
6818
+ path: realtimeConfig.path || "/wss",
6819
+ transports: realtimeConfig.transports || ["websocket", "polling"],
6820
+ pingInterval: realtimeConfig.pingIntervalMs || 25e3,
6821
+ pingTimeout: realtimeConfig.pingTimeoutMs || 2e4,
6822
+ maxHttpBufferSize: realtimeConfig.maxPayloadBytes || 64 * 1024,
6823
+ cors: corsOptions
5878
6824
  });
6825
+ if (realtimeConfig.scale?.mode === "cluster" && realtimeConfig.scale.adapter) {
6826
+ try {
6827
+ const redisAdapterModule = await import("@socket.io/redis-adapter").catch(() => null);
6828
+ const ioredisModule = await import("ioredis").catch(() => null);
6829
+ if (!redisAdapterModule || !ioredisModule) {
6830
+ throw new Error(
6831
+ "[loly:realtime] Redis adapter dependencies not found. Install @socket.io/redis-adapter and ioredis for cluster mode: pnpm add @socket.io/redis-adapter ioredis"
6832
+ );
6833
+ }
6834
+ const { createAdapter } = redisAdapterModule;
6835
+ const Redis = ioredisModule.default || ioredisModule;
6836
+ const pubClient = new Redis(realtimeConfig.scale.adapter.url);
6837
+ const subClient = pubClient.duplicate();
6838
+ io.adapter(createAdapter(pubClient, subClient));
6839
+ } catch (error) {
6840
+ console.error(
6841
+ "[loly:realtime] Failed to setup Redis adapter:",
6842
+ error instanceof Error ? error.message : String(error)
6843
+ );
6844
+ throw error;
6845
+ }
6846
+ }
5879
6847
  for (const wssRoute of wssRoutes) {
5880
- let namespacePath = wssRoute.pattern.replace(/^\/wss/, "");
6848
+ const normalized = wssRoute.normalized;
6849
+ if (!normalized) {
6850
+ console.warn(
6851
+ `[loly:realtime] Skipping route ${wssRoute.pattern}: No normalized route definition`
6852
+ );
6853
+ continue;
6854
+ }
6855
+ let namespacePath = normalized.namespace || wssRoute.pattern.replace(/^\/wss/, "");
5881
6856
  if (!namespacePath.startsWith("/")) {
5882
6857
  namespacePath = "/" + namespacePath;
5883
6858
  }
@@ -5885,34 +6860,158 @@ function setupWssEvents(options) {
5885
6860
  namespacePath = "/";
5886
6861
  }
5887
6862
  const namespace = io.of(namespacePath);
5888
- namespace.on("connection", (socket) => {
5889
- Object.entries(wssRoute.handlers).forEach(([event, handler]) => {
5890
- if (event.toLowerCase() === "connection") {
5891
- const ctx = {
5892
- socket,
5893
- io: namespace.server,
5894
- params: {},
5895
- pathname: wssRoute.pattern,
5896
- actions: generateActions(socket, namespace)
5897
- };
5898
- handler(ctx);
5899
- } else {
5900
- socket.on(event, (data) => {
5901
- const ctx = {
5902
- socket,
5903
- io: namespace.server,
5904
- actions: generateActions(socket, namespace),
5905
- params: {},
5906
- pathname: wssRoute.pattern,
5907
- data
5908
- };
5909
- handler(ctx);
6863
+ console.log(`[loly:realtime] Registered namespace: ${namespacePath} (from pattern: ${wssRoute.pattern})`);
6864
+ namespace.on("connection", async (socket) => {
6865
+ console.log(`[loly:realtime] Client connected to namespace ${namespacePath}, socket: ${socket.id}`);
6866
+ const requestId = generateRequestId();
6867
+ socket.requestId = requestId;
6868
+ const log = createWssLogger(namespacePath, socket);
6869
+ try {
6870
+ const user = await executeAuth(normalized.auth, socket, namespacePath);
6871
+ socket.data = socket.data || {};
6872
+ socket.data.user = user;
6873
+ if (user && user.id) {
6874
+ await presence.addSocketForUser(String(user.id), socket.id);
6875
+ }
6876
+ const baseCtx = {
6877
+ socket,
6878
+ io: namespace.server,
6879
+ req: {
6880
+ headers: socket.handshake.headers,
6881
+ ip: socket.handshake.address,
6882
+ url: socket.handshake.url,
6883
+ cookies: socket.handshake.headers.cookie ? parseCookies2(socket.handshake.headers.cookie) : void 0
6884
+ },
6885
+ user: user || null,
6886
+ params: {},
6887
+ pathname: wssRoute.pattern,
6888
+ actions: generateActions(socket, namespace, presence),
6889
+ state: stateStore,
6890
+ log
6891
+ };
6892
+ if (normalized.onConnect) {
6893
+ try {
6894
+ await normalized.onConnect(baseCtx);
6895
+ } catch (error) {
6896
+ log.error("Error in onConnect hook", {
6897
+ error: error instanceof Error ? error.message : String(error)
6898
+ });
6899
+ }
6900
+ }
6901
+ for (const [eventName, eventDef] of normalized.events.entries()) {
6902
+ socket.on(eventName, async (data) => {
6903
+ const eventRequestId = generateRequestId();
6904
+ socket.requestId = eventRequestId;
6905
+ const eventLog = createWssLogger(namespacePath, socket);
6906
+ eventLog.debug(`Event received: ${eventName}`, { data });
6907
+ try {
6908
+ const ctx = {
6909
+ ...baseCtx,
6910
+ data,
6911
+ log: eventLog
6912
+ };
6913
+ if (eventDef.schema) {
6914
+ const validation = validateSchema(eventDef.schema, data);
6915
+ if (!validation.success) {
6916
+ ctx.actions.error("BAD_PAYLOAD", "Invalid payload", {
6917
+ error: validation.error
6918
+ });
6919
+ eventLog.warn("Schema validation failed", {
6920
+ error: validation.error
6921
+ });
6922
+ return;
6923
+ }
6924
+ ctx.data = validation.data;
6925
+ }
6926
+ if (eventDef.guard) {
6927
+ const allowed = await executeGuard(eventDef.guard, ctx);
6928
+ if (!allowed) {
6929
+ ctx.actions.error("FORBIDDEN", "Access denied");
6930
+ eventLog.warn("Guard check failed");
6931
+ return;
6932
+ }
6933
+ }
6934
+ const globalLimit = realtimeConfig.limits;
6935
+ if (globalLimit) {
6936
+ const globalAllowed = await rateLimiter.checkLimit(
6937
+ socket.id,
6938
+ {
6939
+ eventsPerSecond: globalLimit.eventsPerSecond || 30,
6940
+ burst: globalLimit.burst || 60
6941
+ }
6942
+ );
6943
+ if (!globalAllowed) {
6944
+ ctx.actions.error("RATE_LIMIT", "Rate limit exceeded");
6945
+ eventLog.warn("Global rate limit exceeded");
6946
+ return;
6947
+ }
6948
+ }
6949
+ if (eventDef.rateLimit) {
6950
+ const eventAllowed = await rateLimiter.checkLimit(
6951
+ `${socket.id}:${eventName}`,
6952
+ eventDef.rateLimit
6953
+ );
6954
+ if (!eventAllowed) {
6955
+ ctx.actions.error("RATE_LIMIT", "Event rate limit exceeded");
6956
+ eventLog.warn("Event rate limit exceeded");
6957
+ return;
6958
+ }
6959
+ }
6960
+ await eventDef.handler(ctx);
6961
+ eventLog.debug(`Event handled: ${eventName}`);
6962
+ } catch (error) {
6963
+ const errorLog = createWssLogger(namespacePath, socket);
6964
+ errorLog.error(`Error handling event ${eventName}`, {
6965
+ error: error instanceof Error ? error.message : String(error),
6966
+ stack: error instanceof Error ? error.stack : void 0
6967
+ });
6968
+ socket.emit("__loly:error", {
6969
+ code: "INTERNAL_ERROR",
6970
+ message: "An error occurred while processing your request",
6971
+ requestId: eventRequestId
6972
+ });
6973
+ }
5910
6974
  });
5911
6975
  }
5912
- });
6976
+ socket.on("disconnect", async (reason) => {
6977
+ const userId = socket.data?.user?.id;
6978
+ if (userId) {
6979
+ await presence.removeSocketForUser(String(userId), socket.id);
6980
+ }
6981
+ if (normalized.onDisconnect) {
6982
+ try {
6983
+ const disconnectCtx = {
6984
+ ...baseCtx,
6985
+ log: createWssLogger(namespacePath, socket)
6986
+ };
6987
+ await normalized.onDisconnect(disconnectCtx, reason);
6988
+ } catch (error) {
6989
+ log.error("Error in onDisconnect hook", {
6990
+ error: error instanceof Error ? error.message : String(error)
6991
+ });
6992
+ }
6993
+ }
6994
+ log.info("Socket disconnected", { reason });
6995
+ });
6996
+ } catch (error) {
6997
+ log.error("Error during connection setup", {
6998
+ error: error instanceof Error ? error.message : String(error)
6999
+ });
7000
+ socket.disconnect();
7001
+ }
5913
7002
  });
5914
7003
  }
5915
7004
  }
7005
+ function parseCookies2(cookieString) {
7006
+ const cookies = {};
7007
+ cookieString.split(";").forEach((cookie) => {
7008
+ const [name, value] = cookie.trim().split("=");
7009
+ if (name && value) {
7010
+ cookies[name] = decodeURIComponent(value);
7011
+ }
7012
+ });
7013
+ return cookies;
7014
+ }
5916
7015
 
5917
7016
  // modules/server/application.ts
5918
7017
  import http from "http";
@@ -6143,9 +7242,10 @@ async function startServer(options = {}) {
6143
7242
  config
6144
7243
  });
6145
7244
  const routeLoader = isDev ? new FilesystemRouteLoader(appDir, projectRoot) : new ManifestRouteLoader(projectRoot);
6146
- setupWssEvents({
7245
+ await setupWssEvents({
6147
7246
  httpServer,
6148
- wssRoutes
7247
+ wssRoutes,
7248
+ projectRoot
6149
7249
  });
6150
7250
  setupRoutes({
6151
7251
  app,
@@ -6534,6 +7634,90 @@ async function buildApp(options = {}) {
6534
7634
  console.log(`[framework][build] Build completed successfully`);
6535
7635
  }
6536
7636
 
7637
+ // modules/realtime/define-wss-route.ts
7638
+ function defineWssRoute(definition) {
7639
+ if (process.env.NODE_ENV !== "production") {
7640
+ validateRouteDefinition(definition);
7641
+ }
7642
+ return definition;
7643
+ }
7644
+ function validateRouteDefinition(definition) {
7645
+ if (!definition) {
7646
+ throw new Error(
7647
+ "[loly:realtime] Route definition is required. Use defineWssRoute({ events: { ... } })"
7648
+ );
7649
+ }
7650
+ if (!definition.events || typeof definition.events !== "object") {
7651
+ throw new Error(
7652
+ "[loly:realtime] Route definition must have an 'events' object. Example: defineWssRoute({ events: { message: { handler: ... } } })"
7653
+ );
7654
+ }
7655
+ if (Object.keys(definition.events).length === 0) {
7656
+ throw new Error(
7657
+ "[loly:realtime] Route definition must have at least one event handler"
7658
+ );
7659
+ }
7660
+ for (const [eventName, eventDef] of Object.entries(definition.events)) {
7661
+ if (typeof eventName !== "string" || eventName.trim() === "") {
7662
+ throw new Error(
7663
+ "[loly:realtime] Event names must be non-empty strings"
7664
+ );
7665
+ }
7666
+ if (eventName === "__loly:error") {
7667
+ throw new Error(
7668
+ "[loly:realtime] '__loly:error' is a reserved event name"
7669
+ );
7670
+ }
7671
+ if (typeof eventDef === "function") {
7672
+ continue;
7673
+ }
7674
+ if (typeof eventDef !== "object" || eventDef === null) {
7675
+ throw new Error(
7676
+ `[loly:realtime] Event '${eventName}' must be a handler function or an object with a 'handler' property`
7677
+ );
7678
+ }
7679
+ if (typeof eventDef.handler !== "function") {
7680
+ throw new Error(
7681
+ `[loly:realtime] Event '${eventName}' must have a 'handler' function`
7682
+ );
7683
+ }
7684
+ if (eventDef.schema !== void 0) {
7685
+ if (typeof eventDef.schema !== "object" || eventDef.schema === null || typeof eventDef.schema.parse !== "function" && typeof eventDef.schema.safeParse !== "function") {
7686
+ throw new Error(
7687
+ `[loly:realtime] Event '${eventName}' schema must be a Zod or Valibot schema (must have 'parse' or 'safeParse' method)`
7688
+ );
7689
+ }
7690
+ }
7691
+ if (eventDef.rateLimit !== void 0) {
7692
+ if (typeof eventDef.rateLimit !== "object" || eventDef.rateLimit === null || typeof eventDef.rateLimit.eventsPerSecond !== "number" || eventDef.rateLimit.eventsPerSecond <= 0) {
7693
+ throw new Error(
7694
+ `[loly:realtime] Event '${eventName}' rateLimit must have a positive 'eventsPerSecond' number`
7695
+ );
7696
+ }
7697
+ }
7698
+ if (eventDef.guard !== void 0 && typeof eventDef.guard !== "function") {
7699
+ throw new Error(
7700
+ `[loly:realtime] Event '${eventName}' guard must be a function`
7701
+ );
7702
+ }
7703
+ }
7704
+ if (definition.auth !== void 0 && typeof definition.auth !== "function") {
7705
+ throw new Error(
7706
+ "[loly:realtime] 'auth' must be a function"
7707
+ );
7708
+ }
7709
+ if (definition.onConnect !== void 0 && typeof definition.onConnect !== "function") {
7710
+ throw new Error(
7711
+ "[loly:realtime] 'onConnect' must be a function"
7712
+ );
7713
+ }
7714
+ if (definition.onDisconnect !== void 0 && typeof definition.onDisconnect !== "function") {
7715
+ throw new Error(
7716
+ "[loly:realtime] 'onDisconnect' must be a function"
7717
+ );
7718
+ }
7719
+ }
7720
+
6537
7721
  // modules/runtime/client/bootstrap.tsx
6538
7722
  import { hydrateRoot } from "react-dom/client";
6539
7723
 
@@ -7667,6 +8851,7 @@ export {
7667
8851
  createModuleLogger,
7668
8852
  createRateLimiter,
7669
8853
  defaultRateLimiter,
8854
+ defineWssRoute,
7670
8855
  generateRequestId,
7671
8856
  getAppDir,
7672
8857
  getBuildDir,