@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 +495 -0
- package/dist/index.d.ts +771 -0
- package/dist/index.js +618 -0
- package/package.json +55 -0
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
|