@shivaedev/supervisor 0.0.0 → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 - 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - Supervise local application processes in per-attempt Effect scopes, with readiness deadlines, captured stdout and stderr, capped exponential restart backoff, and graceful shutdown that escalates to SIGKILL.
8
+ - Decode JSON readiness handshakes, compose dependency start order in application Effects, and observe current readiness and typed failures through native Effect values.
9
+
10
+ ## 0.1.0 - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - Publish a package skeleton with no API yet.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShivaeDev
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 CHANGED
@@ -1,3 +1,88 @@
1
1
  # @shivaedev/supervisor
2
2
 
3
- This name is reserved for an upcoming package in [ShivaeDev Platform](https://github.com/ShivaeDev/platform). Real releases start above 0.0.0.
3
+ Own an application's local processes with the same Effect scopes that own the application. Wait for readiness, restart crashed children with backoff, and stop them when their scope closes.
4
+
5
+ ## Why you want this
6
+
7
+ A desktop shell or development host should not need its own process supervisor. Express startup order in application code, then let each process live inside a scope:
8
+
9
+ ```ts
10
+ const application = Effect.scoped(
11
+ Effect.gen(function* () {
12
+ const database = yield* supervise(startDatabase, { name: "postgres" });
13
+ const server = yield* supervise(startServer(database.ready), { name: "server" });
14
+ yield* supervise(startRunner(server.ready), { name: "runner" });
15
+ yield* Effect.never;
16
+ }),
17
+ );
18
+ ```
19
+
20
+ Each `start` is a scoped Effect. `supervise` returns after that process is ready, so the next line can use its readiness data.
21
+
22
+ ## Using it
23
+
24
+ ### Process ownership
25
+
26
+ `supervise(start, options)` evaluates `start` in a fresh scope for each attempt. The returned process supplies a PID, a readiness Effect and an exit Effect. An unexpected exit closes that attempt's scope, waits for backoff, and starts another attempt. Closing the caller's scope interrupts supervision and releases every attempt it owns.
27
+
28
+ The application expresses dependencies by awaiting one supervisor before starting the next. It also chooses whether a later dependency restart should restart its dependents; the package does not infer a dependency graph.
29
+
30
+ ### Spawn a process and read its readiness signal
31
+
32
+ The process adapter uses Effect's `ChildProcessSpawner`. Supply its implementation at the application boundary:
33
+
34
+ ```ts
35
+ import { NodeServices } from "@effect/platform-node";
36
+ import { Effect, Schema } from "effect";
37
+ import { spawn } from "@shivaedev/supervisor/adapters/spawn.ts";
38
+ import { jsonReadiness } from "@shivaedev/supervisor/readiness.ts";
39
+ import { supervise } from "@shivaedev/supervisor/supervisor.ts";
40
+
41
+ const serve = Effect.scoped(
42
+ Effect.gen(function* () {
43
+ const server = yield* supervise(
44
+ spawn({
45
+ command: "node",
46
+ args: ["server.js"],
47
+ readiness: jsonReadiness(Schema.Struct({ port: Schema.Int })),
48
+ onOutput: ({ source, text }) => Effect.logInfo(text).pipe(Effect.annotateLogs({ source })),
49
+ }),
50
+ {
51
+ name: "server",
52
+ readinessTimeout: "30 seconds",
53
+ backoff: { initial: "500 millis", maximum: "5 seconds" },
54
+ },
55
+ );
56
+ yield* Effect.logInfo(`Listening on ${server.ready.port}`);
57
+ yield* server.await;
58
+ }),
59
+ ).pipe(Effect.provide(NodeServices.layer));
60
+ ```
61
+
62
+ The first stdout line is a JSON readiness message, such as `{"port":3000}`. The decoder preserves Schema failures and reports `ReadinessMissing` if stdout ends without a line. Both stdout and stderr are captured as lines, including readiness and later output. `onOutput` receives each line; its default writes Effect logs with the PID and source.
63
+
64
+ A custom `readiness` function consumes the stdout line Stream and returns an Effect with application readiness data. Use `withoutReadiness` from the same readiness module for a child that is ready as soon as it is spawned. The adapter keeps draining its output.
65
+
66
+ The adapter accepts `cwd`, environment overrides in `env`, and a `stopTimeout` that defaults to five seconds. Scope cleanup sends SIGTERM and escalates to SIGKILL through the process spawner when the child does not exit. Choose the spawner implementation at your application's boundary.
67
+
68
+ ### Observe supervision and failures
69
+
70
+ The returned `ready` value comes from the first successful handshake. `state` is a native `SubscriptionRef`: its `ready` state carries the current PID and readiness value; `backoff` carries the restart count, delay in milliseconds and native `Exit` from the previous attempt. Backoff doubles after each exit up to the configured maximum. The defaults are 500 milliseconds and five seconds.
71
+
72
+ Startup fails with the original acquisition or readiness failure, `ReadinessTimeout`, or `ProcessExited` when the child exits successfully before readiness. Signal termination remains a native platform failure during startup; after readiness it is an unexpected exit and triggers a restart. A failed acquisition or readiness check on a replacement stops supervision, sets the `failed` state with its native Cause, and fails `await` in the typed error channel. `await` otherwise runs until interrupted. Interruption stays interruption and never triggers a restart.
73
+
74
+ ### API and installation
75
+
76
+ Import names from their defining modules:
77
+
78
+ - `supervisor.ts`: `supervise`, `SupervisorOptions`, `ProcessState`.
79
+ - `Process.ts`: the `Process` port for another scoped process adapter.
80
+ - `adapters/spawn.ts`: `spawn`, `SpawnOptions`, `Output`.
81
+ - `readiness.ts`: `jsonReadiness`, `withoutReadiness`, `ReadinessMissing`.
82
+ - `ReadinessTimeout.ts` and `ProcessExited.ts`: typed startup failures.
83
+
84
+ ```sh
85
+ pnpm add @shivaedev/supervisor effect @effect/platform-node
86
+ ```
87
+
88
+ The package needs Node.js 24 or later. The bundled adapter uses native process signals; process-tree cleanup follows the supplied spawner's platform semantics.
@@ -0,0 +1,7 @@
1
+ import type { Effect } from "effect";
2
+ export interface Process<A, E> {
3
+ readonly exited: Effect.Effect<number, E>;
4
+ readonly pid: number;
5
+ readonly ready: Effect.Effect<A, E>;
6
+ }
7
+ //# sourceMappingURL=Process.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Process.d.ts","sourceRoot":"","sources":["../src/Process.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAErC,MAAM,WAAW,OAAO,CAAC,CAAC,EAAE,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAC1C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CACpC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Process.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Process.js","sourceRoot":"","sources":["../src/Process.ts"],"names":[],"mappings":""}
@@ -0,0 +1,9 @@
1
+ import { Schema } from "effect";
2
+ declare const ProcessExited_base: Schema.Class<ProcessExited, Schema.TaggedStruct<"ProcessExited", {
3
+ readonly code: Schema.Number;
4
+ readonly name: Schema.String;
5
+ }>, import("effect/Cause").YieldableError>;
6
+ export declare class ProcessExited extends ProcessExited_base {
7
+ }
8
+ export {};
9
+ //# sourceMappingURL=ProcessExited.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProcessExited.d.ts","sourceRoot":"","sources":["../src/ProcessExited.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;;;;;AAEhC,qBAAa,aAAc,SAAQ,kBAGjC;CAAG"}
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect";
2
+ export class ProcessExited extends Schema.TaggedError()("ProcessExited", {
3
+ code: Schema.Number,
4
+ name: Schema.String,
5
+ }) {
6
+ }
7
+ //# sourceMappingURL=ProcessExited.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProcessExited.js","sourceRoot":"","sources":["../src/ProcessExited.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,OAAO,aAAc,SAAQ,MAAM,CAAC,WAAW,EAAiB,CAAC,eAAe,EAAE;IACvF,IAAI,EAAE,MAAM,CAAC,MAAM;IACnB,IAAI,EAAE,MAAM,CAAC,MAAM;CACnB,CAAC;CAAG"}
@@ -0,0 +1,8 @@
1
+ import { Schema } from "effect";
2
+ declare const ReadinessTimeout_base: Schema.Class<ReadinessTimeout, Schema.TaggedStruct<"ReadinessTimeout", {
3
+ readonly name: Schema.String;
4
+ }>, import("effect/Cause").YieldableError>;
5
+ export declare class ReadinessTimeout extends ReadinessTimeout_base {
6
+ }
7
+ export {};
8
+ //# sourceMappingURL=ReadinessTimeout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ReadinessTimeout.d.ts","sourceRoot":"","sources":["../src/ReadinessTimeout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;;;;AAEhC,qBAAa,gBAAiB,SAAQ,qBAEpC;CAAG"}
@@ -0,0 +1,6 @@
1
+ import { Schema } from "effect";
2
+ export class ReadinessTimeout extends Schema.TaggedError()("ReadinessTimeout", {
3
+ name: Schema.String,
4
+ }) {
5
+ }
6
+ //# sourceMappingURL=ReadinessTimeout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ReadinessTimeout.js","sourceRoot":"","sources":["../src/ReadinessTimeout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,OAAO,gBAAiB,SAAQ,MAAM,CAAC,WAAW,EAAoB,CAAC,kBAAkB,EAAE;IAChG,IAAI,EAAE,MAAM,CAAC,MAAM;CACnB,CAAC;CAAG"}
@@ -0,0 +1,18 @@
1
+ import { type Duration, Effect, type PlatformError, Scope, Stream } from "effect";
2
+ import { type ChildProcessSpawner } from "effect/process";
3
+ import type { Process } from "#Process.ts";
4
+ export interface Output {
5
+ readonly source: "stdout" | "stderr";
6
+ readonly text: string;
7
+ }
8
+ export interface SpawnOptions<A, E, R> {
9
+ readonly args?: readonly string[];
10
+ readonly command: string;
11
+ readonly cwd?: string;
12
+ readonly env?: Readonly<Record<string, string>>;
13
+ readonly onOutput?: (output: Output) => Effect.Effect<void>;
14
+ readonly readiness: (stdout: Stream.Stream<string, PlatformError.PlatformError>) => Effect.Effect<A, E, R>;
15
+ readonly stopTimeout?: Duration.Input;
16
+ }
17
+ export declare const spawn: <A, E, R>(options: SpawnOptions<A, E, R>) => Effect.Effect<Process<A, E | PlatformError.PlatformError>, PlatformError.PlatformError, R | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope>;
18
+ //# sourceMappingURL=spawn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../src/adapters/spawn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAe,KAAK,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzG,OAAO,EAAgB,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AACD,MAAM,WAAW,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAChD,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC5D,QAAQ,CAAC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,aAAa,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3G,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;CACtC;AACD,eAAO,MAAM,KAAK,GAA4C,CAAC,EAAE,CAAC,EAAE,CAAC,uLAiDnE,CAAC"}
@@ -0,0 +1,36 @@
1
+ import { Deferred, Effect, Exit, Fiber, Scope, Stream } from "effect";
2
+ import { ChildProcess } from "effect/process";
3
+ export const spawn = Effect.fn("Supervisor.spawn")(function* (options) {
4
+ const processScope = yield* Scope.fork(yield* Effect.scope);
5
+ const child = yield* ChildProcess.make(options.command, options.args ?? [], {
6
+ cwd: options.cwd,
7
+ env: options.env,
8
+ extendEnv: true,
9
+ forceKillAfter: options.stopTimeout ?? "5 seconds",
10
+ killSignal: "SIGTERM",
11
+ stderr: "pipe",
12
+ stdin: "ignore",
13
+ stdout: "pipe",
14
+ }).pipe(Scope.provide(processScope));
15
+ function record(source) {
16
+ return (text) => options.onOutput?.({ source, text }) ?? Effect.logInfo(text).pipe(Effect.annotateLogs({ pid: child.pid, source }));
17
+ }
18
+ const [logs, readiness] = yield* child.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.broadcastN({ capacity: 16, n: 2 }));
19
+ const output = yield* Effect.forkScoped(Effect.all([Stream.runForEach(logs, record("stdout")), child.stderr.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach(record("stderr")))], { concurrency: "unbounded", discard: true }));
20
+ const ready = yield* Deferred.make();
21
+ const readinessWorker = yield* Effect.forkScoped(Effect.scoped(Effect.gen(function* () {
22
+ const pull = yield* Stream.toPull(readiness);
23
+ return yield* options.readiness(Stream.fromPull(Effect.succeed(pull)));
24
+ })).pipe(Effect.onExit((exit) => Deferred.done(ready, exit))));
25
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
26
+ yield* Fiber.interrupt(readinessWorker);
27
+ yield* Scope.close(processScope, Exit.void);
28
+ yield* Fiber.join(output);
29
+ }).pipe(Effect.orDie));
30
+ return {
31
+ exited: child.exitCode,
32
+ pid: Number(child.pid),
33
+ ready: Deferred.await(ready).pipe(Effect.raceFirst(Fiber.join(output).pipe(Effect.andThen(Effect.never)))),
34
+ };
35
+ });
36
+ //# sourceMappingURL=spawn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn.js","sourceRoot":"","sources":["../../src/adapters/spawn.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ import { Effect, type PlatformError, Schema, Stream } from "effect";
2
+ declare const ReadinessMissing_base: Schema.Class<ReadinessMissing, Schema.TaggedStruct<"ReadinessMissing", {}>, import("effect/Cause").YieldableError>;
3
+ export declare class ReadinessMissing extends ReadinessMissing_base {
4
+ }
5
+ export declare function jsonReadiness<TSchema extends Schema.Constraint>(schema: TSchema): (stdout: Stream.Stream<string, PlatformError.PlatformError>) => Effect.Effect<TSchema["Type"], PlatformError.PlatformError | ReadinessMissing | Schema.SchemaError, TSchema["DecodingServices"]>;
6
+ export declare const withoutReadiness: (stdout: Stream.Stream<string, PlatformError.PlatformError, never>) => Effect.Effect<void, never, import("effect/Scope").Scope>;
7
+ export {};
8
+ //# sourceMappingURL=readiness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readiness.d.ts","sourceRoot":"","sources":["../src/readiness.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAU,KAAK,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;;AAC5E,qBAAa,gBAAiB,SAAQ,qBAA8D;CAAG;AACvG,wBAAgB,aAAa,CAAC,OAAO,SAAS,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,YAC/D,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,aAAa,CAAC,sIAYlE;AACD,eAAO,MAAM,gBAAgB,iIAE3B,CAAC"}
@@ -0,0 +1,15 @@
1
+ import { Effect, Option, Schema, Stream } from "effect";
2
+ export class ReadinessMissing extends Schema.TaggedError()("ReadinessMissing", {}) {
3
+ }
4
+ export function jsonReadiness(schema) {
5
+ return (stdout) => stdout.pipe(Stream.runHead, Effect.flatMap((line) => Effect.gen(function* () {
6
+ if (Option.isNone(line)) {
7
+ return yield* Effect.fail(new ReadinessMissing({}));
8
+ }
9
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(line.value);
10
+ })));
11
+ }
12
+ export const withoutReadiness = Effect.fnUntraced(function* (stdout) {
13
+ yield* Effect.forkScoped(Stream.runDrain(stdout));
14
+ });
15
+ //# sourceMappingURL=readiness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readiness.js","sourceRoot":"","sources":["../src/readiness.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAsB,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC5E,MAAM,OAAO,gBAAiB,SAAQ,MAAM,CAAC,WAAW,EAAoB,CAAC,kBAAkB,EAAE,EAAE,CAAC;CAAG;AACvG,MAAM,UAAU,aAAa,CAAoC,MAAe;IAC/E,OAAO,CAAC,MAA0D,EAAE,EAAE,CACrE,MAAM,CAAC,IAAI,CACV,MAAM,CAAC,OAAO,EACd,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CACvB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,gBAAgB,CAAC,EAAE,CAAC,CAAC,CAAC;QACrD,CAAC;QACD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACrF,CAAC,CAAC,CACF,CACD,CAAC;AACJ,CAAC;AACD,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAA0D;IACtH,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;AACnD,CAAC,CAAC,CAAC"}
@@ -0,0 +1,35 @@
1
+ import { Cause, Duration, Effect, Exit, type Scope, SubscriptionRef } from "effect";
2
+ import type { Process } from "#Process.ts";
3
+ import { ProcessExited } from "#ProcessExited.ts";
4
+ import { ReadinessTimeout } from "#ReadinessTimeout.ts";
5
+ export interface SupervisorOptions {
6
+ readonly backoff?: {
7
+ readonly initial: Duration.Input;
8
+ readonly maximum: Duration.Input;
9
+ };
10
+ readonly name: string;
11
+ readonly readinessTimeout?: Duration.Input;
12
+ }
13
+ export type ProcessState<A, E> = {
14
+ readonly status: "starting";
15
+ readonly restarts: number;
16
+ } | {
17
+ readonly status: "ready";
18
+ readonly restarts: number;
19
+ readonly pid: number;
20
+ readonly value: A;
21
+ } | {
22
+ readonly status: "backoff";
23
+ readonly restarts: number;
24
+ readonly delay: number;
25
+ readonly exit: Exit.Exit<number, E>;
26
+ } | {
27
+ readonly status: "failed";
28
+ readonly cause: Cause.Cause<E>;
29
+ };
30
+ export declare const supervise: <A, E, TStartError, R>(start: Effect.Effect<Process<A, E>, TStartError, R | Scope.Scope>, options: SupervisorOptions) => Effect.Effect<{
31
+ await: Effect.Effect<never, E | TStartError | ProcessExited | ReadinessTimeout, never>;
32
+ ready: A;
33
+ state: SubscriptionRef.SubscriptionRef<ProcessState<A, E | TStartError | ProcessExited | ReadinessTimeout>>;
34
+ }, E | TStartError | ProcessExited | ReadinessTimeout, Scope.Scope | Exclude<R, Scope.Scope>>;
35
+ //# sourceMappingURL=supervisor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../src/supervisor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAY,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAS,KAAK,KAAK,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACrG,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAExD,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAA;KAAE,CAAC;IAC1F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;CAC3C;AAED,MAAM,MAAM,YAAY,CAAC,CAAC,EAAE,CAAC,IAC1B;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAChG;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAA;CAAE,GACtH;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;CAAE,CAAC;AAEjE,eAAO,MAAM,SAAS,GAAgD,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC;;;;6FA6CxF,CAAC"}
@@ -0,0 +1,39 @@
1
+ import { Cause, Deferred, Duration, Effect, Exit, Fiber, SubscriptionRef } from "effect";
2
+ import { ProcessExited } from "#ProcessExited.ts";
3
+ import { ReadinessTimeout } from "#ReadinessTimeout.ts";
4
+ export const supervise = Effect.fn("Supervisor.supervise")(function* (start, options) {
5
+ const first = yield* Deferred.make();
6
+ const state = yield* SubscriptionRef.make({ restarts: 0, status: "starting" });
7
+ const initial = Duration.toMillis(Duration.fromInputUnsafe(options.backoff?.initial ?? "500 millis"));
8
+ const maximum = Duration.toMillis(Duration.fromInputUnsafe(options.backoff?.maximum ?? "5 seconds"));
9
+ const attempt = Effect.fnUntraced(function* (restarts) {
10
+ yield* SubscriptionRef.set(state, { restarts, status: "starting" });
11
+ const child = yield* start;
12
+ const value = yield* child.ready.pipe(Effect.raceFirst(child.exited.pipe(Effect.flatMap((code) => Effect.fail(new ProcessExited({ code, name: options.name }))))), Effect.timeoutOrElse({
13
+ duration: options.readinessTimeout ?? "30 seconds",
14
+ orElse: () => Effect.fail(new ReadinessTimeout({ name: options.name })),
15
+ }));
16
+ yield* SubscriptionRef.set(state, { pid: child.pid, restarts, status: "ready", value });
17
+ yield* Deferred.succeed(first, value);
18
+ const exit = yield* Effect.exit(child.exited);
19
+ if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)) {
20
+ return yield* Effect.failCause(exit.cause);
21
+ }
22
+ return exit;
23
+ });
24
+ const watch = Effect.gen(function* () {
25
+ let restarts = 0;
26
+ let delay = Math.min(initial, maximum);
27
+ for (;;) {
28
+ const exit = yield* Effect.scoped(attempt(restarts));
29
+ restarts += 1;
30
+ yield* SubscriptionRef.set(state, { delay, exit, restarts, status: "backoff" });
31
+ yield* Effect.sleep(delay);
32
+ delay = Math.min(delay * 2, maximum);
33
+ }
34
+ });
35
+ const worker = yield* Effect.forkScoped(watch.pipe(Effect.onError((cause) => SubscriptionRef.set(state, { cause, status: "failed" })), Effect.onExit((exit) => Deferred.done(first, exit))));
36
+ const value = yield* Deferred.await(first).pipe(Effect.onError(() => Fiber.interrupt(worker)));
37
+ return { await: Fiber.join(worker), ready: value, state };
38
+ });
39
+ //# sourceMappingURL=supervisor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supervisor.js","sourceRoot":"","sources":["../src/supervisor.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,12 +1,70 @@
1
1
  {
2
2
  "name": "@shivaedev/supervisor",
3
- "version": "0.0.0",
4
- "description": "Name reserved for an upcoming ShivaeDev Platform package.",
5
- "license": "MIT",
3
+ "version": "0.2.0",
4
+ "description": "Scoped local process supervision for Effect applications",
5
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/supervisor#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/ShivaeDev/platform/issues"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/ShivaeDev/platform.git",
9
12
  "directory": "packages/supervisor"
10
13
  },
