routstrd 0.3.11 → 0.4.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/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/IMPLEMENTATION.md +253 -0
- package/README.md +46 -0
- package/SECURITY.md +23 -0
- package/SKILL.md +13 -3
- package/bun.lock +68 -41
- package/dist/daemon/index.js +48837 -11383
- package/dist/index.js +5027 -421
- package/package.json +8 -3
- package/src/cli.test.ts +55 -0
- package/src/cli.ts +601 -217
- package/src/daemon/args.ts +8 -1
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.ts +119 -39
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
- package/src/daemon/wallet/coco-client.test.ts +669 -0
- package/src/daemon/wallet/coco-client.ts +1628 -0
- package/src/daemon/wallet/cocod-client.ts +135 -3
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +171 -0
- package/src/daemon/wallet/migration.ts +154 -0
- package/src/daemon/wallet/paths.ts +33 -0
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/integrations/claudecode.ts +2 -1
- package/src/integrations/hermes.ts +46 -6
- package/src/integrations/openclaw.ts +3 -2
- package/src/integrations/opencode.ts +3 -2
- package/src/integrations/pi.ts +3 -2
- package/src/integrations/registry.ts +7 -5
- package/src/start-daemon.ts +189 -23
- package/src/utils/clients.ts +21 -0
- package/src/utils/config.ts +11 -0
- package/src/utils/daemon-client.ts +104 -25
- package/src/utils/logger.ts +42 -28
- package/tests/integrations/hermes.test.ts +42 -5
- package/tests/utils/daemon-client.test.ts +61 -0
- package/tests/wallet/short-keyset-token.test.ts +106 -0
package/src/start-daemon.ts
CHANGED
|
@@ -1,52 +1,167 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readSync,
|
|
7
|
+
} from "fs";
|
|
2
8
|
import { logger } from "./utils/logger";
|
|
3
9
|
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
|
4
10
|
import { withCrossProcessLock } from "./utils/process-lock";
|
|
11
|
+
import { urlHosts } from "./utils/daemon-client";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
5
13
|
|
|
6
14
|
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
|
|
7
15
|
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The spawned daemon's stdout/stderr is redirected to DEBUG_LOG_PATH, so when
|
|
19
|
+
* it exits early we can surface its actual error from there instead of just
|
|
20
|
+
* telling the user to go read logs.
|
|
21
|
+
*/
|
|
22
|
+
function readDaemonOutput(offset: number): string {
|
|
23
|
+
let fd: number | undefined;
|
|
12
24
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
25
|
+
if (!existsSync(DEBUG_LOG_PATH)) return "";
|
|
26
|
+
fd = openSync(DEBUG_LOG_PATH, "r");
|
|
27
|
+
const size = fstatSync(fd).size;
|
|
28
|
+
if (size <= offset) return "";
|
|
29
|
+
const bytesToRead = Math.min(size - offset, 256 * 1024);
|
|
30
|
+
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
31
|
+
const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset);
|
|
32
|
+
return buffer
|
|
33
|
+
.toString("utf8", 0, bytesRead)
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map((line) => line.trim())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.slice(-30)
|
|
38
|
+
.join("\n");
|
|
17
39
|
} catch {
|
|
18
|
-
return
|
|
40
|
+
return "";
|
|
19
41
|
} finally {
|
|
20
|
-
|
|
42
|
+
if (fd !== undefined) closeSync(fd);
|
|
21
43
|
}
|
|
22
44
|
}
|
|
23
45
|
|
|
46
|
+
const STARTUP_LOG_PREFIX = "[routstrd:start]";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Print only explicitly tagged, user-safe progress emitted by the detached
|
|
50
|
+
* daemon. Return the next byte offset so each line is shown exactly once.
|
|
51
|
+
*/
|
|
52
|
+
function printStartupProgress(offset: number): {
|
|
53
|
+
offset: number;
|
|
54
|
+
lastMessage?: string;
|
|
55
|
+
} {
|
|
56
|
+
let fd: number | undefined;
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(DEBUG_LOG_PATH)) return { offset };
|
|
59
|
+
fd = openSync(DEBUG_LOG_PATH, "r");
|
|
60
|
+
const size = fstatSync(fd).size;
|
|
61
|
+
if (size <= offset) return { offset };
|
|
62
|
+
|
|
63
|
+
// Startup messages are short. Bound each read so a noisy daemon cannot
|
|
64
|
+
// make the waiting CLI repeatedly load a large debug log into memory.
|
|
65
|
+
const bytesToRead = Math.min(size - offset, 64 * 1024);
|
|
66
|
+
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
67
|
+
const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset);
|
|
68
|
+
const lastNewlineIndex = buffer.subarray(0, bytesRead).lastIndexOf(0x0a);
|
|
69
|
+
const completeBytes = lastNewlineIndex + 1;
|
|
70
|
+
if (completeBytes === 0) return { offset };
|
|
71
|
+
const appended = buffer.toString("utf8", 0, completeBytes);
|
|
72
|
+
|
|
73
|
+
let lastMessage: string | undefined;
|
|
74
|
+
for (const line of appended.split("\n")) {
|
|
75
|
+
const markerIndex = line.indexOf(STARTUP_LOG_PREFIX);
|
|
76
|
+
if (markerIndex === -1) continue;
|
|
77
|
+
const message = line.slice(markerIndex + STARTUP_LOG_PREFIX.length).trim();
|
|
78
|
+
if (message) {
|
|
79
|
+
console.log(` ${message}`);
|
|
80
|
+
lastMessage = message;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { offset: offset + completeBytes, lastMessage };
|
|
84
|
+
} catch {
|
|
85
|
+
return { offset };
|
|
86
|
+
} finally {
|
|
87
|
+
if (fd !== undefined) closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatElapsed(elapsedMs: number): string {
|
|
92
|
+
const seconds = Math.floor(elapsedMs / 1000);
|
|
93
|
+
if (seconds < 60) return `${seconds}s`;
|
|
94
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fileSize(path: string): number {
|
|
98
|
+
let fd: number | undefined;
|
|
99
|
+
try {
|
|
100
|
+
fd = openSync(path, "r");
|
|
101
|
+
return fstatSync(fd).size;
|
|
102
|
+
} finally {
|
|
103
|
+
if (fd !== undefined) closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function healthyDaemonHost(
|
|
108
|
+
port: string,
|
|
109
|
+
host = "127.0.0.1",
|
|
110
|
+
): Promise<string | null> {
|
|
111
|
+
// Return the responding candidate so status output shows a connectable URL.
|
|
112
|
+
for (const candidate of urlHosts(host)) {
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
115
|
+
try {
|
|
116
|
+
const existing = await fetch(`http://${candidate}:${port}/health`, {
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
if (existing.ok) return candidate;
|
|
120
|
+
} catch {
|
|
121
|
+
// Try the next candidate host.
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
24
129
|
async function startDaemonUnlocked(
|
|
25
|
-
options: { port?: string; provider?: string },
|
|
130
|
+
options: { port?: string; host?: string; provider?: string },
|
|
26
131
|
): Promise<void> {
|
|
27
132
|
const args: string[] = [];
|
|
28
133
|
const port = options.port || "8008";
|
|
134
|
+
const host = options.host || "127.0.0.1";
|
|
29
135
|
const pollIntervalMs = 250;
|
|
30
136
|
const startupTimeoutMs = 10 * 60 * 1000;
|
|
31
137
|
|
|
32
|
-
|
|
33
|
-
|
|
138
|
+
const existingHost = await healthyDaemonHost(port, host);
|
|
139
|
+
if (existingHost) {
|
|
140
|
+
console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`);
|
|
34
141
|
return;
|
|
35
142
|
}
|
|
36
143
|
|
|
37
144
|
if (options.port) {
|
|
38
145
|
args.push("--port", options.port);
|
|
39
146
|
}
|
|
147
|
+
if (options.host) {
|
|
148
|
+
args.push("--host", options.host);
|
|
149
|
+
}
|
|
40
150
|
if (options.provider) {
|
|
41
151
|
args.push("--provider", options.provider);
|
|
42
152
|
}
|
|
43
153
|
|
|
44
|
-
|
|
45
|
-
|
|
154
|
+
let daemonScript = fileURLToPath(new URL("./daemon/index.js", import.meta.url));
|
|
155
|
+
if (!existsSync(daemonScript)) {
|
|
156
|
+
daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url));
|
|
157
|
+
}
|
|
46
158
|
|
|
159
|
+
const debugLogOffset = existsSync(DEBUG_LOG_PATH)
|
|
160
|
+
? fileSize(DEBUG_LOG_PATH)
|
|
161
|
+
: 0;
|
|
47
162
|
const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
|
|
48
163
|
|
|
49
|
-
const proc = Bun.spawn(["
|
|
164
|
+
const proc = Bun.spawn(["bun", daemonScript, ...args], {
|
|
50
165
|
stdout: debugLogFd,
|
|
51
166
|
stderr: debugLogFd,
|
|
52
167
|
stdin: "ignore",
|
|
@@ -55,40 +170,91 @@ async function startDaemonUnlocked(
|
|
|
55
170
|
|
|
56
171
|
proc.unref();
|
|
57
172
|
|
|
173
|
+
// The daemon must outlive a successful start, but a start we give up on must not
|
|
174
|
+
// leave a detached process holding the wallet locks. `detached` makes it a group leader.
|
|
175
|
+
const killSpawnedDaemon = (): void => {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
178
|
+
} catch {
|
|
179
|
+
// Already exited.
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const abandonOnSignal = (): void => {
|
|
183
|
+
killSpawnedDaemon();
|
|
184
|
+
process.exit(1);
|
|
185
|
+
};
|
|
186
|
+
process.once("SIGINT", abandonOnSignal);
|
|
187
|
+
process.once("SIGTERM", abandonOnSignal);
|
|
188
|
+
|
|
58
189
|
let exitCode: number | null = null;
|
|
59
190
|
proc.exited.then((code) => {
|
|
60
191
|
exitCode = code;
|
|
61
192
|
});
|
|
62
193
|
|
|
194
|
+
const startedAt = Date.now();
|
|
195
|
+
const heartbeatIntervalMs = 10_000;
|
|
196
|
+
let nextHeartbeatAt = heartbeatIntervalMs;
|
|
197
|
+
let progressLogOffset = debugLogOffset;
|
|
198
|
+
let currentPhase = "starting daemon";
|
|
199
|
+
|
|
63
200
|
const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
|
|
64
201
|
for (let i = 0; i < maxPolls; i++) {
|
|
65
202
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
66
203
|
|
|
204
|
+
const progress = printStartupProgress(progressLogOffset);
|
|
205
|
+
progressLogOffset = progress.offset;
|
|
206
|
+
if (progress.lastMessage) {
|
|
207
|
+
currentPhase = progress.lastMessage
|
|
208
|
+
.replace(/\.$/, "")
|
|
209
|
+
.toLowerCase();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const elapsedMs = Date.now() - startedAt;
|
|
213
|
+
if (elapsedMs >= nextHeartbeatAt) {
|
|
214
|
+
console.log(
|
|
215
|
+
` Still ${currentPhase} (${formatElapsed(elapsedMs)} elapsed)...`,
|
|
216
|
+
);
|
|
217
|
+
nextHeartbeatAt += heartbeatIntervalMs;
|
|
218
|
+
}
|
|
219
|
+
|
|
67
220
|
if (exitCode !== null) {
|
|
221
|
+
const daemonOutput = readDaemonOutput(debugLogOffset);
|
|
68
222
|
throw new Error(
|
|
69
|
-
`Daemon process exited early with code ${exitCode}
|
|
223
|
+
`Daemon process exited early with code ${exitCode}.` +
|
|
224
|
+
(daemonOutput
|
|
225
|
+
? `\n\nDaemon output:\n${daemonOutput}`
|
|
226
|
+
: ` Check logs in ${LOGS_DIR}`),
|
|
70
227
|
);
|
|
71
228
|
}
|
|
72
229
|
|
|
73
|
-
if (await
|
|
74
|
-
|
|
230
|
+
if (await healthyDaemonHost(port, host)) {
|
|
231
|
+
process.off("SIGINT", abandonOnSignal);
|
|
232
|
+
process.off("SIGTERM", abandonOnSignal);
|
|
233
|
+
printStartupProgress(progressLogOffset);
|
|
234
|
+
console.log(
|
|
235
|
+
`Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`,
|
|
236
|
+
);
|
|
75
237
|
return;
|
|
76
238
|
}
|
|
77
239
|
}
|
|
78
240
|
|
|
241
|
+
killSpawnedDaemon();
|
|
79
242
|
throw new Error(
|
|
80
|
-
`Daemon
|
|
243
|
+
`Daemon did not become healthy within ${Math.round(startupTimeoutMs / 1000)} seconds; ` +
|
|
244
|
+
`the incomplete daemon (PID ${proc.pid}) was stopped. Check logs in ${LOGS_DIR}`,
|
|
81
245
|
);
|
|
82
246
|
}
|
|
83
247
|
|
|
84
248
|
export async function startDaemon(
|
|
85
|
-
options: { port?: string; provider?: string } = {},
|
|
249
|
+
options: { port?: string; host?: string; provider?: string } = {},
|
|
86
250
|
): Promise<void> {
|
|
87
251
|
const port = options.port || "8008";
|
|
252
|
+
const host = options.host || "127.0.0.1";
|
|
88
253
|
const startupTimeoutMs = 10 * 60 * 1000;
|
|
89
254
|
|
|
90
|
-
|
|
91
|
-
|
|
255
|
+
const existingHost = await healthyDaemonHost(port, host);
|
|
256
|
+
if (existingHost) {
|
|
257
|
+
console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`);
|
|
92
258
|
return;
|
|
93
259
|
}
|
|
94
260
|
|
package/src/utils/clients.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
callDaemon,
|
|
3
3
|
loadConfig,
|
|
4
4
|
getDaemonBaseUrl,
|
|
5
|
+
getUserNpub,
|
|
5
6
|
ensureDaemonRunning,
|
|
6
7
|
} from "./daemon-client";
|
|
7
8
|
import { logger } from "./logger";
|
|
@@ -202,6 +203,7 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
|
|
|
202
203
|
if (options.hermes) integrationKeys.push("hermes");
|
|
203
204
|
|
|
204
205
|
if (integrationKeys.length > 0) {
|
|
206
|
+
let hadFailure = false;
|
|
205
207
|
for (const key of integrationKeys) {
|
|
206
208
|
const integrationFn = CLIENT_INTEGRATIONS[key];
|
|
207
209
|
const integrationConfig = CLIENT_CONFIGS[key];
|
|
@@ -222,15 +224,34 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
|
|
|
222
224
|
console.log(` Client ID: ${client.id}`);
|
|
223
225
|
console.log(` API Key: ${client.apiKey}`);
|
|
224
226
|
} catch (error) {
|
|
227
|
+
const message = (error as Error)?.message ?? String(error);
|
|
225
228
|
logger.error(
|
|
226
229
|
`Failed to set up ${integrationConfig.name} integration:`,
|
|
227
230
|
error,
|
|
228
231
|
);
|
|
232
|
+
console.error(
|
|
233
|
+
`\n Error: failed to set up ${integrationConfig.name} integration.`,
|
|
234
|
+
);
|
|
235
|
+
console.error(` ${message}`);
|
|
236
|
+
if (/NIP-98|registered npub\/pubkey|registered/i.test(message)) {
|
|
237
|
+
const npub = getUserNpub(config);
|
|
238
|
+
console.error(
|
|
239
|
+
` The daemon at ${getDaemonBaseUrl(config)} rejected this account.`,
|
|
240
|
+
);
|
|
241
|
+
if (npub) {
|
|
242
|
+
console.error(
|
|
243
|
+
` Register/authorize this npub on the remote daemon first:`,
|
|
244
|
+
);
|
|
245
|
+
console.error(` ${npub}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
hadFailure = true;
|
|
229
249
|
continue;
|
|
230
250
|
}
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`);
|
|
254
|
+
if (hadFailure) process.exit(1);
|
|
234
255
|
return;
|
|
235
256
|
}
|
|
236
257
|
|
package/src/utils/config.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface NwcConfig {
|
|
|
32
32
|
|
|
33
33
|
export interface RoutstrdConfig {
|
|
34
34
|
port: number;
|
|
35
|
+
host: string;
|
|
35
36
|
provider: string | null;
|
|
36
37
|
cocodPath: string | null;
|
|
37
38
|
mode?: "xcashu" | "apikeys";
|
|
@@ -54,11 +55,21 @@ export interface RoutstrdConfig {
|
|
|
54
55
|
relays?: string[];
|
|
55
56
|
/** NWC integration configuration */
|
|
56
57
|
nwc?: NwcConfig;
|
|
58
|
+
/**
|
|
59
|
+
* Default max_tokens (chat/completions) and max_output_tokens (responses)
|
|
60
|
+
* injected into proxied requests when the client does not supply one.
|
|
61
|
+
* Caps the completion budget the SDK prices against (completion × maxTokens)
|
|
62
|
+
* instead of the provider's worst-case max_completion_cost. Set to 0 to
|
|
63
|
+
* disable injection and always forward the client's value as-is.
|
|
64
|
+
*/
|
|
65
|
+
maxTokens?: number;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export const DEFAULT_CONFIG: RoutstrdConfig = {
|
|
60
69
|
port: 8008,
|
|
70
|
+
host: "127.0.0.1",
|
|
61
71
|
provider: null,
|
|
62
72
|
cocodPath: null,
|
|
63
73
|
mode: "apikeys",
|
|
74
|
+
maxTokens: 64000,
|
|
64
75
|
};
|
|
@@ -29,12 +29,40 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
|
|
|
29
29
|
return DEFAULT_CONFIG;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
/** Format a bind address for use as a URL host. */
|
|
33
|
+
export function urlHost(host?: string): string {
|
|
34
|
+
return urlHosts(host)[0] ?? "127.0.0.1";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Return connectable URL hosts for a bind address, in preference order. */
|
|
38
|
+
export function urlHosts(host?: string): string[] {
|
|
39
|
+
if (!host || host === "0.0.0.0") return ["127.0.0.1", "[::1]"];
|
|
40
|
+
if (host === "::") return ["[::1]", "127.0.0.1"];
|
|
41
|
+
if (host.startsWith("[") && host.endsWith("]")) return [host];
|
|
42
|
+
if (host.includes(":")) return [`[${host.replace(/%/g, "%25")}]`];
|
|
43
|
+
return [host];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function localDaemonBaseUrls(config: RoutstrdConfig): string[] {
|
|
47
|
+
return urlHosts(config.host).map(
|
|
48
|
+
(host) => `http://${host}:${config.port}`,
|
|
35
49
|
);
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
class DaemonConnectionError extends Error {
|
|
53
|
+
constructor(cause: unknown) {
|
|
54
|
+
super("Failed to connect to daemon", { cause });
|
|
55
|
+
this.name = "DaemonConnectionError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
|
|
60
|
+
if (config.daemonUrl) {
|
|
61
|
+
return config.daemonUrl.replace(/\/$/, "");
|
|
62
|
+
}
|
|
63
|
+
return `http://${urlHost(config.host)}:${config.port}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
38
66
|
export function getAuthBaseUrl(config: RoutstrdConfig): string {
|
|
39
67
|
if (config.authUrl) {
|
|
40
68
|
return config.authUrl.replace(/\/$/, "");
|
|
@@ -67,14 +95,20 @@ async function _callUrl(
|
|
|
67
95
|
);
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
const headers = new Headers();
|
|
99
|
+
if (authorization) headers.set("Authorization", authorization);
|
|
100
|
+
if (bodyString) headers.set("Content-Type", "application/json");
|
|
101
|
+
|
|
102
|
+
let response: Response;
|
|
103
|
+
try {
|
|
104
|
+
response = await fetch(url, {
|
|
105
|
+
method,
|
|
106
|
+
headers,
|
|
107
|
+
body: bodyString,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new DaemonConnectionError(error);
|
|
111
|
+
}
|
|
78
112
|
|
|
79
113
|
if (!response.ok) {
|
|
80
114
|
const errorData = (await response.json()) as { error?: string };
|
|
@@ -84,13 +118,33 @@ async function _callUrl(
|
|
|
84
118
|
return response.json() as Promise<CommandResponse>;
|
|
85
119
|
}
|
|
86
120
|
|
|
121
|
+
async function callLocalDaemon(
|
|
122
|
+
path: string,
|
|
123
|
+
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
|
124
|
+
config: RoutstrdConfig,
|
|
125
|
+
): Promise<CommandResponse> {
|
|
126
|
+
// Retry only connection failures; HTTP errors prove that a server answered.
|
|
127
|
+
let connectionError: DaemonConnectionError | undefined;
|
|
128
|
+
for (const baseUrl of localDaemonBaseUrls(config)) {
|
|
129
|
+
try {
|
|
130
|
+
return await _callUrl(baseUrl, path, options, config);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!(error instanceof DaemonConnectionError)) throw error;
|
|
133
|
+
connectionError = error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw connectionError ?? new Error("No daemon host candidates available");
|
|
137
|
+
}
|
|
138
|
+
|
|
87
139
|
export async function callDaemon(
|
|
88
140
|
path: string,
|
|
89
141
|
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {},
|
|
90
142
|
): Promise<CommandResponse> {
|
|
91
143
|
const config = await loadConfig();
|
|
92
|
-
|
|
93
|
-
|
|
144
|
+
if (config.daemonUrl) {
|
|
145
|
+
return _callUrl(getDaemonBaseUrl(config), path, options, config);
|
|
146
|
+
}
|
|
147
|
+
return callLocalDaemon(path, options, config);
|
|
94
148
|
}
|
|
95
149
|
|
|
96
150
|
/** Like callDaemon but sends requests to the auth proxy URL instead.
|
|
@@ -100,26 +154,50 @@ export async function callAuth(
|
|
|
100
154
|
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {},
|
|
101
155
|
): Promise<CommandResponse> {
|
|
102
156
|
const config = await loadConfig();
|
|
103
|
-
|
|
104
|
-
|
|
157
|
+
if (!config.authUrl && !config.daemonUrl) {
|
|
158
|
+
return callLocalDaemon(path, options, config);
|
|
159
|
+
}
|
|
160
|
+
return _callUrl(getAuthBaseUrl(config), path, options, config);
|
|
105
161
|
}
|
|
106
162
|
|
|
107
163
|
export async function isDaemonRunning(): Promise<boolean> {
|
|
108
164
|
try {
|
|
109
165
|
const config = await loadConfig();
|
|
110
|
-
const baseUrl = getDaemonBaseUrl(config);
|
|
111
|
-
const url = `${baseUrl}/health`;
|
|
112
166
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
authorization
|
|
167
|
+
if (config.daemonUrl) {
|
|
168
|
+
const baseUrl = config.daemonUrl.replace(/\/$/, "");
|
|
169
|
+
const url = `${baseUrl}/health`;
|
|
170
|
+
let authorization: string | undefined;
|
|
171
|
+
if (config.nsec) {
|
|
172
|
+
const secretKey = parseSecretKey(config.nsec);
|
|
173
|
+
authorization = await createNIP98Authorization(secretKey, url, "GET");
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(url, {
|
|
177
|
+
headers: authorization ? { Authorization: authorization } : {},
|
|
178
|
+
});
|
|
179
|
+
return response.ok;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
117
183
|
}
|
|
118
184
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
185
|
+
// A wildcard bind may be listening on either loopback family.
|
|
186
|
+
for (const baseUrl of localDaemonBaseUrls(config)) {
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
189
|
+
try {
|
|
190
|
+
const response = await fetch(`${baseUrl}/health`, {
|
|
191
|
+
signal: controller.signal,
|
|
192
|
+
});
|
|
193
|
+
if (response.ok) return true;
|
|
194
|
+
} catch {
|
|
195
|
+
// Try the next candidate host.
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timeoutId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
123
201
|
} catch {
|
|
124
202
|
return false;
|
|
125
203
|
}
|
|
@@ -145,6 +223,7 @@ export async function startDaemonProcess(): Promise<void> {
|
|
|
145
223
|
const config = await loadConfig();
|
|
146
224
|
await startDaemon({
|
|
147
225
|
port: String(config.port || 8008),
|
|
226
|
+
host: config.host || undefined,
|
|
148
227
|
provider: config.provider || undefined,
|
|
149
228
|
});
|
|
150
229
|
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync } from "fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from "fs";
|
|
3
2
|
import { join } from "path";
|
|
4
3
|
|
|
5
4
|
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
5
|
+
|
|
6
|
+
// `bun test` sets NODE_ENV=test. Test runs import modules that pull in this
|
|
7
|
+
// singleton, so without this guard their output would be written into the real
|
|
8
|
+
// ~/.routstrd log files alongside production daemon output.
|
|
9
|
+
const isTest = process.env.NODE_ENV === "test";
|
|
10
|
+
|
|
6
11
|
const LOG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`;
|
|
7
12
|
const LOGS_DIR = join(LOG_DIR, "logs");
|
|
13
|
+
/** Wallet-engine (coco-core / Cashu) diagnostics land in their own directory. */
|
|
14
|
+
export const COCO_LOGS_DIR = join(LOG_DIR, "coco-logs");
|
|
8
15
|
|
|
9
|
-
function getLogFileForDate(date: Date = new Date()): string {
|
|
16
|
+
function getLogFileForDate(logsDir: string, date: Date = new Date()): string {
|
|
10
17
|
const year = date.getFullYear();
|
|
11
18
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
12
19
|
const day = String(date.getDate()).padStart(2, "0");
|
|
13
|
-
return join(
|
|
20
|
+
return join(logsDir, `${year}-${month}-${day}.log`);
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
if (!existsSync(
|
|
18
|
-
|
|
23
|
+
function ensureDir(dir: string) {
|
|
24
|
+
if (!existsSync(dir)) {
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
19
26
|
}
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
// NOTE: writes are synchronous on purpose — the daemon calls process.exit()
|
|
30
|
+
// right after logger.error(...) on fatal paths, and async writes were being
|
|
31
|
+
// silently dropped, making startup failures invisible.
|
|
32
|
+
function writeLog(logsDir: string, level: string, ...args: unknown[]) {
|
|
33
|
+
if (isTest) return;
|
|
34
|
+
ensureDir(logsDir);
|
|
24
35
|
const timestamp = new Date().toISOString();
|
|
25
36
|
const message = args
|
|
26
37
|
.map((a) => {
|
|
@@ -38,28 +49,31 @@ async function writeLog(level: string, ...args: unknown[]) {
|
|
|
38
49
|
})
|
|
39
50
|
.join(" ");
|
|
40
51
|
const line = `[${timestamp}] [${level}] ${message}\n`;
|
|
41
|
-
const logFile = getLogFileForDate(new Date(timestamp));
|
|
52
|
+
const logFile = getLogFileForDate(logsDir, new Date(timestamp));
|
|
42
53
|
try {
|
|
43
|
-
|
|
54
|
+
appendFileSync(logFile, line);
|
|
44
55
|
} catch (error) {
|
|
45
56
|
console.error("Failed to write log:", error);
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
export
|
|
50
|
-
log: (...args: unknown[]) =>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
writeLog("
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
writeLog("INFO", ...args)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
60
|
+
export interface FileLogger {
|
|
61
|
+
log: (...args: unknown[]) => void;
|
|
62
|
+
debug: (...args: unknown[]) => void;
|
|
63
|
+
warn: (...args: unknown[]) => void;
|
|
64
|
+
error: (...args: unknown[]) => void;
|
|
65
|
+
info: (...args: unknown[]) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createLogger(logsDir: string): FileLogger {
|
|
69
|
+
return {
|
|
70
|
+
log: (...args) => writeLog(logsDir, "INFO", ...args),
|
|
71
|
+
debug: (...args) => writeLog(logsDir, "DEBUG", ...args),
|
|
72
|
+
warn: (...args) => writeLog(logsDir, "WARN", ...args),
|
|
73
|
+
error: (...args) => writeLog(logsDir, "ERROR", ...args),
|
|
74
|
+
info: (...args) => writeLog(logsDir, "INFO", ...args),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const logger = createLogger(LOGS_DIR);
|
|
79
|
+
export const cocoLogger = createLogger(COCO_LOGS_DIR);
|