procband 0.3.5 → 0.3.7

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/README.md CHANGED
@@ -1,38 +1,120 @@
1
1
  # procband
2
2
 
3
- ## Purpose
3
+ > Supervise subprocesses from TypeScript when a script needs prefixed output,
4
+ > readiness matching, restart policy, and process-tree shutdown without becoming
5
+ > a standalone process manager.
4
6
 
5
- `procband` supervises subprocesses from TypeScript. It prefixes child output,
6
- matches future log lines, waits for terminal exit, optionally restarts failed
7
- processes, tree-kills descendants during shutdown, and propagates unobserved
8
- terminal failures to the parent process.
7
+ `procband` wraps one child process per `supervise()` call. It starts the child
8
+ immediately, prefixes `stdout` and `stderr`, lets your script wait for future
9
+ log lines, and resolves to a final `ProcessResult` when no more restart attempt
10
+ will run.
9
11
 
10
- ## Installation
12
+ ## Install
11
13
 
12
14
  ```sh
13
15
  pnpm add procband
14
16
  ```
15
17
 
16
- ## Quick Example
18
+ ## First Process
19
+
20
+ Use `waitFor()` when the next step depends on output that the child will print
21
+ after supervision starts:
17
22
 
18
23
  ```ts
19
24
  import process from 'node:process'
20
25
  import { supervise } from 'procband'
21
26
 
22
27
  const proc = supervise({
28
+ name: 'worker',
23
29
  command: process.execPath,
24
30
  args: ['-e', 'console.log("ready")'],
25
31
  })
26
32
 
27
- await proc.waitFor('ready')
33
+ const ready = await proc.waitFor('ready')
34
+ console.log(`matched ${ready.line}`)
35
+
28
36
  const result = await proc
29
- console.log(result)
37
+ console.log(result.exitCode)
30
38
  ```
31
39
 
32
- ## Documentation Map
40
+ The child output is written to the parent process with a `worker` prefix, the
41
+ readiness wait resolves when a future line contains `ready`, and `await proc`
42
+ returns the terminal result.
43
+
44
+ ## Choose the Failure Behavior
45
+
46
+ By default, a failed terminal exit resolves to `ProcessResult`. Use this when a
47
+ supervisor script needs to inspect the outcome:
48
+
49
+ ```ts
50
+ import process from 'node:process'
51
+ import { supervise } from 'procband'
52
+
53
+ const result = await supervise({
54
+ name: 'job',
55
+ command: process.execPath,
56
+ args: ['-e', 'process.exit(1)'],
57
+ })
58
+
59
+ if (result.exitCode !== 0) {
60
+ console.error(`job failed with ${result.exitCode}`)
61
+ }
62
+ ```
63
+
64
+ Use `expectSuccess()` for foreground command-runner steps where a failed exit
65
+ should reject:
66
+
67
+ ```ts
68
+ import { ProcessExitError, supervise } from 'procband'
69
+
70
+ try {
71
+ await supervise({
72
+ name: 'db',
73
+ command: 'pnpm',
74
+ args: ['db', 'ensure'],
75
+ }).expectSuccess()
76
+ } catch (error) {
77
+ if (error instanceof ProcessExitError) {
78
+ console.error(error.command, error.args, error.exitCode)
79
+ }
80
+ throw error
81
+ }
82
+ ```
83
+
84
+ If your script never awaits the process or calls `wait()`, an unobserved failed
85
+ terminal exit sets the parent `process.exitCode` and starts stopping other live
86
+ `procband` processes in the same parent script.
87
+
88
+ ## Prefix an Existing Stream
89
+
90
+ Use `createPrefixStream()` when another API already owns a process lifecycle
91
+ but its output should use procband's label and color formatting:
92
+
93
+ ```ts
94
+ import { createPrefixStream } from 'procband'
95
+
96
+ const output = createPrefixStream({ label: 'postgres' })
97
+ output.pipe(process.stdout)
98
+ postgres.stdout.pipe(output)
99
+ ```
100
+
101
+ The transform preserves stream backpressure, buffers partial lines and UTF-8
102
+ characters across chunks, and flushes a final unterminated line when input ends.
103
+ Pass `color: [red, green, blue]` to select a prefix color; otherwise procband
104
+ assigns the next color from its shared palette.
105
+
106
+ ## Documentation
107
+
108
+ Start with the page that matches the decision in front of you:
109
+
110
+ | Page | Use it when |
111
+ | --------------------------------------------------------- | ---------------------------------------------------------------- |
112
+ | [Docs overview](docs/index.md) | You need the moving parts, boundaries, and next page to read. |
113
+ | [Readiness and matching](docs/guides/readiness.md) | A script must wait for output or react to repeated log lines. |
114
+ | [Restart failed processes](docs/guides/restarts.md) | A child can fail transiently and should be retried with limits. |
115
+ | [Foreground commands](docs/guides/foreground-commands.md) | A script step should reject when a command exits unsuccessfully. |
116
+ | [Concepts and lifecycle](docs/context.md) | You need lifecycle, shutdown, error, and invariant details. |
33
117
 
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
- - Sequencing example: [examples/sequenced-processes.ts](examples/sequenced-processes.ts)
38
- - Exact exported signatures: [dist/index.d.mts](dist/index.d.mts)
118
+ Runnable examples live in [`examples/`](examples/). Exact public signatures are
119
+ generated from the TypeScript source and published through
120
+ [`dist/index.d.mts`](dist/index.d.mts).
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
+ import { Readable, Transform, Writable } from "node:stream";
1
2
  import { ChildProcess } from "node:child_process";
