@ayepi/updown 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 +73 -0
- package/dist/index.cjs +210 -0
- package/dist/index.d.cts +113 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +202 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
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,73 @@
|
|
|
1
|
+
# @ayepi/updown
|
|
2
|
+
|
|
3
|
+
Graceful **startup and shutdown** orchestration. Register named components with
|
|
4
|
+
dependencies; `up()` starts them in dependency order, and `down()` (or a process
|
|
5
|
+
signal) tears them down in reverse through a two-phase **pre → post** shutdown.
|
|
6
|
+
Zero dependencies; works with any runtime.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pnpm add @ayepi/updown
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { updown } from '@ayepi/updown'
|
|
14
|
+
|
|
15
|
+
const lc = updown() // SIGTERM/SIGINT trigger shutdown by default
|
|
16
|
+
|
|
17
|
+
lc.register({ name: 'db', up: () => db.connect(), post: () => db.end() })
|
|
18
|
+
lc.register({
|
|
19
|
+
name: 'http',
|
|
20
|
+
deps: ['db'],
|
|
21
|
+
up: () => server.listen(),
|
|
22
|
+
pre: () => server.stopAcceptingNewConnections(), // drain
|
|
23
|
+
post: () => server.close(), // teardown
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await lc.up() // db, then http
|
|
27
|
+
app.get('/livez', () => lc.isLive() ? 200 : 503)
|
|
28
|
+
app.get('/readyz', () => lc.isReady() ? 200 : 503)
|
|
29
|
+
// SIGTERM → pre (drain) → isReady=false → post (close) → process exits
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Lifecycle & health
|
|
33
|
+
|
|
34
|
+
- **`up()`** — starts components in dependency order (independent ones in
|
|
35
|
+
parallel). Resolves when everything is up; rejects if any `up` throws.
|
|
36
|
+
- **`down()`** — runs all `pre` hooks (reverse-dependency order), then all `post`
|
|
37
|
+
hooks. Idempotent and best-effort (a throwing hook is reported via `onError`,
|
|
38
|
+
not fatal). Always resolves.
|
|
39
|
+
- **`isLive()`** — `true` once `up()` finishes and shutdown has **not** been
|
|
40
|
+
requested. Flips `false` the instant shutdown begins.
|
|
41
|
+
- **`isReady()`** — `true` once `up()` finishes and the **pre** phase hasn't
|
|
42
|
+
finished. Stays `true` while draining, flips `false` when **post** begins.
|
|
43
|
+
- **`whenDown()`** — resolve when shutdown completes, *without* triggering it
|
|
44
|
+
(await a signal-driven shutdown: `await lc.whenDown()`).
|
|
45
|
+
- **`list()`** — every component with its `deps`, `status`
|
|
46
|
+
(`idle → starting → up → pre → post → down`, or `failed`), and last `error`.
|
|
47
|
+
|
|
48
|
+
## Options
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
updown({
|
|
52
|
+
signals: ['SIGTERM', 'SIGINT'], // or false to disable
|
|
53
|
+
exit: true, // process.exit(0) after a signal-triggered shutdown
|
|
54
|
+
timeout: 30_000, // bound up()/down(); shutdown resolves even if a hook hangs
|
|
55
|
+
onError: (err, phase, name) => log.error({ err, phase, name }),
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A default instance is also exported with top-level `register`/`up`/`down`/
|
|
60
|
+
`whenDown`/`isReady`/`isLive`/`list` for the common single-lifecycle case.
|
|
61
|
+
|
|
62
|
+
## For AI coding agents
|
|
63
|
+
|
|
64
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
65
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
66
|
+
|
|
67
|
+
- [`ayepi-updown.md`](./ayepi-updown.md)
|
|
68
|
+
|
|
69
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/updown) and are **not** shipped in the npm tarball.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT © Philip Diffenderfer
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/** Process signals that trigger shutdown by default. */
|
|
4
|
+
const DEFAULT_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
5
|
+
/** Exit code used after a signal-triggered shutdown. */
|
|
6
|
+
const EXIT_OK = 0;
|
|
7
|
+
const globalProcess = () => globalThis.process;
|
|
8
|
+
/** Race a promise against a timeout; resolves to `'timeout'` if it elapses first. */
|
|
9
|
+
function withTimeout(p, ms) {
|
|
10
|
+
if (!ms) return p;
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const timer = setTimeout(() => resolve("timeout"), ms);
|
|
13
|
+
timer.unref?.();
|
|
14
|
+
p.then((v) => {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
resolve(v);
|
|
17
|
+
}, (e) => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
reject(e);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create a lifecycle controller. See the {@link UpDown} methods.
|
|
25
|
+
*/
|
|
26
|
+
function updown(opts = {}) {
|
|
27
|
+
const comps = /* @__PURE__ */ new Map();
|
|
28
|
+
const status = /* @__PURE__ */ new Map();
|
|
29
|
+
const errors = /* @__PURE__ */ new Map();
|
|
30
|
+
let upPromise = null;
|
|
31
|
+
let downPromise = null;
|
|
32
|
+
let upDone = false;
|
|
33
|
+
let downRequested = false;
|
|
34
|
+
let preDone = false;
|
|
35
|
+
let resolveDown;
|
|
36
|
+
const downSignal = new Promise((r) => resolveDown = r);
|
|
37
|
+
let handlers = [];
|
|
38
|
+
const fail = (msg) => {
|
|
39
|
+
throw new Error(`updown: ${msg}`);
|
|
40
|
+
};
|
|
41
|
+
const dependentsOf = (name) => [...comps.values()].filter((c) => (c.deps ?? []).includes(name)).map((c) => c.name);
|
|
42
|
+
function checkGraph() {
|
|
43
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
44
|
+
const done = /* @__PURE__ */ new Set();
|
|
45
|
+
const visit = (n, trail) => {
|
|
46
|
+
if (done.has(n)) return;
|
|
47
|
+
if (visiting.has(n)) fail(`dependency cycle: ${[...trail, n].join(" → ")}`);
|
|
48
|
+
visiting.add(n);
|
|
49
|
+
for (const d of comps.get(n).deps ?? []) {
|
|
50
|
+
if (!comps.has(d)) fail(`"${n}" depends on unknown component "${d}"`);
|
|
51
|
+
visit(d, [...trail, n]);
|
|
52
|
+
}
|
|
53
|
+
visiting.delete(n);
|
|
54
|
+
done.add(n);
|
|
55
|
+
};
|
|
56
|
+
for (const n of comps.keys()) visit(n, []);
|
|
57
|
+
}
|
|
58
|
+
const proc = opts.process ?? globalProcess();
|
|
59
|
+
function wireSignals() {
|
|
60
|
+
const sigs = opts.signals === false ? [] : opts.signals ?? DEFAULT_SIGNALS;
|
|
61
|
+
if (!proc?.on) return;
|
|
62
|
+
for (const sig of sigs) {
|
|
63
|
+
const h = () => void api.down().then(() => opts.exit !== false && proc.exit?.(EXIT_OK));
|
|
64
|
+
proc.on(sig, h);
|
|
65
|
+
handlers.push({
|
|
66
|
+
sig,
|
|
67
|
+
h
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function unwireSignals() {
|
|
72
|
+
for (const { sig, h } of handlers) proc?.off?.(sig, h);
|
|
73
|
+
handlers = [];
|
|
74
|
+
}
|
|
75
|
+
function startUpGraph() {
|
|
76
|
+
const started = /* @__PURE__ */ new Map();
|
|
77
|
+
const start = (name) => {
|
|
78
|
+
const existing = started.get(name);
|
|
79
|
+
if (existing) return existing;
|
|
80
|
+
const c = comps.get(name);
|
|
81
|
+
const p = (async () => {
|
|
82
|
+
await Promise.all((c.deps ?? []).map(start));
|
|
83
|
+
if (downRequested) return;
|
|
84
|
+
status.set(name, "starting");
|
|
85
|
+
try {
|
|
86
|
+
await c.up?.();
|
|
87
|
+
status.set(name, "up");
|
|
88
|
+
} catch (e) {
|
|
89
|
+
status.set(name, "failed");
|
|
90
|
+
errors.set(name, e);
|
|
91
|
+
opts.onError?.(e, "up", name);
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
started.set(name, p);
|
|
96
|
+
return p;
|
|
97
|
+
};
|
|
98
|
+
return Promise.all([...comps.keys()].map(start)).then(() => void 0);
|
|
99
|
+
}
|
|
100
|
+
/** Run one shutdown phase in reverse-dependency order (dependents tear down before their deps). */
|
|
101
|
+
async function runPhase(hook) {
|
|
102
|
+
const done = /* @__PURE__ */ new Map();
|
|
103
|
+
const run = (name) => {
|
|
104
|
+
const existing = done.get(name);
|
|
105
|
+
if (existing) return existing;
|
|
106
|
+
const c = comps.get(name);
|
|
107
|
+
const p = (async () => {
|
|
108
|
+
await Promise.all(dependentsOf(name).map(run));
|
|
109
|
+
const st = status.get(name);
|
|
110
|
+
if (st === "idle" || st === "failed") return;
|
|
111
|
+
status.set(name, hook);
|
|
112
|
+
const fn = c[hook];
|
|
113
|
+
if (fn) try {
|
|
114
|
+
await fn();
|
|
115
|
+
} catch (e) {
|
|
116
|
+
status.set(name, "failed");
|
|
117
|
+
errors.set(name, e);
|
|
118
|
+
opts.onError?.(e, hook, name);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (hook === "post") status.set(name, "down");
|
|
122
|
+
})();
|
|
123
|
+
done.set(name, p);
|
|
124
|
+
return p;
|
|
125
|
+
};
|
|
126
|
+
await Promise.all([...comps.keys()].map(run));
|
|
127
|
+
}
|
|
128
|
+
const api = {
|
|
129
|
+
register(component) {
|
|
130
|
+
if (upPromise) fail(`cannot register "${component.name}" after up() has started`);
|
|
131
|
+
if (comps.has(component.name)) fail(`duplicate component "${component.name}"`);
|
|
132
|
+
comps.set(component.name, component);
|
|
133
|
+
status.set(component.name, "idle");
|
|
134
|
+
return api;
|
|
135
|
+
},
|
|
136
|
+
up() {
|
|
137
|
+
if (upPromise) return upPromise;
|
|
138
|
+
checkGraph();
|
|
139
|
+
wireSignals();
|
|
140
|
+
upPromise = (async () => {
|
|
141
|
+
if (await withTimeout(startUpGraph(), opts.timeout) === "timeout") throw new Error(`updown: up() timed out after ${opts.timeout}ms`);
|
|
142
|
+
upDone = true;
|
|
143
|
+
})();
|
|
144
|
+
return upPromise;
|
|
145
|
+
},
|
|
146
|
+
down() {
|
|
147
|
+
if (downPromise) return downPromise;
|
|
148
|
+
downRequested = true;
|
|
149
|
+
downPromise = (async () => {
|
|
150
|
+
if (upPromise) await upPromise.catch(() => {});
|
|
151
|
+
if (await withTimeout((async () => {
|
|
152
|
+
await runPhase("pre");
|
|
153
|
+
preDone = true;
|
|
154
|
+
await runPhase("post");
|
|
155
|
+
})(), opts.timeout) === "timeout") opts.onError?.(/* @__PURE__ */ new Error(`updown: down() timed out after ${opts.timeout}ms`), "post", "*");
|
|
156
|
+
unwireSignals();
|
|
157
|
+
resolveDown();
|
|
158
|
+
})();
|
|
159
|
+
return downPromise;
|
|
160
|
+
},
|
|
161
|
+
whenDown() {
|
|
162
|
+
return downSignal;
|
|
163
|
+
},
|
|
164
|
+
isReady() {
|
|
165
|
+
return upDone && !preDone;
|
|
166
|
+
},
|
|
167
|
+
isLive() {
|
|
168
|
+
return upDone && !downRequested;
|
|
169
|
+
},
|
|
170
|
+
list() {
|
|
171
|
+
return [...comps.values()].map((c) => {
|
|
172
|
+
/* v8 ignore next */ const st = status.get(c.name) ?? "idle";
|
|
173
|
+
const s = {
|
|
174
|
+
name: c.name,
|
|
175
|
+
deps: c.deps ?? [],
|
|
176
|
+
status: st
|
|
177
|
+
};
|
|
178
|
+
return errors.has(c.name) ? {
|
|
179
|
+
...s,
|
|
180
|
+
error: errors.get(c.name)
|
|
181
|
+
} : s;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
return api;
|
|
186
|
+
}
|
|
187
|
+
const instance = updown();
|
|
188
|
+
/** Register a component on the default lifecycle. */
|
|
189
|
+
const register = (component) => instance.register(component);
|
|
190
|
+
/** Start the default lifecycle. */
|
|
191
|
+
const up = () => instance.up();
|
|
192
|
+
/** Shut down the default lifecycle. */
|
|
193
|
+
const down = () => instance.down();
|
|
194
|
+
/** Resolve when the default lifecycle has shut down (without triggering it). */
|
|
195
|
+
const whenDown = () => instance.whenDown();
|
|
196
|
+
/** Readiness of the default lifecycle. */
|
|
197
|
+
const isReady = () => instance.isReady();
|
|
198
|
+
/** Liveness of the default lifecycle. */
|
|
199
|
+
const isLive = () => instance.isLive();
|
|
200
|
+
/** Snapshot of the default lifecycle's components. */
|
|
201
|
+
const list = () => instance.list();
|
|
202
|
+
//#endregion
|
|
203
|
+
exports.down = down;
|
|
204
|
+
exports.isLive = isLive;
|
|
205
|
+
exports.isReady = isReady;
|
|
206
|
+
exports.list = list;
|
|
207
|
+
exports.register = register;
|
|
208
|
+
exports.up = up;
|
|
209
|
+
exports.updown = updown;
|
|
210
|
+
exports.whenDown = whenDown;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # @ayepi/updown
|
|
4
|
+
*
|
|
5
|
+
* Graceful **startup and shutdown** orchestration. Register named components with
|
|
6
|
+
* dependencies; `up()` starts them in dependency order, and `down()` (or a process
|
|
7
|
+
* signal) tears them down in reverse order through a two-phase **pre → post**
|
|
8
|
+
* shutdown.
|
|
9
|
+
*
|
|
10
|
+
* Health semantics, suitable for liveness/readiness probes:
|
|
11
|
+
*
|
|
12
|
+
* - **`isLive()`** — `true` once `up()` finishes and shutdown has **not** been
|
|
13
|
+
* requested. Flips `false` the moment shutdown begins.
|
|
14
|
+
* - **`isReady()`** — `true` once `up()` finishes and the **pre** phase has not yet
|
|
15
|
+
* finished. Stays `true` while draining (pre), flips `false` when **post** begins.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { updown } from '@ayepi/updown'
|
|
19
|
+
*
|
|
20
|
+
* const lc = updown()
|
|
21
|
+
* lc.register({ name: 'db', up: () => db.connect(), post: () => db.end() })
|
|
22
|
+
* lc.register({ name: 'http', deps: ['db'], up: () => listen(), pre: () => drain(), post: () => close() })
|
|
23
|
+
*
|
|
24
|
+
* await lc.up() // db then http
|
|
25
|
+
* lc.isReady() // true
|
|
26
|
+
* // SIGTERM → pre (drain) → isReady=false → post (close) → process exits
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
32
|
+
/** A process signal name (e.g. `'SIGTERM'`). */
|
|
33
|
+
type Signal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGUSR2' | (string & {});
|
|
34
|
+
/** A registered lifecycle component. */
|
|
35
|
+
interface Component {
|
|
36
|
+
/** Unique name. */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/** Names of components that must be **up** before this one starts (shutdown runs in reverse). */
|
|
39
|
+
readonly deps?: readonly string[];
|
|
40
|
+
/** Startup work — `up()` awaits this. */
|
|
41
|
+
readonly up?: () => MaybePromise<void>;
|
|
42
|
+
/** Pre-shutdown hook (the drain phase: stop accepting work, finish in-flight). */
|
|
43
|
+
readonly pre?: () => MaybePromise<void>;
|
|
44
|
+
/** Post-shutdown hook (the teardown phase: close resources). */
|
|
45
|
+
readonly post?: () => MaybePromise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** A component's current lifecycle status. */
|
|
48
|
+
type Status = 'idle' | 'starting' | 'up' | 'pre' | 'post' | 'down' | 'failed';
|
|
49
|
+
/** A component's name, deps, current {@link Status}, and last error (if any). */
|
|
50
|
+
interface ComponentStatus {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly deps: readonly string[];
|
|
53
|
+
readonly status: Status;
|
|
54
|
+
readonly error?: unknown;
|
|
55
|
+
}
|
|
56
|
+
/** The shutdown phase a hook error occurred in. */
|
|
57
|
+
type Phase = 'up' | 'pre' | 'post';
|
|
58
|
+
/** The minimal process surface signal handling uses (the global `process`, or your own). */
|
|
59
|
+
interface ProcessLike {
|
|
60
|
+
on?(event: string, handler: () => void): void;
|
|
61
|
+
off?(event: string, handler: () => void): void;
|
|
62
|
+
exit?(code: number): void;
|
|
63
|
+
}
|
|
64
|
+
/** Options for {@link updown}. */
|
|
65
|
+
interface UpDownOptions {
|
|
66
|
+
/** Process signals that trigger `down()` (default `['SIGTERM', 'SIGINT']`); `false` to disable. */
|
|
67
|
+
readonly signals?: readonly Signal[] | false;
|
|
68
|
+
/** Call `process.exit(0)` after a **signal-triggered** shutdown completes (default `true`). Explicit `down()` never exits. */
|
|
69
|
+
readonly exit?: boolean;
|
|
70
|
+
/** Bound `up()` and `down()` each to this many milliseconds (0 / omitted = no timeout). */
|
|
71
|
+
readonly timeout?: number;
|
|
72
|
+
/** Called when a component hook throws (shutdown is best-effort and continues). */
|
|
73
|
+
readonly onError?: (error: unknown, phase: Phase, name: string) => void;
|
|
74
|
+
/** Override the process object signals attach to (defaults to the global `process`). */
|
|
75
|
+
readonly process?: ProcessLike;
|
|
76
|
+
}
|
|
77
|
+
/** The lifecycle controller returned by {@link updown}. */
|
|
78
|
+
interface UpDown {
|
|
79
|
+
/** Register a component. Chainable. Throws after `up()` has started or on a duplicate name. */
|
|
80
|
+
register(component: Component): UpDown;
|
|
81
|
+
/** Start all components in dependency order. Idempotent (returns the same promise). Rejects if any `up` throws. */
|
|
82
|
+
up(): Promise<void>;
|
|
83
|
+
/** Run the pre then post shutdown phases in reverse-dependency order. Idempotent. Always resolves (best-effort). */
|
|
84
|
+
down(): Promise<void>;
|
|
85
|
+
/** Resolve when shutdown has completed — **without** triggering it (await a signal-driven `down()`). */
|
|
86
|
+
whenDown(): Promise<void>;
|
|
87
|
+
/** `true` once up completes and the pre phase has not finished (ok to serve traffic). */
|
|
88
|
+
isReady(): boolean;
|
|
89
|
+
/** `true` once up completes and shutdown has not been requested. */
|
|
90
|
+
isLive(): boolean;
|
|
91
|
+
/** A snapshot of every registered component and its status. */
|
|
92
|
+
list(): ComponentStatus[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create a lifecycle controller. See the {@link UpDown} methods.
|
|
96
|
+
*/
|
|
97
|
+
declare function updown(opts?: UpDownOptions): UpDown;
|
|
98
|
+
/** Register a component on the default lifecycle. */
|
|
99
|
+
declare const register: (component: Component) => UpDown;
|
|
100
|
+
/** Start the default lifecycle. */
|
|
101
|
+
declare const up: () => Promise<void>;
|
|
102
|
+
/** Shut down the default lifecycle. */
|
|
103
|
+
declare const down: () => Promise<void>;
|
|
104
|
+
/** Resolve when the default lifecycle has shut down (without triggering it). */
|
|
105
|
+
declare const whenDown: () => Promise<void>;
|
|
106
|
+
/** Readiness of the default lifecycle. */
|
|
107
|
+
declare const isReady: () => boolean;
|
|
108
|
+
/** Liveness of the default lifecycle. */
|
|
109
|
+
declare const isLive: () => boolean;
|
|
110
|
+
/** Snapshot of the default lifecycle's components. */
|
|
111
|
+
declare const list: () => ComponentStatus[];
|
|
112
|
+
//#endregion
|
|
113
|
+
export { Component, ComponentStatus, Phase, ProcessLike, Signal, Status, UpDown, UpDownOptions, down, isLive, isReady, list, register, up, updown, whenDown };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # @ayepi/updown
|
|
4
|
+
*
|
|
5
|
+
* Graceful **startup and shutdown** orchestration. Register named components with
|
|
6
|
+
* dependencies; `up()` starts them in dependency order, and `down()` (or a process
|
|
7
|
+
* signal) tears them down in reverse order through a two-phase **pre → post**
|
|
8
|
+
* shutdown.
|
|
9
|
+
*
|
|
10
|
+
* Health semantics, suitable for liveness/readiness probes:
|
|
11
|
+
*
|
|
12
|
+
* - **`isLive()`** — `true` once `up()` finishes and shutdown has **not** been
|
|
13
|
+
* requested. Flips `false` the moment shutdown begins.
|
|
14
|
+
* - **`isReady()`** — `true` once `up()` finishes and the **pre** phase has not yet
|
|
15
|
+
* finished. Stays `true` while draining (pre), flips `false` when **post** begins.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { updown } from '@ayepi/updown'
|
|
19
|
+
*
|
|
20
|
+
* const lc = updown()
|
|
21
|
+
* lc.register({ name: 'db', up: () => db.connect(), post: () => db.end() })
|
|
22
|
+
* lc.register({ name: 'http', deps: ['db'], up: () => listen(), pre: () => drain(), post: () => close() })
|
|
23
|
+
*
|
|
24
|
+
* await lc.up() // db then http
|
|
25
|
+
* lc.isReady() // true
|
|
26
|
+
* // SIGTERM → pre (drain) → isReady=false → post (close) → process exits
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
32
|
+
/** A process signal name (e.g. `'SIGTERM'`). */
|
|
33
|
+
type Signal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGUSR2' | (string & {});
|
|
34
|
+
/** A registered lifecycle component. */
|
|
35
|
+
interface Component {
|
|
36
|
+
/** Unique name. */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/** Names of components that must be **up** before this one starts (shutdown runs in reverse). */
|
|
39
|
+
readonly deps?: readonly string[];
|
|
40
|
+
/** Startup work — `up()` awaits this. */
|
|
41
|
+
readonly up?: () => MaybePromise<void>;
|
|
42
|
+
/** Pre-shutdown hook (the drain phase: stop accepting work, finish in-flight). */
|
|
43
|
+
readonly pre?: () => MaybePromise<void>;
|
|
44
|
+
/** Post-shutdown hook (the teardown phase: close resources). */
|
|
45
|
+
readonly post?: () => MaybePromise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** A component's current lifecycle status. */
|
|
48
|
+
type Status = 'idle' | 'starting' | 'up' | 'pre' | 'post' | 'down' | 'failed';
|
|
49
|
+
/** A component's name, deps, current {@link Status}, and last error (if any). */
|
|
50
|
+
interface ComponentStatus {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly deps: readonly string[];
|
|
53
|
+
readonly status: Status;
|
|
54
|
+
readonly error?: unknown;
|
|
55
|
+
}
|
|
56
|
+
/** The shutdown phase a hook error occurred in. */
|
|
57
|
+
type Phase = 'up' | 'pre' | 'post';
|
|
58
|
+
/** The minimal process surface signal handling uses (the global `process`, or your own). */
|
|
59
|
+
interface ProcessLike {
|
|
60
|
+
on?(event: string, handler: () => void): void;
|
|
61
|
+
off?(event: string, handler: () => void): void;
|
|
62
|
+
exit?(code: number): void;
|
|
63
|
+
}
|
|
64
|
+
/** Options for {@link updown}. */
|
|
65
|
+
interface UpDownOptions {
|
|
66
|
+
/** Process signals that trigger `down()` (default `['SIGTERM', 'SIGINT']`); `false` to disable. */
|
|
67
|
+
readonly signals?: readonly Signal[] | false;
|
|
68
|
+
/** Call `process.exit(0)` after a **signal-triggered** shutdown completes (default `true`). Explicit `down()` never exits. */
|
|
69
|
+
readonly exit?: boolean;
|
|
70
|
+
/** Bound `up()` and `down()` each to this many milliseconds (0 / omitted = no timeout). */
|
|
71
|
+
readonly timeout?: number;
|
|
72
|
+
/** Called when a component hook throws (shutdown is best-effort and continues). */
|
|
73
|
+
readonly onError?: (error: unknown, phase: Phase, name: string) => void;
|
|
74
|
+
/** Override the process object signals attach to (defaults to the global `process`). */
|
|
75
|
+
readonly process?: ProcessLike;
|
|
76
|
+
}
|
|
77
|
+
/** The lifecycle controller returned by {@link updown}. */
|
|
78
|
+
interface UpDown {
|
|
79
|
+
/** Register a component. Chainable. Throws after `up()` has started or on a duplicate name. */
|
|
80
|
+
register(component: Component): UpDown;
|
|
81
|
+
/** Start all components in dependency order. Idempotent (returns the same promise). Rejects if any `up` throws. */
|
|
82
|
+
up(): Promise<void>;
|
|
83
|
+
/** Run the pre then post shutdown phases in reverse-dependency order. Idempotent. Always resolves (best-effort). */
|
|
84
|
+
down(): Promise<void>;
|
|
85
|
+
/** Resolve when shutdown has completed — **without** triggering it (await a signal-driven `down()`). */
|
|
86
|
+
whenDown(): Promise<void>;
|
|
87
|
+
/** `true` once up completes and the pre phase has not finished (ok to serve traffic). */
|
|
88
|
+
isReady(): boolean;
|
|
89
|
+
/** `true` once up completes and shutdown has not been requested. */
|
|
90
|
+
isLive(): boolean;
|
|
91
|
+
/** A snapshot of every registered component and its status. */
|
|
92
|
+
list(): ComponentStatus[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create a lifecycle controller. See the {@link UpDown} methods.
|
|
96
|
+
*/
|
|
97
|
+
declare function updown(opts?: UpDownOptions): UpDown;
|
|
98
|
+
/** Register a component on the default lifecycle. */
|
|
99
|
+
declare const register: (component: Component) => UpDown;
|
|
100
|
+
/** Start the default lifecycle. */
|
|
101
|
+
declare const up: () => Promise<void>;
|
|
102
|
+
/** Shut down the default lifecycle. */
|
|
103
|
+
declare const down: () => Promise<void>;
|
|
104
|
+
/** Resolve when the default lifecycle has shut down (without triggering it). */
|
|
105
|
+
declare const whenDown: () => Promise<void>;
|
|
106
|
+
/** Readiness of the default lifecycle. */
|
|
107
|
+
declare const isReady: () => boolean;
|
|
108
|
+
/** Liveness of the default lifecycle. */
|
|
109
|
+
declare const isLive: () => boolean;
|
|
110
|
+
/** Snapshot of the default lifecycle's components. */
|
|
111
|
+
declare const list: () => ComponentStatus[];
|
|
112
|
+
//#endregion
|
|
113
|
+
export { Component, ComponentStatus, Phase, ProcessLike, Signal, Status, UpDown, UpDownOptions, down, isLive, isReady, list, register, up, updown, whenDown };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/** Process signals that trigger shutdown by default. */
|
|
3
|
+
const DEFAULT_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
4
|
+
/** Exit code used after a signal-triggered shutdown. */
|
|
5
|
+
const EXIT_OK = 0;
|
|
6
|
+
const globalProcess = () => globalThis.process;
|
|
7
|
+
/** Race a promise against a timeout; resolves to `'timeout'` if it elapses first. */
|
|
8
|
+
function withTimeout(p, ms) {
|
|
9
|
+
if (!ms) return p;
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const timer = setTimeout(() => resolve("timeout"), ms);
|
|
12
|
+
timer.unref?.();
|
|
13
|
+
p.then((v) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
resolve(v);
|
|
16
|
+
}, (e) => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
reject(e);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a lifecycle controller. See the {@link UpDown} methods.
|
|
24
|
+
*/
|
|
25
|
+
function updown(opts = {}) {
|
|
26
|
+
const comps = /* @__PURE__ */ new Map();
|
|
27
|
+
const status = /* @__PURE__ */ new Map();
|
|
28
|
+
const errors = /* @__PURE__ */ new Map();
|
|
29
|
+
let upPromise = null;
|
|
30
|
+
let downPromise = null;
|
|
31
|
+
let upDone = false;
|
|
32
|
+
let downRequested = false;
|
|
33
|
+
let preDone = false;
|
|
34
|
+
let resolveDown;
|
|
35
|
+
const downSignal = new Promise((r) => resolveDown = r);
|
|
36
|
+
let handlers = [];
|
|
37
|
+
const fail = (msg) => {
|
|
38
|
+
throw new Error(`updown: ${msg}`);
|
|
39
|
+
};
|
|
40
|
+
const dependentsOf = (name) => [...comps.values()].filter((c) => (c.deps ?? []).includes(name)).map((c) => c.name);
|
|
41
|
+
function checkGraph() {
|
|
42
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
43
|
+
const done = /* @__PURE__ */ new Set();
|
|
44
|
+
const visit = (n, trail) => {
|
|
45
|
+
if (done.has(n)) return;
|
|
46
|
+
if (visiting.has(n)) fail(`dependency cycle: ${[...trail, n].join(" → ")}`);
|
|
47
|
+
visiting.add(n);
|
|
48
|
+
for (const d of comps.get(n).deps ?? []) {
|
|
49
|
+
if (!comps.has(d)) fail(`"${n}" depends on unknown component "${d}"`);
|
|
50
|
+
visit(d, [...trail, n]);
|
|
51
|
+
}
|
|
52
|
+
visiting.delete(n);
|
|
53
|
+
done.add(n);
|
|
54
|
+
};
|
|
55
|
+
for (const n of comps.keys()) visit(n, []);
|
|
56
|
+
}
|
|
57
|
+
const proc = opts.process ?? globalProcess();
|
|
58
|
+
function wireSignals() {
|
|
59
|
+
const sigs = opts.signals === false ? [] : opts.signals ?? DEFAULT_SIGNALS;
|
|
60
|
+
if (!proc?.on) return;
|
|
61
|
+
for (const sig of sigs) {
|
|
62
|
+
const h = () => void api.down().then(() => opts.exit !== false && proc.exit?.(EXIT_OK));
|
|
63
|
+
proc.on(sig, h);
|
|
64
|
+
handlers.push({
|
|
65
|
+
sig,
|
|
66
|
+
h
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function unwireSignals() {
|
|
71
|
+
for (const { sig, h } of handlers) proc?.off?.(sig, h);
|
|
72
|
+
handlers = [];
|
|
73
|
+
}
|
|
74
|
+
function startUpGraph() {
|
|
75
|
+
const started = /* @__PURE__ */ new Map();
|
|
76
|
+
const start = (name) => {
|
|
77
|
+
const existing = started.get(name);
|
|
78
|
+
if (existing) return existing;
|
|
79
|
+
const c = comps.get(name);
|
|
80
|
+
const p = (async () => {
|
|
81
|
+
await Promise.all((c.deps ?? []).map(start));
|
|
82
|
+
if (downRequested) return;
|
|
83
|
+
status.set(name, "starting");
|
|
84
|
+
try {
|
|
85
|
+
await c.up?.();
|
|
86
|
+
status.set(name, "up");
|
|
87
|
+
} catch (e) {
|
|
88
|
+
status.set(name, "failed");
|
|
89
|
+
errors.set(name, e);
|
|
90
|
+
opts.onError?.(e, "up", name);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
started.set(name, p);
|
|
95
|
+
return p;
|
|
96
|
+
};
|
|
97
|
+
return Promise.all([...comps.keys()].map(start)).then(() => void 0);
|
|
98
|
+
}
|
|
99
|
+
/** Run one shutdown phase in reverse-dependency order (dependents tear down before their deps). */
|
|
100
|
+
async function runPhase(hook) {
|
|
101
|
+
const done = /* @__PURE__ */ new Map();
|
|
102
|
+
const run = (name) => {
|
|
103
|
+
const existing = done.get(name);
|
|
104
|
+
if (existing) return existing;
|
|
105
|
+
const c = comps.get(name);
|
|
106
|
+
const p = (async () => {
|
|
107
|
+
await Promise.all(dependentsOf(name).map(run));
|
|
108
|
+
const st = status.get(name);
|
|
109
|
+
if (st === "idle" || st === "failed") return;
|
|
110
|
+
status.set(name, hook);
|
|
111
|
+
const fn = c[hook];
|
|
112
|
+
if (fn) try {
|
|
113
|
+
await fn();
|
|
114
|
+
} catch (e) {
|
|
115
|
+
status.set(name, "failed");
|
|
116
|
+
errors.set(name, e);
|
|
117
|
+
opts.onError?.(e, hook, name);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (hook === "post") status.set(name, "down");
|
|
121
|
+
})();
|
|
122
|
+
done.set(name, p);
|
|
123
|
+
return p;
|
|
124
|
+
};
|
|
125
|
+
await Promise.all([...comps.keys()].map(run));
|
|
126
|
+
}
|
|
127
|
+
const api = {
|
|
128
|
+
register(component) {
|
|
129
|
+
if (upPromise) fail(`cannot register "${component.name}" after up() has started`);
|
|
130
|
+
if (comps.has(component.name)) fail(`duplicate component "${component.name}"`);
|
|
131
|
+
comps.set(component.name, component);
|
|
132
|
+
status.set(component.name, "idle");
|
|
133
|
+
return api;
|
|
134
|
+
},
|
|
135
|
+
up() {
|
|
136
|
+
if (upPromise) return upPromise;
|
|
137
|
+
checkGraph();
|
|
138
|
+
wireSignals();
|
|
139
|
+
upPromise = (async () => {
|
|
140
|
+
if (await withTimeout(startUpGraph(), opts.timeout) === "timeout") throw new Error(`updown: up() timed out after ${opts.timeout}ms`);
|
|
141
|
+
upDone = true;
|
|
142
|
+
})();
|
|
143
|
+
return upPromise;
|
|
144
|
+
},
|
|
145
|
+
down() {
|
|
146
|
+
if (downPromise) return downPromise;
|
|
147
|
+
downRequested = true;
|
|
148
|
+
downPromise = (async () => {
|
|
149
|
+
if (upPromise) await upPromise.catch(() => {});
|
|
150
|
+
if (await withTimeout((async () => {
|
|
151
|
+
await runPhase("pre");
|
|
152
|
+
preDone = true;
|
|
153
|
+
await runPhase("post");
|
|
154
|
+
})(), opts.timeout) === "timeout") opts.onError?.(/* @__PURE__ */ new Error(`updown: down() timed out after ${opts.timeout}ms`), "post", "*");
|
|
155
|
+
unwireSignals();
|
|
156
|
+
resolveDown();
|
|
157
|
+
})();
|
|
158
|
+
return downPromise;
|
|
159
|
+
},
|
|
160
|
+
whenDown() {
|
|
161
|
+
return downSignal;
|
|
162
|
+
},
|
|
163
|
+
isReady() {
|
|
164
|
+
return upDone && !preDone;
|
|
165
|
+
},
|
|
166
|
+
isLive() {
|
|
167
|
+
return upDone && !downRequested;
|
|
168
|
+
},
|
|
169
|
+
list() {
|
|
170
|
+
return [...comps.values()].map((c) => {
|
|
171
|
+
/* v8 ignore next */ const st = status.get(c.name) ?? "idle";
|
|
172
|
+
const s = {
|
|
173
|
+
name: c.name,
|
|
174
|
+
deps: c.deps ?? [],
|
|
175
|
+
status: st
|
|
176
|
+
};
|
|
177
|
+
return errors.has(c.name) ? {
|
|
178
|
+
...s,
|
|
179
|
+
error: errors.get(c.name)
|
|
180
|
+
} : s;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return api;
|
|
185
|
+
}
|
|
186
|
+
const instance = updown();
|
|
187
|
+
/** Register a component on the default lifecycle. */
|
|
188
|
+
const register = (component) => instance.register(component);
|
|
189
|
+
/** Start the default lifecycle. */
|
|
190
|
+
const up = () => instance.up();
|
|
191
|
+
/** Shut down the default lifecycle. */
|
|
192
|
+
const down = () => instance.down();
|
|
193
|
+
/** Resolve when the default lifecycle has shut down (without triggering it). */
|
|
194
|
+
const whenDown = () => instance.whenDown();
|
|
195
|
+
/** Readiness of the default lifecycle. */
|
|
196
|
+
const isReady = () => instance.isReady();
|
|
197
|
+
/** Liveness of the default lifecycle. */
|
|
198
|
+
const isLive = () => instance.isLive();
|
|
199
|
+
/** Snapshot of the default lifecycle's components. */
|
|
200
|
+
const list = () => instance.list();
|
|
201
|
+
//#endregion
|
|
202
|
+
export { down, isLive, isReady, list, register, up, updown, whenDown };
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayepi/updown",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Graceful startup/shutdown orchestration — named components with dependencies, two-phase shutdown, liveness/readiness",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ClickerMonkey/ayepi.git",
|
|
12
|
+
"directory": "packages/updown"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/updown#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ClickerMonkey/ayepi/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
41
|
+
"publint": "^0.3.0",
|
|
42
|
+
"tsdown": "^0.12.0",
|
|
43
|
+
"vitest": "^2.1.8"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"lifecycle",
|
|
47
|
+
"graceful-shutdown",
|
|
48
|
+
"startup",
|
|
49
|
+
"shutdown",
|
|
50
|
+
"healthcheck",
|
|
51
|
+
"liveness",
|
|
52
|
+
"readiness",
|
|
53
|
+
"ayepi"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run --coverage",
|
|
59
|
+
"publint": "publint"
|
|
60
|
+
}
|
|
61
|
+
}
|