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 +21 -0
- package/README.md +37 -0
- package/dist/index.d.mts +287 -0
- package/dist/index.mjs +668 -0
- package/docs/context.md +127 -0
- package/examples/basic-usage.ts +32 -0
- package/examples/restart-on-failure.ts +42 -0
- package/examples/tsconfig.json +7 -0
- package/package.json +39 -10
- package/readme.md +0 -1
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)
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|
package/docs/context.md
ADDED
|
@@ -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 })
|
package/package.json
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "procband",
|
|
3
|
-
"version": "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
|
-
"
|
|
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...
|