@colyseus/tools 0.16.16 → 0.16.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/tools",
3
- "version": "0.16.16",
3
+ "version": "0.16.18",
4
4
  "description": "Simplify the development and production settings for your Colyseus project.",
5
5
  "input": "./src/index.ts",
6
6
  "main": "./build/index.js",
@@ -49,13 +49,15 @@
49
49
  "devDependencies": {
50
50
  "@types/cors": "^2.8.10",
51
51
  "@types/dotenv": "^8.2.0",
52
+ "pm2": "^6.0.14",
53
+ "mocha": "^11.7.5",
52
54
  "uwebsockets-express": "^1.1.10",
53
- "@colyseus/redis-presence": "^0.16.4",
54
55
  "@colyseus/redis-driver": "^0.16.1",
56
+ "@colyseus/ws-transport": "^0.16.5",
55
57
  "@colyseus/core": "^0.16.24",
56
- "@colyseus/bun-websockets": "^0.16.5",
57
58
  "@colyseus/uwebsockets-transport": "^0.16.10",
58
- "@colyseus/ws-transport": "^0.16.5"
59
+ "@colyseus/bun-websockets": "^0.16.5",
60
+ "@colyseus/redis-presence": "^0.16.4"
59
61
  },
60
62
  "peerDependencies": {
61
63
  "@colyseus/core": "0.16.x",
@@ -73,6 +75,7 @@
73
75
  }
74
76
  ],
75
77
  "scripts": {
76
- "start": "tsx example/app.ts"
78
+ "start": "tsx example/app.ts",
79
+ "test": "mocha --require tsx test/pm2-deployment.test.ts"
77
80
  }
78
81
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Monkey-patch for PM2's ActionMethods.js
3
+ * Adds custom methods like updateProcessConfig without modifying the original file.
4
+ */
5
+ 'use strict';
6
+
7
+ const Utility = require('../Utility');
8
+
9
+ // Import the original ActionMethods module
10
+ const originalActionMethods = require('./ActionMethods_orig.js');
11
+
12
+ module.exports = function(God) {
13
+ // Apply original ActionMethods
14
+ originalActionMethods(God);
15
+
16
+ /**
17
+ * Update process configuration without restart.
18
+ * Merges opts.env.current_conf into proc.pm2_env.
19
+ * @method updateProcessConfig
20
+ * @param {Object} opts - { id: pm_id, env: { current_conf: { ... } } }
21
+ * @param {Function} cb
22
+ */
23
+ God.updateProcessConfig = function updateProcessConfig(opts, cb) {
24
+ var id = opts.id;
25
+
26
+ if (typeof(id) === 'undefined')
27
+ return cb(God.logAndGenerateError('opts.id not passed to updateProcessConfig', opts));
28
+ if (!(id in God.clusters_db))
29
+ return cb(God.logAndGenerateError('Process id unknown'), {});
30
+
31
+ const proc = God.clusters_db[id];
32
+
33
+ if (!proc || !proc.pm2_env)
34
+ return cb(God.logAndGenerateError('Process not found or missing pm2_env'), {});
35
+
36
+ // Merge env into pm2_env.env
37
+ if (opts.env) {
38
+ Utility.extend(proc.pm2_env, opts.env);
39
+ Utility.extend(proc.pm2_env.env, opts.env); // why both?
40
+ }
41
+
42
+ // Merge current_conf directly into pm2_env
43
+ Utility.extendExtraConfig(proc, opts);
44
+
45
+ return cb(null, Utility.clone(proc.pm2_env));
46
+ };
47
+ };
@@ -14,8 +14,7 @@ const io = require('@pm2/io');
14
14
  const path = require('path');
15
15
  const shared = require('./shared');
16
16
 
17
- const opts = { env: process.env.NODE_ENV || "production", };
18
- let config = undefined;
17
+ let appConfig = undefined;
19
18
 