2
- import { Readable, Writable } from "node:stream";
3
3
 
4
4
  //#region src/types.d.ts
5
5
  /**
@@ -62,6 +62,12 @@ interface ProcessConfig {
62
62
  * Defaults to `name`.
63
63
  */
64
64
  label?: string;
65
+ /**
66
+ * Whether child output should be prefixed with the process label.
67
+ *
68
+ * Defaults to `true`. Set to `false` for raw child output.
69
+ */
70
+ prefix?: boolean;
65
71
  /**
66
72
  * RGB color for this process's `stdout` prefix.
67
73
  *
@@ -104,6 +110,20 @@ interface ProcessConfig {
104
110
  * A 24-bit RGB color tuple.
105
111
  */
106
112
  type RgbColor = readonly [red: number, green: number, blue: number];
113
+ /**
114
+ * Options for `createPrefixStream()`.
115
+ */
116
+ interface PrefixStreamOptions {
117
+ /** Human-facing label added to each output line. */
118
+ label: string;
119
+ /**
120
+ * RGB color for the prefix.
121
+ *
122
+ * When omitted, `procband` assigns the next color from its default palette.
123
+ * The reserved `stderr` red cannot be used here.
124
+ */
125
+ color?: RgbColor;
126
+ }
107
127
  /**
108
128
  * Controls whether and how a supervised process restarts after exit.
109
129
  */
@@ -221,6 +241,18 @@ interface ProcessResult {
221
241
  */
222
242
  restartSuppressed: boolean;
223
243
  }
244
+ /**
245
+ * Options for `wait()`.
246
+ */
247
+ interface WaitOptions {
248
+ /**
249
+ * Reject with `ProcessExitError` when the process exits unsuccessfully.
250
+ *
251
+ * Defaults to `false`, preserving procband's supervision-oriented behavior
252
+ * where terminal failures resolve to `ProcessResult`.
253
+ */
254
+ rejectOnFailure?: boolean;
255
+ }
224
256
  /**
225
257
  * A supervised child-process handle.
226
258
  *
@@ -257,11 +289,16 @@ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
257
289
  /**
258
290
  * Wait for the process to become terminal with no further restart pending.
259
291
  *
260
- * Unlike many process helpers, this resolves for both success and failure.
261
- * Inspect the returned `ProcessResult` instead of relying on promise
262
- * rejection for non-zero exits.
292
+ * By default this resolves for both success and failure. Pass
293
+ * `rejectOnFailure: true` to reject failed exits with `ProcessExitError`.
263
294
  */
