@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/dist/index.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
import { TaggedError } from "@outfitter/contracts";
|
|
3
|
+
var DaemonErrorBase = TaggedError("DaemonError")();
|
|
4
|
+
|
|
5
|
+
class DaemonError extends DaemonErrorBase {
|
|
6
|
+
}
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
import { TaggedError as TaggedError2 } from "@outfitter/contracts";
|
|
9
|
+
var StaleSocketErrorBase = TaggedError2("StaleSocketError")();
|
|
10
|
+
var ConnectionRefusedErrorBase = TaggedError2("ConnectionRefusedError")();
|
|
11
|
+
var ConnectionTimeoutErrorBase = TaggedError2("ConnectionTimeoutError")();
|
|
12
|
+
var ProtocolErrorBase = TaggedError2("ProtocolError")();
|
|
13
|
+
var LockErrorBase = TaggedError2("LockError")();
|
|
14
|
+
|
|
15
|
+
class StaleSocketError extends StaleSocketErrorBase {
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class ConnectionRefusedError extends ConnectionRefusedErrorBase {
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ConnectionTimeoutError extends ConnectionTimeoutErrorBase {
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class ProtocolError extends ProtocolErrorBase {
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class LockError extends LockErrorBase {
|
|
28
|
+
}
|
|
29
|
+
// src/platform.ts
|
|
30
|
+
import { platform as osPlatform, tmpdir, userInfo } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
function isUnixPlatform() {
|
|
33
|
+
const plat = osPlatform();
|
|
34
|
+
return plat === "darwin" || plat === "linux";
|
|
35
|
+
}
|
|
36
|
+
function getRuntimeDir() {
|
|
37
|
+
const xdgRuntime = process.env["XDG_RUNTIME_DIR"];
|
|
38
|
+
if (xdgRuntime) {
|
|
39
|
+
return xdgRuntime;
|
|
40
|
+
}
|
|
41
|
+
const plat = osPlatform();
|
|
42
|
+
if (plat === "darwin") {
|
|
43
|
+
return process.env["TMPDIR"] ?? tmpdir();
|
|
44
|
+
}
|
|
45
|
+
if (plat === "linux") {
|
|
46
|
+
return `/run/user/${userInfo().uid}`;
|
|
47
|
+
}
|
|
48
|
+
return process.env["TEMP"] ?? tmpdir();
|
|
49
|
+
}
|
|
50
|
+
function getSocketPath(toolName) {
|
|
51
|
+
if (!isUnixPlatform()) {
|
|
52
|
+
return `\\\\.\\pipe\\${toolName}-daemon`;
|
|
53
|
+
}
|
|
54
|
+
return join(getRuntimeDir(), toolName, "daemon.sock");
|
|
55
|
+
}
|
|
56
|
+
function getLockPath(toolName) {
|
|
57
|
+
if (!isUnixPlatform()) {
|
|
58
|
+
return join(tmpdir(), `${toolName}-daemon.lock`);
|
|
59
|
+
}
|
|
60
|
+
return join(getRuntimeDir(), toolName, "daemon.lock");
|
|
61
|
+
}
|
|
62
|
+
function getPidPath(toolName) {
|
|
63
|
+
if (!isUnixPlatform()) {
|
|
64
|
+
return join(tmpdir(), `${toolName}-daemon.pid`);
|
|
65
|
+
}
|
|
66
|
+
return join(getRuntimeDir(), toolName, "daemon.pid");
|
|
67
|
+
}
|
|
68
|
+
function getDaemonDir(toolName) {
|
|
69
|
+
if (!isUnixPlatform()) {
|
|
70
|
+
return tmpdir();
|
|
71
|
+
}
|
|
72
|
+
return join(getRuntimeDir(), toolName);
|
|
73
|
+
}
|
|
74
|
+
// src/locking.ts
|
|
75
|
+
import { unlink } from "node:fs/promises";
|
|
76
|
+
function isProcessAlive(pid) {
|
|
77
|
+
if (pid <= 0) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error && "code" in error && error.code === "EPERM") {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function isDaemonAlive(lockPath) {
|
|
91
|
+
const file = Bun.file(lockPath);
|
|
92
|
+
if (!await file.exists()) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const content = await file.text();
|
|
97
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
98
|
+
if (Number.isNaN(pid)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return isProcessAlive(pid);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function acquireDaemonLock(lockPath) {
|
|
107
|
+
const file = Bun.file(lockPath);
|
|
108
|
+
if (await file.exists()) {
|
|
109
|
+
try {
|
|
110
|
+
const content = await file.text();
|
|
111
|
+
const existingPid = Number.parseInt(content.trim(), 10);
|
|
112
|
+
if (!Number.isNaN(existingPid) && isProcessAlive(existingPid)) {
|
|
113
|
+
return {
|
|
114
|
+
isOk: () => false,
|
|
115
|
+
isErr: () => true,
|
|
116
|
+
error: new LockError({
|
|
117
|
+
message: `Daemon already running with PID ${existingPid}`,
|
|
118
|
+
lockPath,
|
|
119
|
+
pid: existingPid
|
|
120
|
+
})
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
const pid = process.pid;
|
|
126
|
+
try {
|
|
127
|
+
await Bun.write(lockPath, `${pid}
|
|
128
|
+
`);
|
|
129
|
+
return {
|
|
130
|
+
isOk: () => true,
|
|
131
|
+
isErr: () => false,
|
|
132
|
+
value: { lockPath, pid }
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
isOk: () => false,
|
|
137
|
+
isErr: () => true,
|
|
138
|
+
error: new LockError({
|
|
139
|
+
message: `Failed to write lock file: ${error instanceof Error ? error.message : String(error)}`,
|
|
140
|
+
lockPath,
|
|
141
|
+
pid
|
|
142
|
+
})
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function releaseDaemonLock(handle) {
|
|
147
|
+
const { lockPath, pid } = handle;
|
|
148
|
+
const file = Bun.file(lockPath);
|
|
149
|
+
if (!await file.exists()) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const content = await file.text();
|
|
154
|
+
const filePid = Number.parseInt(content.trim(), 10);
|
|
155
|
+
if (filePid === pid) {
|
|
156
|
+
await unlink(lockPath);
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
async function readLockPid(lockPath) {
|
|
161
|
+
const file = Bun.file(lockPath);
|
|
162
|
+
if (!await file.exists()) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const content = await file.text();
|
|
167
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
168
|
+
return Number.isNaN(pid) ? undefined : pid;
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// src/lifecycle.ts
|
|
174
|
+
import { mkdir, unlink as unlink2, writeFile } from "node:fs/promises";
|
|
175
|
+
import { dirname } from "node:path";
|
|
176
|
+
import { Result } from "@outfitter/contracts";
|
|
177
|
+
function pidFileExists(path) {
|
|
178
|
+
return Bun.file(path).exists();
|
|
179
|
+
}
|
|
180
|
+
async function writePidFile(path, pid) {
|
|
181
|
+
try {
|
|
182
|
+
const dir = dirname(path);
|
|
183
|
+
await mkdir(dir, { recursive: true });
|
|
184
|
+
await writeFile(path, String(pid), { flag: "wx" });
|
|
185
|
+
return Result.ok(undefined);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return Result.err(new DaemonError({
|
|
188
|
+
code: "PID_ERROR",
|
|
189
|
+
message: `Failed to write PID file: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function removePidFile(path) {
|
|
194
|
+
try {
|
|
195
|
+
await unlink2(path);
|
|
196
|
+
return Result.ok(undefined);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
199
|
+
return Result.ok(undefined);
|
|
200
|
+
}
|
|
201
|
+
return Result.err(new DaemonError({
|
|
202
|
+
code: "PID_ERROR",
|
|
203
|
+
message: `Failed to remove PID file: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function runShutdownHandlers(handlers, timeout, logger) {
|
|
208
|
+
const errors = [];
|
|
209
|
+
const runHandlers = async () => {
|
|
210
|
+
for (const handler of handlers) {
|
|
211
|
+
try {
|
|
212
|
+
await handler();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
215
|
+
errors.push(err);
|
|
216
|
+
logger?.warn("Shutdown handler failed", { error: err.message });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
221
|
+
setTimeout(() => resolve("timeout"), timeout);
|
|
222
|
+
});
|
|
223
|
+
const result = await Promise.race([
|
|
224
|
+
runHandlers().then(() => "completed"),
|
|
225
|
+
timeoutPromise
|
|
226
|
+
]);
|
|
227
|
+
return {
|
|
228
|
+
completed: result === "completed",
|
|
229
|
+
errors
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function createDaemon(options) {
|
|
233
|
+
const internalState = {
|
|
234
|
+
state: "stopped",
|
|
235
|
+
options: {
|
|
236
|
+
name: options.name,
|
|
237
|
+
pidFile: options.pidFile,
|
|
238
|
+
logger: options.logger,
|
|
239
|
+
shutdownTimeout: options.shutdownTimeout ?? 5000
|
|
240
|
+
},
|
|
241
|
+
shutdownHandlers: [],
|
|
242
|
+
signalHandlers: {},
|
|
243
|
+
isShuttingDown: false
|
|
244
|
+
};
|
|
245
|
+
async function doStop() {
|
|
246
|
+
const { logger } = internalState.options;
|
|
247
|
+
if (internalState.isShuttingDown) {
|
|
248
|
+
return Result.ok(undefined);
|
|
249
|
+
}
|
|
250
|
+
if (internalState.state === "stopped") {
|
|
251
|
+
return Result.err(new DaemonError({
|
|
252
|
+
code: "NOT_RUNNING",
|
|
253
|
+
message: `Daemon "${internalState.options.name}" is not running`
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
internalState.isShuttingDown = true;
|
|
257
|
+
internalState.state = "stopping";
|
|
258
|
+
logger?.info("Daemon stopping", { name: internalState.options.name });
|
|
259
|
+
const { completed } = await runShutdownHandlers(internalState.shutdownHandlers, internalState.options.shutdownTimeout, logger);
|
|
260
|
+
if (!completed) {
|
|
261
|
+
logger?.warn("Shutdown handlers timed out", {
|
|
262
|
+
name: internalState.options.name,
|
|
263
|
+
timeout: internalState.options.shutdownTimeout
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (internalState.signalHandlers.sigterm) {
|
|
267
|
+
process.off("SIGTERM", internalState.signalHandlers.sigterm);
|
|
268
|
+
}
|
|
269
|
+
if (internalState.signalHandlers.sigint) {
|
|
270
|
+
process.off("SIGINT", internalState.signalHandlers.sigint);
|
|
271
|
+
}
|
|
272
|
+
const removeResult = await removePidFile(internalState.options.pidFile);
|
|
273
|
+
if (removeResult.isErr()) {
|
|
274
|
+
logger?.error("Failed to remove PID file", {
|
|
275
|
+
error: removeResult.error.message
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
internalState.state = "stopped";
|
|
279
|
+
internalState.isShuttingDown = false;
|
|
280
|
+
logger?.info("Daemon stopped", { name: internalState.options.name });
|
|
281
|
+
if (!completed) {
|
|
282
|
+
return Result.err(new DaemonError({
|
|
283
|
+
code: "SHUTDOWN_TIMEOUT",
|
|
284
|
+
message: `Shutdown handlers exceeded timeout of ${internalState.options.shutdownTimeout}ms`
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
return Result.ok(undefined);
|
|
288
|
+
}
|
|
289
|
+
const daemon = {
|
|
290
|
+
get state() {
|
|
291
|
+
return internalState.state;
|
|
292
|
+
},
|
|
293
|
+
async start() {
|
|
294
|
+
const { logger } = internalState.options;
|
|
295
|
+
if (internalState.state !== "stopped") {
|
|
296
|
+
return Result.err(new DaemonError({
|
|
297
|
+
code: "ALREADY_RUNNING",
|
|
298
|
+
message: `Daemon "${internalState.options.name}" is already running`
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
internalState.state = "starting";
|
|
302
|
+
logger?.info("Daemon starting", { name: internalState.options.name });
|
|
303
|
+
if (await pidFileExists(internalState.options.pidFile)) {
|
|
304
|
+
internalState.state = "stopped";
|
|
305
|
+
return Result.err(new DaemonError({
|
|
306
|
+
code: "ALREADY_RUNNING",
|
|
307
|
+
message: `PID file already exists: ${internalState.options.pidFile}`
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
const writeResult = await writePidFile(internalState.options.pidFile, process.pid);
|
|
311
|
+
if (writeResult.isErr()) {
|
|
312
|
+
internalState.state = "stopped";
|
|
313
|
+
return writeResult;
|
|
314
|
+
}
|
|
315
|
+
const sigTermHandler = () => {
|
|
316
|
+
logger?.info("Received SIGTERM signal");
|
|
317
|
+
doStop();
|
|
318
|
+
};
|
|
319
|
+
const sigIntHandler = () => {
|
|
320
|
+
logger?.info("Received SIGINT signal");
|
|
321
|
+
doStop();
|
|
322
|
+
};
|
|
323
|
+
internalState.signalHandlers.sigterm = sigTermHandler;
|
|
324
|
+
internalState.signalHandlers.sigint = sigIntHandler;
|
|
325
|
+
process.on("SIGTERM", sigTermHandler);
|
|
326
|
+
process.on("SIGINT", sigIntHandler);
|
|
327
|
+
internalState.state = "running";
|
|
328
|
+
logger?.info("Daemon started", {
|
|
329
|
+
name: internalState.options.name,
|
|
330
|
+
pid: process.pid
|
|
331
|
+
});
|
|
332
|
+
return Result.ok(undefined);
|
|
333
|
+
},
|
|
334
|
+
stop() {
|
|
335
|
+
return doStop();
|
|
336
|
+
},
|
|
337
|
+
isRunning() {
|
|
338
|
+
return internalState.state === "running";
|
|
339
|
+
},
|
|
340
|
+
onShutdown(handler) {
|
|
341
|
+
internalState.shutdownHandlers.push(handler);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
return daemon;
|
|
345
|
+
}
|
|
346
|
+
// src/ipc.ts
|
|
347
|
+
import { unlink as unlink3 } from "node:fs/promises";
|
|
348
|
+
function createIpcServer(socketPath) {
|
|
349
|
+
let messageHandler = null;
|
|
350
|
+
let server = null;
|
|
351
|
+
let isListening = false;
|
|
352
|
+
const socketBuffers = new WeakMap;
|
|
353
|
+
function processSocketBuffer(socket) {
|
|
354
|
+
const buffer = socketBuffers.get(socket) ?? "";
|
|
355
|
+
const lines = buffer.split(`
|
|
356
|
+
`);
|
|
357
|
+
socketBuffers.set(socket, lines.pop() ?? "");
|
|
358
|
+
for (const line of lines) {
|
|
359
|
+
if (!line.trim())
|
|
360
|
+
continue;
|
|
361
|
+
try {
|
|
362
|
+
const message = JSON.parse(line);
|
|
363
|
+
if (message.type === "request" && messageHandler) {
|
|
364
|
+
(async () => {
|
|
365
|
+
try {
|
|
366
|
+
const result = await messageHandler(message.payload);
|
|
367
|
+
const response = {
|
|
368
|
+
id: message.id,
|
|
369
|
+
type: "response",
|
|
370
|
+
payload: result
|
|
371
|
+
};
|
|
372
|
+
socket.write(`${JSON.stringify(response)}
|
|
373
|
+
`);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const errorResponse = {
|
|
376
|
+
id: message.id,
|
|
377
|
+
type: "error",
|
|
378
|
+
payload: {
|
|
379
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
socket.write(`${JSON.stringify(errorResponse)}
|
|
383
|
+
`);
|
|
384
|
+
}
|
|
385
|
+
})();
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
async listen() {
|
|
392
|
+
if (isListening)
|
|
393
|
+
return;
|
|
394
|
+
try {
|
|
395
|
+
await unlink3(socketPath);
|
|
396
|
+
} catch {}
|
|
397
|
+
server = Bun.listen({
|
|
398
|
+
unix: socketPath,
|
|
399
|
+
socket: {
|
|
400
|
+
data(socket, data) {
|
|
401
|
+
const text = Buffer.isBuffer(data) ? data.toString("utf-8") : String(data);
|
|
402
|
+
const currentBuffer = socketBuffers.get(socket) ?? "";
|
|
403
|
+
socketBuffers.set(socket, currentBuffer + text);
|
|
404
|
+
processSocketBuffer(socket);
|
|
405
|
+
},
|
|
406
|
+
open(socket) {
|
|
407
|
+
socketBuffers.set(socket, "");
|
|
408
|
+
},
|
|
409
|
+
close(socket) {
|
|
410
|
+
socketBuffers.delete(socket);
|
|
411
|
+
},
|
|
412
|
+
error() {}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
isListening = true;
|
|
416
|
+
},
|
|
417
|
+
async close() {
|
|
418
|
+
if (!isListening)
|
|
419
|
+
return;
|
|
420
|
+
server?.stop();
|
|
421
|
+
server = null;
|
|
422
|
+
isListening = false;
|
|
423
|
+
try {
|
|
424
|
+
await unlink3(socketPath);
|
|
425
|
+
} catch {}
|
|
426
|
+
},
|
|
427
|
+
onMessage(handler) {
|
|
428
|
+
messageHandler = handler;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function createIpcClient(socketPath) {
|
|
433
|
+
let socket = null;
|
|
434
|
+
let isConnected = false;
|
|
435
|
+
const pendingRequests = new Map;
|
|
436
|
+
let messageBuffer = "";
|
|
437
|
+
function generateId() {
|
|
438
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
439
|
+
}
|
|
440
|
+
function processBuffer() {
|
|
441
|
+
const lines = messageBuffer.split(`
|
|
442
|
+
`);
|
|
443
|
+
messageBuffer = lines.pop() ?? "";
|
|
444
|
+
for (const line of lines) {
|
|
445
|
+
if (!line.trim())
|
|
446
|
+
continue;
|
|
447
|
+
try {
|
|
448
|
+
const message = JSON.parse(line);
|
|
449
|
+
const pending = pendingRequests.get(message.id);
|
|
450
|
+
if (pending) {
|
|
451
|
+
pendingRequests.delete(message.id);
|
|
452
|
+
if (message.type === "response") {
|
|
453
|
+
pending.resolve(message.payload);
|
|
454
|
+
} else if (message.type === "error") {
|
|
455
|
+
const errorPayload = message.payload;
|
|
456
|
+
pending.reject(new Error(errorPayload.message));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} catch {}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function rejectAllPending() {
|
|
463
|
+
for (const [id, pending] of pendingRequests) {
|
|
464
|
+
pending.reject(new Error("Connection closed"));
|
|
465
|
+
pendingRequests.delete(id);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
connect() {
|
|
470
|
+
if (isConnected && socket)
|
|
471
|
+
return Promise.resolve();
|
|
472
|
+
messageBuffer = "";
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
try {
|
|
475
|
+
Bun.connect({
|
|
476
|
+
unix: socketPath,
|
|
477
|
+
socket: {
|
|
478
|
+
data(_socket, data) {
|
|
479
|
+
const text = Buffer.isBuffer(data) ? data.toString("utf-8") : String(data);
|
|
480
|
+
messageBuffer += text;
|
|
481
|
+
processBuffer();
|
|
482
|
+
},
|
|
483
|
+
open(_socket) {
|
|
484
|
+
isConnected = true;
|
|
485
|
+
socket = _socket;
|
|
486
|
+
resolve();
|
|
487
|
+
},
|
|
488
|
+
close() {
|
|
489
|
+
isConnected = false;
|
|
490
|
+
socket = null;
|
|
491
|
+
rejectAllPending();
|
|
492
|
+
},
|
|
493
|
+
error(_socket, error) {
|
|
494
|
+
isConnected = false;
|
|
495
|
+
socket = null;
|
|
496
|
+
reject(error);
|
|
497
|
+
},
|
|
498
|
+
connectError(_socket, error) {
|
|
499
|
+
isConnected = false;
|
|
500
|
+
socket = null;
|
|
501
|
+
reject(error);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
} catch (error) {
|
|
506
|
+
reject(error);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
},
|
|
510
|
+
send(message) {
|
|
511
|
+
if (!(isConnected && socket)) {
|
|
512
|
+
return Promise.reject(new Error("Not connected to server"));
|
|
513
|
+
}
|
|
514
|
+
const id = generateId();
|
|
515
|
+
const request = {
|
|
516
|
+
id,
|
|
517
|
+
type: "request",
|
|
518
|
+
payload: message
|
|
519
|
+
};
|
|
520
|
+
return new Promise((resolve, reject) => {
|
|
521
|
+
pendingRequests.set(id, {
|
|
522
|
+
resolve,
|
|
523
|
+
reject
|
|
524
|
+
});
|
|
525
|
+
try {
|
|
526
|
+
const written = socket?.write(`${JSON.stringify(request)}
|
|
527
|
+
`);
|
|
528
|
+
if (written === 0) {
|
|
529
|
+
pendingRequests.delete(id);
|
|
530
|
+
reject(new Error("Failed to write to socket"));
|
|
531
|
+
}
|
|
532
|
+
} catch (error) {
|
|
533
|
+
pendingRequests.delete(id);
|
|
534
|
+
reject(error);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
},
|
|
538
|
+
close() {
|
|
539
|
+
if (!(isConnected && socket))
|
|
540
|
+
return;
|
|
541
|
+
try {
|
|
542
|
+
socket?.terminate();
|
|
543
|
+
} catch {}
|
|
544
|
+
socket = null;
|
|
545
|
+
isConnected = false;
|
|
546
|
+
rejectAllPending();
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// src/health.ts
|
|
551
|
+
function createHealthChecker(checks) {
|
|
552
|
+
const registeredChecks = [...checks];
|
|
553
|
+
const startTime = Date.now();
|
|
554
|
+
async function runCheck(check) {
|
|
555
|
+
try {
|
|
556
|
+
const result = await check.check();
|
|
557
|
+
if (result.isOk()) {
|
|
558
|
+
return { healthy: true };
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
healthy: false,
|
|
562
|
+
message: result.error.message
|
|
563
|
+
};
|
|
564
|
+
} catch (error) {
|
|
565
|
+
return {
|
|
566
|
+
healthy: false,
|
|
567
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
async check() {
|
|
573
|
+
const results = await Promise.all(registeredChecks.map(async (check) => ({
|
|
574
|
+
name: check.name,
|
|
575
|
+
result: await runCheck(check)
|
|
576
|
+
})));
|
|
577
|
+
const checksRecord = {};
|
|
578
|
+
let allHealthy = true;
|
|
579
|
+
for (const { name, result } of results) {
|
|
580
|
+
checksRecord[name] = result;
|
|
581
|
+
if (!result.healthy) {
|
|
582
|
+
allHealthy = false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
586
|
+
return {
|
|
587
|
+
healthy: allHealthy,
|
|
588
|
+
checks: checksRecord,
|
|
589
|
+
uptime
|
|
590
|
+
};
|
|
591
|
+
},
|
|
592
|
+
register(check) {
|
|
593
|
+
registeredChecks.push(check);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
export {
|
|
598
|
+
releaseDaemonLock,
|
|
599
|
+
readLockPid,
|
|
600
|
+
isUnixPlatform,
|
|
601
|
+
isProcessAlive,
|
|
602
|
+
isDaemonAlive,
|
|
603
|
+
getSocketPath,
|
|
604
|
+
getPidPath,
|
|
605
|
+
getLockPath,
|
|
606
|
+
getDaemonDir,
|
|
607
|
+
createIpcServer,
|
|
608
|
+
createIpcClient,
|
|
609
|
+
createHealthChecker,
|
|
610
|
+
createDaemon,
|
|
611
|
+
acquireDaemonLock,
|
|
612
|
+
StaleSocketError,
|
|
613
|
+
ProtocolError,
|
|
614
|
+
LockError,
|
|
615
|
+
DaemonError,
|
|
616
|
+
ConnectionTimeoutError,
|
|
617
|
+
ConnectionRefusedError
|
|
618
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@outfitter/daemon",
|
|
3
|
+
"description": "Daemon lifecycle, IPC, and health checks for Outfitter",
|
|
4
|
+
"version": "0.1.0-rc.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "bunup --filter @outfitter/daemon",
|
|
23
|
+
"lint": "biome lint ./src",
|
|
24
|
+
"lint:fix": "biome lint --write ./src",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"clean": "rm -rf dist"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@outfitter/contracts": "workspace:*",
|
|
31
|
+
"@outfitter/file-ops": "workspace:*",
|
|
32
|
+
"@outfitter/logging": "workspace:*"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"typescript": "^5.8.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"outfitter",
|
|
40
|
+
"daemon",
|
|
41
|
+
"lifecycle",
|
|
42
|
+
"ipc",
|
|
43
|
+
"health",
|
|
44
|
+
"typescript"
|
|
45
|
+
],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/outfitter-dev/outfitter.git",
|
|
50
|
+
"directory": "packages/daemon"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
}
|
|
55
|
+
}
|