IO-2935-Add-Enhanced-Websocket-Provider - PR notes, Admin UI, Expanded Join/Leave room functionality, moved test button to the client id (Imex / Rome) in footer, so it is still there, just invisible.

Signed-off-by: Dave Richer <dave@imexsystems.ca>
This commit is contained in:
Dave Richer
2024-09-24 19:59:29 -04:00
parent c163554c3f
commit f5f0b75617
8 changed files with 257 additions and 169 deletions

View File

@@ -9,7 +9,7 @@
"version": "0.2.1",
"hasInstallScript": true,
"dependencies": {
"@ant-design/pro-layout": "^7.20.2",
"@ant-design/pro-layout": "^7.19.12",
"@apollo/client": "^3.11.8",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.5.0",
@@ -20,7 +20,7 @@
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.51",
"@vitejs/plugin-react": "^4.3.1",
"antd": "^5.21.0",
"antd": "^5.20.1",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^3.3.0",
"autosize": "^6.0.1",

View File

@@ -8,7 +8,7 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@ant-design/pro-layout": "^7.20.2",
"@ant-design/pro-layout": "^7.19.12",
"@apollo/client": "^3.11.8",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.5.0",
@@ -19,7 +19,7 @@
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.51",
"@vitejs/plugin-react": "^4.3.1",
"antd": "^5.21.0",
"antd": "^5.20.1",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^3.3.0",
"autosize": "^6.0.1",

View File

@@ -604,7 +604,7 @@ export function Manage({ conflict, bodyshop }) {
}}
>
<div style={{ display: "flex" }}>
<div>
<div onClick={broadcastMessage}>
{`${InstanceRenderManager({
imex: t("titles.imexonline"),
rome: t("titles.romeonline"),
@@ -613,8 +613,6 @@ export function Manage({ conflict, bodyshop }) {
</div>
<div id="noticeable-widget" style={{ marginLeft: "1rem" }} />
</div>
<button onClick={broadcastMessage}>Broadcast Message</button>
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>

27
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@aws-sdk/client-ses": "^3.654.0",
"@aws-sdk/credential-provider-node": "^3.654.0",
"@opensearch-project/opensearch": "^2.12.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"aws4": "^1.13.2",
"axios": "^1.7.7",
@@ -2395,6 +2396,20 @@
"node": ">=16.0.0"
}
},
"node_modules/@socket.io/admin-ui": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@socket.io/admin-ui/-/admin-ui-0.5.1.tgz",
"integrity": "sha512-1dlGL2FGm6T+uL1e6iDvbo2eCINwvW7iVbjIblwh5kPPRM1SP8lmZrbFZf4QNJ/cqQ+JLcx49eXGM9WAB4TK7w==",
"license": "MIT",
"dependencies": {
"@types/bcryptjs": "^2.4.2",
"bcryptjs": "^2.4.3",
"debug": "~4.3.1"
},
"peerDependencies": {
"socket.io": ">=3.1.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
@@ -2448,6 +2463,12 @@
}
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@@ -2870,6 +2891,12 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/better-queue": {
"version": "3.8.12",
"resolved": "https://registry.npmjs.org/better-queue/-/better-queue-3.8.12.tgz",

View File

@@ -23,6 +23,7 @@
"@aws-sdk/client-ses": "^3.654.0",
"@aws-sdk/credential-provider-node": "^3.654.0",
"@opensearch-project/opensearch": "^2.12.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"aws4": "^1.13.2",
"axios": "^1.7.7",

182
server.js
View File

@@ -10,6 +10,9 @@ const { createClient } = require("redis");
const { createAdapter } = require("@socket.io/redis-adapter");
const logger = require("./server/utils/logger");
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
const { instrument, RedisStore } = require("@socket.io/admin-ui");
const { isString, isEmpty } = require("lodash");
const applyRedisHelpers = require("./server/utils/redisHelpers");
// Load environment variables
require("dotenv").config({
@@ -41,7 +44,9 @@ const SOCKETIO_CORS_ORIGIN = [
"https://www.promanager.web-est.com",
"https://www.promanager.web-est.com",
"https://old.imex.online",
"https://www.old.imex.online"
"https://www.old.imex.online",
"https://wsadmin.imex.online",
"https://www.wsadmin.imex.online"
];
/**
@@ -129,6 +134,19 @@ const applySocketIO = async (server, app) => {
}
});
if (isString(process.env.REDIS_ADMIN_PASS) && !isEmpty(process.env.REDIS_ADMIN_PASS)) {
logger.log(`[${process.env.NODE_ENV}] Initializing Redis Admin UI....`, "INFO", "redis", "api");
instrument(ioRedis, {
auth: {
type: "basic",
username: "admin",
password: process.env.REDIS_ADMIN_PASS
},
store: new RedisStore(pubClient),
mode: process.env.REDIS_ADMIN_MODE || "development"
});
}
const io = new Server(server, {
path: "/ws",
cors: {
@@ -154,164 +172,6 @@ const applySocketIO = async (server, app) => {
return { pubClient, io, ioRedis };
};
/**
* Apply Redis helper functions
* @param pubClient
* @param app
*/
const applyRedisHelpers = (pubClient, app) => {
// Store session data in Redis
const setSessionData = async (socketId, key, value) => {
await pubClient.hSet(`socket:${socketId}`, key, JSON.stringify(value)); // Use Redis pubClient
};
// Retrieve session data from Redis
const getSessionData = async (socketId, key) => {
const data = await pubClient.hGet(`socket:${socketId}`, key);
return data ? JSON.parse(data) : null;
};
// Clear session data from Redis
const clearSessionData = async (socketId) => {
await pubClient.del(`socket:${socketId}`);
};
// Store multiple session data in Redis
const setMultipleSessionData = async (socketId, keyValues) => {
// keyValues is expected to be an object { key1: value1, key2: value2, ... }
const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]);
await pubClient.hSet(`socket:${socketId}`, ...entries.flat());
};
// Retrieve multiple session data from Redis
const getMultipleSessionData = async (socketId, keys) => {
const data = await pubClient.hmGet(`socket:${socketId}`, keys);
// Redis returns an object with null values for missing keys, so we parse the non-null ones
return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null]));
};
const setMultipleFromArraySessionData = async (socketId, keyValueArray) => {
// Use Redis multi/pipeline to batch the commands
const multi = pubClient.multi();
keyValueArray.forEach(([key, value]) => {
multi.hSet(`socket:${socketId}`, key, JSON.stringify(value));
});
await multi.exec(); // Execute all queued commands
};
// Helper function to add an item to the end of the Redis list
const addItemToEndOfList = async (socketId, key, newItem) => {
try {
await pubClient.rPush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) {
console.error(`Error adding item to the end of the list for socket ${socketId}:`, error);
}
};
// Helper function to add an item to the beginning of the Redis list
const addItemToBeginningOfList = async (socketId, key, newItem) => {
try {
await pubClient.lPush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) {
console.error(`Error adding item to the beginning of the list for socket ${socketId}:`, error);
}
};
// Helper function to clear a list in Redis
const clearList = async (socketId, key) => {
try {
await pubClient.del(`socket:${socketId}:${key}`);
} catch (error) {
console.error(`Error clearing list for socket ${socketId}:`, error);
}
};
const api = {
setSessionData,
getSessionData,
clearSessionData,
setMultipleSessionData,
getMultipleSessionData,
setMultipleFromArraySessionData,
addItemToEndOfList,
addItemToBeginningOfList,
clearList
};
Object.assign(module.exports, api);
app.use((req, res, next) => {
req.sessionUtils = api;
next();
});
// // Demo to show how all the helper functions work
// const demoSessionData = async () => {
// const socketId = "testSocketId";
//
// // Store session data using setSessionData
// await exports.setSessionData(socketId, "field1", "Hello, Redis!");
//
// // Retrieve session data using getSessionData
// const field1Value = await exports.getSessionData(socketId, "field1");
// console.log("Retrieved single field value:", field1Value);
//
// // Store multiple session data using setMultipleSessionData
// await exports.setMultipleSessionData(socketId, { field2: "Second Value", field3: "Third Value" });
//
// // Retrieve multiple session data using getMultipleSessionData
// const multipleFields = await exports.getMultipleSessionData(socketId, ["field2", "field3"]);
// console.log("Retrieved multiple field values:", multipleFields);
//
// // Store multiple session data using setMultipleFromArraySessionData
// await exports.setMultipleFromArraySessionData(socketId, [
// ["field4", "Fourth Value"],
// ["field5", "Fifth Value"]
// ]);
//
// // Retrieve and log all fields
// const allFields = await exports.getMultipleSessionData(socketId, [
// "field1",
// "field2",
// "field3",
// "field4",
// "field5"
// ]);
// console.log("Retrieved all field values:", allFields);
//
// // Add item to the end of a Redis list
// await exports.addItemToEndOfList(socketId, "logEvents", { event: "Log Event 1", timestamp: new Date() });
// await exports.addItemToEndOfList(socketId, "logEvents", { event: "Log Event 2", timestamp: new Date() });
//
// // Add item to the beginning of a Redis list
// await exports.addItemToBeginningOfList(socketId, "logEvents", { event: "First Log Event", timestamp: new Date() });
//
// // Retrieve the entire list (using lRange)
// const logEvents = await pubClient.lRange(`socket:${socketId}:logEvents`, 0, -1);
// console.log("Log Events List:", logEvents.map(JSON.parse));
//
// // **Add the new code below to test clearList**
//
// // Clear the list using clearList
// await exports.clearList(socketId, "logEvents");
// console.log("Log Events List cleared.");
//
// // Retrieve the list after clearing to confirm it's empty
// const logEventsAfterClear = await pubClient.lRange(`socket:${socketId}:logEvents`, 0, -1);
// console.log("Log Events List after clearing:", logEventsAfterClear); // Should be an empty array
//
// // Clear session data
// await exports.clearSessionData(socketId);
// console.log("Session data cleared.");
// };
//
// if (process.env.NODE_ENV === "development") {
// demoSessionData();
// }
};
/**
* Main function to start the server
* @returns {Promise<void>}
@@ -323,14 +183,14 @@ const main = async () => {
const server = http.createServer(app);
const { pubClient, ioRedis } = await applySocketIO(server, app);
applyRedisHelpers(pubClient, app);
const api = applyRedisHelpers(pubClient, app);
// Legacy Socket Events
require("./server/web-sockets/web-socket");
applyMiddleware(app);
applyRoutes(app);
redisSocketEvents(ioRedis);
redisSocketEvents(ioRedis, api);
try {
await server.listen(port);

View File

@@ -0,0 +1,177 @@
/**
* Apply Redis helper functions
* @param pubClient
* @param app
*/
const applyRedisHelpers = (pubClient, app) => {
// Store session data in Redis
const setSessionData = async (socketId, key, value) => {
await pubClient.hSet(`socket:${socketId}`, key, JSON.stringify(value)); // Use Redis pubClient
};
// Retrieve session data from Redis
const getSessionData = async (socketId, key) => {
const data = await pubClient.hGet(`socket:${socketId}`, key);
return data ? JSON.parse(data) : null;
};
// Clear session data from Redis
const clearSessionData = async (socketId) => {
await pubClient.del(`socket:${socketId}`);
};
// Store multiple session data in Redis
const setMultipleSessionData = async (socketId, keyValues) => {
// keyValues is expected to be an object { key1: value1, key2: value2, ... }
const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]);
await pubClient.hSet(`socket:${socketId}`, ...entries.flat());
};
// Retrieve multiple session data from Redis
const getMultipleSessionData = async (socketId, keys) => {
const data = await pubClient.hmGet(`socket:${socketId}`, keys);
// Redis returns an object with null values for missing keys, so we parse the non-null ones
return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null]));
};
const setMultipleFromArraySessionData = async (socketId, keyValueArray) => {
// Use Redis multi/pipeline to batch the commands
const multi = pubClient.multi();
keyValueArray.forEach(([key, value]) => {
multi.hSet(`socket:${socketId}`, key, JSON.stringify(value));
});
await multi.exec(); // Execute all queued commands
};
// Helper function to add an item to the end of the Redis list
const addItemToEndOfList = async (socketId, key, newItem) => {
try {
await pubClient.rPush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) {
console.error(`Error adding item to the end of the list for socket ${socketId}:`, error);
}
};
// Helper function to add an item to the beginning of the Redis list
const addItemToBeginningOfList = async (socketId, key, newItem) => {
try {
await pubClient.lPush(`socket:${socketId}:${key}`, JSON.stringify(newItem));
} catch (error) {
console.error(`Error adding item to the beginning of the list for socket ${socketId}:`, error);
}
};
// Helper function to clear a list in Redis
const clearList = async (socketId, key) => {
try {
await pubClient.del(`socket:${socketId}:${key}`);
} catch (error) {
console.error(`Error clearing list for socket ${socketId}:`, error);
}
};
// Add methods to manage room users
const addUserToRoom = async (bodyshopUUID, user) => {
await pubClient.sAdd(`bodyshopRoom:${bodyshopUUID}`, JSON.stringify(user));
};
const removeUserFromRoom = async (bodyshopUUID, user) => {
await pubClient.sRem(`bodyshopRoom:${bodyshopUUID}`, JSON.stringify(user));
};
const getUsersInRoom = async (bodyshopUUID) => {
const users = await pubClient.sMembers(`bodyshopRoom:${bodyshopUUID}`);
return users.map((user) => JSON.parse(user));
};
const api = {
setSessionData,
getSessionData,
clearSessionData,
setMultipleSessionData,
getMultipleSessionData,
setMultipleFromArraySessionData,
addItemToEndOfList,
addItemToBeginningOfList,
clearList,
addUserToRoom,
removeUserFromRoom,
getUsersInRoom
};
Object.assign(module.exports, api);
app.use((req, res, next) => {
req.sessionUtils = api;
next();
});
// // Demo to show how all the helper functions work
// const demoSessionData = async () => {
// const socketId = "testSocketId";
//
// // Store session data using setSessionData
// await exports.setSessionData(socketId, "field1", "Hello, Redis!");
//
// // Retrieve session data using getSessionData
// const field1Value = await exports.getSessionData(socketId, "field1");
// console.log("Retrieved single field value:", field1Value);
//
// // Store multiple session data using setMultipleSessionData
// await exports.setMultipleSessionData(socketId, { field2: "Second Value", field3: "Third Value" });
//
// // Retrieve multiple session data using getMultipleSessionData
// const multipleFields = await exports.getMultipleSessionData(socketId, ["field2", "field3"]);
// console.log("Retrieved multiple field values:", multipleFields);
//
// // Store multiple session data using setMultipleFromArraySessionData
// await exports.setMultipleFromArraySessionData(socketId, [
// ["field4", "Fourth Value"],
// ["field5", "Fifth Value"]
// ]);
//
// // Retrieve and log all fields
// const allFields = await exports.getMultipleSessionData(socketId, [
// "field1",
// "field2",
// "field3",
// "field4",
// "field5"
// ]);
// console.log("Retrieved all field values:", allFields);
//
// // Add item to the end of a Redis list
// await exports.addItemToEndOfList(socketId, "logEvents", { event: "Log Event 1", timestamp: new Date() });
// await exports.addItemToEndOfList(socketId, "logEvents", { event: "Log Event 2", timestamp: new Date() });
//
// // Add item to the beginning of a Redis list
// await exports.addItemToBeginningOfList(socketId, "logEvents", { event: "First Log Event", timestamp: new Date() });
//
// // Retrieve the entire list (using lRange)
// const logEvents = await pubClient.lRange(`socket:${socketId}:logEvents`, 0, -1);
// console.log("Log Events List:", logEvents.map(JSON.parse));
//
// // **Add the new code below to test clearList**
//
// // Clear the list using clearList
// await exports.clearList(socketId, "logEvents");
// console.log("Log Events List cleared.");
//
// // Retrieve the list after clearing to confirm it's empty
// const logEventsAfterClear = await pubClient.lRange(`socket:${socketId}:logEvents`, 0, -1);
// console.log("Log Events List after clearing:", logEventsAfterClear); // Should be an empty array
//
// // Clear session data
// await exports.clearSessionData(socketId);
// console.log("Session data cleared.");
// };
//
// if (process.env.NODE_ENV === "development") {
// demoSessionData();
// }
return api;
};
module.exports = applyRedisHelpers;

View File

@@ -11,12 +11,17 @@ function createLogEvent(socket, level, message) {
logger.log("ioredis-log-event", level, socket.user.email, null, { wsmessage: message });
}
const redisSocketEvents = (io) => {
const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRoom }) => {
// Room management and broadcasting events
function registerRoomAndBroadcastEvents(socket) {
socket.on("join-bodyshop-room", async (bodyshopUUID) => {
socket.join(bodyshopUUID);
await addUserToRoom(bodyshopUUID, { uid: socket.user.uid, email: socket.user.email });
createLogEvent(socket, "DEBUG", `Client joined bodyshop room: ${bodyshopUUID}`);
// Notify all users in the room about the updated user list
const usersInRoom = await getUsersInRoom(bodyshopUUID);
io.to(bodyshopUUID).emit("room-users-updated", usersInRoom);
});
socket.on("leave-bodyshop-room", async (bodyshopUUID) => {
@@ -24,10 +29,30 @@ const redisSocketEvents = (io) => {
createLogEvent(socket, "DEBUG", `Client left bodyshop room: ${bodyshopUUID}`);
});
socket.on("get-room-users", async (bodyshopUUID, callback) => {
const usersInRoom = await getUsersInRoom(bodyshopUUID);
callback(usersInRoom);
});
socket.on("broadcast-to-bodyshop", async (bodyshopUUID, message) => {
io.to(bodyshopUUID).emit("bodyshop-message", message);
createLogEvent(socket, "INFO", `Broadcast message to bodyshop ${bodyshopUUID}`);
});
socket.on("disconnect", async () => {
createLogEvent(socket, "DEBUG", `User disconnected.`);
// Get all rooms the socket is part of
const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id);
for (const bodyshopUUID of rooms) {
await removeUserFromRoom(bodyshopUUID, { uid: socket.user.uid, email: socket.user.email });
// Notify all users in the room about the updated user list
const usersInRoom = await getUsersInRoom(bodyshopUUID);
io.to(bodyshopUUID).emit("room-users-updated", usersInRoom);
}
});
}
// Register all socket events for a given socket connection