@outfitter/daemon 0.2.1 → 0.2.2

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.
@@ -0,0 +1,127 @@
1
+ import { TaggedErrorClass } from "@outfitter/contracts";
2
+ declare const StaleSocketErrorBase: TaggedErrorClass<"StaleSocketError", {
3
+ message: string;
4
+ socketPath: string;
5
+ pid?: number;
6
+ }>;
7
+ declare const ConnectionRefusedErrorBase: TaggedErrorClass<"ConnectionRefusedError", {
8
+ message: string;
9
+ socketPath: string;
10
+ }>;
11
+ declare const ConnectionTimeoutErrorBase: TaggedErrorClass<"ConnectionTimeoutError", {
12
+ message: string;
13
+ socketPath: string;
14
+ timeoutMs: number;
15
+ }>;
16
+ declare const ProtocolErrorBase: TaggedErrorClass<"ProtocolError", {
17
+ message: string;
18
+ socketPath: string;
19
+ details?: string;
20
+ }>;
21
+ declare const LockErrorBase: TaggedErrorClass<"LockError", {
22
+ message: string;
23
+ lockPath: string;
24
+ pid?: number;
25
+ }>;
26
+ /**
27
+ * Socket exists but daemon process is dead.
28
+ *
29
+ * Indicates a stale socket from a crashed daemon that needs cleanup
30
+ * before a new daemon can start.
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const error = new StaleSocketError({
35
+ * message: "Daemon socket is stale",
36
+ * socketPath: "/run/user/1000/waymark/daemon.sock",
37
+ * pid: 12345,
38
+ * });
39
+ * ```
40
+ */
41
+ declare class StaleSocketError extends StaleSocketErrorBase {}
42
+ /**
43
+ * Daemon is not running (connection refused).
44
+ *
45
+ * Socket does not exist or connection was actively refused,
46
+ * indicating no daemon is listening.
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * const error = new ConnectionRefusedError({
51
+ * message: "Connection refused",
52
+ * socketPath: "/run/user/1000/waymark/daemon.sock",
53
+ * });
54
+ * ```
55
+ */
56
+ declare class ConnectionRefusedError extends ConnectionRefusedErrorBase {}
57
+ /**
58
+ * Daemon did not respond within timeout.
59
+ *
60
+ * Connection was established but daemon failed to respond
61
+ * to ping or request within the configured timeout.
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * const error = new ConnectionTimeoutError({
66
+ * message: "Connection timed out after 5000ms",
67
+ * socketPath: "/run/user/1000/waymark/daemon.sock",
68
+ * timeoutMs: 5000,
69
+ * });
70
+ * ```
71
+ */
72
+ declare class ConnectionTimeoutError extends ConnectionTimeoutErrorBase {}
73
+ /**
74
+ * Invalid response format from daemon.
75
+ *
76
+ * Daemon responded but the response could not be parsed
77
+ * or did not match the expected protocol format.
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const error = new ProtocolError({
82
+ * message: "Invalid JSON response",
83
+ * socketPath: "/run/user/1000/waymark/daemon.sock",
84
+ * details: "Unexpected token at position 42",
85
+ * });
86
+ * ```
87
+ */
88
+ declare class ProtocolError extends ProtocolErrorBase {}
89
+ /**
90
+ * Failed to acquire or release daemon lock.
91
+ *
92
+ * Used when PID file operations fail due to permissions,
93
+ * concurrent access, or file system errors.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * const error = new LockError({
98
+ * message: "Daemon already running",
99
+ * lockPath: "/run/user/1000/waymark/daemon.lock",
100
+ * pid: 12345,
101
+ * });
102
+ * ```
103
+ */
104
+ declare class LockError extends LockErrorBase {}
105
+ /**
106
+ * Union of all daemon connection error types.
107
+ *
108
+ * Use for exhaustive matching on connection failures:
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * function handleError(error: DaemonConnectionError): string {
113
+ * switch (error._tag) {
114
+ * case "StaleSocketError":
115
+ * return `Stale socket at ${error.socketPath}`;
116
+ * case "ConnectionRefusedError":
117
+ * return "Daemon not running";
118
+ * case "ConnectionTimeoutError":
119
+ * return `Timeout after ${error.timeoutMs}ms`;
120
+ * case "ProtocolError":
121
+ * return `Protocol error: ${error.details}`;
122
+ * }
123
+ * }
124
+ * ```
125
+ */
126
+ type DaemonConnectionError = StaleSocketError | ConnectionRefusedError | ConnectionTimeoutError | ProtocolError;
127
+ export { StaleSocketError, ConnectionRefusedError, ConnectionTimeoutError, ProtocolError, LockError, DaemonConnectionError };
@@ -0,0 +1,106 @@
1
+ // @bun
2
+ import {
3
+ LockError
4
+ } from "./daemon-qqn2jpsg.js";
5
+
6
+ // packages/daemon/src/locking.ts
7
+ import { unlink } from "fs/promises";
8
+ function isProcessAlive(pid) {
9
+ if (pid <= 0) {
10
+ return false;
11
+ }
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ } catch (error) {
16
+ if (error instanceof Error && "code" in error && error.code === "EPERM") {
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ }
22
+ async function isDaemonAlive(lockPath) {
23
+ const file = Bun.file(lockPath);
24
+ if (!await file.exists()) {
25
+ return false;
26
+ }
27
+ try {
28
+ const content = await file.text();
29
+ const pid = Number.parseInt(content.trim(), 10);
30
+ if (Number.isNaN(pid)) {
31
+ return false;
32
+ }
33
+ return isProcessAlive(pid);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+ async function acquireDaemonLock(lockPath) {
39
+ const file = Bun.file(lockPath);
40
+ if (await file.exists()) {
41
+ try {
42
+ const content = await file.text();
43
+ const existingPid = Number.parseInt(content.trim(), 10);
44
+ if (!Number.isNaN(existingPid) && isProcessAlive(existingPid)) {
45
+ return {
46
+ isOk: () => false,
47
+ isErr: () => true,
48
+ error: new LockError({
49
+ message: `Daemon already running with PID ${existingPid}`,
50
+ lockPath,
51
+ pid: existingPid
52
+ })
53
+ };
54
+ }
55
+ } catch {}
56
+ }
57
+ const pid = process.pid;
58
+ try {
59
+ await Bun.write(lockPath, `${pid}
60
+ `);
61
+ return {
62
+ isOk: () => true,
63
+ isErr: () => false,
64
+ value: { lockPath, pid }
65
+ };
66
+ } catch (error) {
67
+ return {
68
+ isOk: () => false,
69
+ isErr: () => true,
70
+ error: new LockError({
71
+ message: `Failed to write lock file: ${error instanceof Error ? error.message : String(error)}`,
72
+ lockPath,
73
+ pid
74
+ })
75
+ };
76
+ }
77
+ }
78
+ async function releaseDaemonLock(handle) {
79
+ const { lockPath, pid } = handle;
80
+ const file = Bun.file(lockPath);
81
+ if (!await file.exists()) {
82
+ return;
83
+ }
84
+ try {
85
+ const content = await file.text();
86
+ const filePid = Number.parseInt(content.trim(), 10);
87
+ if (filePid === pid) {
88
+ await unlink(lockPath);
89
+ }
90
+ } catch {}
91
+ }
92
+ async function readLockPid(lockPath) {
93
+ const file = Bun.file(lockPath);
94
+ if (!await file.exists()) {
95
+ return;
96
+ }
97
+ try {
98
+ const content = await file.text();
99
+ const pid = Number.parseInt(content.trim(), 10);
100
+ return Number.isNaN(pid) ? undefined : pid;
101
+ } catch {
102
+ return;
103
+ }
104
+ }
105
+
106
+ export { isProcessAlive, isDaemonAlive, acquireDaemonLock, releaseDaemonLock, readLockPid };
@@ -0,0 +1,161 @@
1
+ import { Result, TaggedErrorClass } from "@outfitter/contracts";
2
+ import { LoggerInstance } from "@outfitter/logging";
3
+ declare const DaemonErrorBase: TaggedErrorClass<"DaemonError", {
4
+ code: DaemonErrorCode;
5
+ message: string;
6
+ }>;
7
+ /**
8
+ * Error codes for daemon operations.
9
+ *
10
+ * - `ALREADY_RUNNING`: Daemon start requested but already running
11
+ * - `NOT_RUNNING`: Daemon stop requested but not running
12
+ * - `SHUTDOWN_TIMEOUT`: Graceful shutdown exceeded timeout
13
+ * - `PID_ERROR`: PID file operations failed
14
+ * - `START_FAILED`: Daemon failed to start
15
+ */
16
+ type DaemonErrorCode = "ALREADY_RUNNING" | "NOT_RUNNING" | "SHUTDOWN_TIMEOUT" | "PID_ERROR" | "START_FAILED";
17
+ /**
18
+ * Error type for daemon operations.
19
+ *
20
+ * Uses the TaggedError pattern for type-safe error handling with Result types.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const error = new DaemonError({
25
+ * code: "ALREADY_RUNNING",
26
+ * message: "Daemon is already running with PID 1234",
27
+ * });
28
+ *
29
+ * if (error.code === "ALREADY_RUNNING") {
30
+ * console.log("Stop the existing daemon first");
31
+ * }
32
+ * ```
33
+ */
34
+ declare class DaemonError extends DaemonErrorBase {}
35
+ /**
36
+ * Daemon lifecycle states.
37
+ *
38
+ * State machine transitions:
39
+ * - `stopped` -> `starting` (via start())
40
+ * - `starting` -> `running` (when initialization complete)
41
+ * - `starting` -> `stopped` (if start fails)
42
+ * - `running` -> `stopping` (via stop() or signal)
43
+ * - `stopping` -> `stopped` (when shutdown complete)
44
+ */
45
+ type DaemonState = "stopped" | "starting" | "running" | "stopping";
46
+ /**
47
+ * Configuration options for creating a daemon.
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const options: DaemonOptions = {
52
+ * name: "my-daemon",
53
+ * pidFile: "/var/run/my-daemon.pid",
54
+ * logger: myLogger,
55
+ * shutdownTimeout: 10000, // 10 seconds
56
+ * };
57
+ *
58
+ * const daemon = createDaemon(options);
59
+ * ```
60
+ */
61
+ interface DaemonOptions {
62
+ /**
63
+ * Unique name identifying this daemon.
64
+ * Used in log messages and error context.
65
+ */
66
+ name: string;
67
+ /**
68
+ * Absolute path to the PID file.
69
+ * The daemon writes its process ID here on start and removes it on stop.
70
+ * Used to prevent multiple instances and for external process management.
71
+ */
72
+ pidFile: string;
73
+ /**
74
+ * Optional logger instance for daemon messages.
75
+ * If not provided, logging is disabled.
76
+ */
77
+ logger?: LoggerInstance;
78
+ /**
79
+ * Maximum time in milliseconds to wait for graceful shutdown.
80
+ * After this timeout, the daemon will force stop.
81
+ * @defaultValue 5000
82
+ */
83
+ shutdownTimeout?: number;
84
+ }
85
+ /**
86
+ * Shutdown handler function type.
87
+ *
88
+ * Called during graceful shutdown to allow cleanup of resources.
89
+ * Must complete within the shutdown timeout.
90
+ */
91
+ type ShutdownHandler = () => Promise<void>;
92
+ /**
93
+ * Daemon instance interface.
94
+ *
95
+ * Provides lifecycle management for a background process including
96
+ * start/stop operations, signal handling, and shutdown hooks.
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const daemon = createDaemon({
101
+ * name: "my-service",
102
+ * pidFile: "/var/run/my-service.pid",
103
+ * });
104
+ *
105
+ * // Register cleanup handlers
106
+ * daemon.onShutdown(async () => {
107
+ * await database.close();
108
+ * });
109
+ *
110
+ * // Start the daemon
111
+ * const result = await daemon.start();
112
+ * if (result.isErr()) {
113
+ * console.error("Failed to start:", result.error.message);
114
+ * process.exit(1);
115
+ * }
116
+ *
117
+ * // Daemon is now running...
118
+ * // Stop gracefully when needed
119
+ * await daemon.stop();
120
+ * ```
121
+ */
122
+ interface Daemon {
123
+ /**
124
+ * Current lifecycle state of the daemon.
125
+ */
126
+ readonly state: DaemonState;
127
+ /**
128
+ * Start the daemon.
129
+ *
130
+ * Creates PID file and registers signal handlers.
131
+ * Transitions from `stopped` to `starting` then `running`.
132
+ *
133
+ * @returns Result with void on success, or DaemonError on failure
134
+ */
135
+ start(): Promise<Result<void, DaemonError>>;
136
+ /**
137
+ * Stop the daemon gracefully.
138
+ *
139
+ * Runs shutdown handlers, removes PID file, and cleans up signal handlers.
140
+ * Transitions from `running` to `stopping` then `stopped`.
141
+ *
142
+ * @returns Result with void on success, or DaemonError on failure
143
+ */
144
+ stop(): Promise<Result<void, DaemonError>>;
145
+ /**
146
+ * Check if the daemon is currently running.
147
+ *
148
+ * @returns true if state is "running", false otherwise
149
+ */
150
+ isRunning(): boolean;
151
+ /**
152
+ * Register a shutdown handler to be called during graceful shutdown.
153
+ *
154
+ * Multiple handlers can be registered and will be called in registration order.
155
+ * Handlers must complete within the shutdown timeout.
156
+ *
157
+ * @param handler - Async function to execute during shutdown
158
+ */
159
+ onShutdown(handler: ShutdownHandler): void;
160
+ }
161
+ export { DaemonErrorCode, DaemonError, DaemonState, DaemonOptions, ShutdownHandler, Daemon };
@@ -0,0 +1,32 @@
1
+ import { Daemon, DaemonOptions } from "./daemon-9w2ey87r";
2
+ /**
3
+ * Create a new daemon instance.
4
+ *
5
+ * The daemon manages its own lifecycle including PID file creation/removal,
6
+ * signal handling for graceful shutdown, and execution of registered
7
+ * shutdown handlers.
8
+ *
9
+ * @param options - Daemon configuration options
10
+ * @returns Daemon instance
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const daemon = createDaemon({
15
+ * name: "my-service",
16
+ * pidFile: "/var/run/my-service.pid",
17
+ * shutdownTimeout: 10000,
18
+ * });
19
+ *
20
+ * daemon.onShutdown(async () => {
21
+ * await database.close();
22
+ * });
23
+ *
24
+ * const result = await daemon.start();
25
+ * if (result.isErr()) {
26
+ * console.error("Failed to start:", result.error.message);
27
+ * process.exit(1);
28
+ * }
29
+ * ```
30
+ */
31
+ declare function createDaemon(options: DaemonOptions): Daemon;
32
+ export { createDaemon };
@@ -0,0 +1,50 @@
1
+ // @bun
2
+ // packages/daemon/src/health.ts
3
+ function createHealthChecker(checks) {
4
+ const registeredChecks = [...checks];
5
+ const startTime = Date.now();
6
+ async function runCheck(check) {
7
+ try {
8
+ const result = await check.check();
9
+ if (result.isOk()) {
10
+ return { healthy: true };
11
+ }
12
+ return {
13
+ healthy: false,
14
+ message: result.error.message
15
+ };
16
+ } catch (error) {
17
+ return {
18
+ healthy: false,
19
+ message: error instanceof Error ? error.message : "Unknown error"
20
+ };
21
+ }
22
+ }
23
+ return {
24
+ async check() {
25
+ const results = await Promise.all(registeredChecks.map(async (check) => ({
26
+ name: check.name,
27
+ result: await runCheck(check)
28
+ })));
29
+ const checksRecord = {};
30
+ let allHealthy = true;
31
+ for (const { name, result } of results) {
32
+ checksRecord[name] = result;
33
+ if (!result.healthy) {
34
+ allHealthy = false;
35
+ }
36
+ }
37
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
38
+ return {
39
+ healthy: allHealthy,
40
+ checks: checksRecord,
41
+ uptime
42
+ };
43
+ },
44
+ register(check) {
45
+ registeredChecks.push(check);
46
+ }
47
+ };
48
+ }
49
+
50
+ export { createHealthChecker };
@@ -0,0 +1,207 @@
1
+ // @bun
2
+ // packages/daemon/src/ipc.ts
3
+ import { unlink } from "fs/promises";
4
+ function createIpcServer(socketPath) {
5
+ let messageHandler = null;
6
+ let server = null;
7
+ let isListening = false;
8
+ const socketBuffers = new WeakMap;
9
+ function processSocketBuffer(socket) {
10
+ const buffer = socketBuffers.get(socket) ?? "";
11
+ const lines = buffer.split(`
12
+ `);
13
+ socketBuffers.set(socket, lines.pop() ?? "");
14
+ for (const line of lines) {
15
+ if (!line.trim())
16
+ continue;
17
+ try {
18
+ const message = JSON.parse(line);
19
+ if (message.type === "request" && messageHandler) {
20
+ (async () => {
21
+ try {
22
+ const result = await messageHandler(message.payload);
23
+ const response = {
24
+ id: message.id,
25
+ type: "response",
26
+ payload: result
27
+ };
28
+ socket.write(`${JSON.stringify(response)}
29
+ `);
30
+ } catch (error) {
31
+ const errorResponse = {
32
+ id: message.id,
33
+ type: "error",
34
+ payload: {
35
+ message: error instanceof Error ? error.message : "Unknown error"
36
+ }
37
+ };
38
+ socket.write(`${JSON.stringify(errorResponse)}
39
+ `);
40
+ }
41
+ })();
42
+ }
43
+ } catch {}
44
+ }
45
+ }
46
+ return {
47
+ async listen() {
48
+ if (isListening)
49
+ return;
50
+ try {
51
+ await unlink(socketPath);
52
+ } catch {}
53
+ server = Bun.listen({
54
+ unix: socketPath,
55
+ socket: {
56
+ data(socket, data) {
57
+ const text = Buffer.isBuffer(data) ? data.toString("utf-8") : String(data);
58
+ const currentBuffer = socketBuffers.get(socket) ?? "";
59
+ socketBuffers.set(socket, currentBuffer + text);
60
+ processSocketBuffer(socket);
61
+ },
62
+ open(socket) {
63
+ socketBuffers.set(socket, "");
64
+ },
65
+ close(socket) {
66
+ socketBuffers.delete(socket);
67
+ },
68
+ error() {}
69
+ }
70
+ });
71
+ isListening = true;
72
+ },
73
+ async close() {
74
+ if (!isListening)
75
+ return;
76
+ server?.stop();
77
+ server = null;
78
+ isListening = false;
79
+ try {
80
+ await unlink(socketPath);
81
+ } catch {}
82
+ },
83
+ onMessage(handler) {
84
+ messageHandler = handler;
85
+ }
86
+ };
87
+ }
88
+ function createIpcClient(socketPath) {
89
+ let socket = null;
90
+ let isConnected = false;
91
+ const pendingRequests = new Map;
92
+ let messageBuffer = "";
93
+ function generateId() {
94
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
95
+ }
96
+ function processBuffer() {
97
+ const lines = messageBuffer.split(`
98
+ `);
99
+ messageBuffer = lines.pop() ?? "";
100
+ for (const line of lines) {
101
+ if (!line.trim())
102
+ continue;
103
+ try {
104
+ const message = JSON.parse(line);
105
+ const pending = pendingRequests.get(message.id);
106
+ if (pending) {
107
+ pendingRequests.delete(message.id);
108
+ if (message.type === "response") {
109
+ pending.resolve(message.payload);
110
+ } else if (message.type === "error") {
111
+ const errorPayload = message.payload;
112
+ pending.reject(new Error(errorPayload.message));
113
+ }
114
+ }
115
+ } catch {}
116
+ }
117
+ }
118
+ function rejectAllPending() {
119
+ for (const [id, pending] of pendingRequests) {
120
+ pending.reject(new Error("Connection closed"));
121
+ pendingRequests.delete(id);
122
+ }
123
+ }
124
+ return {
125
+ connect() {
126
+ if (isConnected && socket)
127
+ return Promise.resolve();
128
+ messageBuffer = "";
129
+ return new Promise((resolve, reject) => {
130
+ try {
131
+ Bun.connect({
132
+ unix: socketPath,
133
+ socket: {
134
+ data(_socket, data) {
135
+ const text = Buffer.isBuffer(data) ? data.toString("utf-8") : String(data);
136
+ messageBuffer += text;
137
+ processBuffer();
138
+ },
139
+ open(_socket) {
140
+ isConnected = true;
141
+ socket = _socket;
142
+ resolve();
143
+ },
144
+ close() {
145
+ isConnected = false;
146
+ socket = null;
147
+ rejectAllPending();
148
+ },
149
+ error(_socket, error) {
150
+ isConnected = false;
151
+ socket = null;
152
+ reject(error);
153
+ },
154
+ connectError(_socket, error) {
155
+ isConnected = false;
156
+ socket = null;
157
+ reject(error);
158
+ }
159
+ }
160
+ });
161
+ } catch (error) {
162
+ reject(error);
163
+ }
164
+ });
165
+ },
166
+ send(message) {
167
+ if (!(isConnected && socket)) {
168
+ return Promise.reject(new Error("Not connected to server"));
169
+ }
170
+ const id = generateId();
171
+ const request = {
172
+ id,
173
+ type: "request",
174
+ payload: message
175
+ };
176
+ return new Promise((resolve, reject) => {
177
+ pendingRequests.set(id, {
178
+ resolve,
179
+ reject
180
+ });
181
+ try {
182
+ const written = socket?.write(`${JSON.stringify(request)}
183
+ `);
184
+ if (written === 0) {
185
+ pendingRequests.delete(id);
186
+ reject(new Error("Failed to write to socket"));
187
+ }
188
+ } catch (error) {
189
+ pendingRequests.delete(id);
190
+ reject(error);
191
+ }
192
+ });
193
+ },
194
+ close() {
195
+ if (!(isConnected && socket))
196
+ return;
197
+ try {
198
+ socket?.terminate();
199
+ } catch {}
200
+ socket = null;
201
+ isConnected = false;
202
+ rejectAllPending();
203
+ }
204
+ };
205
+ }
206
+
207
+ export { createIpcServer, createIpcClient };
@@ -0,0 +1,9 @@
1
+ // @bun
2
+ // packages/daemon/src/types.ts
3
+ import { TaggedError } from "@outfitter/contracts";
4
+ var DaemonErrorBase = TaggedError("DaemonError")();
5
+
6
+ class DaemonError extends DaemonErrorBase {
7
+ }
8
+
9
+ export { DaemonError };