procband 0.0.0 → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # procband
2
+
3
+ ## Purpose
4
+
5
+ `procband` supervises subprocesses from TypeScript. It prefixes child output,
6
+ matches future log lines, waits for terminal exit, optionally restarts failed
7
+ processes, and tree-kills descendants during shutdown.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ pnpm add procband
13
+ ```
14
+
15
+ ## Quick Example
16
+
17
+ ```ts
18
+ import process from 'node:process'
19
+ import { supervise } from 'procband'
20
+
21
+ const proc = supervise({
22
+ name: 'api',
23
+ command: process.execPath,
24
+ args: ['-e', 'console.log("ready")'],
25
+ })
26
+
27
+ await proc.waitFor('ready')
28
+ const result = await proc
29
+ console.log(result)
30
+ ```
31
+
32
+ ## Documentation Map
33
+
34
+ - Concepts and lifecycle: [docs/context.md](docs/context.md)
35
+ - Minimal usage example: [examples/basic-usage.ts](examples/basic-usage.ts)
36
+ - Restart example: [examples/restart-on-failure.ts](examples/restart-on-failure.ts)
37
+ - Exact exported signatures: [dist/index.d.mts](dist/index.d.mts)
@@ -0,0 +1,287 @@
1
+ import { ChildProcess } from "node:child_process";
2
+ import { Writable } from "node:stream";
3
+
4
+ //#region src/types.d.ts
5
+ /**
6
+ * Accepted Node.js signal names.
7
+ */
8
+ type Signals = NodeJS.Signals;
9
+ /**
10
+ * Signal values accepted by `kill()` and `stop()`.
11
+ */
12
+ type KillSignal = number | Signals;
13
+ /**
14
+ * A line matcher used by `match()` and `waitFor()`.
15
+ *
16
+ * String patterns use substring matching. `RegExp` patterns are executed
17
+ * against the full observed line.
18
+ */
19
+ type MatchPattern = string | RegExp;
20
+ /**
21
+ * The output streams that can participate in line matching.
22
+ */
23
+ type MatchStream = 'stdout' | 'stderr' | 'both';
24
+ /**
25
+ * Configuration for a single supervised subprocess.
26
+ */
27
+ interface ProcessConfig {
28
+ /**
29
+ * Stable identifier used in prefixed output and emitted match events.
30
+ */
31
+ name: string;
32
+ /**
33
+ * Executable or shell-free command name passed to `spawn()`.
34
+ */
35
+ command: string;
36
+ /**
37
+ * Positional arguments passed to the child process.
38
+ */
39
+ args?: string[];
40
+ /**
41
+ * Working directory for the child process.
42
+ */
43
+ cwd?: string;
44
+ /**
45
+ * Environment variables for the child process.
46
+ */
47
+ env?: Record<string, string | undefined>;
48
+ /**
49
+ * Human-facing label used in prefixed output.
50
+ *
51
+ * Defaults to `name`.
52
+ */
53
+ label?: string;
54
+ /**
55
+ * RGB color for this process's `stdout` prefix.
56
+ *
57
+ * When omitted, `procband` assigns the next color from its default palette.
58
+ * The reserved `stderr` red cannot be used here.
59
+ */
60
+ color?: RgbColor;
61
+ /**
62
+ * Optional extra sink for raw child `stderr` bytes.
63
+ *
64
+ * This is additive. It does not replace the normal prefixed writes to
65
+ * `process.stderr`.
66
+ */
67
+ stderr?: Writable;
68
+ /**
69
+ * Automatic restart behavior for terminal child exits.
70
+ *
71
+ * Use `true` for the built-in defaults or provide an explicit policy.
72
+ */
73
+ restart?: boolean | RestartPolicy;
74
+ }
75
+ /**
76
+ * A 24-bit RGB color tuple.
77
+ */
78
+ type RgbColor = readonly [red: number, green: number, blue: number];
79
+ /**
80
+ * Controls whether and how a supervised process restarts after exit.
81
+ */
82
+ interface RestartPolicy {
83
+ /**
84
+ * Which exit outcomes trigger a restart.
85
+ *
86
+ * Defaults to `"on-failure"`.
87
+ */
88
+ when?: 'on-failure' | 'on-exit';
89
+ /**
90
+ * Delay before spawning the next attempt.
91
+ *
92
+ * Defaults to `1000`.
93
+ */
94
+ delayMs?: number;
95
+ /**
96
+ * Maximum failed exits allowed inside `windowMs` before restart is
97
+ * suppressed.
98
+ *
99
+ * Defaults to `3`.
100
+ */
101
+ maxFailures?: number;
102
+ /**
103
+ * Rolling time window used for failed-exit suppression.
104
+ *
105
+ * Defaults to `30000`.
106
+ */
107
+ windowMs?: number;
108
+ }
109
+ /**
110
+ * Common options for line-matching APIs.
111
+ */
112
+ interface MatchOptions {
113
+ /**
114
+ * Which output stream to inspect.
115
+ *
116
+ * Defaults to `"both"`.
117
+ */
118
+ stream?: MatchStream;
119
+ }
120
+ /**
121
+ * Options for `waitFor()`.
122
+ */
123
+ interface WaitForOptions extends MatchOptions {
124
+ /**
125
+ * Maximum time to wait for a future match before rejecting.
126
+ */
127
+ timeoutMs?: number;
128
+ }
129
+ /**
130
+ * A matched output line observed from a supervised process.
131
+ */
132
+ interface MatchEvent {
133
+ /**
134
+ * The `ProcessConfig.name` associated with the process.
135
+ */
136
+ process: string;
137
+ /**
138
+ * Which child output stream produced the line.
139
+ */
140
+ stream: 'stdout' | 'stderr';
141
+ /**
142
+ * The matched line without its trailing newline.
143
+ */
144
+ line: string;
145
+ /**
146
+ * The `RegExp.exec()` result for regex patterns, or `null` for string
147
+ * patterns.
148
+ */
149
+ match: RegExpExecArray | null;
150
+ /**
151
+ * The wall-clock timestamp, in milliseconds since the Unix epoch, when the
152
+ * line was emitted to subscribers.
153
+ */
154
+ timestamp: number;
155
+ }
156
+ /**
157
+ * Callback invoked for each future matching line.
158
+ */
159
+ type MatchCallback = (event: MatchEvent) => void;
160
+ /**
161
+ * Stops a callback subscription created by `match()`.
162
+ */
163
+ type Unsubscribe = () => void;
164
+ /**
165
+ * Options for `stop()`.
166
+ */
167
+ interface StopOptions {
168
+ /**
169
+ * Signal used for the initial process-tree shutdown attempt.
170
+ *
171
+ * Defaults to `"SIGTERM"`.
172
+ */
173
+ signal?: KillSignal;
174
+ /**
175
+ * Delay before escalating to `SIGKILL`.
176
+ *
177
+ * Defaults to `5000`.
178
+ */
179
+ killAfterMs?: number;
180
+ }
181
+ /**
182
+ * Final state for a supervised process after all restart attempts are done.
183
+ */
184
+ interface ProcessResult {
185
+ /**
186
+ * The `ProcessConfig.name` for the completed process.
187
+ */
188
+ name: string;
189
+ /**
190
+ * Final child exit code, or `null` when the process exited by signal.
191
+ */
192
+ code: number | null;
193
+ /**
194
+ * Final terminating signal, or `null` when the process exited normally.
195
+ */
196
+ signal: Signals | null;
197
+ /**
198
+ * Number of restart attempts that were started.
199
+ */
200
+ restarts: number;
201
+ /**
202
+ * Whether restart was disabled by the failure-suppression guard.
203
+ */
204
+ restartSuppressed: boolean;
205
+ }
206
+ /**
207
+ * A supervised child-process handle.
208
+ *
209
+ * `ProcbandProcess` behaves like the current active `ChildProcess`, while also
210
+ * exposing matching, terminal shutdown, and final-result helpers. The wrapper
211
+ * is thenable, so `await proc` is equivalent to `await proc.wait()`.
212
+ */
213
+ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
214
+ /**
215
+ * Subscribe to future matching lines from this process.
216
+ *
217
+ * Matching is line-based and forward-only. One subscription does not consume
218
+ * events from another. If `onMatch` throws, only that subscription is
219
+ * removed.
220
+ *
221
+ * @param pattern The string or regular expression to match against each
222
+ * future line.
223
+ * @param onMatch Invoked for every future matching line.
224
+ * @param options Optional stream filter.
225
+ * @returns An idempotent function that unsubscribes the callback.
226
+ */
227
+ match(pattern: MatchPattern, onMatch: MatchCallback, options?: MatchOptions): Unsubscribe;
228
+ /**
229
+ * Wait for the first future matching line from this process.
230
+ *
231
+ * @param pattern The string or regular expression to match against each
232
+ * future line.
233
+ * @param options Optional stream filter and timeout.
234
+ * @returns The first future matching event.
235
+ * @throws When `timeoutMs` elapses before a match is observed.
236
+ * @throws When the process becomes terminal before a match is observed.
237
+ */
238
+ waitFor(pattern: MatchPattern, options?: WaitForOptions): Promise<MatchEvent>;
239
+ /**
240
+ * Wait for the process to become terminal with no further restart pending.
241
+ *
242
+ * Unlike many process helpers, this resolves for both success and failure.
243
+ * Inspect the returned `ProcessResult` instead of relying on promise
244
+ * rejection for non-zero exits.
245
+ */
246
+ wait(): Promise<ProcessResult>;
247
+ /**
248
+ * Disable future restarts and terminate the active process tree.
249
+ *
250
+ * `stop()` targets the current child process and any descendants it spawned.
251
+ * It first uses the configured `signal`, then escalates to `SIGKILL` after
252
+ * `killAfterMs` if the tree is still alive.
253
+ */
254
+ stop(options?: StopOptions): Promise<void>;
255
+ }
256
+ //#endregion
257
+ //#region src/process.d.ts
258
+ /**
259
+ * Start supervising a single subprocess.
260
+ *
261
+ * The returned `ProccoliProcess` starts immediately, prefixes child output,
262
+ * exposes line-based matching helpers, and resolves when the process reaches a
263
+ * terminal state with no further restart pending.
264
+ *
265
+ * @param config The subprocess configuration to supervise.
266
+ * @returns A `ChildProcess`-compatible wrapper with procband-specific helpers.
267
+ * @throws When `config` is invalid, such as a missing `name`, missing
268
+ * `command`, or an invalid reserved color.
269
+ * @example
270
+ * ```ts
271
+ * import process from 'node:process'
272
+ * import { supervise } from 'procband'
273
+ *
274
+ * const proc = supervise({
275
+ * name: 'server',
276
+ * command: process.execPath,
277
+ * args: ['-e', 'console.log("ready")'],
278
+ * })
279
+ *
280
+ * await proc.waitFor('ready')
281
+ * const result = await proc
282
+ * console.log(result)
283
+ * ```
284
+ */
285
+ declare function supervise(config: ProcessConfig): ProcbandProcess;
286
+ //#endregion
287
+ export { type MatchCallback, type MatchEvent, type MatchOptions, type ProcbandProcess, type ProcessConfig, type ProcessResult, type RestartPolicy, type RgbColor, type StopOptions, type Unsubscribe, type WaitForOptions, supervise };
package/dist/index.mjs ADDED
@@ -0,0 +1,668 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import process from "node:process";
4
+ import ansiStyles from "ansi-styles";
5
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
6
+ import treeKill from "@alloc/tree-kill";
7
+ //#region src/colors.ts
8
+ const prefixPalette = [
9
+ [
10
+ 99,
11
+ 102,
12
+ 241
13
+ ],
14
+ [
15
+ 14,
16
+ 165,
17
+ 233
18
+ ],
19
+ [
20
+ 6,
21
+ 182,
22
+ 212
23
+ ],
24
+ [
25
+ 16,
26
+ 185,
27
+ 129
28
+ ],
29
+ [
30
+ 132,
31
+ 204,
32
+ 22
33
+ ],
34
+ [
35
+ 245,
36
+ 158,
37
+ 11
38
+ ],
39
+ [
40
+ 249,
41
+ 115,
42
+ 22
43
+ ],
44
+ [
45
+ 236,
46
+ 72,
47
+ 153
48
+ ],
49
+ [
50
+ 168,
51
+ 85,
52
+ 247
53
+ ],
54
+ [
55
+ 139,
56
+ 92,
57
+ 246
58
+ ]
59
+ ];
60
+ const stderrColor = [
61
+ 239,
62
+ 68,
63
+ 68
64
+ ];
65
+ let nextPaletteIndex = 0;
66
+ function resolveProcessColor(color) {
67
+ if (color) return [...color];
68
+ const paletteColor = prefixPalette[nextPaletteIndex % prefixPalette.length];
69
+ nextPaletteIndex += 1;
70
+ return [...paletteColor];
71
+ }
72
+ function validateProcessColor(color) {
73
+ if (!color) return;
74
+ const [red, green, blue] = color;
75
+ for (const value of [
76
+ red,
77
+ green,
78
+ blue
79
+ ]) if (!Number.isInteger(value) || value < 0 || value > 255) throw new Error("ProcessConfig.color must contain integer RGB values between 0 and 255");
80
+ if (red === stderrColor[0] && green === stderrColor[1] && blue === stderrColor[2]) throw new Error("ProcessConfig.color cannot use the reserved stderr color");
81
+ }
82
+ function writePrefixedLine(target, label, color, line, appendNewline) {
83
+ target.write(formatPrefix(label, color) + line + (appendNewline ? "\n" : ""));
84
+ }
85
+ function formatPrefix(label, color) {
86
+ return `${ansiStyles.color.ansi16m(...color)}[${label}]${ansiStyles.color.close} `;
87
+ }
88
+ //#endregion
89
+ //#region src/matching.ts
90
+ var MatchRegistry = class {
91
+ subscriptions = /* @__PURE__ */ new Set();
92
+ closedError = null;
93
+ processName;
94
+ constructor(processName) {
95
+ this.processName = processName;
96
+ }
97
+ match(pattern, onMatch, options) {
98
+ if (this.closedError) return () => {};
99
+ const subscription = {
100
+ active: true,
101
+ kind: "callback",
102
+ pattern,
103
+ stream: options?.stream ?? "both",
104
+ onMatch
105
+ };
106
+ this.subscriptions.add(subscription);
107
+ return () => {
108
+ this.unsubscribe(subscription);
109
+ };
110
+ }
111
+ waitFor(pattern, options) {
112
+ if (this.closedError) return Promise.reject(this.closedError);
113
+ return new Promise((resolve, reject) => {
114
+ const subscription = {
115
+ active: true,
116
+ kind: "promise",
117
+ pattern,
118
+ stream: options?.stream ?? "both",
119
+ resolve: (event) => {
120
+ this.unsubscribe(subscription);
121
+ resolve(event);
122
+ },
123
+ reject: (error) => {
124
+ this.unsubscribe(subscription);
125
+ reject(error);
126
+ },
127
+ timer: null
128
+ };
129
+ if (options?.timeoutMs != null) subscription.timer = setTimeout(() => {
130
+ subscription.reject(/* @__PURE__ */ new Error(`Timed out waiting for a match from process "${this.processName}"`));
131
+ }, options.timeoutMs);
132
+ this.subscriptions.add(subscription);
133
+ });
134
+ }
135
+ emit(stream, line) {
136
+ if (this.closedError) return;
137
+ const baseEvent = createMatchEvent(this.processName, stream, line);
138
+ for (const subscription of [...this.subscriptions]) {
139
+ if (!subscription.active || !matchesStream(subscription.stream, stream)) continue;
140
+ const match = matchPattern(subscription.pattern, line);
141
+ if (!match.matched) continue;
142
+ const event = {
143
+ ...baseEvent,
144
+ match: match.value
145
+ };
146
+ if (subscription.kind === "callback") {
147
+ try {
148
+ subscription.onMatch(event);
149
+ } catch {
150
+ this.unsubscribe(subscription);
151
+ }
152
+ continue;
153
+ }
154
+ subscription.resolve(event);
155
+ }
156
+ }
157
+ close(error) {
158
+ if (this.closedError) return;
159
+ this.closedError = error;
160
+ for (const subscription of [...this.subscriptions]) if (subscription.kind === "promise") subscription.reject(error);
161
+ else this.unsubscribe(subscription);
162
+ }
163
+ unsubscribe(subscription) {
164
+ if (!subscription.active) return;
165
+ subscription.active = false;
166
+ if (subscription.kind === "promise" && subscription.timer) {
167
+ clearTimeout(subscription.timer);
168
+ subscription.timer = null;
169
+ }
170
+ this.subscriptions.delete(subscription);
171
+ }
172
+ };
173
+ function createMatchEvent(processName, stream, line) {
174
+ return {
175
+ process: processName,
176
+ stream,
177
+ line,
178
+ match: null,
179
+ timestamp: Date.now()
180
+ };
181
+ }
182
+ function matchesStream(expected, actual) {
183
+ return expected === "both" || expected === actual;
184
+ }
185
+ function matchPattern(pattern, line) {
186
+ if (typeof pattern === "string") return {
187
+ matched: line.includes(pattern),
188
+ value: null
189
+ };
190
+ pattern.lastIndex = 0;
191
+ const match = pattern.exec(line);
192
+ return {
193
+ matched: match != null,
194
+ value: match
195
+ };
196
+ }
197
+ //#endregion
198
+ //#region src/restart.ts
199
+ const defaultRestartPolicy = Object.freeze({
200
+ when: "on-failure",
201
+ delayMs: 1e3,
202
+ maxFailures: 3,
203
+ windowMs: 3e4
204
+ });
205
+ var RestartController = class {
206
+ policy;
207
+ restarts = 0;
208
+ restartSuppressed = false;
209
+ restartFailures = [];
210
+ restartTimer = null;
211
+ cancelRestartDelay = null;
212
+ constructor(restart) {
213
+ this.policy = normalizeRestartPolicy(restart);
214
+ }
215
+ shouldRestart(code, signal, restartDisabled, finalized) {
216
+ if (finalized || restartDisabled || !this.policy) return false;
217
+ if (this.policy.when === "on-exit") return true;
218
+ return isFailedExit(code, signal);
219
+ }
220
+ prepareRestart(code, signal) {
221
+ if (!this.policy) return false;
222
+ if (isFailedExit(code, signal)) {
223
+ const now = Date.now();
224
+ this.restartFailures.push(now);
225
+ this.restartFailures = this.restartFailures.filter((timestamp) => now - timestamp <= this.policy.windowMs);
226
+ if (this.restartFailures.length > this.policy.maxFailures) {
227
+ this.restartSuppressed = true;
228
+ return false;
229
+ }
230
+ }
231
+ this.restarts += 1;
232
+ return true;
233
+ }
234
+ waitForDelay() {
235
+ if (!this.policy || this.policy.delayMs <= 0) return Promise.resolve(true);
236
+ return new Promise((resolve) => {
237
+ this.restartTimer = setTimeout(() => {
238
+ this.restartTimer = null;
239
+ this.cancelRestartDelay = null;
240
+ resolve(true);
241
+ }, this.policy.delayMs);
242
+ this.cancelRestartDelay = () => {
243
+ if (this.restartTimer) {
244
+ clearTimeout(this.restartTimer);
245
+ this.restartTimer = null;
246
+ }
247
+ this.cancelRestartDelay = null;
248
+ resolve(false);
249
+ };
250
+ });
251
+ }
252
+ cancelDelay() {
253
+ this.cancelRestartDelay?.();
254
+ }
255
+ };
256
+ function normalizeRestartPolicy(restart) {
257
+ if (!restart) return null;
258
+ if (restart === true) return { ...defaultRestartPolicy };
259
+ return {
260
+ when: restart.when ?? defaultRestartPolicy.when,
261
+ delayMs: restart.delayMs ?? defaultRestartPolicy.delayMs,
262
+ maxFailures: restart.maxFailures ?? defaultRestartPolicy.maxFailures,
263
+ windowMs: restart.windowMs ?? defaultRestartPolicy.windowMs
264
+ };
265
+ }
266
+ function isFailedExit(code, signal) {
267
+ return signal != null || code !== 0;
268
+ }
269
+ //#endregion
270
+ //#region src/shutdown.ts
271
+ const liveTargets = /* @__PURE__ */ new Set();
272
+ let parentCleanupInstalled = false;
273
+ let handlingSigint = false;
274
+ function registerCleanupTarget(target) {
275
+ liveTargets.add(target);
276
+ installParentCleanup();
277
+ }
278
+ function unregisterCleanupTarget(target) {
279
+ liveTargets.delete(target);
280
+ if (liveTargets.size === 0) uninstallParentCleanup();
281
+ }
282
+ function killTreeBestEffort(child, signal) {
283
+ treeKill(child, signal).catch(() => {});
284
+ }
285
+ async function stopChildTree(child, close, isClosed, signal, killAfterMs) {
286
+ try {
287
+ await treeKill(child, signal);
288
+ } catch (error) {
289
+ if (!isMissingProcessError(error)) throw error;
290
+ }
291
+ if (!await Promise.race([close.then(() => true), setTimeout$1(killAfterMs, false)]) && !isClosed()) {
292
+ try {
293
+ await treeKill(child, "SIGKILL");
294
+ } catch (error) {
295
+ if (!isMissingProcessError(error)) throw error;
296
+ }
297
+ await close;
298
+ }
299
+ }
300
+ function installParentCleanup() {
301
+ if (parentCleanupInstalled) return;
302
+ parentCleanupInstalled = true;
303
+ process.on("SIGINT", onParentSigint);
304
+ process.on("exit", onParentExit);
305
+ }
306
+ function uninstallParentCleanup() {
307
+ if (!parentCleanupInstalled) return;
308
+ parentCleanupInstalled = false;
309
+ process.off("SIGINT", onParentSigint);
310
+ process.off("exit", onParentExit);
311
+ }
312
+ function onParentExit() {
313
+ for (const target of [...liveTargets]) target.cleanupFromExit();
314
+ }
315
+ function onParentSigint() {
316
+ if (handlingSigint) return;
317
+ handlingSigint = true;
318
+ const pending = [...liveTargets].map((target) => target.cleanupFromSigint());
319
+ Promise.allSettled(pending).finally(() => {
320
+ handlingSigint = false;
321
+ process.off("SIGINT", onParentSigint);
322
+ if (process.listenerCount("SIGINT") === 0) {
323
+ process.kill(process.pid, "SIGINT");
324
+ return;
325
+ }
326
+ process.on("SIGINT", onParentSigint);
327
+ });
328
+ }
329
+ function isMissingProcessError(error) {
330
+ return typeof error === "object" && error != null && "code" in error && error.code === "ESRCH";
331
+ }
332
+ //#endregion
333
+ //#region src/process.ts
334
+ /**
335
+ * Start supervising a single subprocess.
336
+ *
337
+ * The returned `ProccoliProcess` starts immediately, prefixes child output,
338
+ * exposes line-based matching helpers, and resolves when the process reaches a
339
+ * terminal state with no further restart pending.
340
+ *
341
+ * @param config The subprocess configuration to supervise.
342
+ * @returns A `ChildProcess`-compatible wrapper with procband-specific helpers.
343
+ * @throws When `config` is invalid, such as a missing `name`, missing
344
+ * `command`, or an invalid reserved color.
345
+ * @example
346
+ * ```ts
347
+ * import process from 'node:process'
348
+ * import { supervise } from 'procband'
349
+ *
350
+ * const proc = supervise({
351
+ * name: 'server',
352
+ * command: process.execPath,
353
+ * args: ['-e', 'console.log("ready")'],
354
+ * })
355
+ *
356
+ * await proc.waitFor('ready')
357
+ * const result = await proc
358
+ * console.log(result)
359
+ * ```
360
+ */
361
+ function supervise(config) {
362
+ return new ProcbandProcessImpl(config);
363
+ }
364
+ var ProcbandProcessImpl = class extends EventEmitter {
365
+ config;
366
+ name;
367
+ label;
368
+ color;
369
+ matches;
370
+ restart;
371
+ finalPromise;
372
+ finalResolve;
373
+ attempt = null;
374
+ generation = 0;
375
+ stderrSink;
376
+ stderrSinkActive = true;
377
+ stderrSinkCleanup = null;
378
+ stopPromise = null;
379
+ restartDisabled = false;
380
+ finalized = false;
381
+ lastResult = null;
382
+ constructor(config) {
383
+ super();
384
+ validateProcessConfig(config);
385
+ this.config = config;
386
+ this.name = config.name;
387
+ this.label = config.label ?? config.name;
388
+ this.color = resolveProcessColor(config.color);
389
+ this.matches = new MatchRegistry(this.name);
390
+ this.restart = new RestartController(config.restart);
391
+ this.stderrSink = config.stderr ?? null;
392
+ this.finalPromise = new Promise((resolve) => {
393
+ this.finalResolve = resolve;
394
+ });
395
+ this.bindStderrSink();
396
+ this.spawnAttempt();
397
+ registerCleanupTarget(this);
398
+ }
399
+ get pid() {
400
+ return this.currentChild?.pid;
401
+ }
402
+ get stdin() {
403
+ return this.currentChild?.stdin ?? null;
404
+ }
405
+ get stdout() {
406
+ return this.currentChild?.stdout ?? null;
407
+ }
408
+ get stderr() {
409
+ return this.currentChild?.stderr ?? null;
410
+ }
411
+ get stdio() {
412
+ return this.currentChild?.stdio ?? [
413
+ null,
414
+ null,
415
+ null
416
+ ];
417
+ }
418
+ get killed() {
419
+ return this.currentChild?.killed ?? false;
420
+ }
421
+ get exitCode() {
422
+ return this.currentChild?.exitCode ?? this.lastResult?.code ?? null;
423
+ }
424
+ get signalCode() {
425
+ return this.currentChild?.signalCode ?? this.lastResult?.signal ?? null;
426
+ }
427
+ get connected() {
428
+ return this.currentChild?.connected ?? false;
429
+ }
430
+ get spawnargs() {
431
+ return this.currentChild?.spawnargs ?? [];
432
+ }
433
+ get spawnfile() {
434
+ return this.currentChild?.spawnfile ?? this.config.command;
435
+ }
436
+ get currentChild() {
437
+ return this.attempt?.child ?? null;
438
+ }
439
+ match(pattern, onMatch, options) {
440
+ return this.matches.match(pattern, onMatch, options);
441
+ }
442
+ waitFor(pattern, options) {
443
+ return this.matches.waitFor(pattern, options);
444
+ }
445
+ wait() {
446
+ return this.finalPromise;
447
+ }
448
+ then(onfulfilled, onrejected) {
449
+ return this.finalPromise.then(onfulfilled, onrejected);
450
+ }
451
+ catch(onrejected) {
452
+ return this.finalPromise.catch(onrejected);
453
+ }
454
+ finally(onfinally) {
455
+ return this.finalPromise.finally(onfinally);
456
+ }
457
+ kill(signal) {
458
+ const child = this.currentChild;
459
+ if (!child) return false;
460
+ return child.kill(signal);
461
+ }
462
+ ref() {
463
+ this.currentChild?.ref();
464
+ return this;
465
+ }
466
+ unref() {
467
+ this.currentChild?.unref();
468
+ return this;
469
+ }
470
+ disconnect() {
471
+ this.currentChild?.disconnect();
472
+ }
473
+ async stop(options) {
474
+ if (this.stopPromise) return this.stopPromise;
475
+ this.restartDisabled = true;
476
+ this.restart.cancelDelay();
477
+ this.stopPromise = this.stopActiveTree(options?.signal ?? "SIGTERM", options?.killAfterMs ?? 5e3);
478
+ return this.stopPromise;
479
+ }
480
+ cleanupFromExit() {
481
+ this.restartDisabled = true;
482
+ this.restart.cancelDelay();
483
+ const child = this.currentChild;
484
+ if (child) killTreeBestEffort(child);
485
+ }
486
+ async cleanupFromSigint() {
487
+ this.restartDisabled = true;
488
+ this.restart.cancelDelay();
489
+ await this.stopActiveTree("SIGINT", 1e3);
490
+ }
491
+ async stopActiveTree(signal, killAfterMs) {
492
+ const attempt = this.attempt;
493
+ if (!attempt || attempt.closed) {
494
+ await this.wait().then(() => void 0);
495
+ return;
496
+ }
497
+ await stopChildTree(attempt.child, attempt.close, () => attempt.closed, signal, killAfterMs);
498
+ }
499
+ spawnAttempt() {
500
+ this.generation += 1;
501
+ const child = spawn(this.config.command, this.config.args ?? [], {
502
+ cwd: this.config.cwd,
503
+ env: this.config.env,
504
+ stdio: [
505
+ "pipe",
506
+ "pipe",
507
+ "pipe"
508
+ ]
509
+ });
510
+ let settleClose;
511
+ const attempt = {
512
+ child,
513
+ generation: this.generation,
514
+ close: new Promise((resolve) => {
515
+ settleClose = resolve;
516
+ }),
517
+ settleClose,
518
+ stdout: {
519
+ text: "",
520
+ flushed: false
521
+ },
522
+ stderr: {
523
+ text: "",
524
+ flushed: false
525
+ },
526
+ closed: false
527
+ };
528
+ this.attempt = attempt;
529
+ this.attachAttempt(attempt);
530
+ }
531
+ attachAttempt(attempt) {
532
+ const { child } = attempt;
533
+ child.on("spawn", () => {
534
+ if (this.attempt === attempt) this.emit("spawn");
535
+ });
536
+ child.on("error", (error) => {
537
+ if (this.attempt === attempt) this.emit("error", error);
538
+ });
539
+ child.on("exit", (code, signal) => {
540
+ if (this.attempt === attempt) this.emit("exit", code, signal);
541
+ });
542
+ child.on("close", (code, signal) => {
543
+ if (this.attempt !== attempt) return;
544
+ attempt.closed = true;
545
+ this.flushLineBuffer(attempt, "stdout");
546
+ this.flushLineBuffer(attempt, "stderr");
547
+ attempt.settleClose({
548
+ code,
549
+ signal
550
+ });
551
+ this.emit("close", code, signal);
552
+ this.handleAttemptClose(code, signal);
553
+ });
554
+ child.stdout.on("data", (chunk) => {
555
+ if (this.attempt === attempt) this.handleChunk(attempt, "stdout", chunk);
556
+ });
557
+ child.stderr.on("data", (chunk) => {
558
+ if (this.attempt !== attempt) return;
559
+ this.writeStderrSink(chunk);
560
+ this.handleChunk(attempt, "stderr", chunk);
561
+ });
562
+ child.stdout.on("end", () => {
563
+ if (this.attempt === attempt) this.flushLineBuffer(attempt, "stdout");
564
+ });
565
+ child.stderr.on("end", () => {
566
+ if (this.attempt === attempt) this.flushLineBuffer(attempt, "stderr");
567
+ });
568
+ }
569
+ handleChunk(attempt, stream, chunk) {
570
+ const buffer = attempt[stream];
571
+ let text = buffer.text + chunk.toString("utf8");
572
+ let newlineIndex = text.indexOf("\n");
573
+ while (newlineIndex >= 0) {
574
+ const rawLine = text.slice(0, newlineIndex);
575
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
576
+ this.handleLine(stream, line, true);
577
+ text = text.slice(newlineIndex + 1);
578
+ newlineIndex = text.indexOf("\n");
579
+ }
580
+ buffer.text = text;
581
+ }
582
+ handleLine(stream, line, appendNewline) {
583
+ writePrefixedLine(stream === "stdout" ? process.stdout : process.stderr, this.label, stream === "stdout" ? this.color : stderrColor, line, appendNewline);
584
+ this.matches.emit(stream, line);
585
+ }
586
+ async handleAttemptClose(code, signal) {
587
+ if (this.restart.shouldRestart(code, signal, this.restartDisabled, this.finalized)) {
588
+ if (!this.restart.prepareRestart(code, signal)) {
589
+ this.finalize(this.createResult(code, signal));
590
+ return;
591
+ }
592
+ if (!await this.restart.waitForDelay() || this.finalized || this.restartDisabled) {
593
+ this.finalize(this.createResult(code, signal));
594
+ return;
595
+ }
596
+ this.spawnAttempt();
597
+ return;
598
+ }
599
+ this.finalize(this.createResult(code, signal));
600
+ }
601
+ createResult(code, signal) {
602
+ return {
603
+ name: this.name,
604
+ code,
605
+ signal,
606
+ restarts: this.restart.restarts,
607
+ restartSuppressed: this.restart.restartSuppressed
608
+ };
609
+ }
610
+ finalize(result) {
611
+ if (this.finalized) return;
612
+ this.finalized = true;
613
+ this.restart.cancelDelay();
614
+ this.lastResult = result;
615
+ this.matches.close(/* @__PURE__ */ new Error(`Process "${this.name}" exited before a matching line was observed`));
616
+ this.unbindStderrSink();
617
+ unregisterCleanupTarget(this);
618
+ this.finalResolve(result);
619
+ }
620
+ bindStderrSink() {
621
+ const sink = this.stderrSink;
622
+ if (!sink || typeof sink.on !== "function") return;
623
+ const disable = () => {
624
+ this.stderrSinkActive = false;
625
+ };
626
+ sink.on("error", disable);
627
+ sink.on("close", disable);
628
+ this.stderrSinkCleanup = () => {
629
+ sink.off("error", disable);
630
+ sink.off("close", disable);
631
+ };
632
+ }
633
+ unbindStderrSink() {
634
+ this.stderrSinkCleanup?.();
635
+ this.stderrSinkCleanup = null;
636
+ }
637
+ writeStderrSink(chunk) {
638
+ const sink = this.stderrSink;
639
+ if (!sink || !this.stderrSinkActive) return;
640
+ try {
641
+ if ("destroyed" in sink && sink.destroyed) {
642
+ this.stderrSinkActive = false;
643
+ return;
644
+ }
645
+ sink.write(chunk);
646
+ } catch {
647
+ this.stderrSinkActive = false;
648
+ }
649
+ }
650
+ flushLineBuffer(attempt, stream) {
651
+ const buffer = attempt[stream];
652
+ if (buffer.flushed || buffer.text.length === 0) {
653
+ buffer.flushed = true;
654
+ return;
655
+ }
656
+ buffer.flushed = true;
657
+ const line = buffer.text.endsWith("\r") ? buffer.text.slice(0, -1) : buffer.text;
658
+ buffer.text = "";
659
+ this.handleLine(stream, line, false);
660
+ }
661
+ };
662
+ function validateProcessConfig(config) {
663
+ if (!config.name) throw new Error("ProcessConfig.name is required");
664
+ if (!config.command) throw new Error("ProcessConfig.command is required");
665
+ validateProcessColor(config.color);
666
+ }
667
+ //#endregion
668
+ export { supervise };
@@ -0,0 +1,127 @@
1
+ # Overview
2
+
3
+ `procband` supervises one subprocess per `supervise()` call.
4
+
5
+ The returned `ProcbandProcess` is both:
6
+
7
+ - a `ChildProcess`-compatible handle for the current active child attempt
8
+ - a thenable wrapper that resolves to a `ProcessResult` when supervision is done
9
+
10
+ Supervision adds four behaviors on top of raw `spawn()`:
11
+
12
+ - prefixed `stdout` and `stderr`
13
+ - line-based matching for future output
14
+ - optional restart policy with failure suppression
15
+ - tree-aware shutdown for the child and its descendants
16
+
17
+ # When to Use
18
+
19
+ - You are writing project-specific TypeScript scripts, not a CLI.
20
+ - You need to wait for a subprocess to print a "ready" line.
21
+ - You want readable prefixed logs from multiple long-lived child processes.
22
+ - You need one shutdown API that kills descendant processes too.
23
+ - You want automatic restart with a small built-in guard against tight failure
24
+ loops.
25
+
26
+ # When Not to Use
27
+
28
+ - You need a standalone process manager or service supervisor.
29
+ - You need buffered log history or replay for late subscribers.
30
+ - You need shell pipelines, shell parsing, or a command-line tool.
31
+ - You want one API that supervises many processes at once. `procband` keeps the
32
+ unit of supervision to one process per call.
33
+
34
+ # Core Abstractions
35
+
36
+ - `ProcessConfig`
37
+ Declares one supervised subprocess plus its label, color, restart policy, and
38
+ optional raw `stderr` tee.
39
+ - `ProcbandProcess`
40
+ The live wrapper returned by `supervise()`. It is a `ChildProcess`-compatible
41
+ handle, a matching surface, a shutdown surface, and a thenable final result.
42
+ - `MatchEvent`
43
+ A future matched line from `stdout` or `stderr`.
44
+ - `RestartPolicy`
45
+ Rules for restart timing and failed-exit suppression.
46
+
47
+ # Data Flow / Lifecycle
48
+
49
+ 1. `supervise(config)` spawns the first child process immediately.
50
+ 2. Child `stdout` and `stderr` are read as text and split into lines.
51
+ 3. Each line is prefixed and written to the parent `process.stdout` or
52
+ `process.stderr`.
53
+ 4. `stderr` can also be tee'd as raw bytes to `ProcessConfig.stderr`.
54
+ 5. Future matching lines are delivered through `match()` callbacks or
55
+ `waitFor()`.
56
+ 6. When a child exits, `procband` either finalizes or starts a new attempt,
57
+ depending on the restart policy.
58
+ 7. `await proc` or `await proc.wait()` resolves only after the process is
59
+ terminal and no further restart will happen.
60
+
61
+ # Common Tasks -> Recommended APIs
62
+
63
+ - Wait for one readiness line:
64
+ `proc.waitFor('ready')`
65
+ - React to repeated matching output:
66
+ `proc.match(pattern, callback, options)`
67
+ - Stop the process and its descendants:
68
+ `proc.stop()`
69
+ - Inspect final exit state:
70
+ `await proc` or `await proc.wait()`
71
+ - Capture raw child `stderr` in a file or custom stream:
72
+ `ProcessConfig.stderr`
73
+ - Retry failed exits with sane defaults:
74
+ `restart: true`
75
+ - Use explicit retry rules:
76
+ `restart: { when, delayMs, maxFailures, windowMs }`
77
+
78
+ # Invariants and Constraints
79
+
80
+ - Matching is line-based and future-only.
81
+ - String patterns use substring matching.
82
+ - RegExp patterns run against the full observed line.
83
+ - `match()` subscriptions do not interfere with each other.
84
+ - `waitFor()` rejects if the process becomes terminal before a future match is
85
+ observed.
86
+ - `await proc` resolves for both successful and failed exits. Inspect the
87
+ returned `ProcessResult`.
88
+ - The wrapper survives restarts, but inherited `pid`, `stdin`, `stdout`,
89
+ `stderr`, and related `ChildProcess` fields always refer to the current active
90
+ child attempt.
91
+ - `kill()` only signals the current direct child. `stop()` disables restart and
92
+ kills the full process tree.
93
+ - `stderr` prefixes always use the reserved red, even when a custom process
94
+ color is configured.
95
+
96
+ # Error Model
97
+
98
+ - `supervise()` throws synchronously for invalid config such as missing `name`,
99
+ missing `command`, or an invalid reserved color.
100
+ - `waitFor()` rejects on timeout or terminal exit before a future match.
101
+ - A thrown `match()` callback only unsubscribes that callback.
102
+ - Errors from `ProcessConfig.stderr` stop teeing to that sink but do not stop
103
+ supervision.
104
+ - `stop()` may reject if tree-kill fails with a non-`ESRCH` error.
105
+
106
+ # Terminology
107
+
108
+ - Supervised process:
109
+ A `ProcbandProcess` wrapper plus its current child attempt.
110
+ - Child attempt:
111
+ One concrete spawned process instance inside a supervision run.
112
+ - Terminal:
113
+ No child is running and no restart will be started.
114
+ - Restart suppression:
115
+ Automatic disabling of further restarts after too many failed exits inside the
116
+ configured window.
117
+ - Match:
118
+ A future observed output line that satisfies a string or regex pattern.
119
+
120
+ # Non-Goals
121
+
122
+ - A standalone CLI
123
+ - Historical log replay
124
+ - Multi-process orchestration in one top-level API
125
+ - Shell command parsing
126
+ - Full service-management features such as persistence, cron scheduling, or host
127
+ restarts
@@ -0,0 +1,32 @@
1
+ import process from 'node:process'
2
+ import { supervise } from 'procband'
3
+
4
+ const proc = supervise({
5
+ name: 'worker',
6
+ command: process.execPath,
7
+ args: [
8
+ '-e',
9
+ [
10
+ 'console.log("booting")',
11
+ 'setTimeout(() => console.log("ready"), 20)',
12
+ 'setTimeout(() => console.error("warn"), 40)',
13
+ 'setTimeout(() => process.exit(0), 60)',
14
+ ].join(';'),
15
+ ],
16
+ })
17
+
18
+ const unsubscribe = proc.match(
19
+ 'warn',
20
+ event => {
21
+ console.log(`observed ${event.stream}: ${event.line}`)
22
+ },
23
+ { stream: 'stderr' },
24
+ )
25
+
26
+ const ready = await proc.waitFor('ready')
27
+ console.log(`matched ${ready.line}`)
28
+
29
+ const result = await proc
30
+ unsubscribe()
31
+
32
+ console.log(result)
@@ -0,0 +1,42 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import process from 'node:process'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { supervise } from 'procband'
6
+
7
+ const stateDir = mkdtempSync(join(tmpdir(), 'procband-example-'))
8
+ const attemptFile = join(stateDir, 'attempt.txt')
9
+
10
+ writeFileSync(attemptFile, '0')
11
+
12
+ const script = [
13
+ 'const fs = await import("node:fs")',
14
+ 'const file = process.argv[1]',
15
+ 'let attempt = Number(fs.readFileSync(file, "utf8"))',
16
+ 'attempt += 1',
17
+ 'fs.writeFileSync(file, String(attempt))',
18
+ 'console.log(`attempt ${attempt}`)',
19
+ 'if (attempt < 3) process.exit(1)',
20
+ 'console.log("ready")',
21
+ ].join(';')
22
+
23
+ const proc = supervise({
24
+ name: 'job',
25
+ command: process.execPath,
26
+ args: ['-e', script, attemptFile],
27
+ restart: {
28
+ delayMs: 25,
29
+ maxFailures: 5,
30
+ windowMs: 1000,
31
+ },
32
+ })
33
+
34
+ proc.match(/^attempt \d+$/, event => {
35
+ console.log(`observed ${event.line}`)
36
+ })
37
+
38
+ await proc.waitFor('ready')
39
+ const result = await proc
40
+
41
+ console.log(result)
42
+ rmSync(stateDir, { recursive: true, force: true })
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "include": ["./"],
4
+ "compilerOptions": {
5
+ "types": ["node"]
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,13 +1,42 @@
1
1
  {
2
2
  "name": "procband",
3
- "version": "0.0.0",
4
- "description": "",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [],
10
- "author": "",
3
+ "version": "0.1.0",
4
+ "description": "Supervise subprocesses from TypeScript: prefix logs, wait for output patterns, and manage lifecycle/restarts.",
11
5
  "license": "MIT",
12
- "type": "commonjs"
13
- }
6
+ "author": "Alec Larson",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/aleclarson/procband.git"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "docs",
14
+ "examples"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ "types": "./dist/index.d.mts",
19
+ "default": "./dist/index.mjs"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^25.5.2",
23
+ "oxfmt": "^0.43.0",
24
+ "oxlint": "^1.58.0",
25
+ "tsdown": "^0.21.7",
26
+ "typescript": "^6.0.2",
27
+ "vitest": "^4.1.2"
28
+ },
29
+ "dependencies": {
30
+ "@alloc/tree-kill": "^1.3.0",
31
+ "ansi-styles": "^6.2.3"
32
+ },
33
+ "scripts": {
34
+ "dev": "tsdown --sourcemap --watch",
35
+ "build": "tsdown",
36
+ "docs:check": "pnpm build && tsc -p examples --noEmit && node examples/basic-usage.ts && node examples/restart-on-failure.ts",
37
+ "format": "oxfmt .",
38
+ "lint": "oxlint src",
39
+ "typecheck": "tsc --noEmit && tsc -p test --noEmit",
40
+ "test": "vitest"
41
+ }
42
+ }
package/readme.md DELETED
@@ -1 +0,0 @@
1
- Coming soon...