@ayepi/updown 0.1.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/README.md CHANGED
@@ -66,7 +66,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
66
66
 
67
67
  - [`ayepi-updown.md`](./ayepi-updown.md)
68
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.
69
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/updown).
70
70
 
71
71
  ## License
72
72
 
@@ -0,0 +1,412 @@
1
+ <!--
2
+ ayepi-updown.md — reference for `@ayepi/updown`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/updown` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/updown`
11
+
12
+ Graceful **startup and shutdown** orchestration. You register named **components** (a db,
13
+ an HTTP server, a broker, a work engine) with optional `up`/`pre`/`post` hooks and
14
+ `deps`. `up()` starts them in dependency order (independent ones in parallel); `down()` —
15
+ or a process signal — tears them down in reverse through a two-phase **pre → post**
16
+ shutdown. It also exposes `isLive()` / `isReady()` for Kubernetes-style liveness/readiness
17
+ probes. Zero dependencies, works in any runtime (Node, Deno, Bun, the browser). Reach for
18
+ it when you have more than one resource whose order of startup/shutdown matters and you
19
+ want clean draining on `SIGTERM`.
20
+
21
+ ```sh
22
+ pnpm add @ayepi/updown
23
+ ```
24
+
25
+ ## Mental model
26
+
27
+ - A **component** is `{ name, deps?, up?, pre?, post? }`. Every hook is optional.
28
+ - **Startup** (`up()`): runs each component's `up` in **dependency order** — a component
29
+ starts only after every name in its `deps` is `up`. Independent components run in
30
+ parallel.
31
+ - **Shutdown** (`down()`): two global phases, each in **reverse-dependency order**
32
+ (dependents tear down before the things they depend on):
33
+ 1. **pre** — drain. Stop accepting new work, let in-flight work finish. `isReady()`
34
+ stays `true` during this phase.
35
+ 2. **post** — teardown. Close sockets/connections. `isReady()` is already `false`.
36
+ All `pre` hooks across all components complete before **any** `post` hook runs.
37
+ - **Liveness** (`isLive()`): up and not shutting down.
38
+ - **Readiness** (`isReady()`): up and not yet past the drain phase.
39
+
40
+ ## Public API
41
+
42
+ Everything below is exported from the package root. (Internal helpers like `withTimeout`
43
+ are not exported and are omitted.)
44
+
45
+ ### `updown(opts?)` — the factory
46
+
47
+ ```ts
48
+ function updown(opts?: UpDownOptions): UpDown
49
+ ```
50
+
51
+ Creates an independent lifecycle controller. Call it once per process you want to manage.
52
+ The first time you call `up()`, it validates the dependency graph and (unless disabled)
53
+ wires process signal handlers.
54
+
55
+ ### Default instance + bound top-level exports
56
+
57
+ The package also constructs a single shared instance at import time and re-exports its
58
+ methods as standalone functions, for the common single-lifecycle case:
59
+
60
+ ```ts
61
+ const instance = updown()
62
+
63
+ export const register = (component: Component): UpDown => instance.register(component)
64
+ export const up = (): Promise<void> => instance.up()
65
+ export const down = (): Promise<void> => instance.down()
66
+ export const whenDown = (): Promise<void> => instance.whenDown()
67
+ export const isReady = (): boolean => instance.isReady()
68
+ export const isLive = (): boolean => instance.isLive()
69
+ export const list = (): ComponentStatus[] => instance.list()
70
+ ```
71
+
72
+ So you can either own an instance (`const lc = updown(...)`) or use the bound globals:
73
+
74
+ ```ts
75
+ import { register, up, isLive, isReady } from '@ayepi/updown'
76
+
77
+ register({ name: 'db', up: () => db.connect(), post: () => db.end() })
78
+ await up()
79
+ ```
80
+
81
+ The default instance is created with **default options** — signals (`SIGTERM`, `SIGINT`)
82
+ are wired and `exit: true` applies. If you need custom options (timeout, custom signals,
83
+ injected process), create your own instance with `updown(opts)` instead of using the bound
84
+ exports.
85
+
86
+ ### `Component`
87
+
88
+ ```ts
89
+ interface Component {
90
+ /** Unique name. */
91
+ readonly name: string
92
+ /** Names of components that must be **up** before this one starts (shutdown runs in reverse). */
93
+ readonly deps?: readonly string[]
94
+ /** Startup work — `up()` awaits this. */
95
+ readonly up?: () => MaybePromise<void>
96
+ /** Pre-shutdown hook (the drain phase: stop accepting work, finish in-flight). */
97
+ readonly pre?: () => MaybePromise<void>
98
+ /** Post-shutdown hook (the teardown phase: close resources). */
99
+ readonly post?: () => MaybePromise<void>
100
+ }
101
+ ```
102
+
103
+ All hooks may be sync or async (`MaybePromise<void> = void | Promise<void>`). A component
104
+ with no hooks at all is legal — it's just an ordering node / a thing to report in `list()`.
105
+
106
+ ### `UpDownOptions`
107
+
108
+ ```ts
109
+ interface UpDownOptions {
110
+ /** Process signals that trigger `down()` (default `['SIGTERM', 'SIGINT']`); `false` to disable. */
111
+ readonly signals?: readonly Signal[] | false
112
+ /** Call `process.exit(0)` after a **signal-triggered** shutdown completes (default `true`). Explicit `down()` never exits. */
113
+ readonly exit?: boolean
114
+ /** Bound `up()` and `down()` each to this many milliseconds (0 / omitted = no timeout). */
115
+ readonly timeout?: number
116
+ /** Called when a component hook throws (shutdown is best-effort and continues). */
117
+ readonly onError?: (error: unknown, phase: Phase, name: string) => void
118
+ /** Override the process object signals attach to (defaults to the global `process`). */
119
+ readonly process?: ProcessLike
120
+ }
121
+ ```
122
+
123
+ - `signals` — which signals trigger `down()`. `false` disables signal handling entirely
124
+ (useful in tests and libraries). Default `['SIGTERM', 'SIGINT']`.
125
+ - `exit` — only affects **signal-triggered** shutdowns. After such a shutdown completes,
126
+ `process.exit(0)` is called unless `exit: false`. An explicit `down()` call **never**
127
+ exits the process.
128
+ - `timeout` — bounds **both** `up()` and `down()`. If `up()` exceeds it, `up()` rejects
129
+ with `updown: up() timed out after <ms>ms`. If `down()` exceeds it, `down()` still
130
+ resolves but reports a timeout error to `onError` with phase `'post'` and name `'*'`.
131
+ - `onError(error, phase, name)` — called for every hook that throws and for a `down()`
132
+ timeout. `phase` is `'up' | 'pre' | 'post'`.
133
+ - `process` — inject a `ProcessLike` to attach signal handlers to (instead of the global
134
+ `process`). Mainly for tests.
135
+
136
+ ### `UpDown` — the controller interface
137
+
138
+ ```ts
139
+ interface UpDown {
140
+ /** Register a component. Chainable. Throws after `up()` has started or on a duplicate name. */
141
+ register(component: Component): UpDown
142
+ /** Start all components in dependency order. Idempotent (returns the same promise). Rejects if any `up` throws. */
143
+ up(): Promise<void>
144
+ /** Run the pre then post shutdown phases in reverse-dependency order. Idempotent. Always resolves (best-effort). */
145
+ down(): Promise<void>
146
+ /** Resolve when shutdown has completed — **without** triggering it (await a signal-driven `down()`). */
147
+ whenDown(): Promise<void>
148
+ /** `true` once up completes and the pre phase has not finished (ok to serve traffic). */
149
+ isReady(): boolean
150
+ /** `true` once up completes and shutdown has not been requested. */
151
+ isLive(): boolean
152
+ /** A snapshot of every registered component and its status. */
153
+ list(): ComponentStatus[]
154
+ }
155
+ ```
156
+
157
+ Key behaviors:
158
+
159
+ - `register` is **chainable** (returns the `UpDown`) and throws on a duplicate `name` or
160
+ if called after `up()` has started.
161
+ - `up()` is **idempotent**: repeated calls return the same promise. It rejects if any
162
+ `up` hook throws, marking that component `failed`.
163
+ - `down()` is **idempotent** and **best-effort**: it always resolves, even if hooks throw
164
+ or hang (with `timeout`). Throwing hooks are reported via `onError`, not rethrown.
165
+ - `whenDown()` resolves when shutdown finishes but does **not** trigger it — use it to
166
+ block `main()` until a signal arrives.
167
+
168
+ ### Supporting types
169
+
170
+ ```ts
171
+ /** A process signal name (e.g. `'SIGTERM'`). */
172
+ type Signal = 'SIGTERM' | 'SIGINT' | 'SIGHUP' | 'SIGUSR2' | (string & {})
173
+
174
+ /** A component's current lifecycle status. */
175
+ type Status = 'idle' | 'starting' | 'up' | 'pre' | 'post' | 'down' | 'failed'
176
+
177
+ /** The shutdown phase a hook error occurred in. */
178
+ type Phase = 'up' | 'pre' | 'post'
179
+
180
+ /** A component's name, deps, current Status, and last error (if any). */
181
+ interface ComponentStatus {
182
+ readonly name: string
183
+ readonly deps: readonly string[]
184
+ readonly status: Status
185
+ readonly error?: unknown
186
+ }
187
+
188
+ /** The minimal process surface signal handling uses (the global `process`, or your own). */
189
+ interface ProcessLike {
190
+ on?(event: string, handler: () => void): void
191
+ off?(event: string, handler: () => void): void
192
+ exit?(code: number): void
193
+ }
194
+ ```
195
+
196
+ The normal status progression for a started component is
197
+ `idle → starting → up → pre → post → down`. A failed `up`/`pre`/`post` lands it on
198
+ `failed`. A component that was never started (e.g. `down()` before `up()`) stays `idle`
199
+ and its `pre`/`post` are skipped.
200
+
201
+ ## Examples
202
+
203
+ ### Components with dependencies, ordered startup, graceful SIGTERM shutdown
204
+
205
+ ```ts
206
+ import { updown } from '@ayepi/updown'
207
+
208
+ const lc = updown() // SIGTERM/SIGINT trigger shutdown by default; exit(0) afterward
209
+
210
+ lc.register({ name: 'db', up: () => db.connect(), post: () => db.end() })
211
+ lc.register({ name: 'cache', up: () => cache.connect(), post: () => cache.quit() })
212
+ lc.register({
213
+ name: 'http',
214
+ deps: ['db', 'cache'], // starts after db AND cache
215
+ up: () => server.listen(3000),
216
+ pre: () => server.stopAcceptingNewConnections(), // drain
217
+ post: () => server.close(), // teardown
218
+ })
219
+
220
+ await lc.up() // db + cache (parallel), then http
221
+ // On SIGTERM: pre(http) → pre(db,cache) → [isReady=false] → post(http) → post(db,cache) → exit(0)
222
+ ```
223
+
224
+ `register` is chainable, so this is equivalent:
225
+
226
+ ```ts
227
+ updown()
228
+ .register({ name: 'db', up: () => db.connect(), post: () => db.end() })
229
+ .register({ name: 'http', deps: ['db'], up: () => server.listen() })
230
+ .up()
231
+ ```
232
+
233
+ ### Readiness / liveness wiring (HTTP health endpoints)
234
+
235
+ ```ts
236
+ const lc = updown()
237
+
238
+ lc.register({ name: 'http', up: () => server.listen() })
239
+ await lc.up()
240
+
241
+ app.get('/livez', (_req, res) => res.status(lc.isLive() ? 200 : 503).end())
242
+ app.get('/readyz', (_req, res) => res.status(lc.isReady() ? 200 : 503).end())
243
+ ```
244
+
245
+ During a rolling deploy the sequence is: SIGTERM arrives → `isLive()` flips `false`
246
+ immediately → the `pre` (drain) phase runs, during which `isReady()` (and so `/readyz`)
247
+ stays `200` so in-flight requests can finish → once `pre` finishes, `isReady()` flips
248
+ `false` and the `post` teardown runs.
249
+
250
+ > Readiness stays `true` *through* the drain phase by design: you want to keep finishing
251
+ > in-flight requests. If your orchestrator should stop sending traffic the instant
252
+ > shutdown begins, gate routing on `isLive()` instead (it flips `false` the moment
253
+ > shutdown is requested).
254
+
255
+ ### Block `main()` until a signal arrives
256
+
257
+ ```ts
258
+ import { updown } from '@ayepi/updown'
259
+
260
+ async function main() {
261
+ const lc = updown()
262
+ lc.register({ name: 'http', up: () => server.listen() })
263
+ await lc.up()
264
+ await lc.whenDown() // resolves after SIGTERM → drain → teardown completes
265
+ }
266
+ main()
267
+ ```
268
+
269
+ ### Custom options: timeout + error logging, signals disabled
270
+
271
+ ```ts
272
+ const lc = updown({
273
+ signals: ['SIGTERM', 'SIGINT'], // or `false` to manage shutdown yourself
274
+ exit: true, // process.exit(0) after a signal shutdown
275
+ timeout: 30_000, // bound up()/down(); resolve even if a hook hangs
276
+ onError: (err, phase, name) => log.error({ err, phase, name }, 'lifecycle hook failed'),
277
+ })
278
+ ```
279
+
280
+ ### Integrating an ayepi server + broker + work engine
281
+
282
+ A realistic shutdown order: stop the work engine from picking up *new* jobs first
283
+ (`pre`), drain the HTTP server, then close the broker and engine, then disconnect Redis
284
+ last (everything depends on it).
285
+
286
+ ```ts
287
+ import { updown } from '@ayepi/updown'
288
+
289
+ const lc = updown({ onError: (err, phase, name) => log.error({ err, phase, name }) })
290
+
291
+ // Shared transport — closed last because everything depends on it.
292
+ lc.register({
293
+ name: 'redis',
294
+ up: () => redis.connect(),
295
+ post: () => redis.quit(),
296
+ })
297
+
298
+ // Pub/sub broker, depends on redis.
299
+ lc.register({
300
+ name: 'broker',
301
+ deps: ['redis'],
302
+ up: () => broker.start(),
303
+ pre: () => broker.stopReceiving(), // stop accepting new messages
304
+ post: () => broker.close(), // flush + disconnect
305
+ })
306
+
307
+ // @ayepi/work engine — stop claiming jobs on pre, let running jobs finish, close on post.
308
+ lc.register({
309
+ name: 'work',
310
+ deps: ['redis', 'broker'],
311
+ up: () => engine.start(),
312
+ pre: () => engine.drain(), // stop claiming new jobs; await in-flight
313
+ post: () => engine.close(),
314
+ })
315
+
316
+ // @ayepi/core HTTP/WebSocket server — outermost; drains first, closes first.
317
+ lc.register({
318
+ name: 'http',
319
+ deps: ['redis', 'broker', 'work'],
320
+ up: () => server.listen(3000),
321
+ pre: () => server.stopAcceptingNewConnections(),
322
+ post: () => server.close(),
323
+ })
324
+
325
+ await lc.up()
326
+ // startup order: redis → broker → work → http
327
+ // shutdown pre: http → work → broker → redis (reverse; dependents first)
328
+ // shutdown post: http → work → broker → redis (after ALL pre hooks finish)
329
+ ```
330
+
331
+ > Hook names (`engine.drain()`, `broker.stopReceiving()`, etc.) are illustrative — wire
332
+ > them to whatever the corresponding `@ayepi/*` package actually exposes. The point is the
333
+ > shape: `up` to start, `pre` to drain, `post` to close, with `deps` declaring order.
334
+
335
+ ### Inspecting status
336
+
337
+ ```ts
338
+ lc.list()
339
+ // [
340
+ // { name: 'redis', deps: [], status: 'up' },
341
+ // { name: 'http', deps: ['redis','broker','work'], status: 'up' },
342
+ // ...
343
+ // ]
344
+ // After a failed hook the entry also carries `error`:
345
+ // { name: 'http', deps: [...], status: 'failed', error: Error('listen EADDRINUSE') }
346
+ ```
347
+
348
+ ## How it works under the hood
349
+
350
+ **Dependency ordering (topological).** `up()` first calls an internal `checkGraph()` that
351
+ does a DFS over the registered components, detecting cycles (`updown: dependency cycle:
352
+ a → b → a`) and unknown deps (`updown: "a" depends on unknown component "missing"`) — both
353
+ thrown synchronously from `up()`. Startup then walks the graph: each component's start
354
+ promise first `await Promise.all(deps.map(start))`, so a component's `up` runs only after
355
+ all its deps are `up`. Independent subtrees run concurrently. Promises are memoized per
356
+ component, so a shared dependency runs once.
357
+
358
+ **Two-phase shutdown.** `down()` runs `runPhase('pre')` to completion, flips `preDone`,
359
+ then runs `runPhase('post')`. Each phase walks the graph in **reverse**: a component waits
360
+ for all of its *dependents* (`await Promise.all(dependentsOf(name).map(run))`) before
361
+ running its own hook. That means dependents drain/close before the things they depend on,
362
+ in both phases. All `pre` hooks finish before any `post` hook starts. Components that are
363
+ `idle` (never started) or already `failed` are skipped.
364
+
365
+ **How readiness/liveness flip.** Three internal flags drive the probes:
366
+
367
+ - `downRequested` — set `true` synchronously at the very top of `down()`. `isLive()` is
368
+ `upDone && !downRequested`, so liveness drops the instant shutdown begins.
369
+ - `preDone` — set `true` after the `pre` phase completes. `isReady()` is
370
+ `upDone && !preDone`, so readiness stays `true` through draining and drops when `post`
371
+ begins.
372
+ - `upDone` — set `true` only after `up()` finishes. Both probes are `false` before
373
+ startup completes and after a failed `up()`.
374
+
375
+ **Signal handling.** On the first `up()`, `wireSignals()` registers one handler per
376
+ configured signal on the process object (the global `process` or an injected
377
+ `ProcessLike`). Each handler calls `down()` and, unless `exit: false`, `process.exit(0)`
378
+ when it resolves. After shutdown completes, `unwireSignals()` removes every handler via
379
+ `process.off`. If the process object has no `.on` method, signal wiring silently
380
+ no-ops (so it's safe in non-Node runtimes). Explicit `down()` calls never call
381
+ `process.exit`.
382
+
383
+ **Timers / `unref`.** The `timeout` option uses `setTimeout` whose handle is `unref()`'d
384
+ (when the runtime supports it), so a pending timeout never keeps the event loop alive on
385
+ its own. On a `down()` timeout, shutdown still resolves and reports
386
+ `updown: down() timed out after <ms>ms` to `onError` with name `'*'`; on an `up()`
387
+ timeout, `up()` rejects.
388
+
389
+ ## Gotchas / constraints
390
+
391
+ - **Register before `up()`.** `register()` throws once `up()` has started. Wire the whole
392
+ graph first.
393
+ - **`up()` and `down()` are idempotent.** Repeat calls return the *same* promise — you
394
+ can call `down()` from multiple paths (signal + manual) safely.
395
+ - **`down()` never rejects.** It's best-effort: hook errors and timeouts go to `onError`;
396
+ the promise still resolves. Don't rely on a rejection to detect shutdown failure — check
397
+ `list()` for `failed` statuses or watch `onError`.
398
+ - **`up()` *does* reject** if any `up` hook throws (or on timeout), and leaves the failing
399
+ component `failed`. Components already started are **not** auto-rolled-back — call
400
+ `down()` yourself if you want to tear them down.
401
+ - **`exit` only applies to signal-triggered shutdowns.** A manual `await lc.down()` will
402
+ not exit the process; you control the exit.
403
+ - **Shutdown mid-startup is handled.** If `down()` is requested while `up()` is still
404
+ running, in-flight `up` hooks settle but components that haven't started yet are
405
+ **skipped** (their `up` never runs), and they won't have `pre`/`post` run either.
406
+ - **Default-instance exports use default options.** The bound `register`/`up`/`down`/…
407
+ exports are tied to one shared instance created with no options (signals on,
408
+ `exit: true`, no timeout). For any custom option, create your own `updown(opts)`.
409
+ - **`timeout` bounds the *whole* `up()`/`down()`, not per-hook.** A single slow hook can
410
+ consume the entire budget.
411
+ - **Readiness stays up during drain.** This is intentional (finish in-flight work). If you
412
+ want traffic cut the instant shutdown starts, gate on `isLive()` instead of `isReady()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/updown",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Graceful startup/shutdown orchestration — named components with dependencies, two-phase shutdown, liveness/readiness",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,8 @@
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "ayepi-*.md"
22
23
  ],
23
24
  "exports": {
24
25
  ".": {