11
- "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/supervisor#readme"
14
+ "license": "MIT",
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "imports": {
18
+ "#test/*": "./src/test-support/*",
19
+ "#*.ts": {
20
+ "source": "./src/*.ts",
21
+ "types": "./dist/*.d.ts",
22
+ "default": "./dist/*.js"
23
+ }
24
+ },
25
+ "exports": {
26
+ "./*.ts": {
27
+ "source": "./src/*.ts",
28
+ "types": "./dist/*.d.ts",
29
+ "default": "./dist/*.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "!src/**/*.test.ts",
37
+ "!src/**/*.spec.ts",
38
+ "!src/**/test-support",
39
+ "CHANGELOG.md",
40
+ "README.md"
41
+ ],
42
+ "devDependencies": {
43
+ "@effect/platform-node": "4.0.6",
44
+ "@shivaedev/quality": "0.58.0",
45
+ "@shivaedev/test-story": "0.37.0",
46
+ "@types/node": "24.19.2",
47
+ "effect": "4.0.6",
48
+ "typescript": "7.0.2",
49
+ "vitest": "5.0.5"
50
+ },
51
+ "peerDependencies": {
52
+ "effect": "4.0.6"
53
+ },
54
+ "engines": {
55
+ "node": ">=24"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "provenance": true
60
+ },
61
+ "quality": {
62
+ "runtime": "server"
63
+ },
64
+ "scripts": {
65
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
66
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
67
+ "test": "vitest run --configLoader native",
68
+ "typecheck": "tsc --noEmit"
69
+ }
12
70
  }
