@3pm/api
v0.1.1
Published
Node.js client for the 3pm process manager: pm2-style API, event bus subscription, application metrics and events (io)
Maintainers
Readme
@3pm/api
Node.js client for the 3pm process manager,
plus io, the application-metrics SDK (see below).
Same shape as pm2's programmatic API — connect, start, list, stop,
restart, delete, describe, launchBus — but promise-based, with zero
dependencies. It talks straight to the daemon's Unix sockets
(rpc.sock for commands, pub.sock for events) and auto-spawns the daemon
when none is running.
npm install @3pm/apiRequires Node ≥ 18 and a 3pm binary on PATH (or THREEPM_BIN) for
auto-spawn — not needed when a daemon is already up.
Quickstart
const { ThreePM } = require('@3pm/api');
const pm = await ThreePM.connect(); // spawns the daemon if needed
await pm.start({ script: 'app.js', name: 'api', instances: 2, env: { PORT: 3000 } });
console.table((await pm.list()).map(({ id, name, status, pid, restarts }) => ({ id, name, status, pid, restarts })));
const bus = await pm.launchBus({ process: 'api' });
bus.on('log:out', (e) => process.stdout.write(`${e.name}[${e.instance}] ${e.line}\n`));
bus.on('log:err', (e) => process.stderr.write(`${e.name}[${e.instance}] ${e.line}\n`));
bus.on('process:exit', (e) => console.log('exit', e.exit_code, e.exit_signal, e.stderr_tail));
bus.on('process:online', (e) => console.log('online', e.name));
bus.on('daemon:shutdown',(e) => console.log('daemon going down:', e.reason));
await pm.restart('api');
await pm.stop('api');
await pm.delete('api');
pm.disconnect(); // closes the rpc socket and every busAPI
new ThreePM({ home?, bin?, autoSpawn? = true, timeout? = 30000 }) — or await ThreePM.connect(opts).
home resolves like the CLI (resolvePaths): the option, else $THREEPM_HOME, else the home a root daemon
hosts for this account (/var/lib/3pm/users/<user>, marked by a hosted file — hostedHome() returns it or
null), else ~/.3pm. On a hosted home the client never spawns a daemon: when the root daemon is down it
throws daemon_unreachable with err.hosted === true. start() accepts user (run the app as a declared
account, root daemon) and persist: false (kept out of dump.json, never relaunched at boot); a hosted
account's daemon-level calls (save, killDaemon, resurrect, internals, hostMetrics) fail with
forbidden.
| Method | Wire request | Returns |
|---|---|---|
| connect() | ping (checks protocol === 1) | this |
| ping() | ping | {pid, version, protocol} |
| start(opts \| opts[], {ifChanged?}) | start | ProcessRecord[] |
| scale(name, n) | scale | ProcessRecord[] |
| stop(target) | stop | ProcessRecord[] (returns once dead) |
| restart(target, {toggleWatch?}) | restart | ProcessRecord[] |
| delete(target, {flush?}) / del | delete | ProcessRecord[] |
| reset(target) | reset | ProcessRecord[] |
| sendSignal(sig, target, {group?}) | send_signal | ProcessRecord[] |
| list() / ls() | list | ProcessRecord[] |
| describe(target) / show() | describe | ProcessRecord[] (every instance) |
| save() / resurrect() | save / resurrect | {path, count} / ProcessRecord[] |
| killDaemon() | kill_daemon | — (buses stay open to see daemon:shutdown) |
| internals() / hostMetrics() | internals / host_metrics | growable snapshots |
| flushLogs(t?) / rotateLogs(t?) / reloadLogs() | *_logs | touched file paths |
| request(cmd, fields) | anything | raw response — escape hatch for new requests |
| launchBus(filter?) | — | EventBus |
target is an id (3 / "3"), a name ("api"), "all", {id} or {name}.
Start options
pm2 names, pm2 units: script (required; resolved against cwd), name, cwd, args
(string or array), interpreter (inferred from the extension — .js/.ts→node,
.py→python3, .rb→ruby, .sh→bash, .pl→perl — then resolved to an absolute path;
'none' = direct exec), env, instances ('max' = one per CPU), autorestart /
restart_policy, max_memory_restart ("200M"), min_uptime, kill_timeout,
restart_delay, listen_timeout, wait_ready, watch (true, path, list),
ignore_watch, out_file/error_file/log_file ('NULL' disables), merge_logs,
log_date_format, stop_exit_codes, cron_restart, depends_on, max_restarts,
backoff_*, log_rotate, … — the full list is in index.d.ts (StartOptions).
Unknown keys throw invalid_spec with a did-you-mean.
Events
launchBus({ subscribe?, process?, ids?, reconnect? }) sends the daemon an
event_filter so unwanted events never cross the socket:
subscribe: globs over event names —['process:*'],['log_err'],['process:exit', 'log_*']process: one app name;ids: process idsreconnect: true | {delayMs, maxAttempts}: re-subscribe after an EOF (e.g. across3pm update)
| Emitted as | Also as | Payload |
|---|---|---|
| log:out, log:err | log_out, log_err | {id, name, instance, line, ts?} |
| process:event + process:<kind> (start, online, exit, restart, stop, delete, errored) | process | {id, name, kind, exit_code?, exit_signal?, stderr_tail?} |
| log:rotated | log_rotated | {id, name, instance, path} |
| lagged | — | {missed} — the subscriber fell behind |
| daemon:shutdown | daemon_shutdown | {reason: 'kill' \| 'signal' \| 'home_removed'} |
| event | — | every decoded event, unknown kinds included |
| connect, reconnect, close({reason}), error | — | bus lifecycle |
A close with reason: 'eof' (no daemon_shutdown first) is an unannounced daemon death.
Errors
Every failure is a ThreePMError with .code: the daemon's codes (not_found,
invalid_spec, spawn_failed, unknown_request, shutting_down, internal) or
client-side daemon_unreachable, protocol_skew (run 3pm update), timeout,
disconnected, protocol. Codes are exported as CODES.
Application metrics and events: io
The other half of the package: what a process started by 3pm reports
about itself (pm2's io.metric/io.counter/io.notifyError, for 3pm).
Zero dependencies, never blocking, never throwing; outside 3pm (no
THREEPM_CTL_SOCK in the environment) every call is a no-op.
const { io } = require('@3pm/api');
io.init({ source: 'worker', builtins: true }); // one connection per process of the app
const requests = io.counter('http.requests', { labels: { method: 'GET' } });
const latency = io.histogram('http.latency', { unit: 'ms' });
const temp = io.gauge('room.temp', { unit: 'Cel', description: 'studio temperature' });
io.metric('queue.depth', () => queue.length); // read at every flush
requests.labels({ status: 200 }).inc();
latency.observe(12.5);
temp.set(21.5);
io.event('deploy', { level: 'warn', attrs: { version: '1.2' } });
io.notifyError(err, { attrs: { where: 'worker' } }); // an `error` event with the stackThen 3pm show <app> (section "Custom metrics") and 3pm events -f app.
Full reference, bounds and the three languages: docs/app-metrics.md in
the 3pm repository.
| Call | Notes |
|---|---|
| io.init({ source?, flush_ms? = 1000, builtins?, profile?, on_shutdown? }) | Connects in the background; idempotent. source defaults to a name unique per connection: main for the process 3pm spawned, worker-<pid>-<threadId> in a worker thread, pid-<pid> in a forked child (child_process.fork, cluster) or a child of the process holding main; two processes that both look spawned (sh -c 'node a & node b') both claim main, and the one refused falls back to pid-<pid> — two connections of one instance under one source replace each other. The built-in 3pm:profile action is declared on main only (a bare 3pm profile <app> is never ambiguous); profile: true adds it on any source, profile: false withholds it. builtins: true adds the event loop (nodejs.eventloop.delay histogram, .p99/.max, .utilization), GC pauses (nodejs.gc.duration{kind}), memory (nodejs.heap.used/.committed/.limit, nodejs.memory.external/.array_buffers), nodejs.handles.active, process.cpu.utilization, and the counters process.page_faults.major, process.context_switches.involuntary; builtins: { heap_spaces: true } adds nodejs.heap.space.used{space} |
| io.electron(require('electron')) | Electron main process: electron.processes/.process.cpu/.process.memory by type, electron.power.* (battery, thermal state, speed limit), counters and events for crashed processes and hung windows, sleep/wake. Returns stop() |
| createRendererIo(ipcRenderer, { source?, builtins? }) (@3pm/api/renderer) | Renderer/preload side of io.ipcBridge(ipcMain). builtins: true adds renderer.eventloop.delay, renderer.longtask.duration, renderer.heap.*, renderer.blink.*, renderer.cpu.utilization, renderer.dom.nodes (what the runtime offers) |
| gauge / counter / meter / histogram(name, { unit?, description?, labels?, scale? }) | Same name + labels = same object. Counters are cumulative; histograms are base-2 exponential (scale 0..4, default 1) and lower their scale by one (buckets merge in pairs) whenever a 129th bucket would open, so an entry always fits the daemon's cap. .labels({...}) derives a child — cache it rather than calling it per request; past 50 label sets a metric folds into overflow="true", and the fold's .labels() returns the fold |
| metric(name, fn, opts?) | Gauge by callback, evaluated on the event loop at flush. Callbacks and metrics share the 200-name budget: a name is one or the other |
| event(name, { level?, attrs?, exc?, fp? }) | Sent at once (queued while the connection comes up) |
| notifyError(err, { attrs?, name? }) | error-level event carrying exc.{type,message,stacktrace} |
| action(name, { description?, remote? = true, timeout_ms? }, handler) | Expose handler(params, ctx) to 3pm trigger <app> <name> [params] and, unless remote: false, to the fleet backend. What it returns (JSON, ≤ 512 KiB, ≤ 64 levels deep) is the result; past either bound the call fails with code too_large — a bigger one is a file: write ctx.file + '.tmp' then await ctx.commitFile(ext) (past the call's timeout it deletes the .tmp and throws). Whatever the handler throws is the failure, even a value with no readable message. Returns unregister. A 3pm: name throws (the SDK's prefix). At most 32 actions per source, 3pm:profile included. 3pm:profile (CPU/heap sampling profile, {kind: cpu\|heap, duration_ms: 1000..60000, sampling}) is declared on main by default; invalid params, or a timeout leaving less than 15 s after the sampling, fail the call before anything is sampled |
| flush({ wait_ms? }), shutdown() | The whole registry is sent every flush_ms anyway. flush resolves true when every batch was written (and, with wait_ms, left the process); shutdown is safe to call twice |
Strings are bounded in UTF-8 bytes, as the daemon measures them: label
values ≤ 64 bytes (else the series is refused), unit/description cut to
16/128 bytes, event attributes to 256, exception fields to 128/1024/8192.
Cuts land on character boundaries and lone surrogates become U+FFFD, so
nothing sent is ever refused. Everything else that goes wrong (bad name,
reserved label, wrong kind for a known name) returns a no-op metric
(enabled: false) and logs under THREEPM_IO_DEBUG=1.
init also accepts transport knobs, advanced and mainly for tests:
reconnect_min_ms, reconnect_max_ms, max_buffer_bytes (outbound
budget past which metrics and events are dropped, 64 KiB; the actions
declaration and replies have their own and are never crowded out),
max_refusals, refusal_window_ms. io.stats is a copy of { sent,
dropped, connects, closes, refusals } (metrics and events lines).
Then 3pm trigger <app> lists the actions, 3pm trigger <app> <name>
'{"json": true}' runs one on every instance, 3pm profile <app> takes a
profile and prints the hot frames; every call is a proc_action event
(3pm events -f proc_action).
Electron: the renderer has no Unix socket. In the main process,
io.init({ source: 'electron-main' }); io.ipcBridge(ipcMain, { flush_ms?,
max_sources? = 16 }); in a renderer, const { createRendererIo } =
require('@3pm/api/renderer'); const rio = createRendererIo(ipcRenderer,
{ source: 'renderer' }) — same surface, one control connection per
renderer source. The renderer entry loads no Node builtin; the main
process re-validates every relayed entry and bounds what it keeps per
source; a renderer cannot take an automatic name (main, pid-*,
worker-*) or the main process's own source. A renderer's
rio.action() needs an ipcRenderer with on (calls come back on
3pm:action, acknowledgements on 3pm:actions_ack: the declaration is
sent again until the bridge has it, so one made before ipcBridge()
still arrives; shutdown() withdraws the actions and removes the
listeners). Several windows may share a source: each keeps its own
actions — a call goes to the window that declared it (the latest, when
several declared one name) and only its reply is taken — until it
declares again, commits a navigation (a reload too; the next page is
asked for its own), crashes or is destroyed; a navigation that never
commits (prevented, a download, an HTTP 204) keeps them. The bridge profiles a renderer over
webContents.debugger under 3pm:profile when exactly one live window
holds the source (give each window its own source to profile it), and
fails at once when the debugger is already attached (DevTools open).
Compatibility
The wire is growable: unknown events are still surfaced on event, unknown
response tags become a protocol error rather than a hang, and the frozen
fixtures of threepm-protocol are part of this package's test suite.
PROTOCOL_VERSION is checked on every connect().
Development
npm test # unit + wire fixtures + e2e (uses ../../target/{debug,release}/3pm or THREEPM_BIN)