20
19
  io.initModule({
21
20
  pid: path.resolve('/var/run/colyseus-agent.pid'),
@@ -52,9 +51,11 @@ pm2.connect(function(err) {
52
51
  }
53
52
 
54
53
  try {
55
- config = await shared.getAppConfig(ecosystemFilePath);
56
- opts.cwd = cwd;
57
- postDeploy(cwd, onReply);
54
+ const config = await shared.getAppConfig(ecosystemFilePath);
55
+
56
+ appConfig = { ...config.apps[0], cwd };
57
+
58
+ postDeploy(appConfig, onReply);
58
59
 
59
60
  } catch (err) {
60
61
  onReply({ success: false, message: err?.message });
@@ -64,7 +65,7 @@ pm2.connect(function(err) {
64
65
 
65
66
  const restartingAppIds = new Set();
66
67
 
67
- function postDeploy(cwd, reply) {
68
+ function postDeploy(config, reply) {
68
69
  shared.listApps(function(err, apps) {
69
70
  if (err) {
70
71
  console.error(err);
@@ -73,7 +74,7 @@ function postDeploy(cwd, reply) {
73
74
 
74
75
  // first deploy, start all processes
75
76
  if (apps.length === 0) {
76
- return pm2.start(config, {...opts}, (err, result) => {
77
+ return pm2.start(config, (err, result) => {
77
78
  reply({ success: !err, message: err?.message });
78
79
  updateAndSave(err, result);
79
80
  });
@@ -82,7 +83,7 @@ function postDeploy(cwd, reply) {
82
83
  //
83
84
  // detect if cwd has changed, and restart PM2 if it has
84
85
  //
85
- if (apps[0].pm2_env.pm_cwd !== cwd) {
86
+ if (apps[0].pm2_env.pm_cwd !== config.cwd) {
86
87
  console.log("App Root Directory changed. Restarting may take a bit longer...");
87
88
 
88
89
  //
@@ -92,7 +93,7 @@ function postDeploy(cwd, reply) {
92
93
  logIfError(err);
93
94
 
94
95
  // start again
95
- pm2.start(config, { ...opts }, (err, result) => {
96
+ pm2.start(config, (err, result) => {
96
97
  reply({ success: !err, message: err?.message });
97
98
  updateAndSave(err, result);
98
99
  });
@@ -140,6 +141,20 @@ function postDeploy(cwd, reply) {
140
141
  if (err) { return console.error(err); }
141
142
 
142
143
  let numActiveApps = initialApps.length + restartingAppIds.size;
144
+ const maxActiveProcesses = config.instances;
145
+
146
+ // update config of newly spawned processes (memory limit, etc)
147
+ shared.listApps(function(err, apps) {
148
+ logIfError(err);
149
+
150
+ // get new processes excluding initialApps
151
+ const new_app_envs = shared.filterActiveApps(apps)
152
+ .map((app) => app.pm2_env)
153
+ .filter(app_env => !initialApps.find(init_app => init_app.pm_id === app_env.pm_id));
154
+
155
+ new_app_envs.forEach((app_env) =>
156
+ shared.updateProcessConfig(app_env.pm_id, appConfig, logIfError));
157
+ });
143
158
 
144
159
  /**
145
160
  * - Write NGINX config to expose only the new active process
@@ -157,7 +172,7 @@ function postDeploy(cwd, reply) {
157
172
  // (They make take from minutes up to hours to stop)
158
173
  //
159
174
  appsToStop.forEach((app_env) => {
160
- if (numActiveApps < shared.MAX_ACTIVE_PROCESSES) {
175
+ if (numActiveApps < maxActiveProcesses) {
161
176
  numActiveApps++;
162
177
 
163
178
  restartingAppIds.add(app_env.pm_id);
@@ -167,6 +182,9 @@ function postDeploy(cwd, reply) {
167
182
 
168
183
  // reset counter stats (restart_time=0)
169
184
  pm2.reset(app_env.pm_id, logIfError);
185
+
186
+ // update process config (memory limit, etc)
187
+ shared.updateProcessConfig(app_env.pm_id, config, logIfError);
170
188
  });
171
189
 
172
190
  } else {
@@ -174,8 +192,8 @@ function postDeploy(cwd, reply) {
174
192
  }
175
193
  });
176
194
 
177
- if (numActiveApps < shared.MAX_ACTIVE_PROCESSES) {
178
- const missingOnlineApps = shared.MAX_ACTIVE_PROCESSES - numActiveApps;
195
+ if (numActiveApps < maxActiveProcesses) {
196
+ const missingOnlineApps = maxActiveProcesses - numActiveApps;
179
197
 
180
198
  // console.log("Active apps is lower than MAX_ACTIVE_PROCESSES, will SCALE again =>", {
181
199
  // missingOnlineApps,
@@ -183,11 +201,12 @@ function postDeploy(cwd, reply) {
183
201
  // newNumTotalApps: numTotalApps + missingOnlineApps
184
202
  // });
185
203
 
204
+ console.log("Scale up to", numTotalApps + missingOnlineApps);
186
205
  pm2.scale(apps[0].name, numTotalApps + missingOnlineApps, updateAndSaveIfAllRunning);
187
206
  }
188
207
  };
189
208
 
190
- const numHalfMaxActiveProcesses = Math.ceil(shared.MAX_ACTIVE_PROCESSES / 2);
209
+ const numHalfMaxActiveProcesses = Math.ceil(config.instances / 2);
191
210
 
192
211
  /**
193
212
  * Re-use previously stopped apps if available
@@ -206,6 +225,7 @@ function postDeploy(cwd, reply) {
206
225
 
207
226
  // reset counter stats (restart_time=0)
208
227
  pm2.reset(app_env.pm_id, logIfError);
228
+ shared.updateProcessConfig(app_env.pm_id, config, logIfError);
209
229
 
210
230
  // TODO: set timeout here to exit if some processes are not restarting
211
231
 
@@ -229,6 +249,8 @@ function postDeploy(cwd, reply) {
229
249
 
230
250
  numTotalApps = apps.length + numHalfMaxActiveProcesses;
231
251
 
252
+ console.log("Starting first half of new apps... pm2.scale(app, ", numTotalApps, ")");
253
+
232
254
  // Ensure to scale to a number of processes where `numHalfMaxActiveProcesses` can start immediately.
233
255
  pm2.scale(apps[0].name, numTotalApps, onFirstAppsStart.bind(undefined, initialApps));
234
256
  }
@@ -244,12 +266,12 @@ function updateAndSaveIfAllRunning(err) {
244
266
  if (err) { return console.error(err); }
245
267
 
246
268
  updateAndReloadNginx((app_envs) => {
247
- // console.log("updateAndExitIfAllRunning, app_ids (", app_envs.map(app_env => app_env.NODE_APP_INSTANCE) ,") => ", app_envs.length, "/", shared.MAX_ACTIVE_PROCESSES);
269
+ // console.log("updateAndExitIfAllRunning, app_ids (", app_envs.map(app_env => app_env.NODE_APP_INSTANCE) ,") => ", app_envs.length, "/", config.instances);
248
270
 
249
271
  //
250
272
  // TODO: add timeout to exit here, in case some processes are not starting
251
273
  //
252
- if (app_envs.length === shared.MAX_ACTIVE_PROCESSES) {
274
+ if (app_envs.length === appConfig.instances) {
253
275
  complete();
254
276
  }
255
277
  });
@@ -272,18 +294,24 @@ function updateAndReloadNginx(cb) {
272
294
  if (apps.length === 0) { err = "no apps running."; }
273
295
  if (err) { return console.error(err); }
274
296
 
275
- const app_envs = apps
276
- .filter(app => app.pm2_env.status !== cst.STOPPING_STATUS && app.pm2_env.status !== cst.STOPPED_STATUS)
277
- .map((app) => app.pm2_env);
297
+ const app_envs = shared.filterActiveApps(apps).map((app) => app.pm2_env);
278
298
 
279
299
  writeNginxConfig(app_envs);
280
300
 
301
+ // update processes config (memory limit, etc)
302
+ app_envs.forEach((app_env) =>
303
+ shared.updateProcessConfig(app_env.pm_id, appConfig, logIfError));
304
+
281
305
  cb?.(app_envs);
282
306
  });
283
307
  }
284
308
 
285
309
  function writeNginxConfig(app_envs) {
286
310
  // console.log("writeNginxConfig: ", app_envs.map(app_env => app_env.NODE_APP_INSTANCE));
311
+ if (!fs.existsSync(shared.NGINX_SERVERS_CONFIG_FILE)) {
312
+ console.warn(`NGINX config file not found at ${shared.NGINX_SERVERS_CONFIG_FILE}, skipping NGINX config update.`);
313
+ return;
314
+ }
287
315
 
288
316
  const port = 2567;
289
317
  const addresses = [];
package/pm2/shared.js CHANGED
@@ -1,12 +1,25 @@
1
1
  const pm2 = require('pm2');
2
+ const cst = require('pm2/constants');
2
3
  const os = require('os');
3
4
 
4
5
  const NAMESPACE = 'cloud';
5
- const MAX_ACTIVE_PROCESSES = os.cpus().length;
6
+ const MAX_ACTIVE_PROCESSES = Number(process.env.MAX_ACTIVE_PROCESSES || os.cpus().length);
7
+
8
+ const CONFIG_KEYS = [
9
+ 'max_memory_restart',
10
+ 'kill_timeout',
11
+ 'kill_retry_time',
12
+ 'wait_ready',
13
+ 'merge_logs',
14
+ 'cron_restart',
15
+ 'autorestart',
16
+ 'exp_backoff_restart_delay',
17
+ 'restart_delay',
18
+ ];
6
19
 
7
20
  function listApps(callback) {
8
21
  pm2.list((err, apps) => {
9
- if (err) { return callback(err);; }
22
+ if (err) { return callback(err); }
10
23
 
11
24
  // Filter out @colyseus/tools module (PM2 post-deploy agent)
12
25
  apps = apps.filter(app => app.name !== '@colyseus/tools');
@@ -16,8 +29,10 @@ function listApps(callback) {
16
29
  }
17
30
 
18
31
  async function getAppConfig(ecosystemFilePath) {
19
- const module = await import(ecosystemFilePath);
20
- const config = module.default;
32
+ // Clear require cache to force reload of the config file
33
+ const resolvedPath = require.resolve(ecosystemFilePath);
34
+ delete require.cache[resolvedPath];
35
+ const config = require(ecosystemFilePath);
21
36
 
22
37
  /**
23
38
  * Tune PM2 app config
@@ -29,10 +44,16 @@ async function getAppConfig(ecosystemFilePath) {
29
44
  app.namespace = NAMESPACE;
30
45
  app.exec_mode = "fork";
31
46
 
47
+ // default: number of CPU cores
32
48
  if (app.instances === undefined) {
33
49
  app.instances = MAX_ACTIVE_PROCESSES;
34
50
  }
35
51
 
52
+ // default: restart if memory exceeds 512M
53
+ if (app.max_memory_restart === undefined) {
54
+ app.max_memory_restart = '512M';
55
+ }
56
+
36
57
  app.time = true;
37
58
  app.wait_ready = true;
38
59
  app.watch = false;
@@ -52,17 +73,74 @@ async function getAppConfig(ecosystemFilePath) {
52
73
  if (!app.kill_retry_time) {
53
74
  app.kill_retry_time = 5000;
54
75
  }
76
+
77
+ // Ensure these config values are also set in app.env so they take priority
78
+ // over inherited environment variables during PM2's Utility.extend() merge.
79
+ // This prevents the parent process's environment (e.g., @colyseus/tools agent)
80
+ // from overwriting the app's config values like max_memory_restart.
81
+ if (!app.env) {
82
+ app.env = {};
83
+ }
84
+
85
+ CONFIG_KEYS.forEach((key) => {
86
+ if (app[key] !== undefined) {
87
+ app.env[key] = convertValue(app[key]);
88
+ }
89
+ });
55
90
  }
56
91
 
57
92
  return config;
58
93
  }
59
94
 
95
+ function convertValue(value) {
96
+ if (typeof value === 'string') {
97
+ const conversionUnit = {
98
+ // bytes
99
+ 'G': 1024 * 1024 * 1024,
100
+ 'M': 1024 * 1024,
101
+ 'K': 1024,
102
+ // milliseconds
103
+ 'h': 60 * 60 * 1000,
104
+ 'm': 60 * 1000,
105
+ 's': 1000
106
+ };
107
+
108
+ if (!conversionUnit[value.slice(-1)]) {
109
+ return parseInt(value, 10);
110
+
111
+ } else {
112
+ return parseFloat(value.slice(0, -1)) * (conversionUnit[value.slice(-1)]);
113
+ }
114
+ }
115
+ return value;
116
+ }
117
+
118
+ /**
119
+ * Update process configuration without restart.
120
+ * This patches pm2_env directly via a custom God method.
121
+ * @param {number|string} pm_id - Process ID to update
122
+ * @param {Object} config - Configuration object (e.g., { max_memory_restart: '512M', kill_timeout: 30000 })
123
+ * @param {Function} cb - Callback(err, updatedEnv)
124
+ */
125
+ function updateProcessConfig(pm_id, config, cb) {
126
+ const env = {};
127
+
128
+ CONFIG_KEYS.forEach((key) => {
129
+ if (config[key] !== undefined) {
130
+ env[key] = convertValue(config[key]);
131
+ }
132
+ });
133
+
134
+ const opts = { id: pm_id, env, };
135
+ pm2.Client.executeRemote('updateProcessConfig', opts, cb);
136
+ }
137
+
60
138
  module.exports = {
61
139
  /**
62
140
  * Constants
63
141
  */
64
- NGINX_SERVERS_CONFIG_FILE: '/etc/nginx/colyseus_servers.conf',
65
- PROCESS_UNIX_SOCK_PATH: '/run/colyseus/',
142
+ NGINX_SERVERS_CONFIG_FILE: process.env.NGINX_CONFIG_FILE || '/etc/nginx/colyseus_servers.conf',
143
+ PROCESS_UNIX_SOCK_PATH: process.env.UNIX_SOCK_PATH || '/run/colyseus/',
66
144
 
67
145
  MAX_ACTIVE_PROCESSES,
68
146
  NAMESPACE,
@@ -72,4 +150,11 @@ module.exports = {
72
150
  */
73
151
  listApps,
74
152
  getAppConfig,
153
+
154
+ updateProcessConfig,
155
+
156
+ filterActiveApps: (apps) => apps.filter(app =>
157
+ app.pm2_env.status !== cst.STOPPING_STATUS &&
158
+ app.pm2_env.status !== cst.STOPPED_STATUS
159
+ ),
75
160
  }
package/post-deploy.js CHANGED
@@ -90,7 +90,11 @@ function restartAll () {
90
90
  });
91
91
  }
92
92
 
93
- function reloadAll(retry = 0) {
93
+ async function reloadAll(retry = 0) {
94
+ // Read user's ecosystem config to get the desired number of instances
95
+ const config = await shared.getAppConfig(CONFIG_FILE_PATH);
96
+ const desiredInstances = config.apps?.[0]?.instances || shared.MAX_ACTIVE_PROCESSES;
97
+
94
98
  pm2.reload(CONFIG_FILE_PATH, { ...opts }, function (err, apps) {
95
99
  if (err) {
96
100
  //
@@ -110,9 +114,9 @@ function reloadAll(retry = 0) {
110
114
  const name = apps[0].name;
111
115
  const reloadedAppIds = apps.map(app => app.pm_id);
112
116
 
113
- // scale app to use all CPUs available
114
- if (apps.length !== shared.MAX_ACTIVE_PROCESSES) {
115
- pm2.scale(name, shared.MAX_ACTIVE_PROCESSES, () => onAppRunning(reloadedAppIds));
117
+ // scale app to the number of instances specified in user's config
118
+ if (apps.length !== desiredInstances) {
119
+ pm2.scale(name, desiredInstances, () => onAppRunning(reloadedAppIds));
116
120
 
117
121
  } else {
118
122
  onAppRunning(reloadedAppIds);
package/system-boot.js CHANGED
@@ -2,8 +2,88 @@
2
2
 
3
3
  const os = require('os');
4
4
  const fs = require('fs');
5
+ const path = require('path');
5
6
  const { execSync } = require('child_process');
6
7
 
8
+ /**
9
+ * Apply PM2 monkey-patch for custom functionality (e.g., updateProcessConfig)
10
+ * This patches the globally installed PM2 used by the system.
11
+ *
12
+ * Strategy: Rename ActionMethods.js -> ActionMethods_orig.js and replace with
13
+ * our wrapper that imports the original and adds custom methods.
14
+ */
15
+ function applyPM2Patches() {
16
+ try {
17
+ // Find global PM2 installation
18
+ const globalPM2Path = execSync('npm root -g', { encoding: 'utf8' }).trim() + '/pm2';
19
+
20
+ if (!fs.existsSync(globalPM2Path)) {
21
+ console.log('[PM2 Patch] Global PM2 not found, skipping...');
22
+ return;
23
+ }
24
+
25
+ const godPath = path.join(globalPM2Path, 'lib/God');
26
+ const actionMethodsPath = path.join(godPath, 'ActionMethods.js');
27
+ const actionMethodsOrigPath = path.join(godPath, 'ActionMethods_orig.js');
28
+ const ourActionMethodsPath = path.join(__dirname, 'pm2', 'ActionMethods.js');
29
+
30
+ // Check if our patch file exists
31
+ if (!fs.existsSync(ourActionMethodsPath)) {
32
+ console.log('[PM2 Patch] Custom ActionMethods.js not found, skipping...');
33
+ return;
34
+ }
35
+
36
+ // Check if already patched (ActionMethods_orig.js exists)
37
+ if (!fs.existsSync(actionMethodsOrigPath)) {
38
+ // Rename original ActionMethods.js -> ActionMethods_orig.js
39
+ console.log('[PM2 Patch] Renaming original ActionMethods.js -> ActionMethods_orig.js');
40
+ fs.renameSync(actionMethodsPath, actionMethodsOrigPath);
41
+
42
+ } else {
43
+ console.log('[PM2 Patch] Already patched, skipping...');
44
+ }
45
+
46
+ // Copy our ActionMethods.js wrapper
47
+ console.log(`[PM2 Patch] Installing custom ActionMethods.js wrapper (${actionMethodsPath})`);
48
+ fs.copyFileSync(ourActionMethodsPath, actionMethodsPath);
49
+
50
+ // Patch Daemon.js to expose "updateProcessConfig" method
51
+ const daemonPath = path.join(globalPM2Path, 'lib/Daemon.js');
52
+ console.log(`[PM2 Patch] Patching Daemon.js to expose updateProcessConfig (${daemonPath})`);
53
+ if (fs.existsSync(daemonPath)) {
54
+ let daemonContent = fs.readFileSync(daemonPath, 'utf8');
55
+
56
+ // Check if updateProcessConfig is already exposed
57
+ if (!daemonContent.includes('updateProcessConfig')) {
58
+ // Find server.expose({ and add updateProcessConfig after it
59
+ const exposePattern = 'server.expose({';
60
+ const exposeIndex = daemonContent.indexOf(exposePattern);
61
+
62
+ if (exposeIndex !== -1) {
63
+ const insertPos = exposeIndex + exposePattern.length;
64
+ daemonContent = daemonContent.slice(0, insertPos) +
65
+ '\n updateProcessConfig : God.updateProcessConfig,' +
66
+ daemonContent.slice(insertPos);
67
+ fs.writeFileSync(daemonPath, daemonContent);
68
+ console.log(`[PM2 Patch] Daemon.js patched successfully.`);
69
+ } else {
70
+ console.warn('[PM2 Patch] Could not find server.expose pattern in Daemon.js');
71
+ }
72
+ } else {
73
+ console.log('[PM2 Patch] Daemon.js already has updateProcessConfig exposed.');
74
+ }
75
+ } else {
76
+ console.warn('[PM2 Patch] Daemon.js not found at', daemonPath);
77
+ }
78
+
79
+ console.log('[PM2 Patch] PM2 patches applied successfully.');
80
+
81
+ } catch (error) {
82
+ console.warn('[PM2 Patch] Failed to apply PM2 patches:', error.message);
83
+ // Don't exit - patches are optional enhancements
84
+ }
85
+ }
86
+
7
87
  const NGINX_LIMITS_CONFIG_FILE = '/etc/nginx/colyseus_limits.conf';
8
88
  const LIMITS_CONF_FILE = '/etc/security/limits.d/colyseus.conf';
9
89
  const SYSCTL_FILE = '/etc/sysctl.d/local.conf';
@@ -117,4 +197,7 @@ net.ipv4.tcp_keepalive_probes = 3
117
197
  }
118
198
  }
119
199
 
200
+ // Apply PM2 patches before configuring system limits
201
+ applyPM2Patches();
202
+
120
203
  configureSystemLimits();