@colyseus/tools 0.16.0-preview.2 → 0.16.0-preview.4

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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * PM2 Agent for no downtime deployments on Colyseus Cloud.
3
+ *
4
+ * How it works:
5
+ * - New process(es) are spawned (MAX_ACTIVE_PROCESSES/2)
6
+ * - NGINX configuration is updated so new traffic only goes through the new process
7
+ * - Old processes are asynchronously and gracefully stopped.
8
+ * - The rest of the processes are spawned/reactivated.
9
+ */
10
+ const pm2 = require('pm2');
11
+ const fs = require('fs');
12
+ const cst = require('pm2/constants');
13
+ const io = require('@pm2/io');
14
+ const path = require('path');
15
+ const shared = require('./shared');
16
+
17
+ const opts = { env: process.env.NODE_ENV || "production", };
18
+ let config = undefined;
19
+
20
+ io.initModule({
21
+ pid: path.resolve('/var/run/colyseus-agent.pid'),
22
+ widget: {
23
+ type: 'generic',
24
+ logo: 'https://colyseus.io/images/logos/logo-dark-color.png',
25
+ theme : ['#9F1414', '#591313', 'white', 'white'],
26
+ }
27
+ });
28
+
29
+ pm2.connect(function(err) {
30
+ if (err) {
31
+ console.error(err.stack || err);
32
+ process.exit();
33
+ }
34
+ console.log('PM2 post-deploy agent is up and running...');
35
+
36
+ /**
37
+ * Remote actions
38
+ */
39
+ io.action('post-deploy', async function (arg0, reply) {
40
+ const [cwd, ecosystemFilePath] = arg0.split(':');
41
+ console.log("Received 'post-deploy' action!", { cwd, config: ecosystemFilePath });
42
+
43
+ let replied = false;
44
+
45
+ //
46
+ // Override 'reply' to decrement amount of concurrent deployments
47
+ //
48
+ const onReply = function() {
49
+ if (replied) { return; }
50
+ replied = true;
51
+ reply.apply(null, arguments);
52
+ }
53
+
54
+ try {
55
+ config = await shared.getAppConfig(ecosystemFilePath);
56
+ opts.cwd = cwd;
57
+ postDeploy(cwd, onReply);
58
+
59
+ } catch (err) {
60
+ onReply({ success: false, message: err?.message });
61
+ }
62
+ });
63
+ });
64
+
65
+ const restartingAppIds = new Set();
66
+
67
+ function postDeploy(cwd, reply) {
68
+ shared.listApps(function(err, apps) {
69
+ if (err) {
70
+ console.error(err);
71
+ return reply({ success: false, message: err?.message });
72
+ }
73
+
74
+ // first deploy, start all processes
75
+ if (apps.length === 0) {
76
+ return pm2.start(config, {...opts}, (err, result) => {
77
+ reply({ success: !err, message: err?.message });
78
+ updateAndSave(err, result);
79
+ });
80
+ }
81
+
82
+ //
83
+ // detect if cwd has changed, and restart PM2 if it has
84
+ //
85
+ if (apps[0].pm2_env.pm_cwd !== cwd) {
86
+ console.log("App Root Directory changed. Restarting may take a bit longer...");
87
+
88
+ //
89
+ // remove all and start again with new cwd
90
+ //
91
+ return pm2.delete('all', function (err) {
92
+ logIfError(err);
93
+
94
+ // start again
95
+ pm2.start(config, { ...opts }, (err, result) => {
96
+ reply({ success: !err, message: err?.message });
97
+ updateAndSave(err, result);
98
+ });
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Graceful restart logic:
104
+ * List of PM2 app envs to stop or restart
105
+ */
106
+ const appsToStop = [];
107
+ const appsStopped = [];
108
+ let numAppsStopping = 0;
109
+ let numTotalApps = undefined;
110
+
111
+ apps.forEach((app) => {
112
+ const env = app.pm2_env;
113
+
114
+ /**
115
+ * Asynchronously teardown/stop processes with active connections
116
+ */
117
+ if (env.status === cst.STOPPED_STATUS) {
118
+ appsStopped.push(env);
119
+
120
+ } else if (env.status !== cst.STOPPING_STATUS) {
121
+ appsToStop.push(env);
122
+
123
+ } else if (!restartingAppIds.has(env.pm_id)) {
124
+ numAppsStopping++;
125
+ }
126
+ });
127
+
128
+ /**
129
+ * - Start new process
130
+ * - Update NGINX config to expose only the new process
131
+ * - Stop old processes
132
+ * - Spawn/reactivate the rest of the processes (shared.MAX_ACTIVE_PROCESSES)
133
+ */
134
+ const onFirstAppsStart = async (initialApps, err, result) => {
135
+ /**
136
+ * release post-deploy action while proceeding with graceful restart of other processes
137
+ */
138
+ reply({ success: !err, message: err?.message });
139
+
140
+ if (err) { return console.error(err); }
141
+
142
+ let numActiveApps = initialApps.length + restartingAppIds.size;
143
+
144
+ /**
145
+ * - Write NGINX config to expose only the new active process
146
+ * - The old ones processes will go down asynchronously (or will be restarted)
147
+ */
148
+ writeNginxConfig(initialApps);
149
+
150
+ //
151
+ // Wait 1.5 seconds to ensure NGINX is updated & reloaded
152
+ //
153
+ await new Promise(resolve => setTimeout(resolve, 1500));
154
+
155
+ //
156
+ // Asynchronously stop/restart apps with active connections
157
+ // (They make take from minutes up to hours to stop)
158
+ //
159
+ appsToStop.forEach((app_env) => {
160
+ if (numActiveApps < shared.MAX_ACTIVE_PROCESSES) {
161
+ numActiveApps++;
162
+
163
+ restartingAppIds.add(app_env.pm_id);
164
+ pm2.restart(app_env.pm_id, (err, _) => {
165
+ restartingAppIds.delete(app_env.pm_id);
166
+ if (err) { return logIfError(err); }
167
+
168
+ // reset counter stats (restart_time=0)
169
+ pm2.reset(app_env.pm_id, logIfError);
170
+ });
171
+
172
+ } else {
173
+ pm2.stop(app_env.pm_id, logIfError);
174
+ }
175
+ });
176
+
177
+ if (numActiveApps < shared.MAX_ACTIVE_PROCESSES) {
178
+ const missingOnlineApps = shared.MAX_ACTIVE_PROCESSES - numActiveApps;
179
+
180
+ // console.log("Active apps is lower than MAX_ACTIVE_PROCESSES, will SCALE again =>", {
181
+ // missingOnlineApps,
182
+ // numActiveApps,
183
+ // newNumTotalApps: numTotalApps + missingOnlineApps
184
+ // });
185
+
186
+ pm2.scale(apps[0].name, numTotalApps + missingOnlineApps, updateAndSaveIfAllRunning);
187
+ }
188
+ };
189
+
190
+ const numHalfMaxActiveProcesses = Math.ceil(shared.MAX_ACTIVE_PROCESSES / 2);
191
+
192
+ /**
193
+ * Re-use previously stopped apps if available
194
+ */
195
+ if (appsStopped.length >= numHalfMaxActiveProcesses) {
196
+ const initialApps = appsStopped.splice(0, numHalfMaxActiveProcesses);
197
+
198
+ let numSucceeded = 0;
199
+ initialApps.forEach((app_env) => {
200
+ // console.log("pm2.restart => ", app_env.pm_id);
201
+
202
+ restartingAppIds.add(app_env.pm_id);
203
+ pm2.restart(app_env.pm_id, (err) => {
204
+ restartingAppIds.delete(app_env.pm_id);
205
+ if (err) { return replyIfError(err, reply); }
206
+
207
+ // reset counter stats (restart_time=0)
208
+ pm2.reset(app_env.pm_id, logIfError);
209
+
210
+ // TODO: set timeout here to exit if some processes are not restarting
211
+
212
+ numSucceeded++;
213
+ if (numSucceeded === initialApps.length) {
214
+ onFirstAppsStart(initialApps);
215
+ }
216
+ });
217
+ });
218
+
219
+ } else {
220
+ /**
221
+ * Increment to +(MAX/2) processes
222
+ */
223
+ let LAST_NODE_APP_INSTANCE = apps[apps.length - 1].pm2_env.NODE_APP_INSTANCE;
224
+ const initialApps = Array.from({ length: numHalfMaxActiveProcesses }).map((_, i) => {
225
+ const new_app_env = Object.assign({}, apps[0].pm2_env);
226
+ new_app_env.NODE_APP_INSTANCE = ++LAST_NODE_APP_INSTANCE;
227
+ return new_app_env;
228
+ });
229
+
230
+ numTotalApps = apps.length + numHalfMaxActiveProcesses;
231
+
232
+ // Ensure to scale to a number of processes where `numHalfMaxActiveProcesses` can start immediately.
233
+ pm2.scale(apps[0].name, numTotalApps, onFirstAppsStart.bind(undefined, initialApps));
234
+ }
235
+ });
236
+ }
237
+
238
+ function updateAndSave() {
239
+ // console.log("updateAndExit");
240
+ updateAndReloadNginx(() => complete());
241
+ }
242
+
243
+ function updateAndSaveIfAllRunning(err) {
244
+ if (err) { return console.error(err); }
245
+
246
+ 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);
248
+
249
+ //
250
+ // TODO: add timeout to exit here, in case some processes are not starting
251
+ //
252
+ if (app_envs.length === shared.MAX_ACTIVE_PROCESSES) {
253
+ complete();
254
+ }
255
+ });
256
+ }
257
+
258
+ function updateAndReloadNginx(cb) {
259
+ //
260
+ // If you are self-hosting and reading this file, consider using the
261
+ // following in your self-hosted environment:
262
+ //
263
+ // #!/bin/bash
264
+ // # Requires fswatch (`apt install fswatch`)
265
+ // # Reload NGINX when colyseus_servers.conf changes
266
+ // fswatch /etc/nginx/colyseus_servers.conf -m poll_monitor --event=Updated | while read event
267
+ // do
268
+ // service nginx reload
269
+ // done
270
+
271
+ shared.listApps(function(err, apps) {
272
+ if (apps.length === 0) { err = "no apps running."; }
273
+ if (err) { return console.error(err); }
274
+
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);
278
+
279
+ writeNginxConfig(app_envs);
280
+
281
+ cb?.(app_envs);
282
+ });
283
+ }
284
+
285
+ function writeNginxConfig(app_envs) {
286
+ // console.log("writeNginxConfig: ", app_envs.map(app_env => app_env.NODE_APP_INSTANCE));
287
+
288
+ const port = 2567;
289
+ const addresses = [];
290
+
291
+ app_envs.forEach(function(app_env) {
292
+ addresses.push(`unix:${shared.PROCESS_UNIX_SOCK_PATH}${port + app_env.NODE_APP_INSTANCE}.sock`);
293
+ });
294
+
295
+ // write NGINX config
296
+ fs.writeFileSync(shared.NGINX_SERVERS_CONFIG_FILE, addresses.map(address => `server ${address};`).join("\n"), logIfError);
297
+ }
298
+
299
+ function complete() {
300
+ // "pm2 save"
301
+ pm2.dump(logIfError);
302
+ }
303
+
304
+ function logIfError (err) {
305
+ if (err) {
306
+ console.error(err);
307
+ }
308
+ }
309
+
310
+ function replyIfError(err, reply) {
311
+ if (err) {
312
+ console.error(err);
313
+ reply({ success: false, message: err?.message });
314
+ }
315
+ }
package/pm2/shared.js ADDED
@@ -0,0 +1,73 @@
1
+ const pm2 = require('pm2');
2
+ const os = require('os');
3
+
4
+ const NAMESPACE = 'cloud';
5
+ const MAX_ACTIVE_PROCESSES = os.cpus().length;
6
+
7
+ function listApps(callback) {
8
+ pm2.list((err, apps) => {
9
+ if (err) { return callback(err);; }
10
+
11
+ // Filter out @colyseus/tools module (PM2 post-deploy agent)
12
+ apps = apps.filter(app => app.name !== '@colyseus/tools');
13
+
14
+ callback(err, apps);
15
+ });
16
+ }
17
+
18
+ async function getAppConfig(ecosystemFilePath) {
19
+ const module = await import(ecosystemFilePath);
20
+ const config = module.default;
21
+
22
+ /**
23
+ * Tune PM2 app config
24
+ */
25
+ if (config.apps && config.apps.length >= 0) {
26
+ const app = config.apps[0];
27
+
28
+ // app.name = "colyseus-app";
29
+ app.namespace = NAMESPACE;
30
+ app.exec_mode = "fork";
31
+
32
+ app.instances = MAX_ACTIVE_PROCESSES;
33
+
34
+ app.time = true;
35
+ app.wait_ready = true;
36
+ app.watch = false;
37
+
38
+ // default: merge logs into a single file
39
+ if (app.merge_logs === undefined) {
40
+ app.merge_logs = true;
41
+ }
42
+
43
+ // default: wait for 30 minutes before forcibly killing
44
+ // (prevent forcibly killing while rooms are still active)
45
+ if (!app.kill_timeout) {
46
+ app.kill_timeout = 30 * 60 * 1000;
47
+ }
48
+
49
+ // default: retry kill after 1 second
50
+ if (!app.kill_retry_time) {
51
+ app.kill_retry_time = 5000;
52
+ }
53
+ }
54
+
55
+ return config;
56
+ }
57
+
58
+ module.exports = {
59
+ /**
60
+ * Constants
61
+ */
62
+ NGINX_SERVERS_CONFIG_FILE: '/etc/nginx/colyseus_servers.conf',
63
+ PROCESS_UNIX_SOCK_PATH: '/run/colyseus/',
64
+
65
+ MAX_ACTIVE_PROCESSES,
66
+ NAMESPACE,
67
+
68
+ /**
69
+ * Shared methods
70
+ */
71
+ listApps,
72
+ getAppConfig,
73
+ }
package/post-deploy.js CHANGED
@@ -1,17 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  const pm2 = require('pm2');
3
- const os = require('os');
4
3
  const fs = require('fs');
