@outfitter/daemon 0.1.0-rc.1

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 ADDED
@@ -0,0 +1,495 @@
1
+ # @outfitter/daemon
2
+
3
+ Daemon lifecycle management, IPC communication, and health checks for background processes.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @outfitter/daemon
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import {
15
+ createDaemon,
16
+ createIpcServer,
17
+ createHealthChecker,
18
+ getSocketPath,
19
+ getLockPath,
20
+ } from "@outfitter/daemon";
21
+
22
+ // Create a daemon with lifecycle management
23
+ const daemon = createDaemon({
24
+ name: "my-service",
25
+ pidFile: getLockPath("my-service"),
26
+ shutdownTimeout: 10000,
27
+ });
28
+
29
+ // Register cleanup handlers
30
+ daemon.onShutdown(async () => {
31
+ await database.close();
32
+ });
33
+
34
+ // Start the daemon
35
+ const result = await daemon.start();
36
+ if (result.isErr()) {
37
+ console.error("Failed to start:", result.error.message);
38
+ process.exit(1);
39
+ }
40
+
41
+ // Set up IPC server
42
+ const server = createIpcServer(getSocketPath("my-service"));
43
+ server.onMessage(async (msg) => {
44
+ if (msg.type === "status") {
45
+ return { status: "ok", uptime: process.uptime() };
46
+ }
47
+ return { error: "Unknown command" };
48
+ });
49
+ await server.listen();
50
+ ```
51
+
52
+ ## Platform Detection
53
+
54
+ Utilities for detecting the platform and resolving platform-specific paths.
55
+
56
+ ### isUnixPlatform
57
+
58
+ Check if running on a Unix-like platform (macOS or Linux).
59
+
60
+ ```typescript
61
+ import { isUnixPlatform } from "@outfitter/daemon";
62
+
63
+ if (isUnixPlatform()) {
64
+ // Use Unix domain sockets
65
+ } else {
66
+ // Use named pipes (Windows)
67
+ }
68
+ ```
69
+
70
+ ### Path Resolution
71
+
72
+ Get platform-appropriate paths for daemon files.
73
+
74
+ ```typescript
75
+ import {
76
+ getSocketPath,
77
+ getLockPath,
78
+ getPidPath,
79
+ getDaemonDir,
80
+ } from "@outfitter/daemon";
81
+
82
+ const socketPath = getSocketPath("waymark");
83
+ // Linux: "/run/user/1000/waymark/daemon.sock"
84
+ // macOS: "/var/folders/.../waymark/daemon.sock"
85
+ // Windows: "\\\\.\\pipe\\waymark-daemon"
86
+
87
+ const lockPath = getLockPath("waymark");
88
+ // Linux: "/run/user/1000/waymark/daemon.lock"
89
+
90
+ const pidPath = getPidPath("waymark");
91
+ // Linux: "/run/user/1000/waymark/daemon.pid"
92
+
93
+ const daemonDir = getDaemonDir("waymark");
94
+ // Linux: "/run/user/1000/waymark"
95
+ ```
96
+
97
+ Path resolution follows XDG standards:
98
+ - `$XDG_RUNTIME_DIR` takes precedence if set
99
+ - Linux: Falls back to `/run/user/<uid>`
100
+ - macOS: Uses `$TMPDIR`
101
+ - Windows: Uses `%TEMP%`
102
+
103
+ ## Locking
104
+
105
+ PID-based locking with stale detection for ensuring single daemon instances.
106
+
107
+ ### Acquire and Release Locks
108
+
109
+ ```typescript
110
+ import {
111
+ acquireDaemonLock,
112
+ releaseDaemonLock,
113
+ type LockHandle,
114
+ } from "@outfitter/daemon";
115
+
116
+ const result = await acquireDaemonLock("/run/user/1000/waymark/daemon.lock");
117
+
118
+ if (result.isOk()) {
119
+ const handle: LockHandle = result.value;
120
+ console.log(`Lock acquired for PID ${handle.pid}`);
121
+
122
+ try {
123
+ // ... run daemon ...
124
+ } finally {
125
+ await releaseDaemonLock(handle);
126
+ }
127
+ } else {
128
+ console.error(`Failed to acquire lock: ${result.error.message}`);
129
+ }
130
+ ```
131
+
132
+ ### Process Liveness Checks
133
+
134
+ ```typescript
135
+ import { isProcessAlive, isDaemonAlive, readLockPid } from "@outfitter/daemon";
136
+
137
+ // Check if a specific PID is alive
138
+ if (isProcessAlive(12345)) {
139
+ console.log("Process is still running");
140
+ }
141
+
142
+ // Check if a daemon is alive via its lock file
143
+ const alive = await isDaemonAlive("/run/user/1000/waymark/daemon.lock");
144
+ if (!alive) {
145
+ // Safe to start a new daemon
146
+ }
147
+
148
+ // Read the PID from a lock file
149
+ const pid = await readLockPid("/run/user/1000/waymark/daemon.lock");
150
+ if (pid !== undefined) {
151
+ console.log(`Daemon running with PID ${pid}`);
152
+ }
153
+ ```
154
+
155
+ ### LockHandle Interface
156
+
157
+ ```typescript
158
+ interface LockHandle {
159
+ readonly lockPath: string; // Path to the lock file
160
+ readonly pid: number; // PID that owns the lock
161
+ }
162
+ ```
163
+
164
+ ## Lifecycle
165
+
166
+ Daemon lifecycle management with PID file handling, signal handling, and graceful shutdown.
167
+
168
+ ### Creating a Daemon
169
+
170
+ ```typescript
171
+ import { createDaemon, type DaemonOptions } from "@outfitter/daemon";
172
+
173
+ const options: DaemonOptions = {
174
+ name: "my-daemon",
175
+ pidFile: "/var/run/my-daemon.pid",
176
+ logger: myLogger, // Optional @outfitter/logging instance
177
+ shutdownTimeout: 10000, // Optional, default: 5000ms
178
+ };
179
+
180
+ const daemon = createDaemon(options);
181
+ ```
182
+
183
+ ### Daemon Lifecycle
184
+
185
+ ```typescript
186
+ // Register shutdown handlers (called during graceful shutdown)
187
+ daemon.onShutdown(async () => {
188
+ await database.close();
189
+ });
190
+
191
+ daemon.onShutdown(async () => {
192
+ await cache.disconnect();
193
+ });
194
+
195
+ // Start the daemon
196
+ const startResult = await daemon.start();
197
+ if (startResult.isErr()) {
198
+ console.error("Failed to start:", startResult.error.message);
199
+ process.exit(1);
200
+ }
201
+
202
+ // Check running state
203
+ console.log("Running:", daemon.isRunning()); // true
204
+ console.log("State:", daemon.state); // "running"
205
+
206
+ // Stop gracefully (also triggered by SIGTERM/SIGINT)
207
+ const stopResult = await daemon.stop();
208
+ if (stopResult.isErr()) {
209
+ console.error("Shutdown issue:", stopResult.error.message);
210
+ }
211
+ ```
212
+
213
+ ### Daemon States
214
+
215
+ The daemon follows a state machine:
216
+
217
+ | State | Description |
218
+ |-------|-------------|
219
+ | `stopped` | Initial state, daemon not running |
220
+ | `starting` | Transitioning to running (creating PID file) |
221
+ | `running` | Daemon is active and processing |
222
+ | `stopping` | Graceful shutdown in progress |
223
+
224
+ State transitions:
225
+ - `stopped` -> `starting` -> `running` (via `start()`)
226
+ - `running` -> `stopping` -> `stopped` (via `stop()` or signal)
227
+ - `starting` -> `stopped` (if start fails)
228
+
229
+ ### Daemon Interface
230
+
231
+ ```typescript
232
+ interface Daemon {
233
+ readonly state: DaemonState;
234
+ start(): Promise<Result<void, DaemonError>>;
235
+ stop(): Promise<Result<void, DaemonError>>;
236
+ isRunning(): boolean;
237
+ onShutdown(handler: ShutdownHandler): void;
238
+ }
239
+
240
+ type DaemonState = "stopped" | "starting" | "running" | "stopping";
241
+ type ShutdownHandler = () => Promise<void>;
242
+ ```
243
+
244
+ ## IPC
245
+
246
+ Inter-process communication via Unix domain sockets using JSON-serialized messages.
247
+
248
+ ### IPC Server
249
+
250
+ ```typescript
251
+ import { createIpcServer, type IpcServer } from "@outfitter/daemon";
252
+
253
+ const server: IpcServer = createIpcServer("/var/run/my-daemon.sock");
254
+
255
+ // Register message handler
256
+ server.onMessage(async (msg) => {
257
+ const message = msg as { type: string };
258
+
259
+ switch (message.type) {
260
+ case "status":
261
+ return { status: "ok", uptime: process.uptime() };
262
+ case "ping":
263
+ return { pong: true };
264
+ default:
265
+ return { error: "Unknown command" };
266
+ }
267
+ });
268
+
269
+ // Start listening
270
+ await server.listen();
271
+
272
+ // Stop and cleanup
273
+ await server.close();
274
+ ```
275
+
276
+ ### IPC Client
277
+
278
+ ```typescript
279
+ import { createIpcClient, type IpcClient } from "@outfitter/daemon";
280
+
281
+ const client: IpcClient = createIpcClient("/var/run/my-daemon.sock");
282
+
283
+ // Connect to the server
284
+ await client.connect();
285
+
286
+ // Send messages and receive responses
287
+ interface StatusResponse {
288
+ status: string;
289
+ uptime: number;
290
+ }
291
+
292
+ const response = await client.send<StatusResponse>({ type: "status" });
293
+ console.log("Daemon uptime:", response.uptime);
294
+
295
+ // Close connection
296
+ client.close();
297
+ ```
298
+
299
+ ### IPC Interfaces
300
+
301
+ ```typescript
302
+ interface IpcServer {
303
+ listen(): Promise<void>;
304
+ close(): Promise<void>;
305
+ onMessage(handler: IpcMessageHandler): void;
306
+ }
307
+
308
+ interface IpcClient {
309
+ connect(): Promise<void>;
310
+ send<T>(message: unknown): Promise<T>;
311
+ close(): void;
312
+ }
313
+
314
+ type IpcMessageHandler = (message: unknown) => Promise<unknown>;
315
+ ```
316
+
317
+ ## Health Checks
318
+
319
+ Parallel health check execution with aggregated status reporting.
320
+
321
+ ### Creating a Health Checker
322
+
323
+ ```typescript
324
+ import {
325
+ createHealthChecker,
326
+ type HealthCheck,
327
+ type HealthChecker,
328
+ } from "@outfitter/daemon";
329
+ import { Result } from "@outfitter/contracts";
330
+
331
+ // Define health checks
332
+ const checks: HealthCheck[] = [
333
+ {
334
+ name: "database",
335
+ check: async () => {
336
+ try {
337
+ await db.ping();
338
+ return Result.ok(undefined);
339
+ } catch (error) {
340
+ return Result.err(error as Error);
341
+ }
342
+ },
343
+ },
344
+ {
345
+ name: "cache",
346
+ check: async () => {
347
+ try {
348
+ await redis.ping();
349
+ return Result.ok(undefined);
350
+ } catch (error) {
351
+ return Result.err(error as Error);
352
+ }
353
+ },
354
+ },
355
+ ];
356
+
357
+ // Create health checker
358
+ const checker: HealthChecker = createHealthChecker(checks);
359
+
360
+ // Register additional checks at runtime
361
+ checker.register({
362
+ name: "queue",
363
+ check: async () => {
364
+ const connected = await queue.isConnected();
365
+ return connected
366
+ ? Result.ok(undefined)
367
+ : Result.err(new Error("Queue disconnected"));
368
+ },
369
+ });
370
+ ```
371
+
372
+ ### Running Health Checks
373
+
374
+ ```typescript
375
+ const status = await checker.check();
376
+
377
+ console.log("Overall healthy:", status.healthy); // true only if ALL checks pass
378
+ console.log("Uptime (seconds):", status.uptime);
379
+ console.log("Checks:", status.checks);
380
+ // {
381
+ // database: { healthy: true },
382
+ // cache: { healthy: false, message: "Connection refused" },
383
+ // queue: { healthy: true }
384
+ // }
385
+
386
+ if (!status.healthy) {
387
+ const failed = Object.entries(status.checks)
388
+ .filter(([, result]) => !result.healthy)
389
+ .map(([name, result]) => `${name}: ${result.message}`);
390
+
391
+ console.error("Failed checks:", failed.join(", "));
392
+ }
393
+ ```
394
+
395
+ ### Health Check Types
396
+
397
+ ```typescript
398
+ interface HealthCheck {
399
+ name: string;
400
+ check(): Promise<Result<void, Error>>;
401
+ }
402
+
403
+ interface HealthCheckResult {
404
+ healthy: boolean;
405
+ message?: string; // Error message on failure
406
+ }
407
+
408
+ interface HealthStatus {
409
+ healthy: boolean; // true only if ALL checks pass
410
+ checks: Record<string, HealthCheckResult>; // Individual results
411
+ uptime: number; // Seconds since checker created
412
+ }
413
+
414
+ interface HealthChecker {
415
+ check(): Promise<HealthStatus>;
416
+ register(check: HealthCheck): void;
417
+ }
418
+ ```
419
+
420
+ ## Error Types
421
+
422
+ ### DaemonError
423
+
424
+ Main error type for daemon lifecycle operations.
425
+
426
+ ```typescript
427
+ import { DaemonError, type DaemonErrorCode } from "@outfitter/daemon";
428
+
429
+ const error = new DaemonError({
430
+ code: "ALREADY_RUNNING",
431
+ message: "Daemon is already running with PID 1234",
432
+ });
433
+
434
+ // Error codes
435
+ type DaemonErrorCode =
436
+ | "ALREADY_RUNNING" // Daemon start requested but already running
437
+ | "NOT_RUNNING" // Daemon stop requested but not running
438
+ | "SHUTDOWN_TIMEOUT" // Graceful shutdown exceeded timeout
439
+ | "PID_ERROR" // PID file operations failed
440
+ | "START_FAILED"; // Daemon failed to start
441
+ ```
442
+
443
+ ### Connection Errors
444
+
445
+ Discriminated union for IPC connection failures.
446
+
447
+ ```typescript
448
+ import {
449
+ StaleSocketError,
450
+ ConnectionRefusedError,
451
+ ConnectionTimeoutError,
452
+ ProtocolError,
453
+ LockError,
454
+ type DaemonConnectionError,
455
+ } from "@outfitter/daemon";
456
+
457
+ // Handle connection errors with exhaustive matching
458
+ function handleError(error: DaemonConnectionError): string {
459
+ switch (error._tag) {
460
+ case "StaleSocketError":
461
+ return `Stale socket at ${error.socketPath}, PID: ${error.pid}`;
462
+ case "ConnectionRefusedError":
463
+ return "Daemon not running";
464
+ case "ConnectionTimeoutError":
465
+ return `Timeout after ${error.timeoutMs}ms`;
466
+ case "ProtocolError":
467
+ return `Protocol error: ${error.details}`;
468
+ }
469
+ }
470
+
471
+ // Lock errors
472
+ const lockError = new LockError({
473
+ message: "Daemon already running",
474
+ lockPath: "/run/user/1000/waymark/daemon.lock",
475
+ pid: 12345,
476
+ });
477
+ ```
478
+
479
+ ## Platform Support
480
+
481
+ | Platform | Socket Type | Runtime Dir |
482
+ |----------|-------------|-------------|
483
+ | Linux | Unix domain socket | `$XDG_RUNTIME_DIR` or `/run/user/<uid>` |
484
+ | macOS | Unix domain socket | `$TMPDIR` |
485
+ | Windows | Named pipe | `%TEMP%` |
486
+
487
+ ## Related Packages
488
+
489
+ - `@outfitter/contracts` - Result types and TaggedError base classes
490
+ - `@outfitter/logging` - Structured logging for daemon messages
491
+ - `@outfitter/config` - Configuration loading with schema validation
492
+
493
+ ## License
494
+
495
+ MIT