routstrd 0.2.8 → 0.2.10

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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "fs";
2
2
  import { createHash } from "crypto";
3
3
  import { logger } from "../../utils/logger";
4
+ import { withCrossProcessLock } from "../../utils/process-lock";
4
5
 
5
6
  const DEFAULT_CONFIG_DIR =
6
7
  process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
@@ -139,6 +140,7 @@ export function createCocodClient(
139
140
  ): CocodClient {
140
141
  const executable = resolveCocodExecutable(options.cocodPath);
141
142
  const socketPath = options.socketPath || DEFAULT_SOCKET_PATH;
143
+ const startupLockPath = `${socketPath}.startup.lock`;
142
144
  const fetchImpl = options.fetchImpl || (fetch as CocodFetch);
143
145
  const pollIntervalMs = options.pollIntervalMs ?? 100;
144
146
  const startupTimeoutMs = options.startupTimeoutMs ?? 5000;
@@ -228,7 +230,7 @@ export function createCocodClient(
228
230
 
229
231
  async function startDaemon(): Promise<void> {
230
232
  const env = { ...process.env, COCOD_SOCKET: socketPath };
231
- const proc = spawnDaemon([executable, "daemon"], env);
233
+ const proc = spawnDaemon([executable, "init"], env);
232
234
  const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
233
235
  let exitCode: number | null = null;
234
236
 
@@ -239,8 +241,8 @@ export function createCocodClient(
239
241
  for (let i = 0; i < maxPolls; i++) {
240
242
  await delay(pollIntervalMs);
241
243
 
242
- if (exitCode !== null) {
243
- throw new Error(`cocod daemon exited early with code ${exitCode}`);
244
+ if (exitCode !== null && exitCode !== 0) {
245
+ throw new Error(`cocod init exited early with code ${exitCode}`);
244
246
  }
245
247
 
246
248
  if (await pingInternal()) {
@@ -250,7 +252,7 @@ export function createCocodClient(
250
252
  }
251
253
 
252
254
  throw new Error(
253
- `cocod daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
255
+ `cocod failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
254
256
  );
255
257
  }
256
258
 
@@ -260,8 +262,22 @@ export function createCocodClient(
260
262
  }
261
263
 
262
264
  if (!startPromise) {
263
- logger.debug(`Starting cocod daemon via ${executable}...`);
264
- startPromise = startDaemon().finally(() => {
265
+ startPromise = withCrossProcessLock(
266
+ startupLockPath,
267
+ async () => {
268
+ if (await pingInternal()) {
269
+ return;
270
+ }
271
+
272
+ logger.debug(`Starting cocod daemon via ${executable} init...`);
273
+ await startDaemon();
274
+ },
275
+ {
276
+ acquireTimeoutMs: startupTimeoutMs + 30_000,
277
+ staleAfterMs: startupTimeoutMs + 30_000,
278
+ log: (message) => logger.debug(message),
279
+ },
280
+ ).finally(() => {
265
281
  startPromise = null;
266
282
  });
267
283
  }
@@ -1,6 +1,9 @@
1
1
  import { logger } from "./utils/logger";
2
2
  import { existsSync } from "fs";
3
- import { LOGS_DIR } from "./utils/config";
3
+ import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
4
+ import { withCrossProcessLock } from "./utils/process-lock";
5
+
6
+ const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
4
7
 
5
8
  function getTodayLogFile(): string {
6
9
  const now = new Date();
@@ -10,27 +13,32 @@ function getTodayLogFile(): string {
10
13
  return `${LOGS_DIR}/${year}-${month}-${day}.log`;
11
14
  }
12
15
 
13
- export async function startDaemon(
14
- options: { port?: string; provider?: string } = {},
16
+ async function isDaemonHealthy(port: string): Promise<boolean> {
17
+ const controller = new AbortController();
18
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
19
+ try {
20
+ const existing = await fetch(`http://localhost:${port}/health`, {
21
+ signal: controller.signal,
22
+ });
23
+ return existing.ok;
24
+ } catch {
25
+ return false;
26
+ } finally {
27
+ clearTimeout(timeoutId);
28
+ }
29
+ }
30
+
31
+ async function startDaemonUnlocked(
32
+ options: { port?: string; provider?: string },
15
33
  ): Promise<void> {
16
34
  const args: string[] = [];
17
35
  const port = options.port || "8008";
18
36
  const pollIntervalMs = 250;
19
37
  const startupTimeoutMs = 10 * 60 * 1000;
20
38
 
21
- try {
22
- const controller = new AbortController();
23
- const timeoutId = setTimeout(() => controller.abort(), 2000);
24
- const existing = await fetch(`http://localhost:${port}/health`, {
25
- signal: controller.signal,
26
- });
27
- clearTimeout(timeoutId);
28
- if (existing.ok) {
29
- logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
30
- return;
31
- }
32
- } catch {
33
- // Daemon is not running yet; continue with startup.
39
+ if (await isDaemonHealthy(port)) {
40
+ logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
41
+ return;
34
42
  }
35
43
 
36
44
  if (options.port) {
@@ -47,7 +55,7 @@ export async function startDaemon(
47
55
 
48
56
  const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
49
57
  const todayLogFile = getTodayLogFile();
50
- const shellCmd = `bun run "${daemonScript}" ${args.map(a => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
58
+ const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
51
59
 
52
60
  const proc = Bun.spawn(["sh", "-c", shellCmd], {
53
61
  stdout: "inherit",
@@ -73,19 +81,9 @@ export async function startDaemon(
73
81
  );
74
82
  }
75
83
 
76
- try {
77
- const controller = new AbortController();
78
- const timeoutId = setTimeout(() => controller.abort(), 2000);
79
- const res = await fetch(`http://localhost:${port}/health`, {
80
- signal: controller.signal,
81
- });
82
- clearTimeout(timeoutId);
83
- if (res.ok) {
84
- logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
85
- return;
86
- }
87
- } catch {
88
- // Not ready yet
84
+ if (await isDaemonHealthy(port)) {
85
+ logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
86
+ return;
89
87
  }
90
88
  }
91
89
 
@@ -93,3 +91,27 @@ export async function startDaemon(
93
91
  `Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR}`,
94
92
  );
95
93
  }
94
+
95
+ export async function startDaemon(
96
+ options: { port?: string; provider?: string } = {},
97
+ ): Promise<void> {
98
+ const port = options.port || "8008";
99
+ const startupTimeoutMs = 10 * 60 * 1000;
100
+
101
+ if (await isDaemonHealthy(port)) {
102
+ logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
103
+ return;
104
+ }
105
+
106
+ await withCrossProcessLock(
107
+ DAEMON_STARTUP_LOCK_PATH,
108
+ async () => {
109
+ await startDaemonUnlocked(options);
110
+ },
111
+ {
112
+ acquireTimeoutMs: startupTimeoutMs + 30_000,
113
+ staleAfterMs: startupTimeoutMs + 30_000,
114
+ log: (message) => logger.debug(message),
115
+ },
116
+ );
117
+ }
@@ -121,12 +121,15 @@ export async function getClientsList(): Promise<ClientEntry[]> {
121
121
 
122
122
  export async function addDaemonClient(
123
123
  name: string,
124
- clientId?: string,
125
124
  ): Promise<{ message?: string; client: DaemonClient; created: boolean }> {
126
125
  const existingClients = await getClientsList();
127
- const existing = clientId
128
- ? existingClients.find((c) => c.clientId === clientId)
129
- : existingClients.find((c) => c.name === name);
126
+ // Derive id from name by replacing spaces with hyphens
127
+ const derivedId = name.replace(/\s+/g, "-").toLowerCase();
128
+ const config = await loadConfig();
129
+ const suffix = getNpubSuffix(config);
130
+ const clientId = suffix ? addSuffixToId(derivedId, suffix) : derivedId;
131
+
132
+ const existing = existingClients.find((c) => c.clientId === clientId);
130
133
 
131
134
  if (existing) {
132
135
  const client: DaemonClient = {
@@ -139,9 +142,6 @@ export async function addDaemonClient(
139
142
  return { client, created: false };
140
143
  }
141
144
 
142
- // Derive id from name by replacing spaces with hyphens
143
- const derivedId = name.replace(/\s+/g, "-").toLowerCase();
144
-
145
145
  const result = await callDaemon("/clients/add", {
146
146
  method: "POST",
147
147
  body: { name, id: derivedId },
@@ -246,8 +246,7 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
246
246
 
247
247
  try {
248
248
  const { client, created } = await addDaemonClient(
249
- integrationConfig.name,
250
- integrationConfig.clientId,
249
+ integrationConfig.name
251
250
  );
252
251
  if (created) {
253
252
  logger.log(`Created new API key for ${integrationConfig.name}`);
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from "fs";
2
+ import { startDaemon } from "../start-daemon";
2
3
  import {
3
4
  CONFIG_FILE,
4
5
  DEFAULT_CONFIG,
5
- LOGS_DIR,
6
6
  type RoutstrdConfig,
7
7
  } from "./config";
8
8
  import {
@@ -115,31 +115,11 @@ export function getNpubSuffix(config: RoutstrdConfig): string | null {
115
115
  }
116
116
 
117
117
  export async function startDaemonProcess(): Promise<void> {
118
- // Ensure logs directory exists (logger handles date-based files)
119
- if (!existsSync(LOGS_DIR)) {
120
- await Bun.$`mkdir -p ${LOGS_DIR}`;
121
- }
122
-
123
- const proc = Bun.spawn(
124
- ["bun", "run", `${import.meta.dir}/../daemon/index.ts`],
125
- {
126
- stdout: "inherit",
127
- stderr: "inherit",
128
- stdin: "ignore",
129
- detached: true,
130
- },
131
- );
132
-
133
- proc.unref();
134
-
135
- for (let i = 0; i < 50; i++) {
136
- await new Promise((resolve) => setTimeout(resolve, 100));
137
- if (await isDaemonRunning()) {
138
- return;
139
- }
140
- }
141
-
142
- throw new Error("Daemon failed to start within 5 seconds");
118
+ const config = await loadConfig();
119
+ await startDaemon({
120
+ port: String(config.port || 8008),
121
+ provider: config.provider || undefined,
122
+ });
143
123
  }
144
124
 
145
125
  export async function ensureDaemonRunning(): Promise<void> {
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from "crypto";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3
+ import { dirname } from "path";
4
+
5
+ export interface CrossProcessLockOptions {
6
+ /** How long to wait while another process holds the lock. */
7
+ acquireTimeoutMs?: number;
8
+ /** How often to retry acquiring the lock. */
9
+ retryIntervalMs?: number;
10
+ /** Treat locks older than this as stale even if their PID cannot be checked. */
11
+ staleAfterMs?: number;
12
+ /** Optional logger used when removing stale locks. */
13
+ log?: (message: string) => void;
14
+ }
15
+
16
+ interface LockOwner {
17
+ pid: number;
18
+ createdAt: number;
19
+ token?: string;
20
+ }
21
+
22
+ function delay(ms: number): Promise<void> {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+
26
+ function isProcessRunning(pid: number): boolean {
27
+ if (!Number.isFinite(pid) || pid <= 0) {
28
+ return false;
29
+ }
30
+
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch (error) {
35
+ const code = (error as NodeJS.ErrnoException).code;
36
+ return code === "EPERM";
37
+ }
38
+ }
39
+
40
+ async function readLockOwner(lockDir: string): Promise<LockOwner | null> {
41
+ try {
42
+ const raw = await readFile(`${lockDir}/owner.json`, "utf8");
43
+ const parsed = JSON.parse(raw) as Partial<LockOwner>;
44
+ if (
45
+ typeof parsed.pid === "number" &&
46
+ typeof parsed.createdAt === "number"
47
+ ) {
48
+ return {
49
+ pid: parsed.pid,
50
+ createdAt: parsed.createdAt,
51
+ token: typeof parsed.token === "string" ? parsed.token : undefined,
52
+ };
53
+ }
54
+ } catch {
55
+ // The lock may have been created but not fully written yet.
56
+ }
57
+ return null;
58
+ }
59
+
60
+ async function isLockStale(
61
+ lockDir: string,
62
+ staleAfterMs: number,
63
+ ): Promise<boolean> {
64
+ const owner = await readLockOwner(lockDir);
65
+ if (owner) {
66
+ return !isProcessRunning(owner.pid) || Date.now() - owner.createdAt > staleAfterMs;
67
+ }
68
+
69
+ try {
70
+ const info = await stat(lockDir);
71
+ return Date.now() - info.mtimeMs > staleAfterMs;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ export async function acquireCrossProcessLock(
78
+ lockDir: string,
79
+ options: CrossProcessLockOptions = {},
80
+ ): Promise<() => Promise<void>> {
81
+ const acquireTimeoutMs = options.acquireTimeoutMs ?? 120_000;
82
+ const retryIntervalMs = options.retryIntervalMs ?? 100;
83
+ const staleAfterMs = options.staleAfterMs ?? 120_000;
84
+ const deadline = Date.now() + acquireTimeoutMs;
85
+
86
+ await mkdir(dirname(lockDir), { recursive: true });
87
+
88
+ while (true) {
89
+ try {
90
+ await mkdir(lockDir);
91
+ const token = randomUUID();
92
+ const owner: LockOwner = { pid: process.pid, createdAt: Date.now(), token };
93
+ await writeFile(`${lockDir}/owner.json`, JSON.stringify(owner), "utf8");
94
+ let released = false;
95
+ return async () => {
96
+ if (released) return;
97
+ released = true;
98
+
99
+ const currentOwner = await readLockOwner(lockDir);
100
+ if (currentOwner?.token === token) {
101
+ await rm(lockDir, { recursive: true, force: true });
102
+ }
103
+ };
104
+ } catch (error) {
105
+ const code = (error as NodeJS.ErrnoException).code;
106
+ if (code !== "EEXIST") {
107
+ throw error;
108
+ }
109
+
110
+ if (await isLockStale(lockDir, staleAfterMs)) {
111
+ options.log?.(`Removing stale lock at ${lockDir}`);
112
+ await rm(lockDir, { recursive: true, force: true });
113
+ continue;
114
+ }
115
+
116
+ if (Date.now() >= deadline) {
117
+ throw new Error(`Timed out waiting to acquire lock ${lockDir}`);
118
+ }
119
+
120
+ await delay(retryIntervalMs);
121
+ }
122
+ }
123
+ }
124
+
125
+ export async function withCrossProcessLock<T>(
126
+ lockDir: string,
127
+ fn: () => Promise<T>,
128
+ options: CrossProcessLockOptions = {},
129
+ ): Promise<T> {
130
+ const release = await acquireCrossProcessLock(lockDir, options);
131
+ try {
132
+ return await fn();
133
+ } finally {
134
+ await release();
135
+ }
136
+ }