5
4
  const path = require('path');
5
+ const shared = require('./pm2/shared');
6
6
 
7
- const opts = { env: process.env.NODE_ENV || "production" };
8
- const maxCPU = os.cpus().length;
9
-
10
- // // allow deploying from other path as root.
11
- // if (process.env.npm_config_local_prefix) {
12
- // process.chdir(process.env.npm_config_local_prefix);
13
- // pm2.cwd = process.env.npm_config_local_prefix;
14
- // }
7
+ const opts = { env: process.env.NODE_ENV || "production", };
15
8
 
16
9
  const CONFIG_FILE = [
17
10
  'ecosystem.config.cjs',
@@ -20,88 +13,125 @@ const CONFIG_FILE = [
20
13
  'pm2.config.js',
21
14
  ].find((filename) => fs.existsSync(path.resolve(pm2.cwd, filename)));
22
15
 
16
+ /**
17
+ * TODO: if not provided, auto-detect entry-point & dynamically generate ecosystem config
18
+ */
23
19
  if (!CONFIG_FILE) {
24
20
  throw new Error('missing ecosystem config file. make sure to provide one with a valid "script" entrypoint file path.');
25
21
  }
26
22
 
27
- pm2.list(function(err, apps) {
28
- bailOnErr(err);
23
+ const CONFIG_FILE_PATH = `${pm2.cwd}/${CONFIG_FILE}`;
29
24
 
30
- if (apps.length === 0) {
31
- // first deploy
32
- pm2.start(CONFIG_FILE, {...opts}, onAppRunning);
25
+ /**
26
+ * Try to handle post-deploy via PM2 module first (pm2 install @colyseus/tools)
27
+ * If not available, fallback to legacy post-deploy script.
28
+ */
29
+ pm2.trigger('@colyseus/tools', 'post-deploy', `${pm2.cwd}:${CONFIG_FILE_PATH}`, async function (err, result) {
30
+ if (err) {
31
+ console.log("Proceeding with legacy post-deploy script...");
32
+ postDeploy();
33
33
 
34
34
  } else {
35
+ if (result[0].data?.return?.success === false) {
36
+ console.error(result[0].data?.return?.message);
37
+ process.exit(1);
38
+ } else {
39
+ console.log("Post-deploy success.");
40
+ process.exit();
41
+ }
42
+ }
43
+ });
35
44
 
36
- //
37
- // detect if cwd has changed, and restart PM2 if it has
38
- //
39
- if (apps[0].pm2_env.pm_cwd !== pm2.cwd) {
45
+ async function postDeploy() {
46
+ shared.listApps(function (err, apps) {
47
+ bailOnErr(err);
40
48
 
49
+ if (apps.length === 0) {
41
50
  //
42
- // remove all and start again with new cwd
51
+ // first deploy
43
52
  //
44
- pm2.delete('all', function(err) {
45
-
46
- // kill & start again
47
- pm2.kill(function() {
48
- pm2.start(CONFIG_FILE, { ...opts }, onAppRunning);
49
- });
50
-
51
- });
53
+ pm2.start(CONFIG_FILE_PATH, { ...opts }, () => onAppRunning());
52
54
 
53
55
  } else {
56
+
54
57
  //
55
- // reload existing apps
58
+ // detect if cwd has changed, and restart PM2 if it has
56
59
  //
57
- pm2.reload(CONFIG_FILE, {...opts}, function(err, apps) {
58
- bailOnErr(err);
59
-
60
- const name = apps[0].name;
61
-
62
- // scale app to use all CPUs available
63
- if (apps.length !== maxCPU) {
64
- pm2.scale(name, maxCPU, onAppRunning);
65
-
66
- } else {
67
- onAppRunning();
68
- }
69
- });
60
+ if (apps[0].pm2_env.pm_cwd !== pm2.cwd) {
61
+ //
62
+ // remove all and start again with new cwd
63
+ //
64
+ restartAll();
65
+
66
+ } else {
67
+ //
68
+ // reload existing apps
69
+ //
70
+ reloadAll();
71
+ }
70
72
  }
73
+ });
74
+ }
71
75
 
76
+ function onAppRunning(reloadedAppIds) {
77
+ // reset reloaded app stats
78
+ if (reloadedAppIds) {
79
+ resetAppStats(reloadedAppIds);
72
80
  }
73
- });
74
-
75
- function onAppRunning() {
76
- updateColyseusBootService();
77
81
  updateAndReloadNginx();
78
82
  }
79
83
 
80
- function updateColyseusBootService() {
81
- //
82
- // Update colyseus-boot.service to use the correct paths for the application
83
- //
84
+ function restartAll () {
85
+ pm2.delete('all', function (err) {
86
+ // kill & start again
87
+ pm2.kill(function () {
88
+ pm2.start(CONFIG_FILE_PATH, { ...opts }, () => onAppRunning());
89
+ });
90
+ });
91
+ }
84
92
 
85
- const COLYSEUS_CLOUD_BOOT_SERVICE = '/etc/systemd/system/colyseus-boot.service';
93
+ function reloadAll(retry = 0) {
94
+ pm2.reload(CONFIG_FILE_PATH, { ...opts }, function (err, apps) {
95
+ if (err) {
96
+ //
97
+ // Retry in case of "Reload in progress" error.
98
+ //
99
+ if (err.message === 'Reload in progress' && retry < 5) {
100
+ console.warn(err.message, ", retrying...");
101
+ setTimeout(() => reloadAll(retry + 1), 1000);
86
102
 
87
- // ignore if no boot service found
88
- if (!fs.existsSync(COLYSEUS_CLOUD_BOOT_SERVICE)) {
89
- return;
90
- }
103
+ } else {
104
+ bailOnErr(err);
105
+ }
91
106
 
92
- const workingDirectory = pm2.cwd;
93
- const execStart = `${detectPackageManager()} colyseus-post-deploy`;
107
+ return;
108
+ }
94
109
 
95
- const contents = fs.readFileSync(COLYSEUS_CLOUD_BOOT_SERVICE, 'utf8');
96
- try {
97
- fs.writeFileSync(COLYSEUS_CLOUD_BOOT_SERVICE, contents
98
- .replace(/WorkingDirectory=(.*)/, `WorkingDirectory=${workingDirectory}`)
99
- .replace(/ExecStart=(.*)/, `ExecStart=${execStart}`));
100
- } catch (e) {
101
- // couldn't write to file
102
- }
110
+ const name = apps[0].name;
111
+ const reloadedAppIds = apps.map(app => app.pm_id);
112
+
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));
116
+
117
+ } else {
118
+ onAppRunning(reloadedAppIds);
119
+ }
120
+ });
103
121
  }
104
122
 
123
+ function resetAppStats (reloadedAppIds) {
124
+ reloadedAppIds.forEach((pm_id) => {
125
+ pm2.reset(pm_id, (err, _) => {
126
+ if (err) {
127
+ console.error(err);
128
+ } else {
129
+ console.log(`metrics re-set for app_id: ${pm_id}`);
130
+ }
131
+ });
132
+ });
133
+ };
134
+
105
135
  function updateAndReloadNginx() {
106
136
  //
107
137
  // If you are self-hosting and reading this file, consider using the
@@ -115,9 +145,7 @@ function updateAndReloadNginx() {
115
145
  // service nginx reload
116
146
  // done
117
147
 
118
- const NGINX_SERVERS_CONFIG_FILE = '/etc/nginx/colyseus_servers.conf';
119
-
120
- pm2.list(function(err, apps) {
148
+ shared.listApps(function(err, apps) {
121
149
  if (apps.length === 0) {
122
150
  err = "no apps running.";
123
151
  }
@@ -127,11 +155,11 @@ function updateAndReloadNginx() {
127
155
  const addresses = [];
128
156
 
129
157
  apps.forEach(function(app) {
130
- addresses.push(`unix:/run/colyseus/${ port + app.pm2_env.NODE_APP_INSTANCE }.sock`);
158
+ addresses.push(`unix:${shared.PROCESS_UNIX_SOCK_PATH}${port + app.pm2_env.NODE_APP_INSTANCE}.sock`);
131
159
  });
132
160
 
133
161
  // write NGINX config
134
- fs.writeFileSync(NGINX_SERVERS_CONFIG_FILE, addresses.map(address => `server ${address};`).join("\n"), bailOnErr);
162
+ fs.writeFileSync(shared.NGINX_SERVERS_CONFIG_FILE, addresses.map(address => `server ${address};`).join("\n"), bailOnErr);
135
163
 
136
164
  // "pm2 save"
137
165
  pm2.dump(function (err, ret) {
@@ -144,30 +172,6 @@ function updateAndReloadNginx() {
144
172
  });
145
173
  }
146
174
 
147
- function detectPackageManager() {
148
- const lockfiles = {
149
- // npm
150
- "npm exec": path.resolve(pm2.cwd, 'package-lock.json'),
151
-
152
- // yarn
153
- "yarn exec": path.resolve(pm2.cwd, 'yarn.lock'),
154
-
155
- // pnpm
156
- "pnpm exec": path.resolve(pm2.cwd, 'pnpm-lock.yaml'),
157
-
158
- // bun
159
- "bunx": path.resolve(pm2.cwd, 'bun.lockb'),
160
- };
161
-
162
- for (const [key, value] of Object.entries(lockfiles)) {
163
- if (fs.existsSync(value)) {
164
- return key;
165
- }
166
- }
167
-
168
- return "npm";
169
- }
170
-
171
175
  function bailOnErr(err) {
172
176
  if (err) {
173
177
  console.error(err);
@@ -176,4 +180,3 @@ function bailOnErr(err) {
176
180
  process.exit(1);
177
181
  }
178
182
  }
179
-