1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
|
import config from "$config";
import { existsSync } from "fs";
import net from "net";
// Patch net.Socket.prototype.connect to prevent Bun from crashing when
// connecting to a non-existent unix socket. Bun's net.createConnection
// throws ENOENT in a way that bypasses all JS error handlers, taking the
// whole process down. Intercept early and emit a proper error event.
const origConnect = net.Socket.prototype.connect;
net.Socket.prototype.connect = function (...args: any[]) {
const sockPath = typeof args[0] === "string" ? args[0] : args[0]?.path;
if (typeof sockPath === "string" && sockPath.startsWith("/") && !existsSync(sockPath)) {
process.nextTick(() => {
this.emit(
"error",
Object.assign(new Error(`connect ENOENT ${sockPath}`), {
code: "ENOENT",
errno: -2,
syscall: "connect",
address: sockPath,
}),
);
});
return this;
}
return origConnect.apply(this, args);
};
const mod = (await import("@asoltys/clightning-client")).default;
const { LightningClient } = mod as { LightningClient: any };
class LightningUnavailableError extends Error {
code = "LIGHTNING_UNAVAILABLE" as const;
constructor(msg: string) {
super(msg);
this.name = "LightningUnavailableError";
}
}
function isUnavailable(e: any) {
const code = e?.code ?? e?.errno;
return code === "ENOENT" || code === 2 || code === "ECONNREFUSED";
}
function isSocketDied(e: any) {
const code = e?.code ?? e?.errno;
return (
code === "EPIPE" ||
code === "ECONNRESET" ||
code === "ENOENT" ||
code === 2 ||
// A timed-out RPC (see RPC_TIMEOUT below) means the socket is open but cl
// stopped responding (zombie/half-open). Treat it as dead so the client is
// dropped and the next call reconnects — without restarting the process.
code === "ETIMEDOUT"
);
}
// Wall-clock cap for short-lived RPCs. A unix-domain socket has no TCP
// keepalive, so a hung cl would otherwise leave the await pending forever
// (the "stuck spinner past /pay/bob, no error" symptom). 30s matches the
// health-check RPC_TIMEOUT in lib/health.ts.
const RPC_TIMEOUT = 30_000;
// Methods that MUST NOT get a client-side timeout:
// - waitanyinvoice/waitinvoice/waitsendpay/waitblockheight: long-polls that
// are designed to block until something happens.
// - pay/xpay/sendpay/keysend: in-flight payments. Rejecting the caller early
// while the HTLC may still settle is exactly the leaked-debit hazard the
// sendLightning guards exist to prevent — let these run to completion.
// - fetchinvoice/sendinvoice/offer: bolt12 flows that round-trip to a remote
// node over the network and can legitimately take a long time.
const NO_TIMEOUT = new Set([
"waitanyinvoice",
"waitinvoice",
"waitsendpay",
"waitblockheight",
"pay",
"xpay",
"sendpay",
"keysend",
"fetchinvoice",
"sendinvoice",
"offer",
]);
class RpcTimeoutError extends Error {
code = "ETIMEDOUT" as const;
constructor(method: string, ms: number) {
super(`Lightning RPC ${method} timed out after ${ms}ms`);
this.name = "RpcTimeoutError";
}
}
function lightningProxy(rpcPath: string): any {
let client: any = null;
let nextTryAt = 0;
let backoff = 250;
const maxBackoff = 5000;
function ensure() {
if (client) return client;
const now = Date.now();
if (now < nextTryAt) {
throw new LightningUnavailableError(`Lightning not ready at ${rpcPath}`);
}
if (!existsSync(rpcPath)) {
nextTryAt = Date.now() + backoff;
backoff = Math.min(maxBackoff, Math.floor(backoff * 1.8));
throw new LightningUnavailableError(
`Lightning RPC socket not found at ${rpcPath}`,
);
}
try {
client = new LightningClient(rpcPath);
// Prevent unhandled 'error' events from crashing the process
client.on("error", (e: any) => {
if (isSocketDied(e)) client = null;
});
backoff = 250;
nextTryAt = 0;
return client;
} catch (e: any) {
nextTryAt = Date.now() + backoff;
backoff = Math.min(maxBackoff, Math.floor(backoff * 1.8));
if (isUnavailable(e)) {
throw new LightningUnavailableError(
`Lightning RPC unavailable at ${rpcPath} (${e?.code ?? e?.errno ?? "error"})`,
);
}
throw e;
}
}
return new Proxy(
{},
{
get(_t, prop: string | symbol) {
if (prop === "toString") return () => "[LightningProxy]";
if (prop === Symbol.toStringTag) return "LightningProxy";
if (prop === "reset") return () => { client = null; }; // watchdog: drop a zombie socket
return async (...args: any[]) => {
const c = ensure();
const v = c[prop as any];
if (typeof v !== "function") return v;
const method = String(prop);
try {
const call = v.apply(c, args);
if (NO_TIMEOUT.has(method)) return await call;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(
() => reject(new RpcTimeoutError(method, RPC_TIMEOUT)),
RPC_TIMEOUT,
);
});
try {
return await Promise.race([call, timeout]);
} finally {
clearTimeout(timer);
}
} catch (e: any) {
// ETIMEDOUT (from RpcTimeoutError) is included in isSocketDied, so a
// hung RPC drops the client and the next call reconnects.
if (isSocketDied(e)) client = null;
throw e;
}
};
},
},
);
}
// Main proxy for short-lived RPC calls (getinfo, invoice, decode, etc.)
const ln = lightningProxy(config.lightning);
export default ln;
// Dedicated proxy for long-polling (waitanyinvoice) — uses its own socket
// so it doesn't block the main connection
export const lnListen = lightningProxy(config.lightning);
export const lnb = config.lightningb ? lightningProxy(config.lightningb) : ln;
export { LightningUnavailableError };
|