@aidra/pmx-io 0.0.0-stage → 9.13.0

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.
Files changed (67) hide show
  1. package/README.md +69 -2
  2. package/bin/pmx-io-node.js +143 -0
  3. package/index.d.ts +381 -0
  4. package/index.js +27 -0
  5. package/index.mjs +47 -0
  6. package/lib/actions.js +120 -0
  7. package/lib/activate.js +402 -0
  8. package/lib/application.js +106 -0
  9. package/lib/bun.js +155 -0
  10. package/lib/config.js +67 -0
  11. package/lib/constants.js +17 -0
  12. package/lib/debug.js +48 -0
  13. package/lib/esm-hooks.mjs +111 -0
  14. package/lib/exit.js +79 -0
  15. package/lib/features/metrics.js +409 -0
  16. package/lib/features/notify.js +358 -0
  17. package/lib/features/profiling.js +208 -0
  18. package/lib/hooks.js +151 -0
  19. package/lib/inbound.js +174 -0
  20. package/lib/integrations.js +372 -0
  21. package/lib/itg/amqplib.js +169 -0
  22. package/lib/itg/bullmq.js +141 -0
  23. package/lib/itg/common.js +657 -0
  24. package/lib/itg/dbcomment.js +114 -0
  25. package/lib/itg/index.js +103 -0
  26. package/lib/itg/ioredis.js +212 -0
  27. package/lib/itg/kafkajs.js +205 -0
  28. package/lib/itg/mongodb.js +322 -0
  29. package/lib/itg/mysql2.js +205 -0
  30. package/lib/itg/nats.js +531 -0
  31. package/lib/itg/noderedis.js +236 -0
  32. package/lib/itg/pg.js +253 -0
  33. package/lib/itg/pgqueue.js +305 -0
  34. package/lib/itg/postgres.js +252 -0
  35. package/lib/itg/redis-common.js +265 -0
  36. package/lib/itg/sql.js +302 -0
  37. package/lib/metrics/service.js +286 -0
  38. package/lib/metrics/types.js +342 -0
  39. package/lib/modules.js +260 -0
  40. package/lib/node-options.js +62 -0
  41. package/lib/otel/bridge.js +797 -0
  42. package/lib/otel/env.js +114 -0
  43. package/lib/outbound.js +362 -0
  44. package/lib/pmx.js +757 -0
  45. package/lib/relay/hub.js +279 -0
  46. package/lib/relay/transport.js +253 -0
  47. package/lib/standalone/activate.js +307 -0
  48. package/lib/standalone/capture.js +55 -0
  49. package/lib/standalone/container.js +909 -0
  50. package/lib/standalone/transport.js +1023 -0
  51. package/lib/tracing/context.js +90 -0
  52. package/lib/tracing/control.js +124 -0
  53. package/lib/tracing/exporter.js +115 -0
  54. package/lib/tracing/index.js +242 -0
  55. package/lib/tracing/logs.js +176 -0
  56. package/lib/tracing/options.js +105 -0
  57. package/lib/tracing/paths.js +34 -0
  58. package/lib/tracing/tracer.js +850 -0
  59. package/lib/tracing/w3c.js +415 -0
  60. package/lib/transport/ipc.js +97 -0
  61. package/lib/transport/ws.js +511 -0
  62. package/lib/util.js +109 -0
  63. package/package.json +48 -4
  64. package/preload.cjs +26 -0
  65. package/preload.mjs +25 -0
  66. package/standalone.cjs +16 -0
  67. package/standalone.mjs +17 -0