264
- wait(): Promise<ProcessResult>;
295
+ wait(options?: WaitOptions): Promise<ProcessResult>;
296
+ /**
297
+ * Wait for the process to become terminal and reject if it fails.
298
+ *
299
+ * This is equivalent to `wait({ rejectOnFailure: true })`.
300
+ */
301
+ expectSuccess(): Promise<ProcessResult>;
265
302
  /**
266
303
  * Disable future restarts and terminate the active process tree.
267
304
  *
@@ -273,6 +310,44 @@ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
273
310
  kill(signal?: KillSignal): boolean;
274
311
  }
275
312
  //#endregion
313
+ //#region src/errors.d.ts
314
+ /**
315
+ * Error thrown when a process was expected to exit successfully but did not.
316
+ */
317
+ declare class ProcessExitError extends Error {
318
+ readonly config: ProcessConfig;
319
+ readonly result: ProcessResult;
320
+ readonly command: string;
321
+ readonly args: readonly string[];
322
+ readonly code: number | null;
323
+ readonly exitCode: number;
324
+ readonly signal: NodeJS.Signals | null;
325
+ constructor(config: ProcessConfig, result: ProcessResult);
326
+ }
327
+ //#endregion
328
+ //#region src/prefix-stream.d.ts
329
+ /**
330
+ * Create a transform that prefixes arbitrary output with a procband label.
331
+ *
332
+ * The transform buffers incomplete lines and UTF-8 sequences across chunks.
333
+ * CRLF input is normalized to LF, matching supervised process output, and a
334
+ * final unterminated line is emitted when the writable side ends.
335
+ *
336
+ * @param options Label and optional prefix color.
337
+ * @returns A backpressure-aware Node.js transform stream.
338
+ * @throws When `options.color` is invalid or uses procband's reserved stderr
339
+ * color.
340
+ * @example
341
+ * ```ts
342
+ * import { createPrefixStream } from 'procband'
343
+ *
344
+ * const output = createPrefixStream({ label: 'postgres' })
345
+ * output.pipe(process.stdout)
346
+ * postgres.stdout.pipe(output)
347
+ * ```
348
+ */
349
+ declare function createPrefixStream(options: PrefixStreamOptions): Transform;
350
+ //#endregion
276
351
  //#region src/process.d.ts
277
352
  /**
278
353
  * Start supervising a single subprocess.
@@ -303,4 +378,4 @@ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
303
378
  */
304
379
  declare function supervise(config: ProcessConfig): ProcbandProcess;
305
380
  //#endregion
306
- export { type MatchCallback, type MatchEvent, type MatchOptions, type ProcbandProcess, type ProcessConfig, type ProcessResult, type RestartPolicy, type RgbColor, type Unsubscribe, type WaitForOptions, supervise };
381
+ export { type MatchCallback, type MatchEvent, type MatchOptions, type PrefixStreamOptions, type ProcbandProcess, type ProcessConfig, ProcessExitError, type ProcessResult, type RestartPolicy, type RgbColor, type Unsubscribe, type WaitForOptions, type WaitOptions, createPrefixStream, supervise };
package/dist/index.mjs CHANGED
@@ -1,10 +1,51 @@
1
+ import { StringDecoder } from "node:string_decoder";
2
+ import { Transform } from "node:stream";
3
+ import ansiStyles from "ansi-styles";
1
4
  import { spawn } from "node:child_process";
2
5
  import { EventEmitter } from "node:events";
3
6
  import { constants } from "node:os";
4
7
  import process from "node:process";
5
- import ansiStyles from "ansi-styles";
6
8
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
9
  import treeKill, { treeKillSync } from "@alloc/tree-kill";
10
+ //#region src/errors.ts
11
+ /**
12
+ * Error thrown when a process was expected to exit successfully but did not.
13
+ */
14
+ var ProcessExitError = class extends Error {
15
+ config;
16
+ result;
17
+ command;
18
+ args;
19
+ code;
20
+ exitCode;
21
+ signal;
22
+ constructor(config, result) {
23
+ super(formatProcessExitMessage(config, result));
24
+ this.name = "ProcessExitError";
25
+ this.config = config;
26
+ this.result = result;
27
+ this.command = config.command;
28
+ this.args = [...config.args ?? []];
29
+ this.code = result.code;
30
+ this.exitCode = result.exitCode;
31
+ this.signal = result.signal;
32
+ }
33
+ };
34
+ function formatProcessExitMessage(config, result) {
35
+ const command = formatCommand(config);
36
+ const prefix = `Process "${result.name}" failed: ${command}`;
37
+ if (result.signal) return `${prefix} exited by signal ${result.signal} (exit code ${result.exitCode})`;
38
+ if (result.code != null) return `${prefix} exited with code ${result.code}`;
39
+ return `${prefix} exited unsuccessfully (exit code ${result.exitCode})`;
40
+ }
41
+ function formatCommand(config) {
42
+ return [config.command, ...config.args ?? []].map(formatCommandPart).join(" ");
43
+ }
44
+ function formatCommandPart(value) {
45
+ if (/^[\w./:=@%+,-]+$/.test(value)) return value;
46
+ return JSON.stringify(value);
47
+ }
48
+ //#endregion
8
49
  //#region src/colors.ts