package/src/Process.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { Effect } from "effect";
2
+
3
+ export interface Process<A, E> {
4
+ readonly exited: Effect.Effect<number, E>;
5
+ readonly pid: number;
6
+ readonly ready: Effect.Effect<A, E>;
7
+ }
@@ -0,0 +1,6 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class ProcessExited extends Schema.TaggedError<ProcessExited>()("ProcessExited", {
4
+ code: Schema.Number,
5
+ name: Schema.String,
6
+ }) {}
@@ -0,0 +1,5 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class ReadinessTimeout extends Schema.TaggedError<ReadinessTimeout>()("ReadinessTimeout", {
4
+ name: Schema.String,
5
+ }) {}
@@ -0,0 +1,66 @@
1
+ import { Deferred, type Duration, Effect, Exit, Fiber, type PlatformError, Scope, Stream } from "effect";
2
+ import { ChildProcess, type ChildProcessSpawner } from "effect/process";
3
+ import type { Process } from "#Process.ts";
4
+ export interface Output {
5
+ readonly source: "stdout" | "stderr";
6
+ readonly text: string;
7
+ }
8
+ export interface SpawnOptions<A, E, R> {
9
+ readonly args?: readonly string[];
10
+ readonly command: string;
11
+ readonly cwd?: string;
12
+ readonly env?: Readonly<Record<string, string>>;
13
+ readonly onOutput?: (output: Output) => Effect.Effect<void>;
14
+ readonly readiness: (stdout: Stream.Stream<string, PlatformError.PlatformError>) => Effect.Effect<A, E, R>;
15
+ readonly stopTimeout?: Duration.Input;
16
+ }
17
+ export const spawn = Effect.fn("Supervisor.spawn")(function* <A, E, R>(
18
+ options: SpawnOptions<A, E, R>,
19
+ ): Effect.fn.Return<
20
+ Process<A, E | PlatformError.PlatformError>,
21
+ PlatformError.PlatformError,
22
+ R | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
23
+ > {
24
+ const processScope = yield* Scope.fork(yield* Effect.scope);
25
+ const child = yield* ChildProcess.make(options.command, options.args ?? [], {
26
+ cwd: options.cwd,
27
+ env: options.env,
28
+ extendEnv: true,
29
+ forceKillAfter: options.stopTimeout ?? "5 seconds",
30
+ killSignal: "SIGTERM",
31
+ stderr: "pipe",
32
+ stdin: "ignore",
33
+ stdout: "pipe",
34
+ }).pipe(Scope.provide(processScope));
35
+ function record(source: Output["source"]) {
36
+ return (text: string) => options.onOutput?.({ source, text }) ?? Effect.logInfo(text).pipe(Effect.annotateLogs({ pid: child.pid, source }));
37
+ }
38
+ const [logs, readiness] = yield* child.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.broadcastN({ capacity: 16, n: 2 }));
39
+ const output = yield* Effect.forkScoped(
40
+ Effect.all(
41
+ [Stream.runForEach(logs, record("stdout")), child.stderr.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach(record("stderr")))],
42
+ { concurrency: "unbounded", discard: true },
43
+ ),
44
+ );
45
+ const ready = yield* Deferred.make<A, E>();
46
+ const readinessWorker = yield* Effect.forkScoped(
47
+ Effect.scoped(
48
+ Effect.gen(function* () {
49
+ const pull = yield* Stream.toPull(readiness);
50
+ return yield* options.readiness(Stream.fromPull(Effect.succeed(pull)));
51
+ }),
52
+ ).pipe(Effect.onExit((exit) => Deferred.done(ready, exit))),
53
+ );
54
+ yield* Effect.addFinalizer(() =>
55
+ Effect.gen(function* () {
56
+ yield* Fiber.interrupt(readinessWorker);
57
+ yield* Scope.close(processScope, Exit.void);
58
+ yield* Fiber.join(output);
59
+ }).pipe(Effect.orDie),
60
+ );
61
+ return {
62
+ exited: child.exitCode,
63
+ pid: Number(child.pid),
64
+ ready: Deferred.await(ready).pipe(Effect.raceFirst(Fiber.join(output).pipe(Effect.andThen(Effect.never)))),
65
+ };
66
+ });
@@ -0,0 +1,19 @@
1
+ import { Effect, Option, type PlatformError, Schema, Stream } from "effect";
2
+ export class ReadinessMissing extends Schema.TaggedError<ReadinessMissing>()("ReadinessMissing", {}) {}
3
+ export function jsonReadiness<TSchema extends Schema.Constraint>(schema: TSchema) {
4
+ return (stdout: Stream.Stream<string, PlatformError.PlatformError>) =>
5
+ stdout.pipe(
6
+ Stream.runHead,
7
+ Effect.flatMap((line) =>
8
+ Effect.gen(function* () {
9
+ if (Option.isNone(line)) {
10
+ return yield* Effect.fail(new ReadinessMissing({}));
11
+ }
12
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(line.value);
13
+ }),
14
+ ),
15
+ );
16
+ }
17
+ export const withoutReadiness = Effect.fnUntraced(function* (stdout: Stream.Stream<string, PlatformError.PlatformError>) {
18
+ yield* Effect.forkScoped(Stream.runDrain(stdout));
19
+ });
@@ -0,0 +1,63 @@
1
+ import { Cause, Deferred, Duration, Effect, Exit, Fiber, type Scope, SubscriptionRef } from "effect";
2
+ import type { Process } from "#Process.ts";
3
+ import { ProcessExited } from "#ProcessExited.ts";
4
+ import { ReadinessTimeout } from "#ReadinessTimeout.ts";
5
+
6
+ export interface SupervisorOptions {
7
+ readonly backoff?: { readonly initial: Duration.Input; readonly maximum: Duration.Input };
8
+ readonly name: string;
9
+ readonly readinessTimeout?: Duration.Input;
10
+ }
11
+
12
+ export type ProcessState<A, E> =
13
+ | { readonly status: "starting"; readonly restarts: number }
14
+ | { readonly status: "ready"; readonly restarts: number; readonly pid: number; readonly value: A }
15
+ | { readonly status: "backoff"; readonly restarts: number; readonly delay: number; readonly exit: Exit.Exit<number, E> }
16
+ | { readonly status: "failed"; readonly cause: Cause.Cause<E> };
17
+
18
+ export const supervise = Effect.fn("Supervisor.supervise")(function* <A, E, TStartError, R>(
19
+ start: Effect.Effect<Process<A, E>, TStartError, R | Scope.Scope>,
20
+ options: SupervisorOptions,
21
+ ) {
22
+ const first = yield* Deferred.make<A, E | TStartError | ReadinessTimeout | ProcessExited>();
23
+ const state = yield* SubscriptionRef.make<ProcessState<A, E | TStartError | ReadinessTimeout | ProcessExited>>({ restarts: 0, status: "starting" });
24
+ const initial = Duration.toMillis(Duration.fromInputUnsafe(options.backoff?.initial ?? "500 millis"));
25
+ const maximum = Duration.toMillis(Duration.fromInputUnsafe(options.backoff?.maximum ?? "5 seconds"));
26
+ const attempt = Effect.fnUntraced(function* (restarts: number) {
27
+ yield* SubscriptionRef.set(state, { restarts, status: "starting" });
28
+ const child = yield* start;
29
+ const value = yield* child.ready.pipe(
30
+ Effect.raceFirst(child.exited.pipe(Effect.flatMap((code) => Effect.fail(new ProcessExited({ code, name: options.name }))))),
31
+ Effect.timeoutOrElse({
32
+ duration: options.readinessTimeout ?? "30 seconds",
33
+ orElse: () => Effect.fail(new ReadinessTimeout({ name: options.name })),
34
+ }),
35
+ );
36
+ yield* SubscriptionRef.set(state, { pid: child.pid, restarts, status: "ready", value });
37
+ yield* Deferred.succeed(first, value);
38
+ const exit = yield* Effect.exit(child.exited);
39
+ if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)) {
40
+ return yield* Effect.failCause(exit.cause);
41
+ }
42
+ return exit;
43
+ });
44
+ const watch = Effect.gen(function* () {
45
+ let restarts = 0;
46
+ let delay = Math.min(initial, maximum);
47
+ for (;;) {
48
+ const exit = yield* Effect.scoped(attempt(restarts));
49
+ restarts += 1;
50
+ yield* SubscriptionRef.set(state, { delay, exit, restarts, status: "backoff" });
51
+ yield* Effect.sleep(delay);
52
+ delay = Math.min(delay * 2, maximum);
53
+ }
54
+ });
55
+ const worker = yield* Effect.forkScoped(
56
+ watch.pipe(
57
+ Effect.onError((cause) => SubscriptionRef.set(state, { cause, status: "failed" })),
58
+ Effect.onExit((exit) => Deferred.done(first, exit)),
59
+ ),
60
+ );
61
+ const value = yield* Deferred.await(first).pipe(Effect.onError(() => Fiber.interrupt(worker)));
62
+ return { await: Fiber.join(worker), ready: value, state };
63
+ });