@@ -0,0 +1,1023 @@
1
+ // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
+ 'use strict'
3
+
4
+ // The standalone root's prodman transport (80 NIO-262..NIO-269; prodman spec 11 APM-001…053;
5
+ // 95 §1–§4): configuration, handshake, WebSocket session, status, channels, commands, logs and
6
+ // shutdown. The instance keeps sending IPC-shaped messages (`send(type, data)`); `axm:monitor`,
7
+ // `axm:action` and `axm:option:configuration` feed the next `status`, other types become packets.
8
+
9
+ const os = require('os')
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const crypto = require('crypto')
13
+ const { VERSION } = require('../constants')
14
+ const container = require('./container')
15
+ const debug = require('../debug')('axm:standalone')
16
+
17
+ const IO_VERSION = '@pm2/io ' + VERSION
18
+ const STATUS_INTERVAL = 1000
19
+ const PING_INTERVAL = 5000
20
+ const PONG_TIMEOUT = 20000
21
+ const OFFLINE_QUEUE = 200
22
+ const SEND_QUEUE = 1000
23
+ // bytes the socket may hold unsent before packets wait in the (bounded) queue instead
24
+ const MAX_BUFFERED = 1024 * 1024
25
+ const RING = 200
26
+ const LOG_BATCH_MS = 250
27
+ const LOG_BATCH_BYTES = 64 * 1024
28
+ const LOG_BATCH_MAX = 4 * 1024 * 1024
29
+ const LOG_LINES_PER_SECOND = 2000
30
+ const LOG_DROP_REPORT_MS = 60000
31
+ const NOT_QUEUED = ['status', 'logs', 'trace-span', 'trace-spans']
32
+ const REFUSAL = '%s is not available: %s runs without PM2 (@pm2/io standalone)'
33
+
34
+ // -- configuration (NIO-262) ----------------------------------------------------------------------
35
+
36
+ const IPV4 = /^\d+\.\d+\.\d+\.\d+(:\d+)?(\/|$)/
37
+
38
+ function resolveUrl (configured, env) {
39
+ env = env || process.env
40
+ let raw = null
41
+ for (const v of [configured, env.PRODMAN_URL, env.KEYMETRICS_NODE]) {
42
+ if (typeof v === 'string' && v.trim()) {
43
+ raw = v.trim()
44
+ break
45
+ }
46
+ }
47
+ if (raw === null) raw = 'https://pmx.aidra.io'
48
+ if (!raw.includes('://')) {
49
+ const host = raw.split('/')[0]
50
+ const plain = host === 'localhost' || host.startsWith('localhost:') || IPV4.test(raw) || host.startsWith('[')
51
+ raw = (plain ? 'http://' : 'https://') + raw
52
+ }
53
+ return raw.replace(/\/+$/, '')
54
+ }
55
+
56
+ function serverName (cfg, info, host) {
57
+ const app = cfg.appName || null
58
+ const h = host || container.hostname()
59
+ info = info || new container.ContainerInfo({ inContainer: false, runtime: 'unknown', orchestrator: 'none' })
60
+ if (cfg.explicitServerName) return container.expand(String(cfg.explicitServerName), info.nameContext(app, h))
61
+ const dflt = info.defaultMachineName(app, h)
62
+ if (dflt) return dflt
63
+ if (!app) return null
64
+ return container.truncateName(`${app}@${h}`)
65
+ }
66
+
67
+ /** Environment (APM-010) overlaid by `apmOptions` (options win); `base` = a resolved config. */
68
+ function resolveConfig (apmOptions, env, info, base) {
69
+ env = env || process.env
70
+ const o = apmOptions && typeof apmOptions === 'object' ? apmOptions : {}
71
+ const b = base || null
72
+ const opt = (k) => (o[k] !== undefined && o[k] !== null && o[k] !== '' ? o[k] : undefined)
73
+ const pick = (k, envName) => {
74
+ const v = opt(k)
75
+ if (v !== undefined) return v
76
+ if (b && b[k]) return b[k]
77
+ return env[envName] || null
78
+ }
79
+ const cfg = {}
80
+ cfg.url = resolveUrl(opt('url') || (b ? b.url : undefined), env)
81
+ cfg.publicKey = pick('publicKey', 'PM2_PUBLIC_KEY')
82
+ cfg.secretKey = pick('secretKey', 'PM2_SECRET_KEY')
83
+ cfg.appName = pick('appName', 'PM2_APP_NAME')
84
+ let explicit = opt('serverName')
85
+ if (explicit === undefined && b) explicit = b.explicitServerName
86
+ if (explicit === undefined || explicit === null) explicit = env.PM2_MACHINE_NAME || null
87
+ cfg.explicitServerName = explicit
88
+ cfg.sendLogs = opt('sendLogs') !== undefined ? Boolean(o.sendLogs) : Boolean(b && b.sendLogs)
89
+ cfg.disableLogs = opt('disableLogs') !== undefined ? Boolean(o.disableLogs) : Boolean(b && b.disableLogs)
90
+ let cap = opt('captureLogs')
91
+ if (cap === undefined) cap = b ? b.captureLogs : (env.PMX_IO_CAPTURE_LOGS || 'stdio')
92
+ cap = String(cap).toLowerCase()
93
+ if (cap === 'false' || cap === 'none' || cap === '0') cap = 'off'
94
+ if (cap === 'sys') cap = 'stdio'
95
+ if (cap !== 'stdio' && cap !== 'off') {
96
+ debug('unknown captureLogs %s, using stdio', cap)
97
+ cap = 'stdio'
98
+ }
99
+ if (cfg.disableLogs) cap = 'off'
100
+ cfg.captureLogs = cap
101
+ const lf = opt('logFilter')
102
+ cfg.logFilter = lf !== undefined ? lf : (b ? b.logFilter : undefined)
103
+ let proxy = opt('proxy')
104
+ if (proxy === undefined) proxy = b ? b.proxy : (env.PMX_IO_PROXY || null)
105
+ cfg.proxy = proxy || null
106
+ cfg.missing = [['PM2_PUBLIC_KEY', 'publicKey'], ['PM2_SECRET_KEY', 'secretKey'], ['PM2_APP_NAME', 'appName']].filter(([, k]) => !cfg[k]).map(([n]) => n)
107
+ for (const k of ['publicKey', 'secretKey', 'appName']) if (cfg[k] !== null) cfg[k] = String(cfg[k])
108
+ cfg.serverName = serverName(cfg, info)
109
+ return cfg
110
+ }
111
+
112
+ function matchesFilter (flt, text) {
113
+ if (flt === undefined || flt === null || flt === '') return false
114
+ if (typeof flt === 'string') return text.includes(flt)
115
+ if (flt instanceof RegExp) {
116
+ flt.lastIndex = 0
117
+ return flt.test(text)
118
+ }
119
+ return false
120
+ }
121
+
122
+ function redact (url) {
123
+ try {
124
+ const u = new URL(url)
125
+ if (u.username || u.password) {
126
+ u.username = '***'
127
+ u.password = ''
128
+ }
129
+ return u.href
130
+ } catch (_) {
131
+ return '<url>'
132
+ }
133
+ }
134
+
135
+ // -- footprint (NIO-263) --------------------------------------------------------------------------
136
+
137
+ function procCpuTicks (pid) {
138
+ let data
139
+ try {
140
+ data = fs.readFileSync(`/proc/${pid}/stat`, 'latin1')
141
+ } catch (_) {
142
+ return null
143
+ }
144
+ const rp = data.lastIndexOf(')')
145
+ if (rp < 0) return null
146
+ const f = data.slice(rp + 2).split(' ')
147
+ if (f[0] === 'Z') return null
148
+ const u = Number(f[11])
149
+ const s = Number(f[12])
150
+ return Number.isFinite(u) && Number.isFinite(s) ? u + s : null
151
+ }
152
+
153
+ function procMemory (pid) {
154
+ try {
155
+ const t = fs.readFileSync(`/proc/${pid}/smaps_rollup`, 'latin1')
156
+ const m = /^Pss:\s+(\d+)/m.exec(t)
157
+ if (m) return Number(m[1]) * 1024
158
+ } catch (_) {}
159
+ try {
160
+ const t = fs.readFileSync(`/proc/${pid}/statm`, 'latin1').split(' ')
161
+ return Number(t[1]) * 4096
162
+ } catch (_) {
163
+ return null
164
+ }
165
+ }
166
+
167
+ class Footprint {
168
+ constructor (relayPids) {
169
+ this.relayPids = relayPids
170
+ this.linux = fs.existsSync(`/proc/${process.pid}/stat`)
171
+ this.tck = 100
172
+ this._prev = new Map()
173
+ this._prevT = null
174
+ this._prevSelf = null
175
+ }
176
+
177
+ sample () {
178
+ const now = Date.now() / 1000
179
+ const dt = this._prevT !== null ? now - this._prevT : 0
180
+ this._prevT = now
181
+ if (!this.linux) {
182
+ const u = process.cpuUsage()
183
+ const t = (u.user + u.system) / 1e6
184
+ const prev = this._prevSelf
185
+ this._prevSelf = t
186
+ const cpu = prev !== null && dt > 0 ? Math.max(0, (t - prev) / dt * 100) : 0
187
+ return { cpu: Math.round(cpu * 10) / 10, memory: process.memoryUsage().rss }
188
+ }
189
+ const pids = [process.pid]
190
+ try {
191
+ for (const p of this.relayPids()) if (!pids.includes(p)) pids.push(p)
192
+ } catch (_) {}
193
+ let ticks = 0
194
+ let mem = 0
195
+ const seen = new Map()
196
+ for (const pid of pids) {
197
+ const t = procCpuTicks(pid)
198
+ if (t === null) continue
199
+ const m = procMemory(pid)
200
+ if (m !== null) mem += m
201
+ const prev = this._prev.get(pid)
202
+ if (prev !== undefined) ticks += Math.max(0, t - prev)
203
+ seen.set(pid, t)
204
+ }
205
+ this._prev = seen
206
+ const cpu = dt > 0 ? (ticks / this.tck) / dt * 100 : 0
207
+ return { cpu: Math.round(cpu * 10) / 10, memory: mem }
208
+ }
209
+ }
210
+
211
+ // -- host facts -----------------------------------------------------------------------------------
212
+
213
+ function username () {
214
+ for (const k of ['SUDO_USER', 'C9_USER', 'LOGNAME', 'USER', 'LNAME', 'USERNAME']) if (process.env[k]) return process.env[k]
215
+ try {
216
+ return os.userInfo().username
217
+ } catch (_) {
218
+ return null
219
+ }
220
+ }
221
+
222
+ function cpuModel () {
223
+ const c = os.cpus()
224
+ return c && c[0] ? c[0].model : null
225
+ }
226
+
227
+ function appVersion () {
228
+ if (process.env.PMX_APP_VERSION) return process.env.PMX_APP_VERSION
229
+ try {
230
+ const { readPackageJsonSync } = require('../util')
231
+ const pkg = readPackageJsonSync()
232
+ if (pkg && typeof pkg.version === 'string') return pkg.version
233
+ } catch (_) {}
234
+ return null
235
+ }
236
+
237
+ function backoff (baseMs, attempt) {
238
+ const ms = Math.min(60000, baseMs * Math.pow(2, attempt))
239
+ return Math.round(ms * (0.8 + Math.random() * 0.4))
240
+ }
241
+
242
+ function countLines (text) {
243
+ const n = text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
244
+ return n || 1
245
+ }
246
+
247
+ // -- transport ------------------------------------------------------------------------------------
248
+
249
+ class ProdmanTransport {
250
+ /**
251
+ * @param {object} cfg resolveConfig() result
252
+ * @param {object} info ContainerInfo
253
+ * @param {{relayPids?: Function}} [opts]
254
+ */
255
+ constructor (cfg, info, opts) {
256
+ opts = opts || {}
257
+ this.kind = 'standalone'
258
+ this.cfg = cfg
259
+ this.info = info
260
+ this._listeners = []
261
+ this._pending = [] // [channel, text, cb]
262
+ this.tracingLayer = null // 80 NIO-298 (prodman `tracing` key for this app)
263
+ this._ws = null
264
+ this._connecting = false
265
+ this._stopping = false
266
+ this._closed = false
267
+ this._timers = { status: null, ping: null, retry: null, logs: null }
268
+ this._hsAttempt = 0
269
+ this._wsAttempt = 0
270
+ this._lastRefusal = null
271
+ this.collectLogs = false
272
+ this._batchSupported = false
273
+ this.ring = { out: [], err: [] }
274
+ this._batch = { out: [], err: [] }
275
+ this._batchBytes = 0
276
+ this._batchSince = null
277
+ this._windowStart = 0
278
+ this._windowLines = 0
279
+ this._droppedLines = 0
280
+ this._dropReportedAt = null
281
+ this.monitor = {}
282
+ this.actions = new Map()
283
+ this.options = {}
284
+ this.startedAt = Date.now()
285
+ this.uniqueId = crypto.randomBytes(12).toString('hex')
286
+ this.serverUniqueId = crypto.randomBytes(16).toString('hex')
287
+ this.footprint = new Footprint(opts.relayPids || (() => []))
288
+ this.hostMetrics = new container.HostMetrics(info)
289
+ this._shutdownPayload = null
290
+ this._shutdownSentOn = null
291
+ this._ip = null
292
+ this.statusCount = 0
293
+ }
294
+
295
+ get server () {
296
+ return this.cfg.serverName
297
+ }
298
+
299
+ get appName () {
300
+ return this.cfg.appName
301
+ }
302
+
303
+ /** pmx 97 PAP-050: the application (init option > PMX_APPLICATION > default). */
304
+ get application () {
305
+ return require('../application').current()
306
+ }
307
+
308
+ /** PAP-050: init decided the application; the tracing layer is resolved again for it. */
309
+ setApplication () {
310
+ if (this._tracingBlock !== undefined) this._applyTracing(this._tracingBlock)
311
+ }
312
+
313
+ ref () {
314
+ return { pm_id: 0, name: this.cfg.appName, application: this.application, rev: null, server: this.cfg.serverName }
315
+ }
316
+
317
+ batchSupported () {
318
+ return this._batchSupported
319
+ }
320
+
321
+ init () {
322
+ return this
323
+ }
324
+
325
+ start () {
326
+ this._schedule(() => this._run(), 0)
327
+ }
328
+
329
+ reconfigure (cfg) {
330
+ const reconnect = cfg.url !== this.cfg.url || cfg.publicKey !== this.cfg.publicKey || cfg.secretKey !== this.cfg.secretKey ||
331
+ cfg.serverName !== this.cfg.serverName || cfg.proxy !== this.cfg.proxy
332
+ this.cfg = cfg
333
+ if (reconnect && this._ws) this._ws.terminate()
334
+ }
335
+
336
+ // -- connection -----------------------------------------------------------------------------
337
+
338
+ _schedule (fn, ms) {
339
+ if (this._stopping) return
340
+ clearTimeout(this._timers.retry)
341
+ this._timers.retry = setTimeout(fn, ms)
342
+ if (this._timers.retry.unref) this._timers.retry.unref()
343
+ }
344
+
345
+ async _run () {
346
+ if (this._stopping || this._ws || this._connecting) return
347
+ this._connecting = true
348
+ try {
349
+ if (this.info.ecsPending()) {
350
+ try {
351
+ await this.info.fetchEcs()
352
+ const name = serverName(this.cfg, this.info)
353
+ if (name) this.cfg = Object.assign({}, this.cfg, { serverName: name })
354
+ } catch (err) {
355
+ debug('ECS metadata failed: %s', err && err.message)
356
+ }
357
+ }
358
+ debug('prodman %s as %s (app %s)', redact(this.cfg.url), this.server, this.appName)
359
+ let hs
360
+ try {
361
+ hs = await this._handshake()
362
+ } catch (err) {
363
+ this._connecting = false
364
+ if (err && err.refused) {
365
+ if (err.message !== this._lastRefusal) {
366
+ this._lastRefusal = err.message
367
+ try {
368
+ process.stderr.write(`[@pm2/io] prodman refused the connection: ${err.message}\n`)
369
+ } catch (_) {}
370
+ }
371
+ }
372
+ const delay = backoff(1000, this._hsAttempt++)
373
+ debug('handshake failed (%s), retrying in %d ms', err && err.message, delay)
374
+ this._schedule(() => this._run(), delay)
375
+ return
376
+ }
377
+ this._hsAttempt = 0
378
+ this._lastRefusal = null
379
+ if (hs.collectLogs) this.collectLogs = true
380
+ this._batchSupported = hs.traceSpansBatch
381
+ this._applyTracing(hs.tracing)
382
+ this._connect(hs.endpoint)
383
+ } catch (err) {
384
+ this._connecting = false
385
+ debug('connection failed: %s', err && err.message)
386
+ this._schedule(() => this._run(), backoff(5000, this._wsAttempt++))
387
+ }
388
+ }
389
+
390
+ handshakeBody () {
391
+ const limits = this.hostMetrics.limits()
392
+ const n = this.info.inContainer() ? limits.cpusRoundedUp() : (os.cpus() || []).length || 1
393
+ const model = cpuModel() || 'unknown'
394
+ const cpus = []
395
+ for (let i = 0; i < n; i++) cpus.push({ model, speed: 0 })
396
+ return {
397
+ public_id: this.cfg.publicKey,
398
+ private_id: this.cfg.secretKey,
399
+ data: {
400
+ MACHINE_NAME: this.server,
401
+ PUBLIC_KEY: this.cfg.publicKey,
402
+ RECYCLE: false,
403
+ PM2_VERSION: null,
404
+ IO_VERSION,
405
+ MEMORY: Math.floor(limits.memoryBytes / 1048576),
406
+ HOSTNAME: container.hostname(),
407
+ CPUS: cpus
408
+ }
409
+ }
410
+ }
411
+
412
+ async _handshake () {
413
+ const { httpRequest } = require('../transport/ws')
414
+ const body = JSON.stringify(this.handshakeBody())
415
+ const res = await httpRequest('POST', this.cfg.url + '/api/node/verifyPM2', body, {
416
+ 'Content-Type': 'application/json',
417
+ 'User-Agent': '@pmx/io/' + VERSION,
418
+ Accept: 'application/json'
419
+ }, { timeout: 15000, proxy: this.cfg.proxy || undefined })
420
+ let doc
421
+ try {
422
+ doc = JSON.parse(res.body.toString('utf8'))
423
+ } catch (_) {
424
+ throw new Error(`handshake: HTTP ${res.status}, not JSON`)
425
+ }
426
+ const ws = doc && doc.endpoints && typeof doc.endpoints.ws === 'string' ? doc.endpoints.ws : null
427
+ if (!doc || doc.active !== true || !ws) {
428
+ const e = new Error((doc && typeof doc.msg === 'string' && doc.msg) || 'bucket not active')
429
+ e.refused = true
430
+ throw e
431
+ }
432
+ const p = doc.prodman && typeof doc.prodman === 'object' ? doc.prodman : {}
433
+ return { endpoint: ws, collectLogs: p.collect_logs === true, traceSpansBatch: p.trace_spans_batch === true, tracing: p.tracing }
434
+ }
435
+
436
+ _pongTimeout () {
437
+ const v = Number(process.env.PRODMAN_WS_TIMEOUT)
438
+ return Number.isFinite(v) && v > 0 ? v : PONG_TIMEOUT
439
+ }
440
+
441
+ _connect (endpoint) {
442
+ if (this._stopping) {
443
+ this._connecting = false
444
+ return
445
+ }
446
+ const { WebSocketClient } = require('../transport/ws')
447
+ const ws = new WebSocketClient(endpoint, {
448
+ 'X-KM-PUBLIC': this.cfg.publicKey,
449
+ 'X-KM-SECRET': this.cfg.secretKey,
450
+ 'X-KM-SERVER': this.server,
451
+ 'X-PM2-VERSION': IO_VERSION,
452
+ 'X-PROTOCOL-VERSION': '1',
453
+ 'User-Agent': '@pmx/io/' + VERSION
454
+ }, { proxy: this.cfg.proxy || undefined })
455
+ let lastSeen = Date.now()
456
+ let opened = false
457
+ ws.on('open', () => {
458
+ opened = true
459
+ this._ws = ws
460
+ this._connecting = false
461
+ this._wsAttempt = 0
462
+ debug('connected to %s', redact(endpoint))
463
+ try {
464
+ const addr = ws._socket && ws._socket.localAddress
465
+ if (typeof addr === 'string' && addr.includes('.') && !addr.startsWith('127.')) this._ip = addr
466
+ } catch (_) {}
467
+ this._sendStatus(true)
468
+ if (this._shutdownPayload && this._shutdownSentOn !== ws) {
469
+ // CTR-022: re-sent on a new connection while the shutdown is in progress
470
+ this._pending.unshift(['agent:shutdown', JSON.stringify({ channel: 'agent:shutdown', payload: this._shutdownPayload }), null])
471
+ }
472
+ this._drain()
473
+ this._timers.status = setInterval(() => this._sendStatus(false), STATUS_INTERVAL)
474
+ if (this._timers.status.unref) this._timers.status.unref()
475
+ this._timers.ping = setInterval(() => {
476
+ if (Date.now() - lastSeen > this._pongTimeout()) return ws.terminate()
477
+ ws.ping()
478
+ }, PING_INTERVAL)
479
+ if (this._timers.ping.unref) this._timers.ping.unref()
480
+ this._timers.logs = setInterval(() => this._flushLogs(false), LOG_BATCH_MS)
481
+ if (this._timers.logs.unref) this._timers.logs.unref()
482
+ })
483
+ ws.on('drain', () => this._drain())
484
+ ws.on('pong', () => { lastSeen = Date.now() })
485
+ ws.on('ping', () => { lastSeen = Date.now() })
486
+ ws.on('message', (text) => {
487
+ lastSeen = Date.now()
488
+ this._onText(text)
489
+ })
490
+ ws.on('error', (err) => debug('socket error: %s', err && err.message))
491
+ ws.on('close', () => {
492
+ if (!opened) this._connecting = false
493
+ if (this._ws === ws) this._disconnected()
494
+ if (this._stopping) return
495
+ const delay = backoff(5000, this._wsAttempt++)
496
+ debug('connection closed, reconnecting in %d ms', delay)
497
+ this._schedule(() => this._run(), delay)
498
+ })
499
+ }
500
+
501
+ _disconnected () {
502
+ this._ws = null
503
+ clearInterval(this._timers.status)
504
+ clearInterval(this._timers.ping)
505
+ clearInterval(this._timers.logs)
506
+ this._trimOffline()
507
+ this._batch = { out: [], err: [] }
508
+ this._batchBytes = 0
509
+ this._batchSince = null
510
+ }
511
+
512
+ _connected () {
513
+ return Boolean(this._ws && this._ws.readyState === 1)
514
+ }
515
+
516
+ // -- queue ----------------------------------------------------------------------------------
517
+
518
+ _lost (entry) {
519
+ if (entry && entry[2]) {
520
+ try {
521
+ entry[2](new Error('not delivered'))
522
+ } catch (_) {}
523
+ }
524
+ }
525
+
526
+ _trimOffline () {
527
+ const kept = []
528
+ for (const e of this._pending) {
529
+ if (NOT_QUEUED.includes(e[0])) this._lost(e)
530
+ else kept.push(e)
531
+ }
532
+ while (kept.length > OFFLINE_QUEUE) kept.shift()
533
+ this._pending = kept
534
+ }
535
+
536
+ _evict () {
537
+ const q = this._pending
538
+ for (const wanted of ['status', 'logs', 'trace-span', 'trace-spans']) {
539
+ const i = q.findIndex((e) => e[0] === wanted)
540
+ if (i !== -1) return this._lost(q.splice(i, 1)[0])
541
+ }
542
+ const i = q.findIndex((e) => e[0] !== 'process:exception' && e[0] !== 'agent:shutdown')
543
+ if (i !== -1) this._lost(q.splice(i, 1)[0])
544
+ }
545
+
546
+ _drain () {
547
+ // a slow prodman leaves data in the socket: stop at MAX_BUFFERED (resumed on 'drain') so the
548
+ // queue's bounds and eviction apply instead of an unbounded socket buffer
549
+ while (this._pending.length && this._connected() && this._ws.bufferedAmount() < MAX_BUFFERED) {
550
+ const [ch, text, cb] = this._pending.shift()
551
+ if (ch === 'agent:shutdown') this._shutdownSentOn = this._ws
552
+ if (!this._ws.send(text, cb ? (err) => cb(err) : undefined)) {
553
+ this._pending.unshift([ch, text, cb])
554
+ return
555
+ }
556
+ }
557
+ }
558
+
559
+ /** Queue or write one packet. While disconnected only APM-026 channels are kept (≤ 200). */
560
+ packet (channel, payload, cb, front) {
561
+ let text
562
+ try {
563
+ text = JSON.stringify({ channel, payload })
564
+ } catch (err) {
565
+ debug('cannot encode %s: %s', channel, err && err.message)
566
+ if (cb) process.nextTick(cb, err)
567
+ return false
568
+ }
569
+ if (this._closed) {
570
+ if (cb) process.nextTick(cb, new Error('closed'))
571
+ return false
572
+ }
573
+ if (!this._connected()) {
574
+ if (NOT_QUEUED.includes(channel)) {
575
+ if (cb) process.nextTick(cb, new Error('not connected'))
576
+ return false
577
+ }
578
+ this._pending.push([channel, text, cb])
579
+ this._trimOffline()
580
+ return true
581
+ }
582
+ if (this._pending.length >= SEND_QUEUE) this._evict()
583
+ // only exceptions and agent:shutdown are left: drop the oldest exception (hard bound)
584
+ if (this._pending.length >= SEND_QUEUE * 2) this._lost(this._pending.splice(this._pending.findIndex((e) => e[0] !== 'agent:shutdown'), 1)[0])
585
+ if (front) this._pending.unshift([channel, text, cb])
586
+ else this._pending.push([channel, text, cb])
587
+ this._drain()
588
+ return true
589
+ }
590
+
591
+ // -- the instance's messages ----------------------------------------------------------------
592
+
593
+ send (type, data, cb) {
594
+ try {
595
+ return this._route(type, data, cb)
596
+ } catch (err) {
597
+ debug('send failed: %s', err && err.message)
598
+ if (cb) process.nextTick(cb, err)
599
+ return false
600
+ }
601
+ }
602
+
603
+ _route (type, data, cb) {
604
+ const done = () => {
605
+ if (cb) process.nextTick(cb)
606
+ return true
607
+ }
608
+ switch (type) {
609
+ case 'axm:monitor':
610
+ if (data && typeof data === 'object') this.monitor = data
611
+ return done()
612
+ case 'axm:action':
613
+ if (data && typeof data.action_name === 'string') this.actions.set(data.action_name, data)
614
+ return done()
615
+ case 'axm:option:configuration':
616
+ if (data && typeof data === 'object') Object.assign(this.options, data)
617
+ return done()
618
+ case 'human:event': {
619
+ const d = Object.assign({}, data)
620
+ const name = d.__name
621
+ delete d.__name
622
+ return this.packet('human:event', { at: Date.now(), name: typeof name === 'string' ? name : 'event', data: d, process: this.ref() }, cb)
623
+ }
624
+ case 'process:exception': {
625
+ const payload = Object.assign({}, data)
626
+ if (payload.last_logs === undefined && this.cfg.captureLogs !== 'off') {
627
+ payload.last_logs = this.ring.err.slice(-20).concat(this.ring.out.slice(-20))
628
+ }
629
+ return this.packet('process:exception', { at: Date.now(), data: payload, process: this.ref() }, cb)
630
+ }
631
+ case 'trace-span':
632
+ case 'trace-spans':
633
+ case 'axm:reply':
634
+ case 'profilings':
635
+ case 'application:dependencies':
636
+ return this.packet(type, { at: Date.now(), data, process: this.ref() }, cb)
637
+ case 'ready':
638
+ return this.packet('ready', { at: Date.now(), data: {}, process: this.ref() }, cb)
639
+ default:
640
+ if (typeof type === 'string' && type.startsWith('axm:scoped_action:')) return this.packet(type, { at: Date.now(), data, process: this.ref() }, cb)
641
+ debug('not forwarded to prodman: %s', type)
642
+ return done()
643
+ }
644
+ }
645
+
646
+ sendRaw (msg, cb) {
647
+ if (msg === 'ready') return this.send('ready', {}, cb)
648
+ if (msg && typeof msg === 'object' && typeof msg.type === 'string') return this.send(msg.type, msg.data, cb)
649
+ if (cb) process.nextTick(cb)
650
+ return false
651
+ }
652
+
653
+ onMessage (fn) {
654
+ this._listeners.push(fn)
655
+ }
656
+
657
+ offMessage (fn) {
658
+ this._listeners = this._listeners.filter((f) => f !== fn)
659
+ }
660
+
661
+ isConnected () {
662
+ return true
663
+ }
664
+
665
+ /** IPC transports are destroyed on re-init; the prodman connection is not (see destroyAll). */
666
+ destroy () {
667
+ this._listeners = []
668
+ }
669
+
670
+ // -- status (NIO-265) -----------------------------------------------------------------------
671
+
672
+ statusPayload () {
673
+ const fp = this.footprint.sample()
674
+ let options = this.options
675
+ try {
676
+ if (JSON.stringify(options).length > 8192) options = {}
677
+ } catch (_) {
678
+ options = {}
679
+ }
680
+ const limits = this.hostMetrics.limits()
681
+ const host = container.hostname()
682
+ const user = username()
683
+ const bun = typeof process.versions.bun === 'string'
684
+ const script = process.argv[1] || null
685
+ const runtime = {
686
+ kind: bun ? 'bun' : 'node',
687
+ version: bun ? process.versions.bun : process.versions.node,
688
+ versionSource: 'process',
689
+ interpreter: bun ? 'bun' : 'node',
690
+ interpreterPath: process.execPath,
691
+ interpreterArgs: [],
692
+ nodeArgs: process.execArgv,
693
+ virtualenv: null,
694
+ script,
695
+ args: process.argv.slice(2),
696
+ cwd: safeCwd(),
697
+ execMode: 'standalone',
698
+ instances: 1,
699
+ user,
700
+ nodeEnv: process.env.NODE_ENV || null,
701
+ watch: false,
702
+ autorestart: false,
703
+ maxMemoryRestart: null,
704
+ killTimeout: null,
705
+ cronRestart: null,
706
+ mergeLogs: false
707
+ }
708
+ if (this._version === undefined) this._version = appVersion()
709
+ const proc = {
710
+ pid: process.pid,
711
+ name: this.appName,
712
+ pm_id: 0,
713
+ status: 'online',
714
+ exec_mode: 'fork_mode',
715
+ restart_time: 0,
716
+ instances: 1,
717
+ pm_uptime: this.startedAt,
718
+ created_at: this.startedAt,
719
+ unique_id: this.uniqueId,
720
+ cpu: fp.cpu,
721
+ memory: fp.memory,
722
+ axm_monitor: this.monitor,
723
+ axm_actions: Array.from(this.actions.values()),
724
+ axm_options: options,
725
+ axm_dynamic: {},
726
+ version: this._version,
727
+ node_version: process.versions.node,
728
+ node_env: process.env.NODE_ENV || null,
729
+ pm_cwd: safeCwd(),
730
+ path: script,
731
+ args: process.argv.slice(2),
732
+ versioning: null,
733
+ runtime,
734
+ // prodman 02 PROTO-170 (pmx 97 PAP-050)
735
+ application: this.application
736
+ }
737
+ const inContainer = this.info.inContainer()
738
+ const cpus = os.cpus() || []
739
+ const server = {
740
+ username: user,
741
+ hostname: host,
742
+ uptime: os.uptime(),
743
+ platform: os.platform(),
744
+ pm2_version: null,
745
+ pm2_agent_version: IO_VERSION,
746
+ node_version: process.version,
747
+ unique_id: this.serverUniqueId,
748
+ loadavg: os.loadavg(),
749
+ total_mem: limits.memoryBytes,
750
+ type: os.type(),
751
+ arch: os.arch(),
752
+ cpus: inContainer ? limits.cpusRoundedUp() : cpus.length || 1,
753
+ cpu_model: cpuModel(),
754
+ system: this.hostMetrics.collect(),
755
+ agent_kind: '@pm2/io',
756
+ ephemeral: this.info.ephemeral(),
757
+ // prodman 02 PROTO-164: the `tracing` settings key is applied (80 NIO-298)
758
+ tracing_control: 1,
759
+ // prodman 02 PROTO-171: applications support, protocol 1
760
+ applications: 1
761
+ }
762
+ if (inContainer) server.container = this.info.block(this.appName || null, host, limits)
763
+ if (this._ip === null) this._ip = internalIp()
764
+ this.statusCount++
765
+ return { at: Date.now(), server_name: this.server, internal_ip: this._ip, data: { process: [proc], server } }
766
+ }
767
+
768
+ _sendStatus (front) {
769
+ try {
770
+ this.packet('status', this.statusPayload(), null, front)
771
+ } catch (err) {
772
+ debug('status failed: %s', err && err.message)
773
+ }
774
+ }
775
+
776
+ // -- incoming (NIO-266) ---------------------------------------------------------------------
777
+
778
+ _onText (text) {
779
+ let msg
780
+ try {
781
+ msg = JSON.parse(typeof text === 'string' ? text : text.toString('utf8'))
782
+ } catch (_) {
783
+ debug('ignoring a malformed message')
784
+ return
785
+ }
786
+ if (!msg || typeof msg !== 'object' || typeof msg.channel !== 'string') return
787
+ const ch = msg.channel
788
+ const p = msg.payload
789
+ if (!p) return
790
+ try {
791
+ if (ch === 'trigger:action' || ch === 'trigger:scoped_action') this._triggerAction(p)
792
+ else if (ch === 'trigger:pm2:action') this._pm2Action(p)
793
+ else if (ch === 'status:resend') this._sendStatus(true)
794
+ else if (ch === 'prodman:settings') {
795
+ if (typeof p.collect_logs === 'boolean') this.collectLogs = p.collect_logs
796
+ this._applyTracing(p.tracing)
797
+ } else debug('ignoring channel %s', ch)
798
+ } catch (err) {
799
+ debug('handling %s failed: %s', ch, err && err.message)
800
+ }
801
+ }
802
+
803
+ /**
804
+ * 80 NIO-298, prodman 02 PROTO-160/161: the `tracing` key of the handshake / prodman:settings,
805
+ * resolved for this app; a change goes to the instance as a `pmx:tracing` message.
806
+ */
807
+ _applyTracing (block) {
808
+ const { layerFromBlock } = require('../tracing/control')
809
+ this._tracingBlock = block
810
+ const layer = layerFromBlock(block, this.appName, this.application)
811
+ const prev = this.tracingLayer ? JSON.stringify(this.tracingLayer) : '{}'
812
+ this.tracingLayer = layer
813
+ if (JSON.stringify(layer) === prev) return
814
+ const m = { type: 'pmx:tracing', data: layer }
815
+ for (const fn of this._listeners.slice()) {
816
+ try {
817
+ fn(m)
818
+ } catch (_) {}
819
+ }
820
+ }
821
+
822
+ _triggerAction (p) {
823
+ const name = p.action_name
824
+ const id = p.process_id !== undefined ? p.process_id : 0
825
+ if (typeof name !== 'string') return
826
+ if (!this.actions.has(name)) {
827
+ this.packet('trigger:action:failure', { success: false, err: 'unknown action ' + name, id, action_name: name })
828
+ return
829
+ }
830
+ this.packet('trigger:action:success', { success: true, id, action_name: name })
831
+ const m = { msg: name, action_name: name, opts: p.opts !== undefined ? p.opts : p.options, uuid: p.uuid }
832
+ for (const fn of this._listeners.slice()) {
833
+ try {
834
+ fn(m)
835
+ } catch (_) {}
836
+ }
837
+ }
838
+
839
+ _pm2Action (p) {
840
+ const method = p.method_name
841
+ if (typeof method !== 'string') return
842
+ const params = p.parameters && typeof p.parameters === 'object' ? p.parameters : {}
843
+ let err = null
844
+ let data = null
845
+ if (method === 'ping') data = { msg: 'pong' }
846
+ else if (method === 'getLogs') data = this.getLogs(params.lines, params.type)
847
+ else if (method === 'startLogging') {
848
+ this.collectLogs = true
849
+ data = { success: true }
850
+ } else if (method === 'stopLogging') {
851
+ if (!this.cfg.sendLogs) this.collectLogs = false
852
+ data = { success: true }
853
+ } else {
854
+ err = { message: REFUSAL.replace('%s', method).replace('%s', this.appName) }
855
+ }
856
+ this.packet('trigger:pm2:result', {
857
+ ret: { err, data },
858
+ meta: {
859
+ method_name: method,
860
+ app_name: this.appName,
861
+ machine_name: this.server,
862
+ public_key: this.cfg.publicKey,
863
+ request_id: params.request_id
864
+ }
865
+ })
866
+ }
867
+
868
+ // -- logs (NIO-267) -------------------------------------------------------------------------
869
+
870
+ getLogs (lines, kind) {
871
+ let n = Number(lines)
872
+ if (!Number.isFinite(n) || lines === undefined || lines === null) n = RING
873
+ n = Math.max(0, Math.min(RING, Math.floor(n)))
874
+ kind = kind === 'out' || kind === 'err' || kind === 'both' ? kind : 'both'
875
+ const out = n && kind !== 'err' ? this.ring.out.slice(-n).join('') : ''
876
+ const err = n && kind !== 'out' ? this.ring.err.slice(-n).join('') : ''
877
+ return [{ pm_id: 0, name: this.appName, out, err }]
878
+ }
879
+
880
+ forwardingLogs () {
881
+ return Boolean(this.cfg.sendLogs) || this.collectLogs
882
+ }
883
+
884
+ /** The capture sink (the root's own chunks and relayed ones). Never throws. */
885
+ onLogChunk (stream, text) {
886
+ try {
887
+ if (!text || this.cfg.captureLogs === 'off' || matchesFilter(this.cfg.logFilter, text)) return
888
+ stream = stream === 'err' ? 'err' : 'out'
889
+ const ring = this.ring[stream]
890
+ ring.push(text)
891
+ if (ring.length > RING) ring.shift()
892
+ if (this._connected() && this.forwardingLogs()) {
893
+ if (this._batchBytes + text.length > LOG_BATCH_MAX) {
894
+ this._droppedLines += countLines(text)
895
+ } else {
896
+ this._batch[stream].push(text)
897
+ this._batchBytes += text.length
898
+ if (this._batchSince === null) this._batchSince = Date.now()
899
+ if (this._batchBytes >= LOG_BATCH_BYTES) setImmediate(() => this._flushLogs(false))
900
+ }
901
+ }
902
+ } catch (_) {}
903
+ }
904
+
905
+ _flushLogs (force) {
906
+ const now = Date.now()
907
+ const due = this._batchSince !== null && (force || this._batchBytes >= LOG_BATCH_BYTES || now - this._batchSince >= LOG_BATCH_MS)
908
+ const batches = []
909
+ if (due) {
910
+ for (const stream of ['out', 'err']) if (this._batch[stream].length) batches.push([stream, this._batch[stream].join('')])
911
+ this._batch = { out: [], err: [] }
912
+ this._batchBytes = 0
913
+ this._batchSince = null
914
+ }
915
+ if (now - this._windowStart >= 1000) {
916
+ this._windowStart = now
917
+ this._windowLines = 0
918
+ }
919
+ const out = []
920
+ for (const [stream, text] of batches) {
921
+ let lines = text.split(/(?<=\n)/)
922
+ const allowed = Math.max(0, LOG_LINES_PER_SECOND - this._windowLines)
923
+ if (lines.length > allowed) {
924
+ this._droppedLines += lines.length - allowed
925
+ lines = lines.slice(0, allowed)
926
+ }
927
+ this._windowLines += lines.length
928
+ if (lines.length) out.push([stream, lines.join('')])
929
+ }
930
+ let report = 0
931
+ if (this._droppedLines && (this._dropReportedAt === null || now - this._dropReportedAt >= LOG_DROP_REPORT_MS)) {
932
+ report = this._droppedLines
933
+ this._droppedLines = 0
934
+ this._dropReportedAt = now
935
+ }
936
+ for (const [stream, text] of out) this.packet('logs', { at: now, data: text, log_type: stream, process: this.ref() })
937
+ if (report) {
938
+ this.packet('logs', { at: now, data: `[@pm2/io] ${report} log lines not sent (rate limit)\n`, log_type: 'err', process: this.ref() })
939
+ }
940
+ }
941
+
942
+ // -- shutdown (NIO-268, 95 CTR-022) ---------------------------------------------------------
943
+
944
+ /**
945
+ * Sends agent:shutdown (once), flushes for at most `flushMs` and closes with 1000. Resolves
946
+ * when done. Synchronous callers (process 'exit') get a best-effort immediate write.
947
+ */
948
+ shutdown (reason, signal, planned, flushMs) {
949
+ if (this._shutdownPayload || this._closed) return Promise.resolve(false)
950
+ this._shutdownPayload = { at: Date.now(), reason, signal: signal || null, planned: planned === true }
951
+ try {
952
+ this._flushLogs(true)
953
+ } catch (_) {}
954
+ this.packet('agent:shutdown', this._shutdownPayload, null, false)
955
+ return this.flush(flushMs === undefined ? 1000 : flushMs).then(() => {
956
+ this.close(1000, 'Shutting down')
957
+ return true
958
+ })
959
+ }
960
+
961
+ /** Waits until the queue and the socket's buffer are empty (connected or connecting only). */
962
+ flush (ms) {
963
+ const deadline = Date.now() + ms
964
+ return new Promise((resolve) => {
965
+ const check = () => {
966
+ const empty = this._pending.length === 0 && (!this._ws || this._ws.bufferedAmount() === 0)
967
+ if (empty || Date.now() >= deadline || (!this._ws && !this._connecting)) return resolve()
968
+ const t = setTimeout(check, 20)
969
+ if (t.unref) t.unref()
970
+ }
971
+ check()
972
+ })
973
+ }
974
+
975
+ close (code, reason) {
976
+ this._stopping = true
977
+ this._closed = true
978
+ clearTimeout(this._timers.retry)
979
+ this._disconnectTimers()
980
+ if (this._ws) {
981
+ const ws = this._ws
982
+ try {
983
+ ws.close(code || 1000, reason)
984
+ } catch (_) {}
985
+ }
986
+ }
987
+
988
+ _disconnectTimers () {
989
+ clearInterval(this._timers.status)
990
+ clearInterval(this._timers.ping)
991
+ clearInterval(this._timers.logs)
992
+ }
993
+
994
+ destroyAll () {
995
+ this.close(1000, 'Shutting down')
996
+ if (this._ws) {
997
+ const ws = this._ws
998
+ const t = setTimeout(() => ws.terminate(), 100)
999
+ if (t.unref) t.unref()
1000
+ }
1001
+ this._listeners = []
1002
+ }
1003
+ }
1004
+
1005
+ function internalIp () {
1006
+ const ifaces = os.networkInterfaces()
1007
+ for (const name of Object.keys(ifaces)) {
1008
+ for (const a of ifaces[name] || []) {
1009
+ if ((a.family === 'IPv4' || a.family === 4) && !a.internal) return a.address
1010
+ }
1011
+ }
1012
+ return null
1013
+ }
1014
+
1015
+ function safeCwd () {
1016
+ try {
1017
+ return process.cwd()
1018
+ } catch (_) {
1019
+ return null
1020
+ }
1021
+ }
1022
+
1023
+ module.exports = { ProdmanTransport, resolveConfig, resolveUrl, serverName, matchesFilter, Footprint, IO_VERSION, REFUSAL, NOT_QUEUED }