9
50
  const prefixPalette = [
10
51
  [
@@ -70,23 +111,87 @@ function resolveProcessColor(color) {
70
111
  nextPaletteIndex += 1;
71
112
  return [...paletteColor];
72
113
  }
73
- function validateProcessColor(color) {
114
+ function validateProcessColor(color, optionName = "ProcessConfig.color") {
74
115
  if (!color) return;
75
116
  const [red, green, blue] = color;
76
117
  for (const value of [
77
118
  red,
78
119
  green,
79
120
  blue
80
- ]) if (!Number.isInteger(value) || value < 0 || value > 255) throw new Error("ProcessConfig.color must contain integer RGB values between 0 and 255");
81
- if (red === stderrColor[0] && green === stderrColor[1] && blue === stderrColor[2]) throw new Error("ProcessConfig.color cannot use the reserved stderr color");
121
+ ]) if (!Number.isInteger(value) || value < 0 || value > 255) throw new Error(`${optionName} must contain integer RGB values between 0 and 255`);
122
+ if (red === stderrColor[0] && green === stderrColor[1] && blue === stderrColor[2]) throw new Error(`${optionName} cannot use the reserved stderr color`);
82
123
  }
83
124
  function writePrefixedLine(target, label, color, line, appendNewline) {
84
- target.write(formatPrefix(label, color) + line + (appendNewline ? "\n" : ""));
125
+ target.write(formatPrefixedLine(label, color, line, appendNewline));
126
+ }
127
+ function formatPrefixedLine(label, color, line, appendNewline) {
128
+ return (label == null ? "" : formatPrefix(label, color)) + line + (appendNewline ? "\n" : "");
85
129
  }
86
130
  function formatPrefix(label, color) {
87
131
  return `${ansiStyles.color.ansi16m(...color)}[${label}]${ansiStyles.color.close} `;
88
132
  }
89
133
  //#endregion
134
+ //#region src/prefix-stream.ts
135
+ /**
136
+ * Create a transform that prefixes arbitrary output with a procband label.
137
+ *
138
+ * The transform buffers incomplete lines and UTF-8 sequences across chunks.
139
+ * CRLF input is normalized to LF, matching supervised process output, and a
140
+ * final unterminated line is emitted when the writable side ends.
141
+ *
142
+ * @param options Label and optional prefix color.
143
+ * @returns A backpressure-aware Node.js transform stream.
144
+ * @throws When `options.color` is invalid or uses procband's reserved stderr
145
+ * color.
146
+ * @example
147
+ * ```ts
148
+ * import { createPrefixStream } from 'procband'
149
+ *
150
+ * const output = createPrefixStream({ label: 'postgres' })
151
+ * output.pipe(process.stdout)
152
+ * postgres.stdout.pipe(output)
153
+ * ```
154
+ */
155
+ function createPrefixStream(options) {
156
+ validateProcessColor(options.color, "PrefixStreamOptions.color");
157
+ return new PrefixTransform(options.label, resolveProcessColor(options.color));
158
+ }
159
+ var PrefixTransform = class extends Transform {
160
+ decoder = new StringDecoder("utf8");
161
+ label;
162
+ color;
163
+ bufferedText = "";
164
+ constructor(label, color) {
165
+ super();
166
+ this.label = label;
167
+ this.color = color;
168
+ }
169
+ _transform(chunk, _encoding, callback) {
170
+ this.consume(this.decoder.write(chunk));
171
+ callback();
172
+ }
173
+ _flush(callback) {
174
+ this.consume(this.decoder.end());
175
+ if (this.bufferedText.length > 0) {
176
+ this.push(formatPrefixedLine(this.label, this.color, this.bufferedText, false));
177
+ this.bufferedText = "";
178
+ }
179
+ callback();
180
+ }
181
+ consume(decodedText) {
182
+ let text = this.bufferedText + decodedText;
183
+ let newlineIndex = text.indexOf("\n");
184
+ while (newlineIndex >= 0) {
185
+ const rawLine = text.slice(0, newlineIndex);
186
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
187
+ this.push(formatPrefixedLine(this.label, this.color, line, true));
188
+ text = text.slice(newlineIndex + 1);
189
+ newlineIndex = text.indexOf("\n");
190
+ }
191
+ this.bufferedText = text;
192
+ }
193
+ };
194
+ //#endregion
90
195
  //#region src/matching.ts
91
196
  var MatchRegistry = class {
92
197
  subscriptions = /* @__PURE__ */ new Set();
@@ -400,6 +505,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
400
505
  config;
401
506
  name;
402
507
  label;
508
+ prefix;
403
509
  color;
404
510
  matches;
405
511
  restart;
@@ -422,6 +528,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
422
528
  this.config = config;
423
529
  this.name = name;
424
530
  this.label = config.label ?? name;
531
+ this.prefix = config.name === "" ? false : config.prefix ?? true;
425
532
  this.color = resolveProcessColor(config.color);
426
533
  this.matches = new MatchRegistry(this.name);
427
534
  this.restart = new RestartController(config.restart);
@@ -480,10 +587,14 @@ var ProcbandProcessImpl = class extends EventEmitter {
480
587
  waitFor(pattern, options) {
481
588
  return this.matches.waitFor(pattern, options);
482
589
  }
483
- wait() {
590
+ wait(options) {
484
591
  this.markTerminalResultObserved();
592
+ if (options?.rejectOnFailure) return this.finalPromise.then((result) => this.expectSuccessfulResult(result));
485
593
  return this.finalPromise;
486
594
  }
595
+ expectSuccess() {
596
+ return this.wait({ rejectOnFailure: true });
597
+ }
487
598
  then(onfulfilled, onrejected) {
488
599
  this.markTerminalResultObserved();
489
600
  return this.finalPromise.then(onfulfilled, onrejected);
@@ -626,7 +737,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
626
737
  buffer.text = text;
627
738
  }
628
739
  handleLine(stream, line, appendNewline) {
629
- writePrefixedLine(stream === "stdout" ? process.stdout : process.stderr, this.label, stream === "stdout" ? this.color : stderrColor, line, appendNewline);
740
+ writePrefixedLine(stream === "stdout" ? process.stdout : process.stderr, this.prefix ? this.label : null, stream === "stdout" ? this.color : stderrColor, line, appendNewline);
630
741
  this.matches.emit(stream, line);
631
742
  }
632
743
  async handleAttemptClose(code, signal) {
@@ -669,12 +780,16 @@ var ProcbandProcessImpl = class extends EventEmitter {
669
780
  }
670
781
  closeMatches() {
671
782
  const error = /* @__PURE__ */ new Error(`Process "${this.name}" exited before a matching line was observed`);
672
- if (this.matches.hasPendingWait()) writePrefixedLine(process.stderr, this.label, stderrColor, error.message, true);
783
+ if (this.matches.hasPendingWait()) writePrefixedLine(process.stderr, this.prefix ? this.label : null, stderrColor, error.message, true);
673
784
  this.matches.close(error);
674
785
  }
675
786
  markTerminalResultObserved() {
676
787
  this.terminalResultObserved = true;
677
788
  }
789
+ expectSuccessfulResult(result) {
790
+ if (isFailedResult(result)) throw new ProcessExitError(this.config, result);
791
+ return result;
792
+ }
678
793
  shouldPropagateFailure(result) {
679
794
  return result.exitCode !== 0 && !this.shutdownRequested && !this.terminalResultObserved;
680
795
  }
@@ -734,7 +849,7 @@ let propagatedFailure = false;
734
849
  function validateProcessConfig(config) {
735
850
  if (!config.command) throw new Error("ProcessConfig.command is required");
736
851
  validateProcessColor(config.color);
737
- const name = config.name || inferProcessName(config.command);
852
+ const name = config.name === void 0 || config.name === "" ? inferProcessName(config.command) : config.name;
738
853
  if (!name) throw new Error("ProcessConfig.name is required when command does not match /[-\\w]+$/");
739
854
  return name;
740
855
  }
@@ -746,6 +861,9 @@ function getResultExitCode(code, signal) {
746
861
  if (signal) return 128 + (constants.signals[signal] ?? 0);
747
862
  return 1;
748
863
  }
864
+ function isFailedResult(result) {
865
+ return result.exitCode !== 0 || result.signal != null;
866
+ }
749
867
  function propagateFailure(exitCode) {
750
868
  if (propagatedFailure) return;
751
869
  propagatedFailure = true;
@@ -756,4 +874,4 @@ function propagateFailure(exitCode) {
756
874
  });
757
875
  }
758
876
  //#endregion
759
- export { supervise };
877
+ export { ProcessExitError, createPrefixStream, supervise };