machine-bridge-mcp 0.6.2 → 0.8.0
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/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +31 -0
- package/README.md +20 -17
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +65 -26
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +15 -6
- package/docs/PRIVACY.md +43 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +23 -1
- package/package.json +35 -8
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/cli.mjs +369 -272
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +73 -31
- package/src/local/log.mjs +28 -2
- package/src/local/managed-jobs.mjs +194 -125
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +188 -198
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +41 -21
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +157 -81
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +45 -21
- package/wrangler.jsonc +1 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { classifyOperationalError } from "./log.mjs";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
|
|
5
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 75_000;
|
|
6
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
7
|
+
const DEFAULT_OUTAGE_WARN_AFTER_MS = 10_000;
|
|
8
|
+
const DEFAULT_OUTAGE_WARN_REPEAT_MS = 60_000;
|
|
9
|
+
const MAX_CLOSE_REASON_CHARS = 128;
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SCHEDULER = Object.freeze({
|
|
12
|
+
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
|
13
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
14
|
+
setInterval: (callback, delay) => setInterval(callback, delay),
|
|
15
|
+
clearInterval: (timer) => clearInterval(timer),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export class RelayConnection {
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.workerUrl = normalizeWorkerUrl(options.workerUrl);
|
|
21
|
+
if (typeof options.secret !== "string" || options.secret.length < 16) throw new Error("daemon secret is missing or too short");
|
|
22
|
+
this.secret = options.secret;
|
|
23
|
+
this.logger = options.logger || console;
|
|
24
|
+
this.helloMessage = typeof options.helloMessage === "function" ? options.helloMessage : () => ({ type: "hello" });
|
|
25
|
+
this.expectedServer = String(options.expectedServer || "");
|
|
26
|
+
this.expectedVersion = String(options.expectedVersion || "");
|
|
27
|
+
this.onMessage = typeof options.onMessage === "function" ? options.onMessage : () => {};
|
|
28
|
+
this.onDisconnect = typeof options.onDisconnect === "function" ? options.onDisconnect : () => {};
|
|
29
|
+
this.onSuperseded = typeof options.onSuperseded === "function" ? options.onSuperseded : () => {};
|
|
30
|
+
this.onFatal = typeof options.onFatal === "function" ? options.onFatal : () => {};
|
|
31
|
+
this.WebSocketClass = options.WebSocketClass || WebSocket;
|
|
32
|
+
this.scheduler = options.scheduler || DEFAULT_SCHEDULER;
|
|
33
|
+
this.now = typeof options.now === "function" ? options.now : Date.now;
|
|
34
|
+
this.reconnectDelay = typeof options.reconnectDelay === "function" ? options.reconnectDelay : reconnectDelay;
|
|
35
|
+
this.maxPayload = boundedPositiveInteger(options.maxPayload, 8 * 1024 * 1024);
|
|
36
|
+
this.heartbeatIntervalMs = boundedPositiveInteger(options.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
37
|
+
this.heartbeatTimeoutMs = boundedPositiveInteger(options.heartbeatTimeoutMs, DEFAULT_HEARTBEAT_TIMEOUT_MS);
|
|
38
|
+
this.handshakeTimeoutMs = boundedPositiveInteger(options.handshakeTimeoutMs, DEFAULT_HANDSHAKE_TIMEOUT_MS);
|
|
39
|
+
this.outageWarnAfterMs = boundedPositiveInteger(options.outageWarnAfterMs, DEFAULT_OUTAGE_WARN_AFTER_MS);
|
|
40
|
+
this.outageWarnRepeatMs = boundedPositiveInteger(options.outageWarnRepeatMs, DEFAULT_OUTAGE_WARN_REPEAT_MS);
|
|
41
|
+
|
|
42
|
+
this.closed = true;
|
|
43
|
+
this.socket = null;
|
|
44
|
+
this.ready = false;
|
|
45
|
+
this.hasConnected = false;
|
|
46
|
+
this.connectedAt = 0;
|
|
47
|
+
this.lastInboundAt = 0;
|
|
48
|
+
this.reconnectAttempt = 0;
|
|
49
|
+
this.reconnectTimer = null;
|
|
50
|
+
this.heartbeatTimer = null;
|
|
51
|
+
this.handshakeTimer = null;
|
|
52
|
+
this.outageWarnTimer = null;
|
|
53
|
+
this.outageStartedAt = 0;
|
|
54
|
+
this.outageAttempts = 0;
|
|
55
|
+
this.outageNoticeEmitted = false;
|
|
56
|
+
this.lastOutageWarnAt = 0;
|
|
57
|
+
this.lastCloseCategory = "connection_interrupted";
|
|
58
|
+
this.lastTransportErrorClass = "";
|
|
59
|
+
this.pendingCloseCategory = "";
|
|
60
|
+
this.connectedOnce = null;
|
|
61
|
+
this.connectedOnceResolve = null;
|
|
62
|
+
this.connectedOnceReject = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
start() {
|
|
66
|
+
if (!this.closed && this.connectedOnce) return this.connectedOnce;
|
|
67
|
+
this.closed = false;
|
|
68
|
+
this.connectedOnce = new Promise((resolvePromise, rejectPromise) => {
|
|
69
|
+
this.connectedOnceResolve = resolvePromise;
|
|
70
|
+
this.connectedOnceReject = rejectPromise;
|
|
71
|
+
});
|
|
72
|
+
this.connect();
|
|
73
|
+
return this.connectedOnce;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stop() {
|
|
77
|
+
this.closed = true;
|
|
78
|
+
this.ready = false;
|
|
79
|
+
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
80
|
+
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
81
|
+
this.clearTimer("reconnectTimer", "clearTimeout");
|
|
82
|
+
this.clearTimer("outageWarnTimer", "clearTimeout");
|
|
83
|
+
const socket = this.socket;
|
|
84
|
+
this.socket = null;
|
|
85
|
+
if (socket) {
|
|
86
|
+
try { socket.close(1000, "daemon shutdown"); } catch {}
|
|
87
|
+
}
|
|
88
|
+
this.resetOutage();
|
|
89
|
+
this.reconnectAttempt = 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
send(value) {
|
|
93
|
+
if (!this.ready || !this.isSocketOpen(this.socket)) return false;
|
|
94
|
+
return this.sendOnSocket(this.socket, value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
acknowledge(message = {}) {
|
|
98
|
+
const socket = this.socket;
|
|
99
|
+
if (this.closed || this.ready || !this.isSocketOpen(socket)) return false;
|
|
100
|
+
const mismatch = acknowledgementMismatch(message, this.expectedServer, this.expectedVersion);
|
|
101
|
+
if (mismatch) {
|
|
102
|
+
this.logger.debug?.("remote relay acknowledgement rejected", { reason: mismatch });
|
|
103
|
+
this.failPermanently("relay_protocol_mismatch");
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
this.ready = true;
|
|
107
|
+
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
108
|
+
this.connectedAt = this.now();
|
|
109
|
+
this.lastInboundAt = this.connectedAt;
|
|
110
|
+
this.reconnectAttempt = 0;
|
|
111
|
+
this.startHeartbeat();
|
|
112
|
+
|
|
113
|
+
if (!this.hasConnected) {
|
|
114
|
+
this.logger.info?.("remote relay connected");
|
|
115
|
+
} else if (this.outageStartedAt > 0) {
|
|
116
|
+
const outageMs = Math.max(0, this.connectedAt - this.outageStartedAt);
|
|
117
|
+
if (this.outageNoticeEmitted) {
|
|
118
|
+
this.logger.info?.("remote relay connection restored", {
|
|
119
|
+
outage_seconds: roundSeconds(outageMs),
|
|
120
|
+
attempts: this.outageAttempts,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
this.logger.debug?.("remote relay connection recovered after a brief interruption", {
|
|
124
|
+
outage_ms: outageMs,
|
|
125
|
+
attempts: this.outageAttempts,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.hasConnected = true;
|
|
131
|
+
this.resetOutage();
|
|
132
|
+
if (this.connectedOnceResolve) {
|
|
133
|
+
this.connectedOnceResolve(true);
|
|
134
|
+
this.connectedOnceResolve = null;
|
|
135
|
+
this.connectedOnceReject = null;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
connect() {
|
|
141
|
+
if (this.closed || this.socket) return;
|
|
142
|
+
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
143
|
+
this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl), attempt: this.reconnectAttempt + 1 });
|
|
144
|
+
let socket;
|
|
145
|
+
try {
|
|
146
|
+
socket = new this.WebSocketClass(wsUrl, {
|
|
147
|
+
headers: { "X-Bridge-Token": this.secret },
|
|
148
|
+
maxPayload: this.maxPayload,
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
this.lastTransportErrorClass = classifyOperationalError(error);
|
|
152
|
+
this.logger.debug?.("remote relay connection could not be created", { error_class: this.lastTransportErrorClass });
|
|
153
|
+
this.scheduleReconnect("connection_interrupted");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.socket = socket;
|
|
157
|
+
|
|
158
|
+
socket.on("open", () => {
|
|
159
|
+
if (this.socket !== socket || this.closed) {
|
|
160
|
+
try { socket.close(1000, "stale daemon connection"); } catch {}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.lastInboundAt = this.now();
|
|
164
|
+
this.logger.debug?.("remote relay transport opened; awaiting authentication acknowledgement");
|
|
165
|
+
if (!this.sendOnSocket(socket, this.helloMessage())) return;
|
|
166
|
+
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
167
|
+
this.handshakeTimer = this.scheduler.setTimeout(() => {
|
|
168
|
+
if (this.socket !== socket || this.closed || this.ready) return;
|
|
169
|
+
this.logger.debug?.("remote relay authentication acknowledgement timed out", { timeout_ms: this.handshakeTimeoutMs });
|
|
170
|
+
this.pendingCloseCategory = "relay_handshake_timeout";
|
|
171
|
+
terminateSocket(socket);
|
|
172
|
+
}, this.handshakeTimeoutMs);
|
|
173
|
+
this.handshakeTimer?.unref?.();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
socket.on("message", (data) => {
|
|
177
|
+
if (this.socket !== socket || this.closed) return;
|
|
178
|
+
this.lastInboundAt = this.now();
|
|
179
|
+
try {
|
|
180
|
+
const outcome = this.onMessage(data);
|
|
181
|
+
if (outcome && typeof outcome.catch === "function") {
|
|
182
|
+
outcome.catch((error) => this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) }));
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
socket.on("close", (code, reason) => {
|
|
190
|
+
if (this.socket !== socket) return;
|
|
191
|
+
const wasReady = this.ready;
|
|
192
|
+
this.socket = null;
|
|
193
|
+
this.ready = false;
|
|
194
|
+
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
195
|
+
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
196
|
+
const reasonText = sanitizeCloseReason(reason);
|
|
197
|
+
const category = this.pendingCloseCategory || relayCloseCategory(code, reasonText);
|
|
198
|
+
this.pendingCloseCategory = "";
|
|
199
|
+
const connectedForMs = wasReady && this.connectedAt > 0 ? Math.max(0, this.now() - this.connectedAt) : 0;
|
|
200
|
+
this.logger.debug?.("remote relay transport closed", {
|
|
201
|
+
close_code: Number(code) || 0,
|
|
202
|
+
close_reason: reasonText || "<none>",
|
|
203
|
+
category,
|
|
204
|
+
ready: wasReady,
|
|
205
|
+
connected_for_ms: connectedForMs,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (category === "relay_policy_rejected") {
|
|
209
|
+
this.failPermanently("relay_authentication_failed", { socketAlreadyClosed: true, wasReady });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (wasReady) {
|
|
214
|
+
try { this.onDisconnect(); } catch (error) {
|
|
215
|
+
this.logger.error?.("relay disconnect callback failed", { error_class: classifyOperationalError(error) });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (isSupersededClose(code, reasonText)) {
|
|
220
|
+
this.closed = true;
|
|
221
|
+
this.clearTimer("reconnectTimer", "clearTimeout");
|
|
222
|
+
this.clearTimer("outageWarnTimer", "clearTimeout");
|
|
223
|
+
this.logger.warn?.("daemon connection was replaced by a newer authenticated instance");
|
|
224
|
+
queueMicrotask(() => {
|
|
225
|
+
try { this.onSuperseded(); } catch (error) {
|
|
226
|
+
this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (this.closed) return;
|
|
233
|
+
this.scheduleReconnect(category);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
socket.on("error", (error) => {
|
|
237
|
+
if (this.socket !== socket || this.closed) return;
|
|
238
|
+
this.lastTransportErrorClass = classifyOperationalError(error);
|
|
239
|
+
this.logger.debug?.("remote relay transport error", { error_class: this.lastTransportErrorClass });
|
|
240
|
+
if (this.lastTransportErrorClass === "authentication_failed") {
|
|
241
|
+
this.failPermanently("relay_authentication_failed");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.pendingCloseCategory = "relay_transport_error";
|
|
245
|
+
terminateSocket(socket);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
failPermanently(category, { socketAlreadyClosed = false, wasReady = this.ready } = {}) {
|
|
250
|
+
if (this.closed) return;
|
|
251
|
+
const socket = this.socket;
|
|
252
|
+
this.closed = true;
|
|
253
|
+
this.ready = false;
|
|
254
|
+
this.socket = null;
|
|
255
|
+
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
256
|
+
this.clearTimer("handshakeTimer", "clearTimeout");
|
|
257
|
+
this.clearTimer("reconnectTimer", "clearTimeout");
|
|
258
|
+
this.clearTimer("outageWarnTimer", "clearTimeout");
|
|
259
|
+
if (wasReady) {
|
|
260
|
+
try { this.onDisconnect(); } catch (error) {
|
|
261
|
+
this.logger.error?.("relay disconnect callback failed", { error_class: classifyOperationalError(error) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!socketAlreadyClosed) terminateSocket(socket);
|
|
265
|
+
const message = relayFatalMessage(category);
|
|
266
|
+
const error = new Error(message);
|
|
267
|
+
error.code = category;
|
|
268
|
+
const reject = this.connectedOnceReject;
|
|
269
|
+
this.connectedOnceResolve = null;
|
|
270
|
+
this.connectedOnceReject = null;
|
|
271
|
+
this.resetOutage();
|
|
272
|
+
if (!this.hasConnected && reject) {
|
|
273
|
+
reject(error);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.logger.error?.(message, { cause: relayCloseUserCause(category) });
|
|
277
|
+
queueMicrotask(() => {
|
|
278
|
+
try { this.onFatal(error); } catch (callbackError) {
|
|
279
|
+
this.logger.error?.("relay fatal callback failed", { error_class: classifyOperationalError(callbackError) });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
scheduleReconnect(category) {
|
|
285
|
+
if (this.closed || this.reconnectTimer) return;
|
|
286
|
+
this.recordOutage(category);
|
|
287
|
+
const delay = this.reconnectDelay(this.reconnectAttempt++);
|
|
288
|
+
this.scheduleOutageWarning();
|
|
289
|
+
this.maybeRepeatOutageWarning();
|
|
290
|
+
this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay, attempt: this.outageAttempts });
|
|
291
|
+
this.reconnectTimer = this.scheduler.setTimeout(() => {
|
|
292
|
+
this.reconnectTimer = null;
|
|
293
|
+
this.connect();
|
|
294
|
+
}, delay);
|
|
295
|
+
this.reconnectTimer?.unref?.();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
sendOnSocket(socket, value) {
|
|
299
|
+
if (!this.isSocketOpen(socket)) return false;
|
|
300
|
+
try {
|
|
301
|
+
socket.send(JSON.stringify(value));
|
|
302
|
+
return true;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
this.lastTransportErrorClass = classifyOperationalError(error);
|
|
305
|
+
this.pendingCloseCategory = "relay_transport_error";
|
|
306
|
+
this.logger.debug?.("remote relay send failed", { error_class: this.lastTransportErrorClass });
|
|
307
|
+
terminateSocket(socket);
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
startHeartbeat() {
|
|
313
|
+
this.clearTimer("heartbeatTimer", "clearInterval");
|
|
314
|
+
this.heartbeatTimer = this.scheduler.setInterval(() => {
|
|
315
|
+
const socket = this.socket;
|
|
316
|
+
if (this.closed || !this.ready || !this.isSocketOpen(socket)) return;
|
|
317
|
+
const silentForMs = Math.max(0, this.now() - this.lastInboundAt);
|
|
318
|
+
if (silentForMs >= this.heartbeatTimeoutMs) {
|
|
319
|
+
this.logger.debug?.("remote relay heartbeat timed out", { silent_for_ms: silentForMs });
|
|
320
|
+
this.pendingCloseCategory = "relay_heartbeat_timeout";
|
|
321
|
+
terminateSocket(socket);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.sendOnSocket(socket, { type: "heartbeat", ts: this.now() });
|
|
325
|
+
}, this.heartbeatIntervalMs);
|
|
326
|
+
this.heartbeatTimer?.unref?.();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
recordOutage(category) {
|
|
330
|
+
const now = this.now();
|
|
331
|
+
if (this.outageStartedAt === 0) {
|
|
332
|
+
this.outageStartedAt = now;
|
|
333
|
+
this.outageAttempts = 0;
|
|
334
|
+
this.outageNoticeEmitted = false;
|
|
335
|
+
this.lastOutageWarnAt = 0;
|
|
336
|
+
}
|
|
337
|
+
this.outageAttempts += 1;
|
|
338
|
+
this.lastCloseCategory = category;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
scheduleOutageWarning() {
|
|
342
|
+
if (this.outageNoticeEmitted || this.outageWarnTimer || this.outageStartedAt === 0) return;
|
|
343
|
+
const elapsed = Math.max(0, this.now() - this.outageStartedAt);
|
|
344
|
+
const delay = Math.max(0, this.outageWarnAfterMs - elapsed);
|
|
345
|
+
this.outageWarnTimer = this.scheduler.setTimeout(() => {
|
|
346
|
+
this.outageWarnTimer = null;
|
|
347
|
+
if (this.closed || this.ready || this.outageStartedAt === 0) return;
|
|
348
|
+
this.emitOutageWarning();
|
|
349
|
+
}, delay);
|
|
350
|
+
this.outageWarnTimer?.unref?.();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
maybeRepeatOutageWarning() {
|
|
354
|
+
if (!this.outageNoticeEmitted || this.lastOutageWarnAt === 0) return;
|
|
355
|
+
if (this.now() - this.lastOutageWarnAt < this.outageWarnRepeatMs) return;
|
|
356
|
+
this.emitOutageWarning();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
emitOutageWarning() {
|
|
360
|
+
const outageMs = Math.max(0, this.now() - this.outageStartedAt);
|
|
361
|
+
this.outageNoticeEmitted = true;
|
|
362
|
+
this.lastOutageWarnAt = this.now();
|
|
363
|
+
this.logger.warn?.(relayOutageWarningMessage(), {
|
|
364
|
+
outage_seconds: roundSeconds(outageMs),
|
|
365
|
+
attempts: this.outageAttempts,
|
|
366
|
+
cause: relayCloseUserCause(this.lastCloseCategory),
|
|
367
|
+
...(this.lastTransportErrorClass ? { error_class: this.lastTransportErrorClass } : {}),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
resetOutage() {
|
|
372
|
+
this.clearTimer("outageWarnTimer", "clearTimeout");
|
|
373
|
+
this.outageStartedAt = 0;
|
|
374
|
+
this.outageAttempts = 0;
|
|
375
|
+
this.outageNoticeEmitted = false;
|
|
376
|
+
this.lastOutageWarnAt = 0;
|
|
377
|
+
this.lastCloseCategory = "connection_interrupted";
|
|
378
|
+
this.lastTransportErrorClass = "";
|
|
379
|
+
this.pendingCloseCategory = "";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
clearTimer(property, method) {
|
|
383
|
+
const timer = this[property];
|
|
384
|
+
if (!timer) return;
|
|
385
|
+
this.scheduler[method](timer);
|
|
386
|
+
this[property] = null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
isSocketOpen(socket) {
|
|
390
|
+
return Boolean(socket && socket.readyState === this.WebSocketClass.OPEN);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function relayCloseCategory(code, reason = "") {
|
|
395
|
+
const numeric = Number(code);
|
|
396
|
+
if (isSupersededClose(numeric, reason)) return "superseded";
|
|
397
|
+
if (numeric === 1000) return "normal_close";
|
|
398
|
+
if (numeric === 1001 || numeric === 1012 || numeric === 1013) return "relay_restarting_or_unavailable";
|
|
399
|
+
if (numeric === 1006) return "connection_interrupted";
|
|
400
|
+
if (numeric === 1007) return "invalid_transport_payload";
|
|
401
|
+
if (numeric === 1008) return "relay_policy_rejected";
|
|
402
|
+
if (numeric === 1009) return "message_too_large";
|
|
403
|
+
if (numeric === 1011) return "relay_internal_error";
|
|
404
|
+
return "unexpected_close";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function relayFatalMessage(category) {
|
|
408
|
+
if (category === "relay_protocol_mismatch") {
|
|
409
|
+
return "remote relay identity or version does not match this daemon; upgrade and redeploy both components";
|
|
410
|
+
}
|
|
411
|
+
return "remote relay rejected the daemon connection; verify credentials or redeploy the Worker";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function relayOutageWarningMessage() {
|
|
415
|
+
return "remote relay is unavailable; automatic reconnection is still in progress";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function relayCloseUserCause(category) {
|
|
419
|
+
const causes = {
|
|
420
|
+
connection_interrupted: "connection interrupted",
|
|
421
|
+
relay_restarting_or_unavailable: "relay restarting or temporarily unavailable",
|
|
422
|
+
relay_policy_rejected: "relay rejected the connection",
|
|
423
|
+
relay_internal_error: "relay internal error",
|
|
424
|
+
relay_protocol_mismatch: "relay identity or version mismatch",
|
|
425
|
+
relay_authentication_failed: "relay authentication failed",
|
|
426
|
+
relay_handshake_timeout: "relay authentication acknowledgement timed out",
|
|
427
|
+
relay_heartbeat_timeout: "relay stopped responding",
|
|
428
|
+
relay_transport_error: "relay transport error",
|
|
429
|
+
invalid_transport_payload: "invalid transport payload",
|
|
430
|
+
message_too_large: "message exceeded the relay limit",
|
|
431
|
+
normal_close: "connection closed",
|
|
432
|
+
unexpected_close: "unexpected connection close",
|
|
433
|
+
superseded: "connection superseded",
|
|
434
|
+
};
|
|
435
|
+
return causes[String(category || "")] || "connection interrupted";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function acknowledgementMismatch(message, expectedServer = "", expectedVersion = "") {
|
|
439
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return "invalid_acknowledgement";
|
|
440
|
+
if (message.type !== "hello_ack") return "unexpected_acknowledgement_type";
|
|
441
|
+
if (expectedServer && message.server !== expectedServer) return "server_identity_mismatch";
|
|
442
|
+
if (expectedVersion && message.version !== expectedVersion) return "server_version_mismatch";
|
|
443
|
+
if (typeof message.server !== "string" || !message.server || typeof message.version !== "string" || !message.version) return "incomplete_acknowledgement";
|
|
444
|
+
return "";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function isSupersededClose(code, reason) {
|
|
448
|
+
return Number(code) === 1012 && String(reason || "") === "replaced by authenticated daemon";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function reconnectDelay(attempt, random = Math.random) {
|
|
452
|
+
const safeAttempt = Math.max(0, Number.isFinite(Number(attempt)) ? Number(attempt) : 0);
|
|
453
|
+
const base = Math.min(3000 * (2 ** Math.min(safeAttempt, 5)), 60_000);
|
|
454
|
+
return base + Math.floor(random() * 1000);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function normalizeWorkerUrl(value) {
|
|
458
|
+
let url;
|
|
459
|
+
try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
|
|
460
|
+
if (url.protocol !== "https:") throw new Error("Worker URL must use HTTPS");
|
|
461
|
+
if (url.username || url.password) throw new Error("Worker URL must not contain credentials");
|
|
462
|
+
if (url.pathname !== "/" || url.search || url.hash) throw new Error("Worker URL must be an origin without a path, query, or fragment");
|
|
463
|
+
return url.origin;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function sanitizeCloseReason(value) {
|
|
467
|
+
let text;
|
|
468
|
+
try { text = Buffer.isBuffer(value) ? value.toString("utf8") : String(value || ""); } catch { text = ""; }
|
|
469
|
+
return text.replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").trim().slice(0, MAX_CLOSE_REASON_CHARS);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function terminateSocket(socket) {
|
|
473
|
+
try {
|
|
474
|
+
if (typeof socket?.terminate === "function") socket.terminate();
|
|
475
|
+
else socket?.close?.();
|
|
476
|
+
} catch {}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function redactUrl(value) {
|
|
480
|
+
try {
|
|
481
|
+
const url = new URL(String(value));
|
|
482
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
483
|
+
} catch {
|
|
484
|
+
return "<relay-url>";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function boundedPositiveInteger(value, fallback) {
|
|
489
|
+
const number = Number(value);
|
|
490
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function roundSeconds(milliseconds) {
|
|
494
|
+
return Math.max(1, Math.round(milliseconds / 1000));
|
|
495
|
+
}
|