Skip to content

Commit 5f8c281

Browse files
committed
test: add Nub loader recipe
1 parent 5377bac commit 5f8c281

9 files changed

Lines changed: 304 additions & 0 deletions

File tree

examples/using-pm2-and-transpilers/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,32 @@ $ pm2 install typescript
1313
$ pm2 start http.ts
1414
```
1515

16+
### Node with `@nubjs/loader`
17+
18+
Install the loader in the application. Keep PM2 on its normal Node interpreter. The loader needs Node.js 18.19 or later. PM2's daemon and cluster workers use that Node executable.
19+
20+
```sh
21+
npm install --save-dev @nubjs/loader@0.8.3
22+
```
23+
24+
Configure `node_args` as an array so PM2 preloads the loader before its fork and cluster containers start:
25+
26+
```js
27+
module.exports = {
28+
apps: [{
29+
name: 'api',
30+
script: './src/server.ts',
31+
interpreter: 'node',
32+
node_args: ['--import', '@nubjs/loader'],
33+
instances: 2,
34+
exec_mode: 'cluster',
35+
wait_ready: true,
36+
}],
37+
};
38+
```
39+
40+
Start the ecosystem file with `pm2 start ecosystem.config.cjs`. PM2 keeps its Node process containers, IPC readiness, reload behavior, and cluster support. The loader does not provision or select a Node version. Retain the application's TypeScript type-check in CI. Do not set `interpreter` to a launcher executable. PM2 treats it as an arbitrary interpreter instead of using its Node containers.
41+
1642
## Livescript
1743

1844

lib/ProcessUtils.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ module.exports = {
3535
if (ext === '.ts' && stripping)
3636
return
3737

38+
var loader_preload = process.execArgv.some(function (argument, index, args) {
39+
return argument === '--import=@nubjs/loader' ||
40+
(argument === '--import' && args[index + 1] === '@nubjs/loader')
41+
})
42+
if (loader_preload)
43+
return
44+
3845
// Resolve ts-node from the application dependencies, not from PM2's
3946
try {
4047
require(require.resolve('ts-node/register', { paths: [cwd || path.dirname(exec_path)] }))

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
"debug": "4.4.3"
149149
},
150150
"devDependencies": {
151+
"@nubjs/loader": "0.8.3",
151152
"express": "^4.21.0",
152153
"mocha": "^11.7.0",
153154
"should": "^13.2.3"

test/e2e.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ runTest ./test/e2e/cli/start-app.sh
1717
runTest ./test/e2e/cli/operate-regex.sh
1818
#runTest ./test/e2e/cli/bun.sh
1919
runTest ./test/e2e/cli/typescript.sh
20+
runTest ./test/e2e/cli/nub-loader.sh
2021
runTest ./test/e2e/cli/app-configuration.sh
2122
runTest ./test/e2e/cli/binary.sh
2223
runTest ./test/e2e/cli/startOrX.sh

test/e2e/cli/nub-loader.sh

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env bash
2+
3+
SRC=$(cd $(dirname "$0"); pwd)
4+
fixture=$(cd "$SRC/../../fixtures/nub-loader"; pwd)
5+
export PM2_HOME="$fixture/.pm2"
6+
source "${SRC}/../include.sh"
7+
8+
log_file="$fixture/loader.log"
9+
10+
cleanup() {
11+
$pm2 delete all >/dev/null 2>&1 || true
12+
$pm2 kill >/dev/null 2>&1 || true
13+
}
14+
trap cleanup EXIT
15+
16+
pids_for() {
17+
$pm2 jlist | $node -e '
18+
let input = "";
19+
process.stdin.on("data", (chunk) => input += chunk);
20+
process.stdin.on("end", () => {
21+
console.log(JSON.parse(input)
22+
.filter((app) => app.name === "nub-loader-cluster")
23+
.map((app) => app.pid)
24+
.sort((left, right) => left - right)
25+
.join(" "));
26+
});
27+
'
28+
}
29+
30+
probe_reload() {
31+
PM2_BIN="$pm2" APP_NAME="$1" PORT="$2" $node <<'NODE'
32+
const { spawn } = require('child_process');
33+
const http = require('http');
34+
35+
let failures = 0;
36+
let pending = 0;
37+
38+
function request() {
39+
pending += 1;
40+
let settled = false;
41+
const complete = () => {
42+
if (settled)
43+
return;
44+
settled = true;
45+
pending -= 1;
46+
};
47+
const request = http.get(`http://127.0.0.1:${process.env.PORT}`, (response) => {
48+
if (response.statusCode !== 200)
49+
failures += 1;
50+
response.resume();
51+
response.once('end', complete);
52+
});
53+
request.setTimeout(1000, () => request.destroy());
54+
request.once('error', () => {
55+
failures += 1;
56+
complete();
57+
});
58+
}
59+
60+
request();
61+
const reload = spawn(process.env.PM2_BIN, ['reload', process.env.APP_NAME], { stdio: 'inherit' });
62+
const probes = setInterval(request, 10);
63+
64+
reload.once('close', (code) => {
65+
clearInterval(probes);
66+
const deadline = Date.now() + 2000;
67+
const finish = () => {
68+
if (pending > 0 && Date.now() < deadline) {
69+
setTimeout(finish, 10);
70+
return;
71+
}
72+
if (code !== 0 || failures > 0 || pending !== 0) {
73+
console.error({ code, failures, pending });
74+
process.exit(1);
75+
}
76+
};
77+
setTimeout(finish, 50);
78+
});
79+
NODE
80+
}
81+
82+
cd "$fixture"
83+
> "$log_file"
84+
85+
$pm2 start ecosystem.config.cjs
86+
should 'should start the TypeScript fork and cluster apps' 'online' 5
87+
88+
expected_node=$($node -p 'process.versions.node')
89+
! grep -F 'TypeScript support unavailable' "$log_file"
90+
spec 'Should not request ts-node when the loader is preloaded'
91+
ready_count=$(grep -Fc '"event":"ready"' "$log_file")
92+
[ "$ready_count" -eq 3 ]
93+
spec 'Should receive readiness from every fork and cluster worker'
94+
grep -F '"mode":"pm2"' "$log_file"
95+
spec 'Should transform non-erasable TypeScript syntax'
96+
grep -F "\"cwd\":\"$fixture\"" "$log_file"
97+
spec 'Should resolve the loader from the application cwd'
98+
grep -F "\"node\":\"$expected_node\"" "$log_file"
99+
spec 'Should run workers on the daemon Node version'
100+
grep -E '"sourceMapFrame":".*service\.ts:7:' "$log_file"
101+
spec 'Should preserve TypeScript source maps'
102+
103+
old_cluster_pids=$(pids_for)
104+
[ "$(echo "$old_cluster_pids" | wc -w)" -eq 2 ]
105+
spec 'Should record both cluster worker PIDs before reload'
106+
107+
probe_reload nub-loader-cluster 45679
108+
spec 'Should serve requests without interruption during cluster reload'
109+
110+
should 'should keep both TypeScript cluster workers online after reload' 'online' 5
111+
112+
probe_reload plain-js-cluster 45680
113+
spec 'Should keep a plain JavaScript cluster available during reload'
114+
115+
new_cluster_pids=$(pids_for)
116+
[ "$old_cluster_pids" != "$new_cluster_pids" ]
117+
spec 'Should replace cluster workers after readiness reload'
118+
grep -F '"event":"shutdown"' "$log_file"
119+
spec 'Should deliver graceful shutdown to replaced workers'
120+
121+
$pm2 delete all
122+
sleep 1
123+
remaining_workers=0
124+
for pid in $old_cluster_pids $new_cluster_pids; do
125+
if kill -0 "$pid" 2>/dev/null; then
126+
remaining_workers=1
127+
fi
128+
done
129+
[ "$remaining_workers" -eq 0 ]
130+
spec 'Should not leave replaced cluster workers running'
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const path = require('path');
2+
3+
const app = {
4+
script: './service.ts',
5+
cwd: __dirname,
6+
interpreter: 'node',
7+
node_args: ['--import', '@nubjs/loader'],
8+
wait_ready: true,
9+
listen_timeout: 5000,
10+
kill_timeout: 1000,
11+
env: {
12+
PORT: 45678,
13+
},
14+
merge_logs: true,
15+
log_file: path.join(__dirname, 'loader.log'),
16+
};
17+
18+
const plainApp = {
19+
script: './service.js',
20+
cwd: __dirname,
21+
interpreter: 'node',
22+
wait_ready: true,
23+
listen_timeout: 5000,
24+
kill_timeout: 1000,
25+
env: {
26+
PORT: 45680,
27+
},
28+
merge_logs: true,
29+
log_file: path.join(__dirname, 'plain.log'),
30+
};
31+
32+
module.exports = {
33+
apps: [
34+
{
35+
...app,
36+
name: 'nub-loader-fork',
37+
},
38+
{
39+
...app,
40+
name: 'nub-loader-cluster',
41+
env: {
42+
PORT: 45679,
43+
},
44+
instances: 2,
45+
exec_mode: 'cluster',
46+
},
47+
{
48+
...plainApp,
49+
name: 'plain-js-cluster',
50+
instances: 2,
51+
exec_mode: 'cluster',
52+
},
53+
],
54+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const { createServer } = require('node:http');
2+
3+
const server = createServer((_request, response) => {
4+
response.end('ok');
5+
});
6+
7+
let shuttingDown = false;
8+
9+
process.on('SIGINT', () => {
10+
if (shuttingDown)
11+
return;
12+
shuttingDown = true;
13+
console.log(JSON.stringify({ event: 'shutdown', pid: process.pid }));
14+
process.disconnect?.();
15+
server.close();
16+
});
17+
18+
server.listen(Number(process.env.PORT), () => process.send?.('ready'));
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const { createServer } = require('node:http');
2+
3+
enum ContainerMode {
4+
Pm2 = 'pm2',
5+
}
6+
7+
const sourceMapFrame = new Error('source map check').stack?.split('\n')[1]?.trim();
8+
9+
console.log(JSON.stringify({
10+
event: 'ready',
11+
mode: ContainerMode.Pm2,
12+
cwd: process.cwd(),
13+
node: process.versions.node,
14+
sourceMapFrame,
15+
}));
16+
17+
const server = createServer((_request, response) => {
18+
response.end('ok');
19+
});
20+
21+
let shuttingDown = false;
22+
23+
process.on('SIGINT', () => {
24+
if (shuttingDown)
25+
return;
26+
shuttingDown = true;
27+
console.log(JSON.stringify({ event: 'shutdown', pid: process.pid }));
28+
process.disconnect?.();
29+
server.close();
30+
});
31+
32+
server.listen(Number(process.env.PORT), () => process.send?.('ready'));

test/programmatic/interpreter_resolution.mocha.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ describe('TypeScript interpreter resolution', function () {
138138
describe('ProcessUtils.enableTypeScript', function () {
139139
var ProcessUtils = require('../../lib/ProcessUtils');
140140
var fake_app_dir;
141+
var empty_app_dir;
142+
var saved_exec_argv;
143+
var real_console_error;
141144

142145
before(function () {
143146
// Fake app with ts-node in its own node_modules, to prove ts-node is
@@ -149,15 +152,23 @@ describe('TypeScript interpreter resolution', function () {
149152
JSON.stringify({ name: 'ts-node', version: '0.0.0', main: 'register.js' }));
150153
fs.writeFileSync(path.join(ts_node_dir, 'register.js'),
151154
'global.__fake_ts_node_loaded = true;');
155+
empty_app_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pm2-empty-ts-app-'));
156+
saved_exec_argv = process.execArgv;
157+
real_console_error = console.error;
152158
});
153159

154160
after(function () {
161+
process.execArgv = saved_exec_argv;
162+
console.error = real_console_error;
155163
fs.rmSync(fake_app_dir, { recursive: true, force: true });
164+
fs.rmSync(empty_app_dir, { recursive: true, force: true });
156165
delete global.__fake_ts_node_loaded;
157166
});
158167

159168
beforeEach(function () {
160169
delete global.__fake_ts_node_loaded;
170+
process.execArgv = [];
171+
console.error = real_console_error;
161172
});
162173

163174
it('should be a no-op for non-TypeScript files', function () {
@@ -176,6 +187,30 @@ describe('TypeScript interpreter resolution', function () {
176187
ProcessUtils.enableTypeScript(path.join(fake_app_dir, 'app.tsx'), fake_app_dir);
177188
should(global.__fake_ts_node_loaded).eql(true);
178189
});
190+
191+
it('should not request ts-node when @nubjs/loader is preloaded', function () {
192+
if (process.features && process.features.typescript)
193+
this.skip();
194+
195+
var warnings = [];
196+
process.execArgv = ['--import', '@nubjs/loader'];
197+
console.error = function () { warnings.push(Array.prototype.join.call(arguments, ' ')); };
198+
199+
ProcessUtils.enableTypeScript(path.join(empty_app_dir, 'app.ts'), empty_app_dir);
200+
should(warnings).be.empty();
201+
});
202+
203+
it('should retain the ts-node fallback without the loader preload', function () {
204+
if (process.features && process.features.typescript)
205+
this.skip();
206+
207+
var warnings = [];
208+
console.error = function () { warnings.push(Array.prototype.join.call(arguments, ' ')); };
209+
210+
ProcessUtils.enableTypeScript(path.join(empty_app_dir, 'app.ts'), empty_app_dir);
211+
should(warnings).have.length(1);
212+
should(warnings[0]).match(/TypeScript support unavailable/);
213+
});
179214
});
180215

181216
describe('God.nodeApp daemon-side strip flag (cluster)', function () {

0 commit comments

Comments
 (0)