@colyseus/loadtest
Version:
Utility tool for load testing Colyseus.
457 lines (454 loc) • 16.6 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var src_exports = {};
__export(src_exports, {
cli: () => cli
});
module.exports = __toCommonJS(src_exports);
var import_fs = __toESM(require("fs"), 1);
var import_path = __toESM(require("path"), 1);
var import_util = __toESM(require("util"), 1);
var import_blessed = __toESM(require("blessed"), 1);
var import_promises = __toESM(require("timers/promises"), 1);
var import_minimist = __toESM(require("minimist"), 1);
var import_colyseus = require("colyseus.js");
function cli(main) {
const logWriter = {
handle: null,
isClosing: false,
create(filepath) {
if (import_fs.default.existsSync(filepath)) {
const moveTo = `${import_path.default.basename(filepath)}.bkp`;
console.log(`Moving previous "${import_path.default.basename(filepath)}" file to "${moveTo}"`);
import_fs.default.renameSync(filepath, import_path.default.resolve(import_path.default.dirname(filepath), moveTo));
}
this.handle = import_fs.default.createWriteStream(filepath);
},
write(contents, close) {
if (!this.handle || this.isClosing) {
return;
}
if (close) {
this.isClosing = true;
}
return new Promise((resolve, reject) => {
const now = /* @__PURE__ */ new Date();
this.handle.write(`[${now.toLocaleString()}] ${contents}
`, (err) => {
if (err) {
return reject(err);
}
if (this.isClosing) {
this.handle.close();
}
resolve();
});
});
}
};
const argv = (0, import_minimist.default)(process.argv.slice(2));
const packageJson = { name: "@colyseus/loadtest", version: "0.15" };
function displayHelpAndExit() {
console.log(`${packageJson.name} v${packageJson.version}
Options:
--endpoint: WebSocket endpoint for all connections (default: ws://localhost:2567)
--room: room handler name (you can also use --roomId instead to join by id)
--roomId: room id (specify instead of --room)
[--numClients]: number of connections to open (default is 1)
[--delay]: delay to start each connection (in milliseconds)
[--project]: specify a tsconfig.json file path
[--reestablishAllDelay]: delay for closing and re-establishing all connections (in milliseconds)
[--retryFailed]: delay to retry failed connections (in milliseconds)
[--output]: specify an output file for output logs
Example:
colyseus-loadtest example/bot.ts --endpoint ws://localhost:2567 --room state_handler`);
process.exit();
}
if (argv.help) {
displayHelpAndExit();
}
const options = {
endpoint: argv.endpoint || `ws://localhost:2567`,
roomName: argv.room,
roomId: argv.roomId,
numClients: argv.numClients || 1,
delay: argv.delay || 0,
logLevel: argv.logLevel?.toLowerCase() || "all",
// TODO: not being used atm
reestablishAllDelay: argv.reestablishAllDelay || 0,
retryFailed: argv.retryFailed || 0,
output: argv.output && import_path.default.resolve(argv.output),
clientId: 0
};
if (!main) {
console.error("\u274C You must specify an entrypoint function.");
console.error("");
displayHelpAndExit();
}
const connections = [];
if (!options.roomName && !options.roomId) {
console.error("\u274C You need to specify a room with either one of the '--room' or '--roomId' options.");
console.error("");
displayHelpAndExit();
}
if (options.output) {
logWriter.create(options.output);
logWriter.write(`@colyseus/loadtest
${Object.keys(options).filter((key) => options[key]).map((key) => `${key}: ${options[key]}`).join("\n")}`);
}
const screen = import_blessed.default.screen({ smartCSR: true });
const headerBox = import_blessed.default.box({
label: ` \u2694 ${packageJson.name} ${packageJson.version} \u2694 `,
top: 0,
left: 0,
width: "70%",
height: "shrink",
children: [
import_blessed.default.text({ top: 1, left: 1, tags: true, content: `{yellow-fg}endpoint:{/yellow-fg} ${options.endpoint}` }),
import_blessed.default.text({ top: 2, left: 1, tags: true, content: `{yellow-fg}room:{/yellow-fg} ${options.roomName ?? options.roomId}` }),
import_blessed.default.text({ top: 3, left: 1, tags: true, content: `{yellow-fg}serialization method:{/yellow-fg} ...` }),
import_blessed.default.text({ top: 4, left: 1, tags: true, content: `{yellow-fg}time elapsed:{/yellow-fg} ...` })
],
border: { type: "line" },
style: {
label: { fg: "cyan" },
border: { fg: "green" }
}
});
const currentStats = {
connected: 0,
disconnected: 0,
failed: 0
};
const totalStats = {
connected: 0,
disconnected: 0,
failed: 0,
errors: 0
};
const successfulConnectionBox = import_blessed.default.text({ top: 2, left: 1, tags: true, content: `{yellow-fg}connected:{/yellow-fg} ${currentStats.connected}` });
const disconnectedClientsBox = import_blessed.default.text({ top: 3, left: 1, tags: true, content: `{yellow-fg}disconnected:{/yellow-fg} ${currentStats.disconnected}` });
const failedConnectionBox = import_blessed.default.text({ top: 4, left: 1, tags: true, content: `{yellow-fg}failed:{/yellow-fg} ${currentStats.failed}` });
const clientsBox = import_blessed.default.box({
label: " clients ",
left: "70%",
width: "30%",
height: "shrink",
children: [
import_blessed.default.text({ top: 1, left: 1, tags: true, content: `{yellow-fg}numClients:{/yellow-fg} ${options.numClients}` }),
successfulConnectionBox,
disconnectedClientsBox,
failedConnectionBox
],
border: { type: "line" },
tags: true,
style: {
label: { fg: "cyan" },
border: { fg: "green" }
}
});
const processingBox = import_blessed.default.box({
label: " processing ",
top: 7,
left: "70%",
width: "30%",
height: "shrink",
border: { type: "line" },
children: [
import_blessed.default.text({ top: 1, left: 1, tags: true, content: `{yellow-fg}memory:{/yellow-fg} ...` }),
import_blessed.default.text({ top: 2, left: 1, tags: true, content: `{yellow-fg}cpu:{/yellow-fg} ...` })
// blessed.text({ top: 1, left: 1, content: `memory: ${process.memoryUsage().heapUsed} / ${process.memoryUsage().heapTotal}` })
],
tags: true,
style: {
label: { fg: "cyan" },
border: { fg: "green" }
}
});
const networkingBox = import_blessed.default.box({
label: " networking ",
top: 12,
left: "70%",
width: "30%",
border: { type: "line" },
children: [
import_blessed.default.text({ top: 1, left: 1, tags: true, content: `{yellow-fg}bytes received:{/yellow-fg} ...` }),
import_blessed.default.text({ top: 2, left: 1, tags: true, content: `{yellow-fg}bytes sent:{/yellow-fg} ...` })
// blessed.text({ top: 1, left: 1, content: `memory: ${process.memoryUsage().heapUsed} / ${process.memoryUsage().heapTotal}` })
],
tags: true,
style: {
label: { fg: "cyan" },
border: { fg: "green" }
}
});
const logBox = import_blessed.default.box({
label: " logs ",
top: 7,
width: "70%",
padding: 1,
border: { type: "line" },
tags: true,
style: {
label: { fg: "cyan" },
border: { fg: "green" }
},
// scroll
scrollable: true,
input: true,
alwaysScroll: true,
scrollbar: {
style: {
bg: "green"
},
track: {
bg: "gray"
}
},
keys: true,
vi: true,
mouse: true
});
screen.key(["escape", "q", "C-c"], (ch, key) => beforeExit("SIGINT"));
screen.title = "@colyseus/loadtest";
screen.append(headerBox);
screen.append(clientsBox);
screen.append(logBox);
screen.append(processingBox);
screen.append(networkingBox);
screen.render();
const debug = console.debug;
const log = console.log;
const warn = console.warn;
const info = console.info;
const error = console.error;
console.debug = function(...args) {
logBox.content = `{grey-fg}${args.map((arg) => import_util.default.inspect(arg)).join(" ")}{/grey-fg}
${logBox.content}`;
screen.render();
};
console.log = function(...args) {
logBox.content = args.map((arg) => import_util.default.inspect(arg)).join(" ") + "\n" + logBox.content;
screen.render();
};
console.warn = function(...args) {
logBox.content = `{yellow-fg}${args.map((arg) => import_util.default.inspect(arg)).join(" ")}{/yellow-fg}
${logBox.content}`;
screen.render();
};
console.info = function(...args) {
logBox.content = `{blue-fg}${args.map((arg) => import_util.default.inspect(arg)).join(" ")}{/blue-fg}
${logBox.content}`;
screen.render();
};
console.error = function(...args) {
totalStats.errors++;
logBox.content = `{red-fg}${args.map((arg) => import_util.default.inspect(arg)).join(" ")}{/red-fg}
${logBox.content}`;
screen.render();
};
process.on("uncaughtException", (e) => {
console.error(e);
});
let isExiting = false;
async function beforeExit(signal, closeCode = 0) {
log("Writing log file...");
if (isExiting) {
return;
} else {
isExiting = true;
}
const hasError = closeCode > 0;
await logWriter.write(
`Finished. Summary:
Successful connections: ${totalStats.connected}
Failed connections: ${totalStats.failed}
Total errors: ${totalStats.errors}
Logs:
${logBox.content}`,
true
/* closing */
);
process.exit(hasError ? 1 : 0);
}
process.once("exit", (code) => beforeExit("SIGINT", code));
["SIGINT", "SIGTERM", "SIGUSR2"].forEach((signal) => process.once(signal, (signal2) => beforeExit(signal2)));
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} b`;
} else if (bytes < Math.pow(1024, 2)) {
return `${(bytes / 1024).toFixed(2)} kb`;
} else if (bytes < Math.pow(1024, 4)) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
}
function elapsedTime(inputSeconds) {
const days = Math.floor(inputSeconds / (60 * 60 * 24));
const hours = Math.floor(inputSeconds % (60 * 60 * 24) / (60 * 60));
const minutes = Math.floor(inputSeconds % (60 * 60 * 24) % (60 * 60) / 60);
const seconds = Math.floor(inputSeconds % (60 * 60 * 24) % (60 * 60) % 60);
let ddhhmmss = "";
if (days > 0) {
ddhhmmss += days + " day ";
}
if (hours > 0) {
ddhhmmss += hours + " hour ";
}
if (minutes > 0) {
ddhhmmss += minutes + " minutes ";
}
if (seconds > 0) {
ddhhmmss += seconds + " seconds ";
}
return ddhhmmss || "...";
}
const loadTestStartTime = Date.now();
let startTime = process.hrtime();
let startUsage = process.cpuUsage();
let bytesReceived = 0;
let bytesSent = 0;
setInterval(() => {
const elapsedTimeText = headerBox.children[3];
elapsedTimeText.content = `{yellow-fg}time elapsed:{/yellow-fg} ${elapsedTime(Math.round((Date.now() - loadTestStartTime) / 1e3))}`;
const memoryText = processingBox.children[0];
memoryText.content = `{yellow-fg}memory:{/yellow-fg} ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`;
var elapTime = process.hrtime(startTime);
var elapUsage = process.cpuUsage(startUsage);
var elapTimeMS = elapTime[0] * 1e3 + elapTime[1] / 1e6;
var elapUserMS = elapUsage.user / 1e3;
var elapSystMS = elapUsage.system / 1e3;
var cpuPercent = (100 * (elapUserMS + elapSystMS) / elapTimeMS).toFixed(1);
const cpuText = processingBox.children[1];
cpuText.content = `{yellow-fg}cpu:{/yellow-fg} ${cpuPercent}%`;
screen.render();
startTime = process.hrtime();
startUsage = process.cpuUsage();
const bytesReceivedBox = networkingBox.children[0];
bytesReceivedBox.content = `{yellow-fg}bytes received:{/yellow-fg} ${formatBytes(bytesReceived)}`;
const bytesSentBox = networkingBox.children[1];
bytesSentBox.content = `{yellow-fg}bytes sent:{/yellow-fg} ${formatBytes(bytesSent)}`;
}, 1e3);
function handleError(message) {
if (message) {
console.error(message);
logWriter.write(message);
}
currentStats.failed++;
totalStats.failed++;
failedConnectionBox.content = `{red-fg}failed:{/red-fg} ${currentStats.failed}`;
screen.render();
}
async function connect(main2, i) {
try {
await main2({ ...options, clientId: i });
} catch (e) {
handleError(e);
}
}
async function connectAll(main2) {
for (let i = 0; i < options.numClients; i++) {
await connect(main2, i);
if (options.delay > 0) {
await import_promises.default.setTimeout(options.delay);
}
}
}
async function reestablishAll(scripting) {
connections.map((connection) => connection.connection.close());
connections.splice(0, connections.length);
connections.length = 0;
await connectAll(scripting);
}
const handleClientJoin = function(room) {
const serializerIdText = headerBox.children[2];
serializerIdText.content = `{yellow-fg}serialization method:{/yellow-fg} ${room.serializerId}`;
const ws = room.connection.transport.ws;
ws.addEventListener("message", (event) => {
bytesReceived += new Uint8Array(event.data).length;
});
const _send = ws.send;
ws.send = function(data) {
if (ws.readyState == 1) {
bytesSent += data.byteLength;
}
_send.call(ws, data);
};
currentStats.connected++;
totalStats.connected++;
successfulConnectionBox.content = `{yellow-fg}connected:{/yellow-fg} ${currentStats.connected}`;
screen.render();
room.onLeave(() => {
currentStats.disconnected++;
totalStats.disconnected++;
disconnectedClientsBox.content = `{yellow-fg}disconnected:{/yellow-fg} ${currentStats.disconnected}`;
screen.render();
});
connections.push(room);
};
const _originalJoinOrCreate = import_colyseus.Client.prototype.joinOrCreate;
import_colyseus.Client.prototype.joinOrCreate = async function() {
const room = await _originalJoinOrCreate.apply(this, arguments);
handleClientJoin(room);
return room;
};
const _originalCreate = import_colyseus.Client.prototype.create;
import_colyseus.Client.prototype.create = async function() {
const room = await _originalCreate.apply(this, arguments);
handleClientJoin(room);
return room;
};
const _originalJoin = import_colyseus.Client.prototype.join;
import_colyseus.Client.prototype.join = async function() {
const room = await _originalJoin.apply(this, arguments);
handleClientJoin(room);
return room;
};
const _originalJoinById = import_colyseus.Client.prototype.joinById;
import_colyseus.Client.prototype.joinById = async function() {
const room = await _originalJoinById.apply(this, arguments);
handleClientJoin(room);
return room;
};
try {
(async () => {
await connectAll(main);
if (options.reestablishAllDelay > 0) {
while (true) {
await import_promises.default.setTimeout(options.reestablishAllDelay);
await reestablishAll(main);
}
}
})();
} catch (e) {
error(e.stack);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
cli
});