pubblue 0.4.2 → 0.4.4
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/dist/chunk-AIEPM67G.js +659 -0
- package/dist/chunk-MW35LBNH.js +60 -0
- package/dist/index.js +88 -33
- package/dist/tunnel-daemon-DR4A65ME.js +11 -0
- package/dist/tunnel-daemon-entry.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-3RFMAQOM.js +0 -349
- package/dist/chunk-56IKFMJ2.js +0 -40
- package/dist/tunnel-daemon-K7Z7FUFN.js +0 -9
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNELS,
|
|
3
|
+
CONTROL_CHANNEL,
|
|
4
|
+
decodeMessage,
|
|
5
|
+
encodeMessage,
|
|
6
|
+
makeAckMessage,
|
|
7
|
+
parseAckMessage,
|
|
8
|
+
shouldAcknowledgeMessage
|
|
9
|
+
} from "./chunk-MW35LBNH.js";
|
|
10
|
+
|
|
11
|
+
// src/lib/tunnel-daemon.ts
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as net from "net";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
// src/lib/ack-routing.ts
|
|
17
|
+
function resolveAckChannel(input) {
|
|
18
|
+
if (input.messageChannelOpen) return input.messageChannel;
|
|
19
|
+
if (input.controlChannelOpen) return CONTROL_CHANNEL;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/lib/tunnel-daemon.ts
|
|
24
|
+
var OFFER_TIMEOUT_MS = 1e4;
|
|
25
|
+
var SIGNAL_POLL_WAITING_MS = 500;
|
|
26
|
+
var SIGNAL_POLL_CONNECTED_MS = 2e3;
|
|
27
|
+
var RECOVERY_DELAY_MS = 1e3;
|
|
28
|
+
var WRITE_ACK_TIMEOUT_MS = 5e3;
|
|
29
|
+
var NOT_CONNECTED_WRITE_ERROR = "No browser connected. Ask the user to open the tunnel URL first, then retry.";
|
|
30
|
+
function getTunnelWriteReadinessError(isConnected) {
|
|
31
|
+
return isConnected ? null : NOT_CONNECTED_WRITE_ERROR;
|
|
32
|
+
}
|
|
33
|
+
function shouldRecoverForBrowserAnswerChange(params) {
|
|
34
|
+
const { incomingBrowserAnswer, lastAppliedBrowserAnswer, remoteDescriptionApplied } = params;
|
|
35
|
+
if (!remoteDescriptionApplied) return false;
|
|
36
|
+
if (!incomingBrowserAnswer) return false;
|
|
37
|
+
return incomingBrowserAnswer !== lastAppliedBrowserAnswer;
|
|
38
|
+
}
|
|
39
|
+
async function startDaemon(config) {
|
|
40
|
+
const { tunnelId, apiClient, socketPath, infoPath } = config;
|
|
41
|
+
const ndc = await import("node-datachannel");
|
|
42
|
+
const buffer = { messages: [] };
|
|
43
|
+
const startTime = Date.now();
|
|
44
|
+
let stopped = false;
|
|
45
|
+
let connected = false;
|
|
46
|
+
let recovering = false;
|
|
47
|
+
let remoteDescriptionApplied = false;
|
|
48
|
+
let lastAppliedBrowserAnswer = null;
|
|
49
|
+
let lastBrowserCandidateCount = 0;
|
|
50
|
+
let lastSentCandidateCount = 0;
|
|
51
|
+
const pendingRemoteCandidates = [];
|
|
52
|
+
const localCandidates = [];
|
|
53
|
+
const stickyOutboundByChannel = /* @__PURE__ */ new Map();
|
|
54
|
+
const pendingOutboundAcks = /* @__PURE__ */ new Map();
|
|
55
|
+
const pendingDeliveryAcks = /* @__PURE__ */ new Map();
|
|
56
|
+
let peer = null;
|
|
57
|
+
let channels = /* @__PURE__ */ new Map();
|
|
58
|
+
let pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
|
|
59
|
+
let pollingTimer = null;
|
|
60
|
+
let localCandidateInterval = null;
|
|
61
|
+
let localCandidateStopTimer = null;
|
|
62
|
+
let recoveryTimer = null;
|
|
63
|
+
let lastError = null;
|
|
64
|
+
const debugEnabled = process.env.PUBBLUE_TUNNEL_DEBUG === "1";
|
|
65
|
+
function debugLog(message, error) {
|
|
66
|
+
if (!debugEnabled) return;
|
|
67
|
+
const detail = error === void 0 ? "" : ` | ${error instanceof Error ? `${error.name}: ${error.message}` : typeof error === "string" ? error : JSON.stringify(error)}`;
|
|
68
|
+
console.error(`[pubblue-daemon:${tunnelId}] ${message}${detail}`);
|
|
69
|
+
}
|
|
70
|
+
function markError(message, error) {
|
|
71
|
+
const detail = error === void 0 ? message : `${message}: ${error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error)}`;
|
|
72
|
+
lastError = detail;
|
|
73
|
+
debugLog(message, error);
|
|
74
|
+
}
|
|
75
|
+
function clearPollingTimer() {
|
|
76
|
+
if (pollingTimer) {
|
|
77
|
+
clearTimeout(pollingTimer);
|
|
78
|
+
pollingTimer = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function clearLocalCandidateTimers() {
|
|
82
|
+
if (localCandidateInterval) {
|
|
83
|
+
clearInterval(localCandidateInterval);
|
|
84
|
+
localCandidateInterval = null;
|
|
85
|
+
}
|
|
86
|
+
if (localCandidateStopTimer) {
|
|
87
|
+
clearTimeout(localCandidateStopTimer);
|
|
88
|
+
localCandidateStopTimer = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function clearRecoveryTimer() {
|
|
92
|
+
if (recoveryTimer) {
|
|
93
|
+
clearTimeout(recoveryTimer);
|
|
94
|
+
recoveryTimer = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function setupChannel(name, dc) {
|
|
98
|
+
channels.set(name, dc);
|
|
99
|
+
dc.onOpen(() => {
|
|
100
|
+
if (name === CONTROL_CHANNEL) flushQueuedAcks();
|
|
101
|
+
});
|
|
102
|
+
dc.onMessage((data) => {
|
|
103
|
+
if (typeof data === "string") {
|
|
104
|
+
const msg = decodeMessage(data);
|
|
105
|
+
if (!msg) return;
|
|
106
|
+
const ack = parseAckMessage(msg);
|
|
107
|
+
if (ack) {
|
|
108
|
+
settlePendingAck(ack.messageId, true);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (msg.type === "binary" && !msg.data) {
|
|
112
|
+
pendingInboundBinaryMeta.set(name, msg);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (shouldAcknowledgeMessage(name, msg)) {
|
|
116
|
+
queueAck(msg.id, name);
|
|
117
|
+
}
|
|
118
|
+
buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const pendingMeta = pendingInboundBinaryMeta.get(name);
|
|
122
|
+
if (pendingMeta) pendingInboundBinaryMeta.delete(name);
|
|
123
|
+
const binMsg = pendingMeta ? {
|
|
124
|
+
id: pendingMeta.id,
|
|
125
|
+
type: "binary",
|
|
126
|
+
data: data.toString("base64"),
|
|
127
|
+
meta: { ...pendingMeta.meta, size: data.length }
|
|
128
|
+
} : {
|
|
129
|
+
id: `bin-${Date.now()}`,
|
|
130
|
+
type: "binary",
|
|
131
|
+
data: data.toString("base64"),
|
|
132
|
+
meta: { size: data.length }
|
|
133
|
+
};
|
|
134
|
+
if (shouldAcknowledgeMessage(name, binMsg)) {
|
|
135
|
+
queueAck(binMsg.id, name);
|
|
136
|
+
}
|
|
137
|
+
buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function getAckKey(messageId, channel) {
|
|
141
|
+
return `${channel}:${messageId}`;
|
|
142
|
+
}
|
|
143
|
+
function queueAck(messageId, channel) {
|
|
144
|
+
pendingOutboundAcks.set(getAckKey(messageId, channel), { messageId, channel });
|
|
145
|
+
flushQueuedAcks();
|
|
146
|
+
}
|
|
147
|
+
function flushQueuedAcks() {
|
|
148
|
+
const controlDc = channels.get(CONTROL_CHANNEL);
|
|
149
|
+
for (const [ackKey, ack] of pendingOutboundAcks) {
|
|
150
|
+
const messageDc = channels.get(ack.channel);
|
|
151
|
+
const targetChannel = resolveAckChannel({
|
|
152
|
+
controlChannelOpen: Boolean(controlDc?.isOpen()),
|
|
153
|
+
messageChannelOpen: Boolean(messageDc?.isOpen()),
|
|
154
|
+
messageChannel: ack.channel
|
|
155
|
+
});
|
|
156
|
+
if (!targetChannel) continue;
|
|
157
|
+
const encodedAck = encodeMessage(makeAckMessage(ack.messageId, ack.channel));
|
|
158
|
+
const primaryDc = targetChannel === CONTROL_CHANNEL ? controlDc : messageDc;
|
|
159
|
+
try {
|
|
160
|
+
if (primaryDc?.isOpen()) {
|
|
161
|
+
primaryDc.sendMessage(encodedAck);
|
|
162
|
+
pendingOutboundAcks.delete(ackKey);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
markError("failed to flush queued ack on primary channel", error);
|
|
167
|
+
}
|
|
168
|
+
const fallbackChannel = targetChannel === ack.channel ? CONTROL_CHANNEL : ack.channel;
|
|
169
|
+
const fallbackDc = fallbackChannel === CONTROL_CHANNEL ? controlDc : messageDc;
|
|
170
|
+
try {
|
|
171
|
+
if (fallbackDc?.isOpen()) {
|
|
172
|
+
fallbackDc.sendMessage(encodedAck);
|
|
173
|
+
pendingOutboundAcks.delete(ackKey);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
markError("failed to flush queued ack on fallback channel", error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function waitForDeliveryAck(messageId, timeoutMs) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const timeout = setTimeout(() => {
|
|
183
|
+
pendingDeliveryAcks.delete(messageId);
|
|
184
|
+
resolve(false);
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
pendingDeliveryAcks.set(messageId, { resolve, timeout });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function settlePendingAck(messageId, received) {
|
|
190
|
+
const pending = pendingDeliveryAcks.get(messageId);
|
|
191
|
+
if (!pending) return;
|
|
192
|
+
clearTimeout(pending.timeout);
|
|
193
|
+
pendingDeliveryAcks.delete(messageId);
|
|
194
|
+
pending.resolve(received);
|
|
195
|
+
}
|
|
196
|
+
function failPendingAcks() {
|
|
197
|
+
for (const [messageId, pending] of pendingDeliveryAcks) {
|
|
198
|
+
clearTimeout(pending.timeout);
|
|
199
|
+
pending.resolve(false);
|
|
200
|
+
pendingDeliveryAcks.delete(messageId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function openDataChannel(name) {
|
|
204
|
+
if (!peer) throw new Error("PeerConnection not initialized");
|
|
205
|
+
const existing = channels.get(name);
|
|
206
|
+
if (existing) return existing;
|
|
207
|
+
const dc = peer.createDataChannel(name, { ordered: true });
|
|
208
|
+
setupChannel(name, dc);
|
|
209
|
+
return dc;
|
|
210
|
+
}
|
|
211
|
+
async function waitForChannelOpen(dc, timeoutMs = 5e3) {
|
|
212
|
+
if (dc.isOpen()) return;
|
|
213
|
+
await new Promise((resolve, reject) => {
|
|
214
|
+
let settled = false;
|
|
215
|
+
const timeout = setTimeout(() => {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
reject(new Error("DataChannel open timed out"));
|
|
219
|
+
}, timeoutMs);
|
|
220
|
+
dc.onOpen(() => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
settled = true;
|
|
223
|
+
clearTimeout(timeout);
|
|
224
|
+
resolve();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function maybePersistStickyOutbound(channel, msg, binaryPayload) {
|
|
229
|
+
if (channel !== CHANNELS.CANVAS) return;
|
|
230
|
+
if (msg.type === "event" && msg.data === "hide") {
|
|
231
|
+
stickyOutboundByChannel.delete(channel);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (msg.type !== "html") return;
|
|
235
|
+
stickyOutboundByChannel.set(channel, {
|
|
236
|
+
msg: {
|
|
237
|
+
...msg,
|
|
238
|
+
meta: msg.meta ? { ...msg.meta } : void 0
|
|
239
|
+
},
|
|
240
|
+
binaryPayload
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async function replayStickyOutboundMessages() {
|
|
244
|
+
if (!connected || recovering || stopped) return;
|
|
245
|
+
for (const [channel, sticky] of stickyOutboundByChannel) {
|
|
246
|
+
try {
|
|
247
|
+
let targetDc = channels.get(channel);
|
|
248
|
+
if (!targetDc) targetDc = openDataChannel(channel);
|
|
249
|
+
await waitForChannelOpen(targetDc, 3e3);
|
|
250
|
+
if (sticky.msg.type === "binary" && sticky.binaryPayload) {
|
|
251
|
+
targetDc.sendMessage(
|
|
252
|
+
encodeMessage({
|
|
253
|
+
...sticky.msg,
|
|
254
|
+
meta: {
|
|
255
|
+
...sticky.msg.meta || {},
|
|
256
|
+
size: sticky.binaryPayload.length
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
);
|
|
260
|
+
targetDc.sendMessageBinary(sticky.binaryPayload);
|
|
261
|
+
} else {
|
|
262
|
+
targetDc.sendMessage(encodeMessage(sticky.msg));
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
debugLog(`sticky outbound replay failed for channel ${channel}`, error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function resetNegotiationState() {
|
|
270
|
+
connected = false;
|
|
271
|
+
failPendingAcks();
|
|
272
|
+
remoteDescriptionApplied = false;
|
|
273
|
+
lastAppliedBrowserAnswer = null;
|
|
274
|
+
lastBrowserCandidateCount = 0;
|
|
275
|
+
lastSentCandidateCount = 0;
|
|
276
|
+
pendingRemoteCandidates.length = 0;
|
|
277
|
+
localCandidates.length = 0;
|
|
278
|
+
clearLocalCandidateTimers();
|
|
279
|
+
}
|
|
280
|
+
function startLocalCandidateFlush() {
|
|
281
|
+
clearLocalCandidateTimers();
|
|
282
|
+
localCandidateInterval = setInterval(async () => {
|
|
283
|
+
if (localCandidates.length <= lastSentCandidateCount) return;
|
|
284
|
+
const newOnes = localCandidates.slice(lastSentCandidateCount);
|
|
285
|
+
lastSentCandidateCount = localCandidates.length;
|
|
286
|
+
await apiClient.signal(tunnelId, { candidates: newOnes }).catch((error) => {
|
|
287
|
+
debugLog("failed to publish local ICE candidates", error);
|
|
288
|
+
});
|
|
289
|
+
}, 500);
|
|
290
|
+
localCandidateStopTimer = setTimeout(() => {
|
|
291
|
+
clearLocalCandidateTimers();
|
|
292
|
+
}, 3e4);
|
|
293
|
+
}
|
|
294
|
+
function attachPeerHandlers(currentPeer) {
|
|
295
|
+
currentPeer.onLocalCandidate((candidate, mid) => {
|
|
296
|
+
if (stopped || currentPeer !== peer) return;
|
|
297
|
+
localCandidates.push(JSON.stringify({ candidate, sdpMid: mid }));
|
|
298
|
+
});
|
|
299
|
+
currentPeer.onStateChange((state) => {
|
|
300
|
+
if (stopped || currentPeer !== peer) return;
|
|
301
|
+
if (state === "connected") {
|
|
302
|
+
connected = true;
|
|
303
|
+
flushQueuedAcks();
|
|
304
|
+
void replayStickyOutboundMessages();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (state === "disconnected" || state === "failed" || state === "closed") {
|
|
308
|
+
connected = false;
|
|
309
|
+
scheduleRecovery();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
currentPeer.onDataChannel((dc) => {
|
|
313
|
+
if (stopped || currentPeer !== peer) return;
|
|
314
|
+
setupChannel(dc.getLabel(), dc);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function createPeer() {
|
|
318
|
+
const nextPeer = new ndc.PeerConnection("agent", {
|
|
319
|
+
iceServers: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
|
|
320
|
+
});
|
|
321
|
+
peer = nextPeer;
|
|
322
|
+
channels = /* @__PURE__ */ new Map();
|
|
323
|
+
pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
|
|
324
|
+
attachPeerHandlers(nextPeer);
|
|
325
|
+
openDataChannel(CONTROL_CHANNEL);
|
|
326
|
+
openDataChannel(CHANNELS.CHAT);
|
|
327
|
+
openDataChannel(CHANNELS.CANVAS);
|
|
328
|
+
}
|
|
329
|
+
function closeCurrentPeer() {
|
|
330
|
+
failPendingAcks();
|
|
331
|
+
for (const dc of channels.values()) {
|
|
332
|
+
try {
|
|
333
|
+
dc.close();
|
|
334
|
+
} catch (error) {
|
|
335
|
+
debugLog("failed to close data channel cleanly", error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
channels.clear();
|
|
339
|
+
pendingInboundBinaryMeta.clear();
|
|
340
|
+
if (peer) {
|
|
341
|
+
try {
|
|
342
|
+
peer.close();
|
|
343
|
+
} catch (error) {
|
|
344
|
+
debugLog("failed to close peer connection cleanly", error);
|
|
345
|
+
}
|
|
346
|
+
peer = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function scheduleNextPoll(delayMs) {
|
|
350
|
+
if (stopped) return;
|
|
351
|
+
clearPollingTimer();
|
|
352
|
+
pollingTimer = setTimeout(() => {
|
|
353
|
+
void runPollingLoop();
|
|
354
|
+
}, delayMs);
|
|
355
|
+
}
|
|
356
|
+
async function pollSignalingOnce() {
|
|
357
|
+
const tunnel = await apiClient.get(tunnelId);
|
|
358
|
+
if (shouldRecoverForBrowserAnswerChange({
|
|
359
|
+
incomingBrowserAnswer: tunnel.browserAnswer,
|
|
360
|
+
lastAppliedBrowserAnswer,
|
|
361
|
+
remoteDescriptionApplied
|
|
362
|
+
})) {
|
|
363
|
+
connected = false;
|
|
364
|
+
scheduleRecovery(0, true);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (tunnel.browserAnswer && !remoteDescriptionApplied) {
|
|
368
|
+
if (!peer) return;
|
|
369
|
+
try {
|
|
370
|
+
const answer = JSON.parse(tunnel.browserAnswer);
|
|
371
|
+
peer.setRemoteDescription(answer.sdp, answer.type);
|
|
372
|
+
remoteDescriptionApplied = true;
|
|
373
|
+
lastAppliedBrowserAnswer = tunnel.browserAnswer;
|
|
374
|
+
while (pendingRemoteCandidates.length > 0) {
|
|
375
|
+
const next = pendingRemoteCandidates.shift();
|
|
376
|
+
if (!next) break;
|
|
377
|
+
try {
|
|
378
|
+
peer.addRemoteCandidate(next.candidate, next.sdpMid);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
debugLog("failed to apply queued remote ICE candidate", error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
markError("failed to apply browser answer", error);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
|
|
388
|
+
const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
|
|
389
|
+
lastBrowserCandidateCount = tunnel.browserCandidates.length;
|
|
390
|
+
for (const c of newCandidates) {
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(c);
|
|
393
|
+
if (typeof parsed.candidate !== "string") continue;
|
|
394
|
+
const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
|
|
395
|
+
if (!remoteDescriptionApplied) {
|
|
396
|
+
pendingRemoteCandidates.push({ candidate: parsed.candidate, sdpMid });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (!peer) continue;
|
|
400
|
+
peer.addRemoteCandidate(parsed.candidate, sdpMid);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
debugLog("failed to parse/apply browser ICE candidate", error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function runPollingLoop() {
|
|
408
|
+
if (stopped) return;
|
|
409
|
+
try {
|
|
410
|
+
await pollSignalingOnce();
|
|
411
|
+
} catch (error) {
|
|
412
|
+
markError("signaling poll failed", error);
|
|
413
|
+
}
|
|
414
|
+
scheduleNextPoll(remoteDescriptionApplied ? SIGNAL_POLL_CONNECTED_MS : SIGNAL_POLL_WAITING_MS);
|
|
415
|
+
}
|
|
416
|
+
async function runNegotiationCycle() {
|
|
417
|
+
if (!peer) throw new Error("PeerConnection not initialized");
|
|
418
|
+
resetNegotiationState();
|
|
419
|
+
const offer = await generateOffer(peer, OFFER_TIMEOUT_MS);
|
|
420
|
+
await apiClient.signal(tunnelId, { offer });
|
|
421
|
+
startLocalCandidateFlush();
|
|
422
|
+
}
|
|
423
|
+
async function recoverPeer() {
|
|
424
|
+
if (stopped || recovering) return;
|
|
425
|
+
recovering = true;
|
|
426
|
+
try {
|
|
427
|
+
closeCurrentPeer();
|
|
428
|
+
createPeer();
|
|
429
|
+
await runNegotiationCycle();
|
|
430
|
+
} finally {
|
|
431
|
+
recovering = false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function scheduleRecovery(delayMs = RECOVERY_DELAY_MS, force = false) {
|
|
435
|
+
if (stopped || recovering || recoveryTimer) return;
|
|
436
|
+
recoveryTimer = setTimeout(() => {
|
|
437
|
+
recoveryTimer = null;
|
|
438
|
+
if (stopped || !force && connected) return;
|
|
439
|
+
void recoverPeer().catch((error) => {
|
|
440
|
+
markError("peer recovery failed", error);
|
|
441
|
+
if (!stopped) scheduleRecovery(delayMs, force);
|
|
442
|
+
});
|
|
443
|
+
}, delayMs);
|
|
444
|
+
}
|
|
445
|
+
if (fs.existsSync(socketPath)) {
|
|
446
|
+
let stale = true;
|
|
447
|
+
try {
|
|
448
|
+
const raw = fs.readFileSync(infoPath, "utf-8");
|
|
449
|
+
const info = JSON.parse(raw);
|
|
450
|
+
process.kill(info.pid, 0);
|
|
451
|
+
stale = false;
|
|
452
|
+
} catch {
|
|
453
|
+
stale = true;
|
|
454
|
+
}
|
|
455
|
+
if (stale) {
|
|
456
|
+
try {
|
|
457
|
+
fs.unlinkSync(socketPath);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
debugLog("failed to remove stale daemon socket", error);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
throw new Error(`Daemon already running (socket: ${socketPath})`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
createPeer();
|
|
466
|
+
const ipcServer = net.createServer((conn) => {
|
|
467
|
+
let data = "";
|
|
468
|
+
conn.on("data", (chunk) => {
|
|
469
|
+
data += chunk.toString();
|
|
470
|
+
const newlineIdx = data.indexOf("\n");
|
|
471
|
+
if (newlineIdx === -1) return;
|
|
472
|
+
const line = data.slice(0, newlineIdx);
|
|
473
|
+
data = data.slice(newlineIdx + 1);
|
|
474
|
+
let request;
|
|
475
|
+
try {
|
|
476
|
+
request = JSON.parse(line);
|
|
477
|
+
} catch {
|
|
478
|
+
conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
|
|
479
|
+
`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
handleIpcRequest(request).then((response) => conn.write(`${JSON.stringify(response)}
|
|
483
|
+
`)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: String(err) })}
|
|
484
|
+
`));
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
ipcServer.listen(socketPath);
|
|
488
|
+
const infoDir = path.dirname(infoPath);
|
|
489
|
+
if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
|
|
490
|
+
fs.writeFileSync(
|
|
491
|
+
infoPath,
|
|
492
|
+
JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime })
|
|
493
|
+
);
|
|
494
|
+
scheduleNextPoll(0);
|
|
495
|
+
try {
|
|
496
|
+
await runNegotiationCycle();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
499
|
+
markError("initial negotiation failed", error);
|
|
500
|
+
await cleanup();
|
|
501
|
+
throw new Error(`Failed to generate WebRTC offer: ${message}`);
|
|
502
|
+
}
|
|
503
|
+
async function cleanup() {
|
|
504
|
+
if (stopped) return;
|
|
505
|
+
stopped = true;
|
|
506
|
+
clearPollingTimer();
|
|
507
|
+
clearLocalCandidateTimers();
|
|
508
|
+
clearRecoveryTimer();
|
|
509
|
+
closeCurrentPeer();
|
|
510
|
+
ipcServer.close();
|
|
511
|
+
try {
|
|
512
|
+
fs.unlinkSync(socketPath);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
debugLog("failed to remove daemon socket during cleanup", error);
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
fs.unlinkSync(infoPath);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
debugLog("failed to remove daemon info file during cleanup", error);
|
|
520
|
+
}
|
|
521
|
+
await apiClient.close(tunnelId).catch((error) => {
|
|
522
|
+
markError("failed to close tunnel on API during cleanup", error);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async function shutdown() {
|
|
526
|
+
await cleanup();
|
|
527
|
+
process.exit(0);
|
|
528
|
+
}
|
|
529
|
+
process.on("SIGTERM", () => {
|
|
530
|
+
void shutdown();
|
|
531
|
+
});
|
|
532
|
+
process.on("SIGINT", () => {
|
|
533
|
+
void shutdown();
|
|
534
|
+
});
|
|
535
|
+
async function handleIpcRequest(req) {
|
|
536
|
+
switch (req.method) {
|
|
537
|
+
case "write": {
|
|
538
|
+
const channel = req.params.channel || CHANNELS.CHAT;
|
|
539
|
+
const readinessError = getTunnelWriteReadinessError(connected);
|
|
540
|
+
if (readinessError) return { ok: false, error: readinessError };
|
|
541
|
+
const msg = req.params.msg;
|
|
542
|
+
const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
|
|
543
|
+
const binaryPayload = msg.type === "binary" && binaryBase64 ? Buffer.from(binaryBase64, "base64") : void 0;
|
|
544
|
+
let targetDc = channels.get(channel);
|
|
545
|
+
if (!targetDc) targetDc = openDataChannel(channel);
|
|
546
|
+
try {
|
|
547
|
+
await waitForChannelOpen(targetDc);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
550
|
+
markError(`channel "${channel}" failed to open`, error);
|
|
551
|
+
return { ok: false, error: `Channel "${channel}" not open: ${message}` };
|
|
552
|
+
}
|
|
553
|
+
const waitForAck = shouldAcknowledgeMessage(channel, msg) ? waitForDeliveryAck(msg.id, WRITE_ACK_TIMEOUT_MS) : null;
|
|
554
|
+
try {
|
|
555
|
+
if (msg.type === "binary" && binaryPayload) {
|
|
556
|
+
targetDc.sendMessage(
|
|
557
|
+
encodeMessage({
|
|
558
|
+
...msg,
|
|
559
|
+
meta: {
|
|
560
|
+
...msg.meta || {},
|
|
561
|
+
size: binaryPayload.length
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
);
|
|
565
|
+
targetDc.sendMessageBinary(binaryPayload);
|
|
566
|
+
} else {
|
|
567
|
+
targetDc.sendMessage(encodeMessage(msg));
|
|
568
|
+
}
|
|
569
|
+
} catch (error) {
|
|
570
|
+
if (waitForAck) settlePendingAck(msg.id, false);
|
|
571
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
572
|
+
markError(`failed to send message on channel "${channel}"`, error);
|
|
573
|
+
return { ok: false, error: `Failed to send on channel "${channel}": ${message}` };
|
|
574
|
+
}
|
|
575
|
+
if (waitForAck) {
|
|
576
|
+
const acked = await waitForAck;
|
|
577
|
+
if (!acked) {
|
|
578
|
+
markError(`delivery ack timeout for message ${msg.id}`);
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
error: `Delivery not confirmed for message ${msg.id} within ${WRITE_ACK_TIMEOUT_MS}ms.`
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
maybePersistStickyOutbound(channel, msg, binaryPayload);
|
|
586
|
+
return { ok: true, delivered: true };
|
|
587
|
+
}
|
|
588
|
+
case "read": {
|
|
589
|
+
const channel = req.params.channel;
|
|
590
|
+
let msgs;
|
|
591
|
+
if (channel) {
|
|
592
|
+
msgs = buffer.messages.filter((m) => m.channel === channel);
|
|
593
|
+
buffer.messages = buffer.messages.filter((m) => m.channel !== channel);
|
|
594
|
+
} else {
|
|
595
|
+
msgs = [...buffer.messages];
|
|
596
|
+
buffer.messages = [];
|
|
597
|
+
}
|
|
598
|
+
return { ok: true, messages: msgs };
|
|
599
|
+
}
|
|
600
|
+
case "channels": {
|
|
601
|
+
const chList = [...channels.keys()].map((name) => ({ name, direction: "bidi" }));
|
|
602
|
+
return { ok: true, channels: chList };
|
|
603
|
+
}
|
|
604
|
+
case "status": {
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
connected,
|
|
608
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
609
|
+
channels: [...channels.keys()],
|
|
610
|
+
bufferedMessages: buffer.messages.length,
|
|
611
|
+
lastError
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
case "close": {
|
|
615
|
+
void shutdown();
|
|
616
|
+
return { ok: true };
|
|
617
|
+
}
|
|
618
|
+
default:
|
|
619
|
+
return { ok: false, error: `Unknown method: ${req.method}` };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function generateOffer(peer, timeoutMs) {
|
|
624
|
+
return new Promise((resolve, reject) => {
|
|
625
|
+
let resolved = false;
|
|
626
|
+
const done = (sdp, type) => {
|
|
627
|
+
if (resolved) return;
|
|
628
|
+
resolved = true;
|
|
629
|
+
clearTimeout(timeout);
|
|
630
|
+
resolve(JSON.stringify({ sdp, type }));
|
|
631
|
+
};
|
|
632
|
+
peer.onLocalDescription((sdp, type) => {
|
|
633
|
+
done(sdp, type);
|
|
634
|
+
});
|
|
635
|
+
peer.onGatheringStateChange((state) => {
|
|
636
|
+
if (state === "complete" && !resolved) {
|
|
637
|
+
const desc = peer.localDescription();
|
|
638
|
+
if (desc) done(desc.sdp, desc.type);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
const timeout = setTimeout(() => {
|
|
642
|
+
if (resolved) return;
|
|
643
|
+
const desc = peer.localDescription();
|
|
644
|
+
if (desc) {
|
|
645
|
+
done(desc.sdp, desc.type);
|
|
646
|
+
} else {
|
|
647
|
+
resolved = true;
|
|
648
|
+
reject(new Error(`Timed out after ${timeoutMs}ms`));
|
|
649
|
+
}
|
|
650
|
+
}, timeoutMs);
|
|
651
|
+
peer.setLocalDescription();
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export {
|
|
656
|
+
getTunnelWriteReadinessError,
|
|
657
|
+
shouldRecoverForBrowserAnswerChange,
|
|
658
|
+
startDaemon
|
|
659
|
+
};
|