@strapi-community/plugin-io 5.0.6 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import require$$0$4 from "socket.io";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import require$$1 from "crypto";
3
4
  import * as dates$1 from "date-fns";
4
5
  import dates__default from "date-fns";
5
- import require$$1 from "crypto";
6
6
  import require$$0$5 from "child_process";
7
7
  import require$$0$6 from "os";
8
8
  import require$$0$8 from "path";
@@ -51,10 +51,10 @@ const require$$0$3 = {
51
51
  strapi: strapi$1
52
52
  };
53
53
  const pluginPkg = require$$0$3;
54
- const pluginId$7 = pluginPkg.strapi.name;
55
- var pluginId_1 = { pluginId: pluginId$7 };
56
- const { pluginId: pluginId$6 } = pluginId_1;
57
- function getService$3({ name, plugin = pluginId$6, type: type2 = "plugin" }) {
54
+ const pluginId$9 = pluginPkg.strapi.name;
55
+ var pluginId_1 = { pluginId: pluginId$9 };
56
+ const { pluginId: pluginId$8 } = pluginId_1;
57
+ function getService$3({ name, plugin = pluginId$8, type: type2 = "plugin" }) {
58
58
  let serviceUID = `${type2}::${plugin}`;
59
59
  if (name && name.length) {
60
60
  serviceUID += `.${name}`;
@@ -76,11 +76,24 @@ async function handshake$2(socket, next) {
76
76
  try {
77
77
  let room;
78
78
  if (strategy2 && strategy2.length) {
79
- const strategyType = strategy2 === "jwt" ? "role" : "token";
79
+ let strategyType;
80
+ if (strategy2 === "jwt") {
81
+ strategyType = "role";
82
+ } else if (strategy2 === "admin-jwt") {
83
+ strategyType = "admin";
84
+ } else {
85
+ strategyType = "token";
86
+ }
80
87
  const ctx = await strategyService[strategyType].authenticate(auth);
81
88
  room = strategyService[strategyType].getRoomName(ctx);
89
+ if (strategyType === "admin") {
90
+ socket.adminUser = ctx;
91
+ }
82
92
  } else if (strapi.plugin("users-permissions")) {
83
- const role = await strapi.query("plugin::users-permissions.role").findOne({ where: { type: "public" }, select: ["id", "name"] });
93
+ const role = await strapi.documents("plugin::users-permissions.role").findFirst({
94
+ filters: { type: "public" },
95
+ fields: ["id", "name"]
96
+ });
84
97
  room = strategyService["role"].getRoomName(role);
85
98
  }
86
99
  if (room) {
@@ -111,7 +124,7 @@ var constants$7 = {
111
124
  const { Server } = require$$0$4;
112
125
  const { handshake } = middleware;
113
126
  const { getService: getService$1 } = getService_1;
114
- const { pluginId: pluginId$5 } = pluginId_1;
127
+ const { pluginId: pluginId$7 } = pluginId_1;
115
128
  const { API_TOKEN_TYPE: API_TOKEN_TYPE$1 } = constants$7;
116
129
  let SocketIO$2 = class SocketIO {
117
130
  constructor(options) {
@@ -231,11 +244,11 @@ function requireSanitizeSensitiveFields() {
231
244
  return sanitizeSensitiveFields;
232
245
  }
233
246
  const { SocketIO: SocketIO2 } = structures;
234
- const { pluginId: pluginId$4 } = pluginId_1;
247
+ const { pluginId: pluginId$6 } = pluginId_1;
235
248
  async function bootstrapIO$1({ strapi: strapi2 }) {
236
- const settingsService = strapi2.plugin(pluginId$4).service("settings");
249
+ const settingsService = strapi2.plugin(pluginId$6).service("settings");
237
250
  const settings2 = await settingsService.getSettings();
238
- const monitoringService = strapi2.plugin(pluginId$4).service("monitoring");
251
+ const monitoringService = strapi2.plugin(pluginId$6).service("monitoring");
239
252
  const serverOptions = {
240
253
  cors: {
241
254
  origin: settings2.cors?.origins || ["http://localhost:3000"],
@@ -368,6 +381,11 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
368
381
  }
369
382
  });
370
383
  }
384
+ const presenceService = strapi2.plugin(pluginId$6).service("presence");
385
+ const previewService = strapi2.plugin(pluginId$6).service("preview");
386
+ if (settings2.presence?.enabled !== false) {
387
+ presenceService.startCleanupInterval();
388
+ }
371
389
  io2.server.on("connection", (socket) => {
372
390
  const clientIp = socket.handshake.address || "unknown";
373
391
  const username = socket.user?.username || "anonymous";
@@ -379,6 +397,10 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
379
397
  user: socket.user || null
380
398
  });
381
399
  }
400
+ if (settings2.presence?.enabled !== false) {
401
+ const user = socket.user || socket.adminUser;
402
+ presenceService.registerConnection(socket.id, user);
403
+ }
382
404
  if (settings2.rooms?.autoJoinByRole) {
383
405
  const userRole = socket.user?.role || "public";
384
406
  const rooms = settings2.rooms.autoJoinByRole[userRole] || [];
@@ -423,6 +445,70 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
423
445
  const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id);
424
446
  if (callback) callback({ success: true, rooms });
425
447
  });
448
+ socket.on("presence:join", async ({ uid, documentId }, callback) => {
449
+ if (settings2.presence?.enabled === false) {
450
+ if (callback) callback({ success: false, error: "Presence is disabled" });
451
+ return;
452
+ }
453
+ if (!uid || !documentId) {
454
+ if (callback) callback({ success: false, error: "uid and documentId are required" });
455
+ return;
456
+ }
457
+ const result = await presenceService.joinEntity(socket.id, uid, documentId);
458
+ if (callback) callback(result);
459
+ });
460
+ socket.on("presence:leave", async ({ uid, documentId }, callback) => {
461
+ if (settings2.presence?.enabled === false) {
462
+ if (callback) callback({ success: false, error: "Presence is disabled" });
463
+ return;
464
+ }
465
+ if (!uid || !documentId) {
466
+ if (callback) callback({ success: false, error: "uid and documentId are required" });
467
+ return;
468
+ }
469
+ const result = await presenceService.leaveEntity(socket.id, uid, documentId);
470
+ if (callback) callback(result);
471
+ });
472
+ socket.on("presence:heartbeat", (callback) => {
473
+ const result = presenceService.heartbeat(socket.id);
474
+ if (callback) callback(result);
475
+ });
476
+ socket.on("presence:typing", ({ uid, documentId, fieldName }) => {
477
+ if (settings2.presence?.enabled === false) return;
478
+ presenceService.broadcastTyping(socket.id, uid, documentId, fieldName);
479
+ });
480
+ socket.on("presence:check", async ({ uid, documentId }, callback) => {
481
+ if (settings2.presence?.enabled === false) {
482
+ if (callback) callback({ success: false, error: "Presence is disabled" });
483
+ return;
484
+ }
485
+ const editors = await presenceService.getEntityEditors(uid, documentId);
486
+ if (callback) callback({ success: true, editors, isBeingEdited: editors.length > 0 });
487
+ });
488
+ socket.on("preview:subscribe", async ({ uid, documentId }, callback) => {
489
+ if (settings2.livePreview?.enabled === false) {
490
+ if (callback) callback({ success: false, error: "Live preview is disabled" });
491
+ return;
492
+ }
493
+ if (!uid || !documentId) {
494
+ if (callback) callback({ success: false, error: "uid and documentId are required" });
495
+ return;
496
+ }
497
+ const result = await previewService.subscribe(socket.id, uid, documentId);
498
+ if (callback) callback(result);
499
+ });
500
+ socket.on("preview:unsubscribe", ({ uid, documentId }, callback) => {
501
+ if (!uid || !documentId) {
502
+ if (callback) callback({ success: false, error: "uid and documentId are required" });
503
+ return;
504
+ }
505
+ const result = previewService.unsubscribe(socket.id, uid, documentId);
506
+ if (callback) callback(result);
507
+ });
508
+ socket.on("preview:field-change", ({ uid, documentId, fieldName, value }) => {
509
+ if (settings2.livePreview?.enabled === false) return;
510
+ previewService.emitFieldChange(socket.id, uid, documentId, fieldName, value);
511
+ });
426
512
  socket.on("subscribe-entity", async ({ uid, id }, callback) => {
427
513
  if (settings2.entitySubscriptions?.enabled === false) {
428
514
  if (callback) callback({ success: false, error: "Entity subscriptions are disabled" });
@@ -557,7 +643,7 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
557
643
  strapi2.log.debug(`socket.io: Private message from ${socket.id} to ${to}`);
558
644
  if (callback) callback({ success: true });
559
645
  });
560
- socket.on("disconnect", (reason) => {
646
+ socket.on("disconnect", async (reason) => {
561
647
  if (settings2.monitoring?.enableConnectionLogging) {
562
648
  strapi2.log.info(`socket.io: Client disconnected (id: ${socket.id}, user: ${username}, reason: ${reason})`);
563
649
  monitoringService.logEvent("disconnect", {
@@ -566,6 +652,12 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
566
652
  user: socket.user || null
567
653
  });
568
654
  }
655
+ if (settings2.presence?.enabled !== false) {
656
+ await presenceService.unregisterConnection(socket.id);
657
+ }
658
+ if (settings2.livePreview?.enabled !== false) {
659
+ previewService.cleanupSocket(socket.id);
660
+ }
569
661
  });
570
662
  socket.on("error", (error2) => {
571
663
  strapi2.log.error(`socket.io: Socket error (id: ${socket.id}): ${error2.message}`);
@@ -709,17 +801,52 @@ async function bootstrapIO$1({ strapi: strapi2 }) {
709
801
  }
710
802
  });
711
803
  const enabledContentTypes = allContentTypes.size;
804
+ strapi2.$io.presence = {
805
+ /**
806
+ * Get editors for an entity
807
+ */
808
+ getEditors: (uid, documentId) => presenceService.getEntityEditors(uid, documentId),
809
+ /**
810
+ * Check if entity is being edited
811
+ */
812
+ isBeingEdited: (uid, documentId) => presenceService.isEntityBeingEdited(uid, documentId),
813
+ /**
814
+ * Get presence statistics
815
+ */
816
+ getStats: () => presenceService.getStats()
817
+ };
818
+ strapi2.$io.preview = {
819
+ /**
820
+ * Emit draft change to preview subscribers
821
+ */
822
+ emitDraftChange: (uid, documentId, data, diff2) => previewService.emitDraftChange(uid, documentId, data, diff2),
823
+ /**
824
+ * Emit publish event
825
+ */
826
+ emitPublish: (uid, documentId, data) => previewService.emitPublish(uid, documentId, data),
827
+ /**
828
+ * Emit unpublish event
829
+ */
830
+ emitUnpublish: (uid, documentId) => previewService.emitUnpublish(uid, documentId),
831
+ /**
832
+ * Get preview statistics
833
+ */
834
+ getStats: () => previewService.getStats()
835
+ };
712
836
  const origins = settings2.cors?.origins?.join(", ") || "http://localhost:3000";
713
837
  const features = [];
714
838
  if (settings2.redis?.enabled) features.push("Redis");
715
839
  if (settings2.namespaces?.enabled) features.push(`Namespaces(${Object.keys(settings2.namespaces.list || {}).length})`);
716
840
  if (settings2.security?.rateLimiting?.enabled) features.push("RateLimit");
841
+ if (settings2.presence?.enabled !== false) features.push("Presence");
842
+ if (settings2.livePreview?.enabled !== false) features.push("LivePreview");
843
+ if (settings2.fieldLevelChanges?.enabled !== false) features.push("FieldDiff");
717
844
  strapi2.log.info(`socket.io: Plugin initialized`);
718
- strapi2.log.info(` Origins: ${origins}`);
719
- strapi2.log.info(` Content Types: ${enabledContentTypes}`);
720
- strapi2.log.info(` Max Connections: ${settings2.connection?.maxConnections || 1e3}`);
845
+ strapi2.log.info(` - Origins: ${origins}`);
846
+ strapi2.log.info(` - Content Types: ${enabledContentTypes}`);
847
+ strapi2.log.info(` - Max Connections: ${settings2.connection?.maxConnections || 1e3}`);
721
848
  if (features.length > 0) {
722
- strapi2.log.info(` Features: ${features.join(", ")}`);
849
+ strapi2.log.info(` - Features: ${features.join(", ")}`);
723
850
  }
724
851
  }
725
852
  var io = { bootstrapIO: bootstrapIO$1 };
@@ -793,7 +920,7 @@ function getTransactionCtx() {
793
920
  }
794
921
  return transactionCtx;
795
922
  }
796
- const { pluginId: pluginId$3 } = pluginId_1;
923
+ const { pluginId: pluginId$5 } = pluginId_1;
797
924
  function scheduleAfterTransaction(callback, delay = 0) {
798
925
  const runner = () => setTimeout(callback, delay);
799
926
  const ctx = getTransactionCtx();
@@ -880,17 +1007,47 @@ async function bootstrapLifecycles$1({ strapi: strapi2 }) {
880
1007
  }, 50);
881
1008
  }
882
1009
  };
1010
+ subscriber.beforeUpdate = async (event) => {
1011
+ if (!isActionEnabled(strapi2, uid, "update")) return;
1012
+ const fieldLevelEnabled = strapi2.$ioSettings?.fieldLevelChanges?.enabled !== false;
1013
+ if (!fieldLevelEnabled) return;
1014
+ try {
1015
+ const documentId = event.params.where?.documentId || event.params.documentId;
1016
+ if (!documentId) return;
1017
+ const existing = await strapi2.documents(uid).findOne({ documentId });
1018
+ if (existing) {
1019
+ if (!event.state.io) event.state.io = {};
1020
+ event.state.io.previousData = JSON.parse(JSON.stringify(existing));
1021
+ }
1022
+ } catch (error2) {
1023
+ strapi2.log.debug(`socket.io: Could not fetch previous data for diff: ${error2.message}`);
1024
+ }
1025
+ };
883
1026
  subscriber.afterUpdate = async (event) => {
884
1027
  if (!isActionEnabled(strapi2, uid, "update")) return;
885
- const eventData = {
886
- event: "update",
887
- schema: event.model,
888
- data: JSON.parse(JSON.stringify(event.result))
889
- // Deep clone
890
- };
1028
+ const newData = JSON.parse(JSON.stringify(event.result));
1029
+ const previousData = event.state.io?.previousData || null;
1030
+ const modelInfo = { singularName: event.model.singularName, uid: event.model.uid };
891
1031
  scheduleAfterTransaction(() => {
892
1032
  try {
893
- strapi2.$io.emit(eventData);
1033
+ const diffService = strapi2.plugin(pluginId$5).service("diff");
1034
+ const previewService = strapi2.plugin(pluginId$5).service("preview");
1035
+ const fieldLevelEnabled = strapi2.$ioSettings?.fieldLevelChanges?.enabled !== false;
1036
+ let eventPayload;
1037
+ if (fieldLevelEnabled && previousData && diffService) {
1038
+ eventPayload = diffService.createEventPayload("update", modelInfo, previousData, newData);
1039
+ } else {
1040
+ eventPayload = {
1041
+ event: "update",
1042
+ schema: modelInfo,
1043
+ data: newData
1044
+ };
1045
+ }
1046
+ strapi2.$io.emit(eventPayload);
1047
+ if (previewService && newData.documentId) {
1048
+ const diff2 = fieldLevelEnabled ? eventPayload.diff : null;
1049
+ previewService.emitDraftChange(uid, newData.documentId, newData, diff2);
1050
+ }
894
1051
  } catch (error2) {
895
1052
  strapi2.log.debug(`socket.io: Could not emit update event for ${uid}:`, error2.message);
896
1053
  }
@@ -991,14 +1148,14 @@ var config$1 = {
991
1148
  validator(config2) {
992
1149
  }
993
1150
  };
994
- const { pluginId: pluginId$2 } = pluginId_1;
1151
+ const { pluginId: pluginId$4 } = pluginId_1;
995
1152
  var settings$3 = ({ strapi: strapi2 }) => ({
996
1153
  /**
997
1154
  * GET /io/settings
998
1155
  * Retrieve current plugin settings
999
1156
  */
1000
1157
  async getSettings(ctx) {
1001
- const settingsService = strapi2.plugin(pluginId$2).service("settings");
1158
+ const settingsService = strapi2.plugin(pluginId$4).service("settings");
1002
1159
  const settings2 = await settingsService.getSettings();
1003
1160
  ctx.body = { data: settings2 };
1004
1161
  },
@@ -1007,7 +1164,7 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1007
1164
  * Update plugin settings and hot-reload Socket.IO
1008
1165
  */
1009
1166
  async updateSettings(ctx) {
1010
- const settingsService = strapi2.plugin(pluginId$2).service("settings");
1167
+ const settingsService = strapi2.plugin(pluginId$4).service("settings");
1011
1168
  const { body } = ctx.request;
1012
1169
  await settingsService.getSettings();
1013
1170
  const updatedSettings = await settingsService.setSettings(body);
@@ -1040,7 +1197,7 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1040
1197
  * Get connection and event statistics
1041
1198
  */
1042
1199
  async getStats(ctx) {
1043
- const monitoringService = strapi2.plugin(pluginId$2).service("monitoring");
1200
+ const monitoringService = strapi2.plugin(pluginId$4).service("monitoring");
1044
1201
  const connectionStats = monitoringService.getConnectionStats();
1045
1202
  const eventStats = monitoringService.getEventStats();
1046
1203
  ctx.body = {
@@ -1055,7 +1212,7 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1055
1212
  * Get recent event log
1056
1213
  */
1057
1214
  async getEventLog(ctx) {
1058
- const monitoringService = strapi2.plugin(pluginId$2).service("monitoring");
1215
+ const monitoringService = strapi2.plugin(pluginId$4).service("monitoring");
1059
1216
  const limit = parseInt(ctx.query.limit) || 50;
1060
1217
  const log = monitoringService.getEventLog(limit);
1061
1218
  ctx.body = { data: log };
@@ -1065,7 +1222,7 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1065
1222
  * Send a test event
1066
1223
  */
1067
1224
  async sendTestEvent(ctx) {
1068
- const monitoringService = strapi2.plugin(pluginId$2).service("monitoring");
1225
+ const monitoringService = strapi2.plugin(pluginId$4).service("monitoring");
1069
1226
  const { eventName, data } = ctx.request.body;
1070
1227
  try {
1071
1228
  const result = monitoringService.sendTestEvent(eventName || "test", data || {});
@@ -1079,7 +1236,7 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1079
1236
  * Reset monitoring statistics
1080
1237
  */
1081
1238
  async resetStats(ctx) {
1082
- const monitoringService = strapi2.plugin(pluginId$2).service("monitoring");
1239
+ const monitoringService = strapi2.plugin(pluginId$4).service("monitoring");
1083
1240
  monitoringService.resetStats();
1084
1241
  ctx.body = { data: { success: true } };
1085
1242
  },
@@ -1103,7 +1260,7 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1103
1260
  * Get lightweight stats for dashboard widget
1104
1261
  */
1105
1262
  async getMonitoringStats(ctx) {
1106
- const monitoringService = strapi2.plugin(pluginId$2).service("monitoring");
1263
+ const monitoringService = strapi2.plugin(pluginId$4).service("monitoring");
1107
1264
  const connectionStats = monitoringService.getConnectionStats();
1108
1265
  const eventStats = monitoringService.getEventStats();
1109
1266
  ctx.body = {
@@ -1122,13 +1279,95 @@ var settings$3 = ({ strapi: strapi2 }) => ({
1122
1279
  };
1123
1280
  }
1124
1281
  });
1282
+ const { randomUUID } = require$$1;
1283
+ const sessionTokens = /* @__PURE__ */ new Map();
1284
+ setInterval(() => {
1285
+ const now = Date.now();
1286
+ for (const [token, session] of sessionTokens.entries()) {
1287
+ if (session.expiresAt < now) {
1288
+ sessionTokens.delete(token);
1289
+ }
1290
+ }
1291
+ }, 5 * 60 * 1e3);
1292
+ var presence$3 = ({ strapi: strapi2 }) => ({
1293
+ /**
1294
+ * Creates a session token for admin users to connect to Socket.IO
1295
+ * @param {object} ctx - Koa context
1296
+ */
1297
+ async createSession(ctx) {
1298
+ const adminUser = ctx.state.user;
1299
+ if (!adminUser) {
1300
+ strapi2.log.warn("[plugin-io] Presence session requested without admin user");
1301
+ return ctx.unauthorized("Admin authentication required");
1302
+ }
1303
+ try {
1304
+ const token = randomUUID();
1305
+ const expiresAt = Date.now() + 2 * 60 * 1e3;
1306
+ sessionTokens.set(token, {
1307
+ token,
1308
+ user: {
1309
+ id: adminUser.id,
1310
+ email: adminUser.email,
1311
+ firstname: adminUser.firstname,
1312
+ lastname: adminUser.lastname
1313
+ },
1314
+ expiresAt
1315
+ });
1316
+ strapi2.log.info(`[plugin-io] Presence session created for admin user: ${adminUser.email}`);
1317
+ ctx.body = {
1318
+ token,
1319
+ user: {
1320
+ id: adminUser.id,
1321
+ email: adminUser.email,
1322
+ firstname: adminUser.firstname,
1323
+ lastname: adminUser.lastname
1324
+ },
1325
+ wsPath: "/socket.io",
1326
+ wsUrl: `${ctx.protocol}://${ctx.host}`
1327
+ };
1328
+ } catch (error2) {
1329
+ strapi2.log.error("[plugin-io] Failed to create presence session:", error2);
1330
+ return ctx.internalServerError("Failed to create session");
1331
+ }
1332
+ },
1333
+ /**
1334
+ * Validates and consumes a session token (one-time use)
1335
+ * @param {string} token - Session token to validate
1336
+ * @returns {object|null} Session data or null if invalid/expired
1337
+ */
1338
+ consumeSessionToken(token) {
1339
+ if (!token) {
1340
+ return null;
1341
+ }
1342
+ const session = sessionTokens.get(token);
1343
+ if (!session) {
1344
+ return null;
1345
+ }
1346
+ if (session.expiresAt < Date.now()) {
1347
+ sessionTokens.delete(token);
1348
+ return null;
1349
+ }
1350
+ return session;
1351
+ }
1352
+ });
1125
1353
  const settings$2 = settings$3;
1354
+ const presence$2 = presence$3;
1126
1355
  var controllers$1 = {
1127
- settings: settings$2
1356
+ settings: settings$2,
1357
+ presence: presence$2
1128
1358
  };
1129
1359
  var admin$1 = {
1130
1360
  type: "admin",
1131
1361
  routes: [
1362
+ // Presence Session - issues JWT token for Socket.IO connection
1363
+ {
1364
+ method: "POST",
1365
+ path: "/presence/session",
1366
+ handler: "presence.createSession",
1367
+ config: {
1368
+ policies: ["admin::isAuthenticatedAdmin"]
1369
+ }
1370
+ },
1132
1371
  {
1133
1372
  method: "GET",
1134
1373
  path: "/settings",
@@ -21468,9 +21707,9 @@ function padZeros(value, tok, options) {
21468
21707
  if (!tok.isPadded) {
21469
21708
  return value;
21470
21709
  }
21471
- let diff = Math.abs(tok.maxLen - String(value).length);
21710
+ let diff2 = Math.abs(tok.maxLen - String(value).length);
21472
21711
  let relax = options.relaxZeros !== false;
21473
- switch (diff) {
21712
+ switch (diff2) {
21474
21713
  case 0:
21475
21714
  return "";
21476
21715
  case 1:
@@ -21478,7 +21717,7 @@ function padZeros(value, tok, options) {
21478
21717
  case 2:
21479
21718
  return relax ? "0{0,2}" : "00";
21480
21719
  default: {
21481
- return relax ? `0{0,${diff}}` : `0{${diff}}`;
21720
+ return relax ? `0{0,${diff2}}` : `0{${diff2}}`;
21482
21721
  }
21483
21722
  }
21484
21723
  }
@@ -29401,6 +29640,32 @@ var strategies = ({ strapi: strapi2 }) => {
29401
29640
  const apiTokenService = getService({ type: "admin", plugin: "api-token" });
29402
29641
  const jwtService = getService({ name: "jwt", plugin: "users-permissions" });
29403
29642
  const userService = getService({ name: "user", plugin: "users-permissions" });
29643
+ const admin2 = {
29644
+ name: "io-admin",
29645
+ credentials: function(user) {
29646
+ return `${this.name}-${user.id}`;
29647
+ },
29648
+ authenticate: async function(auth) {
29649
+ const token2 = auth.token;
29650
+ if (!token2) {
29651
+ throw new UnauthorizedError2("Invalid admin credentials");
29652
+ }
29653
+ try {
29654
+ const presenceController = strapi2.plugin("io").controller("presence");
29655
+ const session = presenceController.consumeSessionToken(token2);
29656
+ if (!session) {
29657
+ throw new UnauthorizedError2("Invalid or expired session token");
29658
+ }
29659
+ return session.user;
29660
+ } catch (error2) {
29661
+ strapi2.log.warn("[plugin-io] Admin session verification failed:", error2.message);
29662
+ throw new UnauthorizedError2("Invalid admin credentials");
29663
+ }
29664
+ },
29665
+ getRoomName: function(user) {
29666
+ return `${this.name}-user-${user.id}`;
29667
+ }
29668
+ };
29404
29669
  const role = {
29405
29670
  name: "io-role",
29406
29671
  credentials: function(role2) {
@@ -29543,6 +29808,7 @@ var strategies = ({ strapi: strapi2 }) => {
29543
29808
  }
29544
29809
  };
29545
29810
  return {
29811
+ admin: admin2,
29546
29812
  role,
29547
29813
  token
29548
29814
  };
@@ -29636,12 +29902,12 @@ function transformEntry(entry, type2) {
29636
29902
  // meta: {},
29637
29903
  };
29638
29904
  }
29639
- const { pluginId: pluginId$1 } = pluginId_1;
29905
+ const { pluginId: pluginId$3 } = pluginId_1;
29640
29906
  var settings$1 = ({ strapi: strapi2 }) => {
29641
29907
  const getPluginStore = () => {
29642
29908
  return strapi2.store({
29643
29909
  type: "plugin",
29644
- name: pluginId$1
29910
+ name: pluginId$3
29645
29911
  });
29646
29912
  };
29647
29913
  const getDefaultSettings = () => ({
@@ -29744,6 +30010,41 @@ var settings$1 = ({ strapi: strapi2 }) => {
29744
30010
  enableConnectionLogging: true,
29745
30011
  enableEventLogging: false,
29746
30012
  maxEventLogSize: 100
30013
+ },
30014
+ // Presence System (Collaboration Awareness)
30015
+ presence: {
30016
+ enabled: true,
30017
+ // Enable presence tracking
30018
+ heartbeatInterval: 3e4,
30019
+ // Heartbeat interval in ms
30020
+ staleTimeout: 6e4,
30021
+ // Time before connection considered stale
30022
+ showAvatars: true,
30023
+ // Show user avatars in UI
30024
+ showTypingIndicator: true
30025
+ // Show typing indicators
30026
+ },
30027
+ // Live Preview (Real-time Draft Updates)
30028
+ livePreview: {
30029
+ enabled: true,
30030
+ // Enable live preview
30031
+ draftEvents: true,
30032
+ // Emit events for draft changes
30033
+ debounceMs: 300,
30034
+ // Debounce field changes
30035
+ maxSubscriptionsPerSocket: 50
30036
+ // Max preview subscriptions per socket
30037
+ },
30038
+ // Field-level Changes (Diff-based Updates)
30039
+ fieldLevelChanges: {
30040
+ enabled: true,
30041
+ // Enable field-level diff
30042
+ includeFullData: false,
30043
+ // Include full data alongside diff
30044
+ excludeFields: ["updatedAt", "updatedBy", "createdAt", "createdBy"],
30045
+ // Fields to exclude from diff
30046
+ maxDiffDepth: 3
30047
+ // Maximum nesting depth for diff
29747
30048
  }
29748
30049
  });
29749
30050
  return {
@@ -29784,7 +30085,7 @@ var settings$1 = ({ strapi: strapi2 }) => {
29784
30085
  getDefaultSettings
29785
30086
  };
29786
30087
  };
29787
- const { pluginId } = pluginId_1;
30088
+ const { pluginId: pluginId$2 } = pluginId_1;
29788
30089
  var monitoring$1 = ({ strapi: strapi2 }) => {
29789
30090
  let eventLog = [];
29790
30091
  let eventStats = {
@@ -29928,17 +30229,795 @@ var monitoring$1 = ({ strapi: strapi2 }) => {
29928
30229
  }
29929
30230
  };
29930
30231
  };
30232
+ const { pluginId: pluginId$1 } = pluginId_1;
30233
+ var presence$1 = ({ strapi: strapi2 }) => {
30234
+ const activeConnections = /* @__PURE__ */ new Map();
30235
+ const entityEditors = /* @__PURE__ */ new Map();
30236
+ let cleanupInterval = null;
30237
+ const getEntityKey = (uid, documentId) => `${uid}:${documentId}`;
30238
+ const getPresenceSettings = () => {
30239
+ const settings2 = strapi2.$ioSettings || {};
30240
+ return {
30241
+ enabled: settings2.presence?.enabled ?? true,
30242
+ heartbeatInterval: settings2.presence?.heartbeatInterval ?? 3e4,
30243
+ staleTimeout: settings2.presence?.staleTimeout ?? 6e4,
30244
+ showAvatars: settings2.presence?.showAvatars ?? true
30245
+ };
30246
+ };
30247
+ const broadcastPresenceUpdate = async (uid, documentId) => {
30248
+ const io2 = strapi2.$io?.server;
30249
+ if (!io2) return;
30250
+ const entityKey = getEntityKey(uid, documentId);
30251
+ const editorSocketIds = entityEditors.get(entityKey) || /* @__PURE__ */ new Set();
30252
+ const editors = [];
30253
+ for (const socketId of editorSocketIds) {
30254
+ const connection = activeConnections.get(socketId);
30255
+ if (connection?.user) {
30256
+ editors.push({
30257
+ socketId,
30258
+ user: {
30259
+ id: connection.user.id,
30260
+ username: connection.user.username,
30261
+ email: connection.user.email,
30262
+ firstname: connection.user.firstname,
30263
+ lastname: connection.user.lastname
30264
+ },
30265
+ joinedAt: connection.entities?.get(entityKey) || Date.now()
30266
+ });
30267
+ }
30268
+ }
30269
+ const roomName = `presence:${entityKey}`;
30270
+ io2.to(roomName).emit("presence:update", {
30271
+ uid,
30272
+ documentId,
30273
+ editors,
30274
+ count: editors.length,
30275
+ timestamp: Date.now()
30276
+ });
30277
+ strapi2.log.debug(`socket.io: Presence update for ${entityKey} - ${editors.length} editor(s)`);
30278
+ };
30279
+ return {
30280
+ /**
30281
+ * Registers a new socket connection for presence tracking
30282
+ * @param {string} socketId - Socket ID
30283
+ * @param {object} user - User object (can be null for anonymous)
30284
+ */
30285
+ registerConnection(socketId, user = null) {
30286
+ const settings2 = getPresenceSettings();
30287
+ if (!settings2.enabled) return;
30288
+ activeConnections.set(socketId, {
30289
+ user,
30290
+ entities: /* @__PURE__ */ new Map(),
30291
+ // entityKey -> joinedAt timestamp
30292
+ lastSeen: Date.now(),
30293
+ connectedAt: Date.now()
30294
+ });
30295
+ strapi2.log.debug(`socket.io: Presence registered for socket ${socketId}`);
30296
+ },
30297
+ /**
30298
+ * Unregisters a socket connection and cleans up all entity presence
30299
+ * @param {string} socketId - Socket ID
30300
+ */
30301
+ async unregisterConnection(socketId) {
30302
+ const connection = activeConnections.get(socketId);
30303
+ if (!connection) return;
30304
+ if (connection.entities) {
30305
+ for (const entityKey of connection.entities.keys()) {
30306
+ const [uid, documentId] = entityKey.split(":");
30307
+ await this.leaveEntity(socketId, uid, documentId, false);
30308
+ }
30309
+ }
30310
+ activeConnections.delete(socketId);
30311
+ strapi2.log.debug(`socket.io: Presence unregistered for socket ${socketId}`);
30312
+ },
30313
+ /**
30314
+ * User joins an entity for editing
30315
+ * @param {string} socketId - Socket ID
30316
+ * @param {string} uid - Content type UID
30317
+ * @param {string} documentId - Document ID
30318
+ * @returns {object} Join result with current editors
30319
+ */
30320
+ async joinEntity(socketId, uid, documentId) {
30321
+ const settings2 = getPresenceSettings();
30322
+ if (!settings2.enabled) {
30323
+ return { success: false, error: "Presence is disabled" };
30324
+ }
30325
+ const connection = activeConnections.get(socketId);
30326
+ if (!connection) {
30327
+ return { success: false, error: "Socket not registered for presence" };
30328
+ }
30329
+ const entityKey = getEntityKey(uid, documentId);
30330
+ if (!entityEditors.has(entityKey)) {
30331
+ entityEditors.set(entityKey, /* @__PURE__ */ new Set());
30332
+ }
30333
+ entityEditors.get(entityKey).add(socketId);
30334
+ connection.entities.set(entityKey, Date.now());
30335
+ connection.lastSeen = Date.now();
30336
+ const io2 = strapi2.$io?.server;
30337
+ const socket = io2?.sockets.sockets.get(socketId);
30338
+ if (socket) {
30339
+ socket.join(`presence:${entityKey}`);
30340
+ }
30341
+ await broadcastPresenceUpdate(uid, documentId);
30342
+ strapi2.log.info(`socket.io: User ${connection.user?.username || "anonymous"} joined entity ${entityKey}`);
30343
+ return {
30344
+ success: true,
30345
+ entityKey,
30346
+ editors: await this.getEntityEditors(uid, documentId)
30347
+ };
30348
+ },
30349
+ /**
30350
+ * User leaves an entity
30351
+ * @param {string} socketId - Socket ID
30352
+ * @param {string} uid - Content type UID
30353
+ * @param {string} documentId - Document ID
30354
+ * @param {boolean} broadcast - Whether to broadcast update (default: true)
30355
+ * @returns {object} Leave result
30356
+ */
30357
+ async leaveEntity(socketId, uid, documentId, broadcast = true) {
30358
+ const settings2 = getPresenceSettings();
30359
+ if (!settings2.enabled) {
30360
+ return { success: false, error: "Presence is disabled" };
30361
+ }
30362
+ const entityKey = getEntityKey(uid, documentId);
30363
+ const connection = activeConnections.get(socketId);
30364
+ const editors = entityEditors.get(entityKey);
30365
+ if (editors) {
30366
+ editors.delete(socketId);
30367
+ if (editors.size === 0) {
30368
+ entityEditors.delete(entityKey);
30369
+ }
30370
+ }
30371
+ if (connection?.entities) {
30372
+ connection.entities.delete(entityKey);
30373
+ }
30374
+ const io2 = strapi2.$io?.server;
30375
+ const socket = io2?.sockets.sockets.get(socketId);
30376
+ if (socket) {
30377
+ socket.leave(`presence:${entityKey}`);
30378
+ }
30379
+ if (broadcast) {
30380
+ await broadcastPresenceUpdate(uid, documentId);
30381
+ }
30382
+ strapi2.log.debug(`socket.io: Socket ${socketId} left entity ${entityKey}`);
30383
+ return { success: true, entityKey };
30384
+ },
30385
+ /**
30386
+ * Gets all editors currently editing an entity
30387
+ * @param {string} uid - Content type UID
30388
+ * @param {string} documentId - Document ID
30389
+ * @returns {Array} List of editors with user info
30390
+ */
30391
+ async getEntityEditors(uid, documentId) {
30392
+ const entityKey = getEntityKey(uid, documentId);
30393
+ const editorSocketIds = entityEditors.get(entityKey) || /* @__PURE__ */ new Set();
30394
+ const editors = [];
30395
+ for (const socketId of editorSocketIds) {
30396
+ const connection = activeConnections.get(socketId);
30397
+ if (connection?.user) {
30398
+ editors.push({
30399
+ socketId,
30400
+ user: {
30401
+ id: connection.user.id,
30402
+ username: connection.user.username,
30403
+ email: connection.user.email,
30404
+ firstname: connection.user.firstname,
30405
+ lastname: connection.user.lastname
30406
+ },
30407
+ joinedAt: connection.entities?.get(entityKey) || Date.now()
30408
+ });
30409
+ }
30410
+ }
30411
+ return editors;
30412
+ },
30413
+ /**
30414
+ * Updates heartbeat for a socket to keep presence alive
30415
+ * @param {string} socketId - Socket ID
30416
+ * @returns {object} Heartbeat result
30417
+ */
30418
+ heartbeat(socketId) {
30419
+ const connection = activeConnections.get(socketId);
30420
+ if (!connection) {
30421
+ return { success: false, error: "Socket not registered" };
30422
+ }
30423
+ connection.lastSeen = Date.now();
30424
+ return { success: true, lastSeen: connection.lastSeen };
30425
+ },
30426
+ /**
30427
+ * Cleans up stale connections that haven't sent heartbeat
30428
+ * @returns {number} Number of connections cleaned up
30429
+ */
30430
+ async cleanup() {
30431
+ const settings2 = getPresenceSettings();
30432
+ const staleTimeout = settings2.staleTimeout;
30433
+ const now = Date.now();
30434
+ let cleanedUp = 0;
30435
+ for (const [socketId, connection] of activeConnections) {
30436
+ if (now - connection.lastSeen > staleTimeout) {
30437
+ await this.unregisterConnection(socketId);
30438
+ cleanedUp++;
30439
+ }
30440
+ }
30441
+ if (cleanedUp > 0) {
30442
+ strapi2.log.info(`socket.io: Presence cleanup removed ${cleanedUp} stale connection(s)`);
30443
+ }
30444
+ return cleanedUp;
30445
+ },
30446
+ /**
30447
+ * Starts the cleanup interval
30448
+ */
30449
+ startCleanupInterval() {
30450
+ const settings2 = getPresenceSettings();
30451
+ if (!settings2.enabled) return;
30452
+ cleanupInterval = setInterval(() => {
30453
+ this.cleanup();
30454
+ }, 6e4);
30455
+ strapi2.log.debug("socket.io: Presence cleanup interval started");
30456
+ },
30457
+ /**
30458
+ * Stops the cleanup interval
30459
+ */
30460
+ stopCleanupInterval() {
30461
+ if (cleanupInterval) {
30462
+ clearInterval(cleanupInterval);
30463
+ cleanupInterval = null;
30464
+ }
30465
+ },
30466
+ /**
30467
+ * Gets presence statistics
30468
+ * @returns {object} Presence stats
30469
+ */
30470
+ getStats() {
30471
+ const totalConnections = activeConnections.size;
30472
+ const totalEntitiesBeingEdited = entityEditors.size;
30473
+ let authenticated = 0;
30474
+ let anonymous = 0;
30475
+ for (const connection of activeConnections.values()) {
30476
+ if (connection.user) {
30477
+ authenticated++;
30478
+ } else {
30479
+ anonymous++;
30480
+ }
30481
+ }
30482
+ return {
30483
+ totalConnections,
30484
+ authenticated,
30485
+ anonymous,
30486
+ totalEntitiesBeingEdited,
30487
+ entities: Array.from(entityEditors.entries()).map(([key, editors]) => ({
30488
+ entityKey: key,
30489
+ editorCount: editors.size
30490
+ }))
30491
+ };
30492
+ },
30493
+ /**
30494
+ * Gets all entities a user is currently editing
30495
+ * @param {string} socketId - Socket ID
30496
+ * @returns {Array} List of entity keys
30497
+ */
30498
+ getUserEntities(socketId) {
30499
+ const connection = activeConnections.get(socketId);
30500
+ if (!connection) return [];
30501
+ return Array.from(connection.entities.keys());
30502
+ },
30503
+ /**
30504
+ * Checks if an entity is being edited by anyone
30505
+ * @param {string} uid - Content type UID
30506
+ * @param {string} documentId - Document ID
30507
+ * @returns {boolean} True if entity has editors
30508
+ */
30509
+ isEntityBeingEdited(uid, documentId) {
30510
+ const entityKey = getEntityKey(uid, documentId);
30511
+ const editors = entityEditors.get(entityKey);
30512
+ return editors ? editors.size > 0 : false;
30513
+ },
30514
+ /**
30515
+ * Broadcasts a typing indicator for an entity
30516
+ * @param {string} socketId - Socket ID of typing user
30517
+ * @param {string} uid - Content type UID
30518
+ * @param {string} documentId - Document ID
30519
+ * @param {string} fieldName - Name of field being edited
30520
+ */
30521
+ broadcastTyping(socketId, uid, documentId, fieldName) {
30522
+ const io2 = strapi2.$io?.server;
30523
+ if (!io2) return;
30524
+ const connection = activeConnections.get(socketId);
30525
+ if (!connection?.user) return;
30526
+ const entityKey = getEntityKey(uid, documentId);
30527
+ const roomName = `presence:${entityKey}`;
30528
+ const socket = io2.sockets.sockets.get(socketId);
30529
+ if (socket) {
30530
+ socket.to(roomName).emit("presence:typing", {
30531
+ uid,
30532
+ documentId,
30533
+ user: {
30534
+ id: connection.user.id,
30535
+ username: connection.user.username
30536
+ },
30537
+ fieldName,
30538
+ timestamp: Date.now()
30539
+ });
30540
+ }
30541
+ }
30542
+ };
30543
+ };
30544
+ const { pluginId } = pluginId_1;
30545
+ var preview$1 = ({ strapi: strapi2 }) => {
30546
+ const previewSubscribers = /* @__PURE__ */ new Map();
30547
+ const socketState = /* @__PURE__ */ new Map();
30548
+ const getEntityKey = (uid, documentId) => `${uid}:${documentId}`;
30549
+ const getPreviewSettings = () => {
30550
+ const settings2 = strapi2.$ioSettings || {};
30551
+ return {
30552
+ enabled: settings2.livePreview?.enabled ?? true,
30553
+ draftEvents: settings2.livePreview?.draftEvents ?? true,
30554
+ debounceMs: settings2.livePreview?.debounceMs ?? 300,
30555
+ maxSubscriptionsPerSocket: settings2.livePreview?.maxSubscriptionsPerSocket ?? 50
30556
+ };
30557
+ };
30558
+ const emitToSubscribers = (uid, documentId, eventType, data) => {
30559
+ const io2 = strapi2.$io?.server;
30560
+ if (!io2) return;
30561
+ const entityKey = getEntityKey(uid, documentId);
30562
+ const subscribers = previewSubscribers.get(entityKey);
30563
+ if (!subscribers || subscribers.size === 0) return;
30564
+ const roomName = `preview:${entityKey}`;
30565
+ io2.to(roomName).emit(eventType, {
30566
+ uid,
30567
+ documentId,
30568
+ ...data,
30569
+ timestamp: Date.now()
30570
+ });
30571
+ strapi2.log.debug(`socket.io: Preview event '${eventType}' sent to ${subscribers.size} subscriber(s) for ${entityKey}`);
30572
+ };
30573
+ return {
30574
+ /**
30575
+ * Subscribes a socket to preview updates for an entity
30576
+ * @param {string} socketId - Socket ID
30577
+ * @param {string} uid - Content type UID
30578
+ * @param {string} documentId - Document ID
30579
+ * @returns {object} Subscription result
30580
+ */
30581
+ async subscribe(socketId, uid, documentId) {
30582
+ const settings2 = getPreviewSettings();
30583
+ if (!settings2.enabled) {
30584
+ return { success: false, error: "Live preview is disabled" };
30585
+ }
30586
+ const entityKey = getEntityKey(uid, documentId);
30587
+ const io2 = strapi2.$io?.server;
30588
+ const socket = io2?.sockets.sockets.get(socketId);
30589
+ if (!socket) {
30590
+ return { success: false, error: "Socket not found" };
30591
+ }
30592
+ const currentSubs = Array.from(socket.rooms).filter((r) => r.startsWith("preview:")).length;
30593
+ if (currentSubs >= settings2.maxSubscriptionsPerSocket) {
30594
+ return { success: false, error: `Maximum preview subscriptions (${settings2.maxSubscriptionsPerSocket}) reached` };
30595
+ }
30596
+ if (!previewSubscribers.has(entityKey)) {
30597
+ previewSubscribers.set(entityKey, /* @__PURE__ */ new Set());
30598
+ }
30599
+ previewSubscribers.get(entityKey).add(socketId);
30600
+ socket.join(`preview:${entityKey}`);
30601
+ if (!socketState.has(socketId)) {
30602
+ socketState.set(socketId, { debounceTimers: /* @__PURE__ */ new Map() });
30603
+ }
30604
+ strapi2.log.debug(`socket.io: Socket ${socketId} subscribed to preview for ${entityKey}`);
30605
+ try {
30606
+ const entity = await strapi2.documents(uid).findOne({ documentId });
30607
+ if (entity) {
30608
+ socket.emit("preview:initial", {
30609
+ uid,
30610
+ documentId,
30611
+ data: entity,
30612
+ timestamp: Date.now()
30613
+ });
30614
+ }
30615
+ } catch (err) {
30616
+ strapi2.log.warn(`socket.io: Could not fetch initial preview data for ${entityKey}: ${err.message}`);
30617
+ }
30618
+ return {
30619
+ success: true,
30620
+ entityKey,
30621
+ subscriberCount: previewSubscribers.get(entityKey).size
30622
+ };
30623
+ },
30624
+ /**
30625
+ * Unsubscribes a socket from preview updates
30626
+ * @param {string} socketId - Socket ID
30627
+ * @param {string} uid - Content type UID
30628
+ * @param {string} documentId - Document ID
30629
+ * @returns {object} Unsubscription result
30630
+ */
30631
+ unsubscribe(socketId, uid, documentId) {
30632
+ const entityKey = getEntityKey(uid, documentId);
30633
+ const subscribers = previewSubscribers.get(entityKey);
30634
+ if (subscribers) {
30635
+ subscribers.delete(socketId);
30636
+ if (subscribers.size === 0) {
30637
+ previewSubscribers.delete(entityKey);
30638
+ }
30639
+ }
30640
+ const io2 = strapi2.$io?.server;
30641
+ const socket = io2?.sockets.sockets.get(socketId);
30642
+ if (socket) {
30643
+ socket.leave(`preview:${entityKey}`);
30644
+ }
30645
+ const state = socketState.get(socketId);
30646
+ if (state?.debounceTimers.has(entityKey)) {
30647
+ clearTimeout(state.debounceTimers.get(entityKey));
30648
+ state.debounceTimers.delete(entityKey);
30649
+ }
30650
+ strapi2.log.debug(`socket.io: Socket ${socketId} unsubscribed from preview for ${entityKey}`);
30651
+ return { success: true, entityKey };
30652
+ },
30653
+ /**
30654
+ * Cleans up all subscriptions for a socket
30655
+ * @param {string} socketId - Socket ID
30656
+ */
30657
+ cleanupSocket(socketId) {
30658
+ for (const [entityKey, subscribers] of previewSubscribers) {
30659
+ if (subscribers.has(socketId)) {
30660
+ subscribers.delete(socketId);
30661
+ if (subscribers.size === 0) {
30662
+ previewSubscribers.delete(entityKey);
30663
+ }
30664
+ }
30665
+ }
30666
+ const state = socketState.get(socketId);
30667
+ if (state) {
30668
+ for (const timerId of state.debounceTimers.values()) {
30669
+ clearTimeout(timerId);
30670
+ }
30671
+ socketState.delete(socketId);
30672
+ }
30673
+ },
30674
+ /**
30675
+ * Emits a draft change event to preview subscribers
30676
+ * @param {string} uid - Content type UID
30677
+ * @param {string} documentId - Document ID
30678
+ * @param {object} data - Changed data
30679
+ * @param {object} diff - Field-level diff (optional)
30680
+ */
30681
+ emitDraftChange(uid, documentId, data, diff2 = null) {
30682
+ const settings2 = getPreviewSettings();
30683
+ if (!settings2.enabled || !settings2.draftEvents) return;
30684
+ emitToSubscribers(uid, documentId, "preview:change", {
30685
+ data,
30686
+ diff: diff2,
30687
+ isDraft: true
30688
+ });
30689
+ },
30690
+ /**
30691
+ * Emits a debounced field change event
30692
+ * @param {string} socketId - Socket ID of the editor
30693
+ * @param {string} uid - Content type UID
30694
+ * @param {string} documentId - Document ID
30695
+ * @param {string} fieldName - Name of changed field
30696
+ * @param {*} value - New field value
30697
+ */
30698
+ emitFieldChange(socketId, uid, documentId, fieldName, value) {
30699
+ const settings2 = getPreviewSettings();
30700
+ if (!settings2.enabled) return;
30701
+ const entityKey = getEntityKey(uid, documentId);
30702
+ const state = socketState.get(socketId);
30703
+ if (state?.debounceTimers.has(entityKey)) {
30704
+ clearTimeout(state.debounceTimers.get(entityKey));
30705
+ }
30706
+ const timerId = setTimeout(() => {
30707
+ emitToSubscribers(uid, documentId, "preview:field", {
30708
+ fieldName,
30709
+ value,
30710
+ editorSocketId: socketId
30711
+ });
30712
+ state?.debounceTimers.delete(entityKey);
30713
+ }, settings2.debounceMs);
30714
+ if (state) {
30715
+ state.debounceTimers.set(entityKey, timerId);
30716
+ }
30717
+ },
30718
+ /**
30719
+ * Emits publish event to preview subscribers
30720
+ * @param {string} uid - Content type UID
30721
+ * @param {string} documentId - Document ID
30722
+ * @param {object} data - Published data
30723
+ */
30724
+ emitPublish(uid, documentId, data) {
30725
+ emitToSubscribers(uid, documentId, "preview:publish", {
30726
+ data,
30727
+ isDraft: false
30728
+ });
30729
+ },
30730
+ /**
30731
+ * Emits unpublish event to preview subscribers
30732
+ * @param {string} uid - Content type UID
30733
+ * @param {string} documentId - Document ID
30734
+ */
30735
+ emitUnpublish(uid, documentId) {
30736
+ emitToSubscribers(uid, documentId, "preview:unpublish", {
30737
+ isDraft: true
30738
+ });
30739
+ },
30740
+ /**
30741
+ * Gets the number of preview subscribers for an entity
30742
+ * @param {string} uid - Content type UID
30743
+ * @param {string} documentId - Document ID
30744
+ * @returns {number} Subscriber count
30745
+ */
30746
+ getSubscriberCount(uid, documentId) {
30747
+ const entityKey = getEntityKey(uid, documentId);
30748
+ return previewSubscribers.get(entityKey)?.size || 0;
30749
+ },
30750
+ /**
30751
+ * Gets all entities with active preview subscribers
30752
+ * @returns {Array} List of entity keys with subscriber counts
30753
+ */
30754
+ getActivePreviewEntities() {
30755
+ const entities = [];
30756
+ for (const [entityKey, subscribers] of previewSubscribers) {
30757
+ const [uid, documentId] = entityKey.split(":");
30758
+ entities.push({
30759
+ uid,
30760
+ documentId,
30761
+ entityKey,
30762
+ subscriberCount: subscribers.size
30763
+ });
30764
+ }
30765
+ return entities;
30766
+ },
30767
+ /**
30768
+ * Checks if live preview is enabled
30769
+ * @returns {boolean} True if enabled
30770
+ */
30771
+ isEnabled() {
30772
+ return getPreviewSettings().enabled;
30773
+ },
30774
+ /**
30775
+ * Gets preview statistics
30776
+ * @returns {object} Preview stats
30777
+ */
30778
+ getStats() {
30779
+ let totalSubscriptions = 0;
30780
+ for (const subscribers of previewSubscribers.values()) {
30781
+ totalSubscriptions += subscribers.size;
30782
+ }
30783
+ return {
30784
+ totalEntitiesWithSubscribers: previewSubscribers.size,
30785
+ totalSubscriptions,
30786
+ entities: this.getActivePreviewEntities()
30787
+ };
30788
+ }
30789
+ };
30790
+ };
30791
+ var diff$1 = ({ strapi: strapi2 }) => {
30792
+ const getDiffSettings = () => {
30793
+ const settings2 = strapi2.$ioSettings || {};
30794
+ return {
30795
+ enabled: settings2.fieldLevelChanges?.enabled ?? true,
30796
+ includeFullData: settings2.fieldLevelChanges?.includeFullData ?? false,
30797
+ excludeFields: settings2.fieldLevelChanges?.excludeFields ?? ["updatedAt", "updatedBy", "createdAt", "createdBy"],
30798
+ maxDiffDepth: settings2.fieldLevelChanges?.maxDiffDepth ?? 3
30799
+ };
30800
+ };
30801
+ const isPlainObject2 = (value) => {
30802
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
30803
+ };
30804
+ const isEqual2 = (a, b) => {
30805
+ if (a === b) return true;
30806
+ if (a === null || b === null) return a === b;
30807
+ if (typeof a !== typeof b) return false;
30808
+ if (a instanceof Date && b instanceof Date) {
30809
+ return a.getTime() === b.getTime();
30810
+ }
30811
+ if (Array.isArray(a) && Array.isArray(b)) {
30812
+ if (a.length !== b.length) return false;
30813
+ return a.every((item, index2) => isEqual2(item, b[index2]));
30814
+ }
30815
+ if (isPlainObject2(a) && isPlainObject2(b)) {
30816
+ const keysA = Object.keys(a);
30817
+ const keysB = Object.keys(b);
30818
+ if (keysA.length !== keysB.length) return false;
30819
+ return keysA.every((key) => isEqual2(a[key], b[key]));
30820
+ }
30821
+ return false;
30822
+ };
30823
+ const safeClone = (value) => {
30824
+ if (value === null || value === void 0) return value;
30825
+ if (value instanceof Date) return value.toISOString();
30826
+ if (Array.isArray(value)) return value.map(safeClone);
30827
+ if (isPlainObject2(value)) {
30828
+ const cloned = {};
30829
+ for (const [key, val] of Object.entries(value)) {
30830
+ cloned[key] = safeClone(val);
30831
+ }
30832
+ return cloned;
30833
+ }
30834
+ return value;
30835
+ };
30836
+ const calculateDiffInternal = (oldData, newData, options = {}, depth2 = 0) => {
30837
+ const { excludeFields = [], maxDiffDepth = 3 } = options;
30838
+ const diff2 = {};
30839
+ if (!oldData || !newData) {
30840
+ return { _replaced: true, old: safeClone(oldData), new: safeClone(newData) };
30841
+ }
30842
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldData || {}), ...Object.keys(newData || {})]);
30843
+ for (const key of allKeys) {
30844
+ if (excludeFields.includes(key)) continue;
30845
+ const oldValue = oldData?.[key];
30846
+ const newValue = newData?.[key];
30847
+ if (isEqual2(oldValue, newValue)) continue;
30848
+ if (isPlainObject2(oldValue) && isPlainObject2(newValue) && depth2 < maxDiffDepth) {
30849
+ const nestedDiff = calculateDiffInternal(oldValue, newValue, options, depth2 + 1);
30850
+ if (Object.keys(nestedDiff).length > 0) {
30851
+ diff2[key] = nestedDiff;
30852
+ }
30853
+ } else {
30854
+ diff2[key] = {
30855
+ old: safeClone(oldValue),
30856
+ new: safeClone(newValue)
30857
+ };
30858
+ }
30859
+ }
30860
+ return diff2;
30861
+ };
30862
+ return {
30863
+ /**
30864
+ * Calculates field-level diff between old and new data
30865
+ * @param {object} oldData - Previous data state
30866
+ * @param {object} newData - New data state
30867
+ * @returns {object} Diff result with changed fields and metadata
30868
+ */
30869
+ calculateDiff(oldData, newData) {
30870
+ const settings2 = getDiffSettings();
30871
+ if (!settings2.enabled) {
30872
+ return {
30873
+ enabled: false,
30874
+ hasChanges: !isEqual2(oldData, newData),
30875
+ diff: null,
30876
+ fullData: newData
30877
+ };
30878
+ }
30879
+ const diff2 = calculateDiffInternal(oldData, newData, {
30880
+ excludeFields: settings2.excludeFields,
30881
+ maxDiffDepth: settings2.maxDiffDepth
30882
+ });
30883
+ const changedFields = Object.keys(diff2);
30884
+ const hasChanges = changedFields.length > 0;
30885
+ const result = {
30886
+ enabled: true,
30887
+ hasChanges,
30888
+ changedFields,
30889
+ changedFieldCount: changedFields.length,
30890
+ diff: hasChanges ? diff2 : null,
30891
+ timestamp: Date.now()
30892
+ };
30893
+ if (settings2.includeFullData) {
30894
+ result.fullData = newData;
30895
+ }
30896
+ return result;
30897
+ },
30898
+ /**
30899
+ * Applies a diff to a target object
30900
+ * @param {object} target - Target object to apply diff to
30901
+ * @param {object} diff - Diff to apply
30902
+ * @returns {object} Updated target object
30903
+ */
30904
+ applyDiff(target, diff2) {
30905
+ if (!diff2 || typeof diff2 !== "object") return target;
30906
+ const result = { ...target };
30907
+ for (const [key, change] of Object.entries(diff2)) {
30908
+ if (change._replaced) {
30909
+ result[key] = change.new;
30910
+ } else if (change.old !== void 0 && change.new !== void 0) {
30911
+ result[key] = change.new;
30912
+ } else if (isPlainObject2(change)) {
30913
+ result[key] = this.applyDiff(result[key] || {}, change);
30914
+ }
30915
+ }
30916
+ return result;
30917
+ },
30918
+ /**
30919
+ * Validates if a diff is applicable to a content type
30920
+ * @param {string} uid - Content type UID
30921
+ * @param {object} diff - Diff to validate
30922
+ * @returns {object} Validation result
30923
+ */
30924
+ validateDiff(uid, diff2) {
30925
+ if (!diff2) {
30926
+ return { valid: true, errors: [] };
30927
+ }
30928
+ const contentType = strapi2.contentTypes[uid];
30929
+ if (!contentType) {
30930
+ return { valid: false, errors: [`Content type ${uid} not found`] };
30931
+ }
30932
+ const errors2 = [];
30933
+ const attributes = contentType.attributes || {};
30934
+ for (const field of Object.keys(diff2)) {
30935
+ if (!attributes[field] && field !== "id" && field !== "documentId") {
30936
+ errors2.push(`Field '${field}' does not exist in ${uid}`);
30937
+ }
30938
+ }
30939
+ return {
30940
+ valid: errors2.length === 0,
30941
+ errors: errors2
30942
+ };
30943
+ },
30944
+ /**
30945
+ * Creates an event payload with diff information
30946
+ * @param {string} eventType - Event type (create, update, delete)
30947
+ * @param {object} schema - Content type schema info
30948
+ * @param {object} oldData - Previous data (null for create)
30949
+ * @param {object} newData - New data (null for delete)
30950
+ * @returns {object} Event payload with diff
30951
+ */
30952
+ createEventPayload(eventType, schema2, oldData, newData) {
30953
+ const settings2 = getDiffSettings();
30954
+ if (eventType === "create") {
30955
+ return {
30956
+ event: eventType,
30957
+ schema: { singularName: schema2.singularName, uid: schema2.uid },
30958
+ data: newData,
30959
+ diff: null,
30960
+ timestamp: Date.now()
30961
+ };
30962
+ }
30963
+ if (eventType === "delete") {
30964
+ return {
30965
+ event: eventType,
30966
+ schema: { singularName: schema2.singularName, uid: schema2.uid },
30967
+ data: { id: oldData?.id, documentId: oldData?.documentId },
30968
+ deletedData: settings2.includeFullData ? oldData : null,
30969
+ diff: null,
30970
+ timestamp: Date.now()
30971
+ };
30972
+ }
30973
+ const diffResult = this.calculateDiff(oldData, newData);
30974
+ const payload = {
30975
+ event: eventType,
30976
+ schema: { singularName: schema2.singularName, uid: schema2.uid },
30977
+ documentId: newData?.documentId || newData?.id,
30978
+ diff: diffResult.diff,
30979
+ changedFields: diffResult.changedFields,
30980
+ hasChanges: diffResult.hasChanges,
30981
+ timestamp: Date.now()
30982
+ };
30983
+ if (settings2.includeFullData || !settings2.enabled) {
30984
+ payload.data = newData;
30985
+ }
30986
+ return payload;
30987
+ },
30988
+ /**
30989
+ * Checks if diff feature is enabled
30990
+ * @returns {boolean} True if enabled
30991
+ */
30992
+ isEnabled() {
30993
+ return getDiffSettings().enabled;
30994
+ },
30995
+ /**
30996
+ * Gets current diff settings
30997
+ * @returns {object} Current settings
30998
+ */
30999
+ getSettings() {
31000
+ return getDiffSettings();
31001
+ }
31002
+ };
31003
+ };
29931
31004
  const strategy = strategies;
29932
31005
  const sanitize = sanitize_1;
29933
31006
  const transform = transform$1;
29934
31007
  const settings = settings$1;
29935
31008
  const monitoring = monitoring$1;
31009
+ const presence = presence$1;
31010
+ const preview = preview$1;
31011
+ const diff = diff$1;
29936
31012
  var services$1 = {
29937
31013
  sanitize,
29938
31014
  strategy,
29939
31015
  transform,
29940
31016
  settings,
29941
- monitoring
31017
+ monitoring,
31018
+ presence,
31019
+ preview,
31020
+ diff
29942
31021
  };
29943
31022
  const bootstrap = bootstrap_1;
29944
31023
  const config = config$1;