dropspace 0.0.0-stage → 0.1.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.
Files changed (67) hide show
  1. package/README.md +17 -2
  2. package/dist/automerge/index.d.ts +108 -0
  3. package/dist/automerge/index.d.ts.map +1 -0
  4. package/dist/automerge/index.js +437 -0
  5. package/dist/automerge/index.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +372 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/client/channel.d.ts +153 -0
  11. package/dist/client/channel.d.ts.map +1 -0
  12. package/dist/client/channel.js +704 -0
  13. package/dist/client/channel.js.map +1 -0
  14. package/dist/client/client.d.ts +376 -0
  15. package/dist/client/client.d.ts.map +1 -0
  16. package/dist/client/client.js +1421 -0
  17. package/dist/client/client.js.map +1 -0
  18. package/dist/client/events.d.ts +14 -0
  19. package/dist/client/events.d.ts.map +1 -0
  20. package/dist/client/events.js +71 -0
  21. package/dist/client/events.js.map +1 -0
  22. package/dist/client/identity.d.ts +89 -0
  23. package/dist/client/identity.d.ts.map +1 -0
  24. package/dist/client/identity.js +274 -0
  25. package/dist/client/identity.js.map +1 -0
  26. package/dist/client/index.d.ts +8 -0
  27. package/dist/client/index.d.ts.map +1 -0
  28. package/dist/client/index.js +8 -0
  29. package/dist/client/index.js.map +1 -0
  30. package/dist/client/peer-connection.d.ts +190 -0
  31. package/dist/client/peer-connection.d.ts.map +1 -0
  32. package/dist/client/peer-connection.js +647 -0
  33. package/dist/client/peer-connection.js.map +1 -0
  34. package/dist/client/peer-selection.d.ts +70 -0
  35. package/dist/client/peer-selection.d.ts.map +1 -0
  36. package/dist/client/peer-selection.js +193 -0
  37. package/dist/client/peer-selection.js.map +1 -0
  38. package/dist/client/storage.d.ts +118 -0
  39. package/dist/client/storage.d.ts.map +1 -0
  40. package/dist/client/storage.js +463 -0
  41. package/dist/client/storage.js.map +1 -0
  42. package/dist/files/index.d.ts +140 -0
  43. package/dist/files/index.d.ts.map +1 -0
  44. package/dist/files/index.js +751 -0
  45. package/dist/files/index.js.map +1 -0
  46. package/dist/files/sha256.d.ts +24 -0
  47. package/dist/files/sha256.d.ts.map +1 -0
  48. package/dist/files/sha256.js +147 -0
  49. package/dist/files/sha256.js.map +1 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +4 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/protocol/envelope.d.ts +61 -0
  55. package/dist/protocol/envelope.d.ts.map +1 -0
  56. package/dist/protocol/envelope.js +268 -0
  57. package/dist/protocol/envelope.js.map +1 -0
  58. package/dist/protocol/framing.d.ts +69 -0
  59. package/dist/protocol/framing.d.ts.map +1 -0
  60. package/dist/protocol/framing.js +145 -0
  61. package/dist/protocol/framing.js.map +1 -0
  62. package/dist/protocol/index.d.ts +3 -0
  63. package/dist/protocol/index.d.ts.map +1 -0
  64. package/dist/protocol/index.js +3 -0
  65. package/dist/protocol/index.js.map +1 -0
  66. package/fixtures/envelopes.json +132 -0
  67. package/package.json +51 -4
@@ -0,0 +1,1421 @@
1
+ import { PROTOCOL_VERSION, parseSignalingMessage, serializeSignalingEnvelope, redactSignalingEnvelopeForLog, decodeWireFrame, encodeWireFrame, WireFrameType, } from "../protocol/index.js";
2
+ import { loadOrCreateDeviceIdentity, formatCanonicalChallenge, } from "./identity.js";
3
+ import { TypedEventEmitter } from "./events.js";
4
+ import { SmartPeerSelector, } from "./peer-selection.js";
5
+ import { ManagedPeerSession, } from "./peer-connection.js";
6
+ export class DropspaceError extends Error {
7
+ code;
8
+ retryable;
9
+ retryAfterMs;
10
+ status;
11
+ constructor(code, message, opts) {
12
+ super(message);
13
+ this.name = "DropspaceError";
14
+ this.code = code;
15
+ this.retryable = opts?.retryable ?? false;
16
+ this.retryAfterMs = opts?.retryAfterMs;
17
+ this.status = opts?.status;
18
+ }
19
+ }
20
+ const PERMANENT_ERROR_CODES = new Set([
21
+ "APP_DISABLED",
22
+ "ORIGIN_NOT_ALLOWED",
23
+ "DEVICE_REVOKED",
24
+ "AUTH_FAILED",
25
+ "AUTH_REQUIRED",
26
+ "SPACE_EXPIRED",
27
+ ]);
28
+ let reqCounter = 0;
29
+ function nextRequestId() {
30
+ reqCounter = (reqCounter + 1) % 1_000_000_000;
31
+ return `req_${Date.now().toString(36)}_${reqCounter.toString(36)}`;
32
+ }
33
+ function defaultWebSocketFactory(url) {
34
+ if (typeof globalThis.WebSocket !== "function") {
35
+ throw new Error("Global WebSocket is not available; inject webSocketFactory in CreateDropspaceClientOptions");
36
+ }
37
+ return new globalThis.WebSocket(url);
38
+ }
39
+ function defaultPeerConnectionFactory(config) {
40
+ if (typeof globalThis.RTCPeerConnection !== "function") {
41
+ throw new Error("Global RTCPeerConnection is not available; inject peerConnectionFactory in CreateDropspaceClientOptions");
42
+ }
43
+ return new globalThis.RTCPeerConnection(config);
44
+ }
45
+ export class DropspaceClient {
46
+ options;
47
+ events = new TypedEventEmitter();
48
+ peerSelector = new SmartPeerSelector();
49
+ identity = null;
50
+ sessionToken = null;
51
+ sessionId = null;
52
+ sessionGeneration = 0;
53
+ sessionExpiresAt = 0;
54
+ defaultSpaceId = null;
55
+ turnCredentials = null;
56
+ signalingState = "disconnected";
57
+ ws = null;
58
+ manuallyClosed = false;
59
+ wsReconnectAttempt = 0;
60
+ wsReconnectTimer = null;
61
+ heartbeatTimer = null;
62
+ capabilities;
63
+ subscribedSpaces = new Set();
64
+ peersBySpace = new Map();
65
+ revokedPeersBySpace = new Map();
66
+ hubBySpace = new Map();
67
+ trustedPeers = new Map();
68
+ peerSessions = new Map();
69
+ peersWentOffline = new Set();
70
+ peerQuality = new Map();
71
+ routeByPeer = new Map();
72
+ pendingRequests = new Map();
73
+ hubSeenRelayIds = new Map();
74
+ debugLogs = [];
75
+ pairing = {
76
+ createInvite: (opts) => this.createPairingInvite(opts),
77
+ claimInvite: (tokenOrDeepLink) => this.claimPairingInvite(tokenOrDeepLink),
78
+ approve: (pairingId, grantedPermissions) => this.approvePairing(pairingId, grantedPermissions),
79
+ reject: (pairingId, reason) => this.rejectPairing(pairingId, reason),
80
+ cancel: (pairingId) => this.cancelPairing(pairingId),
81
+ revoke: (opts) => this.revokePeerAccess(opts),
82
+ listTrustedDevices: () => this.listTrustedDevices(),
83
+ };
84
+ devices = {
85
+ subscribeSpace: (spaceId) => this.subscribeSpace(spaceId),
86
+ getPeers: (spaceId) => this.getSpacePeers(spaceId),
87
+ updateCapabilities: (caps) => this.updateCapabilities(caps),
88
+ selectPeer: (query) => this.selectBestPeer(query),
89
+ setExplicitHub: (spaceId, hubDeviceId) => this.setExplicitHub(spaceId, hubDeviceId),
90
+ getSpaceHub: (spaceId) => this.hubBySpace.get(spaceId ?? this.defaultSpaceId ?? "") ?? null,
91
+ };
92
+ channels = {
93
+ sendBinary: (peerDeviceId, payload, options) => this.sendBinaryMessage(peerDeviceId, payload, options),
94
+ sendJson: (peerDeviceId, value, options) => this.sendJsonMessage(peerDeviceId, value, options),
95
+ };
96
+ constructor(options) {
97
+ this.options = options;
98
+ this.capabilities = [...(options.capabilities ?? ["messaging", "files", "crdt"])];
99
+ }
100
+ getIdentity() {
101
+ if (!this.identity) {
102
+ throw new Error("DropspaceClient has not been started yet; call await client.start()");
103
+ }
104
+ return this.identity;
105
+ }
106
+ getSessionInfo() {
107
+ return {
108
+ sessionId: this.sessionId,
109
+ generation: this.sessionGeneration,
110
+ expiresAt: this.sessionExpiresAt,
111
+ defaultSpaceId: this.defaultSpaceId,
112
+ signalingState: this.signalingState,
113
+ };
114
+ }
115
+ getDebugLogs() {
116
+ return this.debugLogs;
117
+ }
118
+ redactDebugValue(val) {
119
+ if (typeof val === "string") {
120
+ return val
121
+ .replace(/dinv_[A-Za-z0-9_-]+/g, "[REDACTED_INVITE_TOKEN]")
122
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]");
123
+ }
124
+ if (Array.isArray(val)) {
125
+ return val.map((item) => this.redactDebugValue(item));
126
+ }
127
+ if (val && typeof val === "object") {
128
+ const out = {};
129
+ for (const [k, v] of Object.entries(val)) {
130
+ if (/token|secret|password|credential|privateKey|sdp/i.test(k)) {
131
+ out[k] = "[REDACTED]";
132
+ }
133
+ else {
134
+ out[k] = this.redactDebugValue(v);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ return val;
140
+ }
141
+ recordDebug(category, summary, details) {
142
+ const entry = {
143
+ timestamp: Date.now(),
144
+ category,
145
+ summary: String(this.redactDebugValue(summary)),
146
+ details: details
147
+ ? this.redactDebugValue(details)
148
+ : undefined,
149
+ };
150
+ this.debugLogs.push(entry);
151
+ if (this.debugLogs.length > 250) {
152
+ this.debugLogs.shift();
153
+ }
154
+ this.events.emit("debugEvent", entry);
155
+ }
156
+ async start(signal) {
157
+ if (signal?.aborted) {
158
+ throw new Error("DropspaceClient.start aborted");
159
+ }
160
+ this.manuallyClosed = false;
161
+ if (!this.identity) {
162
+ this.identity = await loadOrCreateDeviceIdentity({
163
+ storage: this.options.identityStorage,
164
+ deviceLabel: this.options.deviceLabel,
165
+ algorithm: this.options.algorithm,
166
+ });
167
+ }
168
+ await this.authenticateHttpSession(signal);
169
+ await this.connectSignalingWebSocket(signal);
170
+ // Subscribe to default space and load persisted trusted peers
171
+ if (this.defaultSpaceId) {
172
+ await this.subscribeSpace(this.defaultSpaceId);
173
+ }
174
+ try {
175
+ await this.listTrustedDevices();
176
+ }
177
+ catch {
178
+ // Non-fatal on startup
179
+ }
180
+ return this.identity;
181
+ }
182
+ getFetch() {
183
+ const f = this.options.fetchImpl ?? globalThis.fetch;
184
+ if (typeof f !== "function") {
185
+ throw new Error("fetch is not available in this runtime");
186
+ }
187
+ return f.bind(globalThis);
188
+ }
189
+ getCoreHttpUrl() {
190
+ const raw = this.options.coreHttpUrl ?? this.options.baseUrl ?? "";
191
+ if (!raw) {
192
+ throw new DropspaceError("INVALID_ENVELOPE", "DropspaceClient requires coreHttpUrl or baseUrl");
193
+ }
194
+ return raw.replace(/\/+$/, "");
195
+ }
196
+ async getCapabilities(signal) {
197
+ const fetchFn = this.getFetch();
198
+ const baseUrl = this.getCoreHttpUrl();
199
+ const url = this.options.appId
200
+ ? `${baseUrl}/api/v1/apps/${encodeURIComponent(this.options.appId)}/capabilities`
201
+ : `${baseUrl}/api/v1/capabilities`;
202
+ const res = await fetchFn(url, { method: "GET", signal });
203
+ if (!res.ok) {
204
+ const errBody = (await res.json().catch(() => ({})));
205
+ throw new DropspaceError(errBody.code ?? "INTERNAL_ERROR", errBody.message ?? `Capabilities request failed (${res.status})`, { status: res.status, retryAfterMs: errBody.retryAfterMs });
206
+ }
207
+ return (await res.json());
208
+ }
209
+ async parseHttpError(res, fallbackPrefix) {
210
+ const errBody = (await res.json().catch(() => ({})));
211
+ const retryHeader = res.headers.get("retry-after");
212
+ const retryAfterMs = errBody.retryAfterMs ??
213
+ (retryHeader && /^\d+$/.test(retryHeader) ? Number(retryHeader) * 1000 : undefined);
214
+ const code = errBody.code ?? "INTERNAL_ERROR";
215
+ const retryable = code === "RATE_LIMITED" ||
216
+ code === "SERVICE_OVERLOADED" ||
217
+ res.status === 429 ||
218
+ res.status === 503;
219
+ return new DropspaceError(code, errBody.message ?? `${fallbackPrefix} (${res.status})`, { retryable, retryAfterMs, status: res.status });
220
+ }
221
+ isPermanentStopError(err) {
222
+ if (err instanceof DropspaceError) {
223
+ if (PERMANENT_ERROR_CODES.has(err.code))
224
+ return true;
225
+ if (err.code === "TICKET_EXPIRED" && !this.options.getAccessTicket)
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+ async authenticateHttpSession(signal) {
231
+ const id = this.getIdentity();
232
+ const fetchFn = this.getFetch();
233
+ const baseUrl = this.getCoreHttpUrl();
234
+ const appId = this.options.appId;
235
+ const accessTicket = this.options.getAccessTicket
236
+ ? await this.options.getAccessTicket()
237
+ : undefined;
238
+ const chalUrl = appId
239
+ ? `${baseUrl}/api/v1/apps/${encodeURIComponent(appId)}/auth/challenge`
240
+ : `${baseUrl}/api/v1/auth/challenge`;
241
+ // 1. Request short-lived challenge
242
+ const chalRes = await fetchFn(chalUrl, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json" },
245
+ body: JSON.stringify({
246
+ deviceId: id.deviceId,
247
+ publicKey: id.publicKey,
248
+ algorithm: id.algorithm,
249
+ deviceLabel: id.deviceLabel,
250
+ purpose: "session_auth",
251
+ ...(appId ? { appId } : {}),
252
+ ...(accessTicket ? { accessTicket } : {}),
253
+ }),
254
+ signal,
255
+ });
256
+ if (!chalRes.ok) {
257
+ throw await this.parseHttpError(chalRes, "Challenge request failed");
258
+ }
259
+ const chalData = (await chalRes.json());
260
+ // 2. Sign canonical challenge with private key
261
+ const canonical = formatCanonicalChallenge(chalData);
262
+ const signature = await id.sign(canonical);
263
+ // 3. Submit signature to obtain sessionToken
264
+ const verifyUrl = appId
265
+ ? `${baseUrl}/api/v1/apps/${encodeURIComponent(appId)}/auth/verify`
266
+ : `${baseUrl}/api/v1/auth/verify`;
267
+ const verifyRes = await fetchFn(verifyUrl, {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify({
271
+ challengeId: chalData.challengeId,
272
+ deviceId: id.deviceId,
273
+ signature,
274
+ ...(appId ? { appId } : {}),
275
+ }),
276
+ signal,
277
+ });
278
+ if (!verifyRes.ok) {
279
+ throw await this.parseHttpError(verifyRes, "Challenge verification failed");
280
+ }
281
+ const verifyData = (await verifyRes.json());
282
+ this.sessionToken = verifyData.sessionToken;
283
+ this.sessionId = verifyData.sessionId;
284
+ this.sessionExpiresAt = verifyData.expiresAt;
285
+ this.defaultSpaceId = verifyData.defaultSpaceId;
286
+ this.turnCredentials = verifyData.turnCredentials ?? null;
287
+ this.subscribedSpaces.add(verifyData.defaultSpaceId);
288
+ this.recordDebug("security", "Authenticated HTTP challenge-response session", {
289
+ appId: verifyData.appId ?? appId,
290
+ deviceId: id.deviceId,
291
+ sessionId: verifyData.sessionId,
292
+ securityLevel: id.securityLevel,
293
+ });
294
+ }
295
+ getWsUrl() {
296
+ let wsUrl;
297
+ if (this.options.coreWsUrl) {
298
+ wsUrl = this.options.coreWsUrl;
299
+ }
300
+ else {
301
+ const base = this.getCoreHttpUrl();
302
+ if (base.startsWith("https://")) {
303
+ wsUrl = `wss://${base.slice("https://".length)}/ws`;
304
+ }
305
+ else if (base.startsWith("http://")) {
306
+ wsUrl = `ws://${base.slice("http://".length)}/ws`;
307
+ }
308
+ else {
309
+ wsUrl = `${base}/ws`;
310
+ }
311
+ }
312
+ if (this.options.appId && !wsUrl.includes("appId=")) {
313
+ const sep = wsUrl.includes("?") ? "&" : "?";
314
+ wsUrl = `${wsUrl}${sep}appId=${encodeURIComponent(this.options.appId)}`;
315
+ }
316
+ return wsUrl;
317
+ }
318
+ transitionSignalingState(next, reason) {
319
+ if (this.signalingState === next)
320
+ return;
321
+ const prev = this.signalingState;
322
+ this.signalingState = next;
323
+ this.events.emit("signalingStateChange", {
324
+ previousState: prev,
325
+ state: next,
326
+ sessionId: this.sessionId,
327
+ generation: this.sessionGeneration,
328
+ reason,
329
+ });
330
+ }
331
+ connectSignalingWebSocket(signal) {
332
+ if (!this.sessionToken) {
333
+ return Promise.reject(new Error("Missing sessionToken before WebSocket connect"));
334
+ }
335
+ const wsFactory = this.options.webSocketFactory ?? defaultWebSocketFactory;
336
+ const wsUrl = this.getWsUrl();
337
+ this.transitionSignalingState(this.wsReconnectAttempt > 0 ? "reconnecting" : "connecting", "opening_websocket");
338
+ return new Promise((resolve, reject) => {
339
+ let settled = false;
340
+ const ws = wsFactory(wsUrl);
341
+ this.ws = ws;
342
+ const onOpen = () => {
343
+ if (this.manuallyClosed || this.ws !== ws)
344
+ return;
345
+ this.transitionSignalingState("authenticating", "ws_open");
346
+ const authReqId = nextRequestId();
347
+ const authEnv = {
348
+ protocolVersion: PROTOCOL_VERSION,
349
+ type: "auth.authenticate",
350
+ requestId: authReqId,
351
+ payload: {
352
+ sessionToken: this.sessionToken,
353
+ deviceLabel: this.identity?.deviceLabel ?? "Dropspace Device",
354
+ capabilities: this.capabilities,
355
+ },
356
+ };
357
+ try {
358
+ ws.send(serializeSignalingEnvelope(authEnv));
359
+ }
360
+ catch (err) {
361
+ if (!settled) {
362
+ settled = true;
363
+ reject(err instanceof Error ? err : new Error(String(err)));
364
+ }
365
+ }
366
+ };
367
+ const onMessage = (ev) => {
368
+ if (this.manuallyClosed || this.ws !== ws)
369
+ return;
370
+ const rawText = typeof ev.data === "string"
371
+ ? ev.data
372
+ : ev.data instanceof ArrayBuffer
373
+ ? new TextDecoder().decode(ev.data)
374
+ : Buffer.from(ev.data).toString("utf-8");
375
+ let env;
376
+ try {
377
+ env = parseSignalingMessage(rawText);
378
+ }
379
+ catch {
380
+ return;
381
+ }
382
+ this.recordDebug("signaling", `Received ${env.type}`, {
383
+ envelope: redactSignalingEnvelopeForLog(env),
384
+ });
385
+ if (env.type === "auth.authenticated") {
386
+ this.sessionId = String(env.payload.sessionId ?? this.sessionId ?? "");
387
+ this.sessionGeneration = Number(env.payload.generation ?? 1);
388
+ if (env.payload.turnCredentials) {
389
+ this.turnCredentials = env.payload.turnCredentials;
390
+ }
391
+ this.wsReconnectAttempt = 0;
392
+ this.transitionSignalingState("connected", "authenticated");
393
+ this.startHeartbeat();
394
+ // Re-subscribe to all active spaces on reconnect without tearing down healthy P2P connections!
395
+ for (const spId of this.subscribedSpaces) {
396
+ void this.sendSignalingEnvelope({
397
+ protocolVersion: PROTOCOL_VERSION,
398
+ type: "presence.subscribe",
399
+ spaceId: spId,
400
+ payload: { spaceId: spId },
401
+ }).catch(() => { });
402
+ }
403
+ if (!settled) {
404
+ settled = true;
405
+ resolve();
406
+ }
407
+ if (env.requestId) {
408
+ const pend = this.pendingRequests.get(env.requestId);
409
+ if (pend) {
410
+ clearTimeout(pend.timer);
411
+ this.pendingRequests.delete(env.requestId);
412
+ pend.resolve(env);
413
+ }
414
+ }
415
+ return;
416
+ }
417
+ if (env.type === "error" && !settled && this.signalingState === "authenticating") {
418
+ settled = true;
419
+ const errCode = String(env.payload.code ?? "AUTH_FAILED");
420
+ const errMsg = String(env.payload.message ?? "WebSocket authentication failed");
421
+ const retryAfterMs = typeof env.payload.retryAfterMs === "number" ? env.payload.retryAfterMs : undefined;
422
+ const err = new DropspaceError(errCode, errMsg, {
423
+ retryable: Boolean(env.payload.retryable),
424
+ retryAfterMs,
425
+ });
426
+ if (this.isPermanentStopError(err)) {
427
+ this.manuallyClosed = true;
428
+ }
429
+ reject(err);
430
+ return;
431
+ }
432
+ this.handleIncomingSignalingEnvelope(env);
433
+ };
434
+ const onClose = () => {
435
+ ws.removeEventListener("open", onOpen);
436
+ ws.removeEventListener("message", onMessage);
437
+ ws.removeEventListener("close", onClose);
438
+ ws.removeEventListener("error", onError);
439
+ this.stopHeartbeat();
440
+ if (!settled) {
441
+ settled = true;
442
+ reject(new Error("Signaling WebSocket closed before authentication completed"));
443
+ }
444
+ if (!this.manuallyClosed && this.ws === ws) {
445
+ // IMPORTANT (Section 10): Signaling disconnect MUST NOT close healthy P2P connections!
446
+ this.transitionSignalingState("reconnecting", "socket_closed");
447
+ this.scheduleSignalingReconnect();
448
+ }
449
+ };
450
+ const onError = () => {
451
+ // Error will be followed by close
452
+ };
453
+ ws.addEventListener("open", onOpen);
454
+ ws.addEventListener("message", onMessage);
455
+ ws.addEventListener("close", onClose);
456
+ ws.addEventListener("error", onError);
457
+ if (signal) {
458
+ signal.addEventListener("abort", () => {
459
+ if (!settled) {
460
+ settled = true;
461
+ ws.close();
462
+ reject(new Error("WebSocket connect aborted"));
463
+ }
464
+ }, { once: true });
465
+ }
466
+ });
467
+ }
468
+ scheduleSignalingReconnect(serverRetryAfterMs) {
469
+ if (this.manuallyClosed || this.wsReconnectTimer)
470
+ return;
471
+ const maxAttempts = this.options.maxReconnectAttempts ?? 20;
472
+ if (this.wsReconnectAttempt >= maxAttempts) {
473
+ this.transitionSignalingState("closed", "max_reconnect_attempts_reached");
474
+ return;
475
+ }
476
+ this.wsReconnectAttempt += 1;
477
+ const base = this.options.baseBackoffMs ?? 100;
478
+ const cap = 30_000;
479
+ const expCeiling = Math.min(cap, base * Math.pow(2, this.wsReconnectAttempt - 1));
480
+ // Full jitter prevents reconnect storms after outages
481
+ const fullJitterDelay = Math.floor(Math.random() * expCeiling);
482
+ const delay = typeof serverRetryAfterMs === "number" && serverRetryAfterMs > fullJitterDelay
483
+ ? serverRetryAfterMs
484
+ : fullJitterDelay;
485
+ this.wsReconnectTimer = setTimeout(async () => {
486
+ this.wsReconnectTimer = null;
487
+ if (this.manuallyClosed)
488
+ return;
489
+ try {
490
+ await this.authenticateHttpSession();
491
+ await this.connectSignalingWebSocket();
492
+ }
493
+ catch (err) {
494
+ if (this.isPermanentStopError(err)) {
495
+ this.manuallyClosed = true;
496
+ this.transitionSignalingState("closed", "permanent_auth_or_policy_error");
497
+ if (err instanceof DropspaceError) {
498
+ this.events.emit("error", {
499
+ code: err.code,
500
+ message: err.message,
501
+ retryable: false,
502
+ });
503
+ }
504
+ return;
505
+ }
506
+ const retryMs = err instanceof DropspaceError ? err.retryAfterMs : undefined;
507
+ this.scheduleSignalingReconnect(retryMs);
508
+ }
509
+ }, delay);
510
+ }
511
+ startHeartbeat() {
512
+ this.stopHeartbeat();
513
+ const intervalMs = this.options.heartbeatIntervalMs ?? 20_000;
514
+ this.heartbeatTimer = setInterval(() => {
515
+ if (this.signalingState === "connected") {
516
+ void this.sendSignalingEnvelope({
517
+ protocolVersion: PROTOCOL_VERSION,
518
+ type: "heartbeat.ping",
519
+ payload: { clientTime: Date.now() },
520
+ }).catch(() => { });
521
+ }
522
+ }, intervalMs);
523
+ }
524
+ stopHeartbeat() {
525
+ if (this.heartbeatTimer) {
526
+ clearInterval(this.heartbeatTimer);
527
+ this.heartbeatTimer = null;
528
+ }
529
+ }
530
+ async sendSignalingEnvelope(envelope) {
531
+ if (!this.ws || this.signalingState !== "connected") {
532
+ throw new Error("Signaling WebSocket is not connected");
533
+ }
534
+ const raw = serializeSignalingEnvelope(envelope);
535
+ this.recordDebug("signaling", `Sent ${envelope.type}`, {
536
+ envelope: redactSignalingEnvelopeForLog(envelope),
537
+ });
538
+ this.ws.send(raw);
539
+ }
540
+ sendSignalingRequest(envelope, timeoutMs = 8000) {
541
+ const requestId = nextRequestId();
542
+ const fullEnvelope = {
543
+ protocolVersion: PROTOCOL_VERSION,
544
+ requestId,
545
+ ...envelope,
546
+ };
547
+ return new Promise((resolve, reject) => {
548
+ const timer = setTimeout(() => {
549
+ this.pendingRequests.delete(requestId);
550
+ reject(new Error(`Signaling request '${envelope.type}' timed out after ${timeoutMs}ms`));
551
+ }, timeoutMs);
552
+ this.pendingRequests.set(requestId, { resolve, reject, timer });
553
+ this.sendSignalingEnvelope(fullEnvelope).catch((err) => {
554
+ clearTimeout(timer);
555
+ this.pendingRequests.delete(requestId);
556
+ reject(err instanceof Error ? err : new Error(String(err)));
557
+ });
558
+ });
559
+ }
560
+ handleIncomingSignalingEnvelope(env) {
561
+ if (env.requestId && this.pendingRequests.has(env.requestId)) {
562
+ const pend = this.pendingRequests.get(env.requestId);
563
+ clearTimeout(pend.timer);
564
+ this.pendingRequests.delete(env.requestId);
565
+ if (env.type === "error") {
566
+ const code = String(env.payload.code ?? "UNKNOWN_ERROR");
567
+ const message = String(env.payload.message ?? "Signaling error");
568
+ const retryable = Boolean(env.payload.retryable);
569
+ const retryAfterMs = typeof env.payload.retryAfterMs === "number" ? env.payload.retryAfterMs : undefined;
570
+ const err = new DropspaceError(code, `${code}: ${message}`, {
571
+ retryable,
572
+ retryAfterMs,
573
+ });
574
+ pend.reject(err);
575
+ this.events.emit("error", {
576
+ code,
577
+ message,
578
+ retryable,
579
+ retryAfterMs,
580
+ });
581
+ return;
582
+ }
583
+ pend.resolve(env);
584
+ }
585
+ switch (env.type) {
586
+ case "error": {
587
+ this.events.emit("error", {
588
+ code: String(env.payload.code),
589
+ message: String(env.payload.message),
590
+ retryable: Boolean(env.payload.retryable),
591
+ retryAfterMs: typeof env.payload.retryAfterMs === "number" ? env.payload.retryAfterMs : undefined,
592
+ });
593
+ break;
594
+ }
595
+ case "presence.snapshot": {
596
+ const spaceId = String(env.spaceId ?? env.payload.spaceId ?? "");
597
+ const peers = env.payload.peers ?? [];
598
+ const hubDeviceId = typeof env.payload.hubDeviceId === "string"
599
+ ? env.payload.hubDeviceId
600
+ : null;
601
+ const revocations = env.payload.revocations ?? [];
602
+ this.hubBySpace.set(spaceId, hubDeviceId);
603
+ const spaceMap = new Map();
604
+ for (const p of peers) {
605
+ spaceMap.set(p.deviceId, p);
606
+ }
607
+ this.peersBySpace.set(spaceId, spaceMap);
608
+ let revSet = this.revokedPeersBySpace.get(spaceId);
609
+ if (!revSet) {
610
+ revSet = new Set();
611
+ this.revokedPeersBySpace.set(spaceId, revSet);
612
+ }
613
+ for (const r of revocations) {
614
+ revSet.add(r.revokedDeviceId);
615
+ // Close any lingering peer session with a revoked peer (offline revocation catch-up!)
616
+ const sess = this.peerSessions.get(r.revokedDeviceId);
617
+ if (sess) {
618
+ sess.close(false);
619
+ this.peerSessions.delete(r.revokedDeviceId);
620
+ }
621
+ }
622
+ this.events.emit("presenceSnapshot", {
623
+ spaceId,
624
+ hubDeviceId,
625
+ peers,
626
+ revocations,
627
+ });
628
+ // Auto-connect to online trusted peers if configured
629
+ if (this.options.autoConnectTrustedPeers ?? true) {
630
+ for (const p of peers) {
631
+ if (p.deviceId !== this.identity?.deviceId &&
632
+ p.online &&
633
+ this.trustedPeers.has(p.deviceId)) {
634
+ // Polite/impolite tie-breaker: lexicographically smaller deviceId initiates auto-connect
635
+ if ((this.identity?.deviceId ?? "") < p.deviceId) {
636
+ void this.connect(p.deviceId, { spaceId }).catch(() => { });
637
+ }
638
+ }
639
+ }
640
+ }
641
+ break;
642
+ }
643
+ case "presence.update": {
644
+ const spaceId = String(env.spaceId ?? env.payload.spaceId ?? "");
645
+ const status = env.payload.status ?? "updated";
646
+ const peer = env.payload.peer;
647
+ if (!peer || !peer.deviceId)
648
+ break;
649
+ let spaceMap = this.peersBySpace.get(spaceId);
650
+ if (!spaceMap) {
651
+ spaceMap = new Map();
652
+ this.peersBySpace.set(spaceId, spaceMap);
653
+ }
654
+ spaceMap.set(peer.deviceId, peer);
655
+ this.events.emit("presenceUpdate", { spaceId, status, peer });
656
+ // Check if this peer is the space's designated hub
657
+ const activeHub = this.hubBySpace.get(spaceId);
658
+ if (activeHub && activeHub === peer.deviceId) {
659
+ this.events.emit("hubStatusChange", {
660
+ spaceId,
661
+ hubDeviceId: activeHub,
662
+ reachable: peer.online,
663
+ });
664
+ }
665
+ const existingSession = this.peerSessions.get(peer.deviceId);
666
+ if (!peer.online) {
667
+ this.peersWentOffline.add(peer.deviceId);
668
+ if (existingSession &&
669
+ existingSession.getState() !== "connected" &&
670
+ !existingSession.isManuallyClosed()) {
671
+ existingSession.setWaitingForPeer("peer_went_offline");
672
+ }
673
+ }
674
+ else {
675
+ const returnedFromOffline = this.peersWentOffline.delete(peer.deviceId);
676
+ if (returnedFromOffline &&
677
+ existingSession &&
678
+ existingSession.getState() === "connected" &&
679
+ !existingSession.isManuallyClosed() &&
680
+ (this.options.autoConnectTrustedPeers ?? true) &&
681
+ this.trustedPeers.has(peer.deviceId) &&
682
+ (this.identity?.deviceId ?? "") < peer.deviceId) {
683
+ void existingSession.forceReconnect("peer_returned_online").catch(() => { });
684
+ }
685
+ else if (peer.deviceId !== this.identity?.deviceId &&
686
+ !existingSession?.isManuallyClosed() &&
687
+ (existingSession?.getState() === "waiting_for_peer" ||
688
+ existingSession?.getState() === "reconnecting" ||
689
+ existingSession?.getState() === "failed" ||
690
+ ((this.options.autoConnectTrustedPeers ?? true) &&
691
+ this.trustedPeers.has(peer.deviceId) &&
692
+ (this.identity?.deviceId ?? "") < peer.deviceId))) {
693
+ void this.connect(peer.deviceId, { spaceId }).catch(() => { });
694
+ }
695
+ }
696
+ break;
697
+ }
698
+ case "pairing.awaiting_approval": {
699
+ const req = {
700
+ pairingId: String(env.payload.pairingId ?? ""),
701
+ spaceId: String(env.spaceId ?? env.payload.spaceId ?? ""),
702
+ claimerDeviceId: String(env.payload.claimerDeviceId ?? ""),
703
+ claimerLabel: String(env.payload.claimerLabel ?? "Remote Device"),
704
+ claimerPublicKey: String(env.payload.claimerPublicKey ?? ""),
705
+ claimerAlgorithm: env.payload.claimerAlgorithm ?? "ECDSA_P256_SHA256",
706
+ claimerFingerprint: String(env.payload.claimerFingerprint ?? ""),
707
+ requestedPermissions: env.payload.requestedPermissions ?? [],
708
+ expiresAt: Number(env.payload.expiresAt ?? 0),
709
+ };
710
+ this.recordDebug("pairing", `Pairing ${req.pairingId} awaiting approval`, {
711
+ claimerDeviceId: req.claimerDeviceId,
712
+ claimerFingerprint: req.claimerFingerprint,
713
+ });
714
+ this.events.emit("pairingAwaitingApproval", req);
715
+ break;
716
+ }
717
+ case "pairing.accepted": {
718
+ const trusted = {
719
+ pairingId: String(env.payload.pairingId ?? ""),
720
+ spaceId: String(env.spaceId ?? env.payload.spaceId ?? ""),
721
+ peerDeviceId: String(env.payload.peerDeviceId ?? ""),
722
+ peerLabel: String(env.payload.peerLabel ?? "Trusted Device"),
723
+ peerPublicKey: String(env.payload.peerPublicKey ?? ""),
724
+ peerAlgorithm: env.payload.peerAlgorithm ?? "ECDSA_P256_SHA256",
725
+ peerFingerprint: String(env.payload.peerFingerprint ?? ""),
726
+ permissions: env.payload.permissions ?? [],
727
+ createdAt: Number(env.payload.createdAt ?? Date.now()),
728
+ };
729
+ this.trustedPeers.set(trusted.peerDeviceId, trusted);
730
+ this.subscribedSpaces.add(trusted.spaceId);
731
+ void this.subscribeSpace(trusted.spaceId).catch(() => { });
732
+ this.recordDebug("pairing", `Pairing accepted with ${trusted.peerDeviceId}`, {
733
+ pairingId: trusted.pairingId,
734
+ spaceId: trusted.spaceId,
735
+ permissions: trusted.permissions,
736
+ });
737
+ this.events.emit("pairingAccepted", trusted);
738
+ if ((this.options.autoConnectTrustedPeers ?? true) &&
739
+ (this.identity?.deviceId ?? "") < trusted.peerDeviceId) {
740
+ void this.connect(trusted.peerDeviceId, {
741
+ spaceId: trusted.spaceId,
742
+ }).catch(() => { });
743
+ }
744
+ break;
745
+ }
746
+ case "pairing.status_changed": {
747
+ this.events.emit("pairingStatusChanged", {
748
+ pairingId: String(env.payload.pairingId ?? ""),
749
+ spaceId: String(env.spaceId ?? env.payload.spaceId ?? ""),
750
+ state: env.payload.state ?? "revoked",
751
+ });
752
+ break;
753
+ }
754
+ case "revocation.notice": {
755
+ const rev = {
756
+ revocationId: typeof env.payload.revocationId === "string"
757
+ ? env.payload.revocationId
758
+ : undefined,
759
+ spaceId: String(env.spaceId ?? env.payload.spaceId ?? ""),
760
+ revokedDeviceId: String(env.payload.revokedDeviceId ?? ""),
761
+ revokedByDeviceId: String(env.payload.revokedByDeviceId ?? ""),
762
+ revokedAt: Number(env.payload.revokedAt ?? Date.now()),
763
+ reason: String(env.payload.reason ?? "Access revoked"),
764
+ };
765
+ let revSet = this.revokedPeersBySpace.get(rev.spaceId);
766
+ if (!revSet) {
767
+ revSet = new Set();
768
+ this.revokedPeersBySpace.set(rev.spaceId, revSet);
769
+ }
770
+ revSet.add(rev.revokedDeviceId);
771
+ this.trustedPeers.delete(rev.revokedDeviceId);
772
+ // Immediately terminate active P2P session if the revoked device is the remote peer OR us!
773
+ if (rev.revokedDeviceId === this.identity?.deviceId) {
774
+ this.peersBySpace.delete(rev.spaceId);
775
+ for (const [key, trusted] of this.trustedPeers.entries()) {
776
+ if (trusted.spaceId === rev.spaceId) {
777
+ this.trustedPeers.delete(key);
778
+ }
779
+ }
780
+ for (const [peerId, sess] of this.peerSessions.entries()) {
781
+ sess.close(false);
782
+ this.peerSessions.delete(peerId);
783
+ }
784
+ }
785
+ else {
786
+ this.peersBySpace.get(rev.spaceId)?.delete(rev.revokedDeviceId);
787
+ const targetSess = this.peerSessions.get(rev.revokedDeviceId);
788
+ if (targetSess) {
789
+ targetSess.close(false);
790
+ this.peerSessions.delete(rev.revokedDeviceId);
791
+ }
792
+ }
793
+ this.recordDebug("security", `Revocation enforced for ${rev.revokedDeviceId}`, {
794
+ spaceId: rev.spaceId,
795
+ reason: rev.reason,
796
+ });
797
+ this.events.emit("revocationNotice", rev);
798
+ break;
799
+ }
800
+ case "topology.hub_changed": {
801
+ const spaceId = String(env.spaceId ?? env.payload.spaceId ?? "");
802
+ const hubDeviceId = typeof env.payload.hubDeviceId === "string"
803
+ ? env.payload.hubDeviceId
804
+ : null;
805
+ this.hubBySpace.set(spaceId, hubDeviceId);
806
+ this.events.emit("hubChanged", {
807
+ spaceId,
808
+ hubDeviceId,
809
+ updatedBy: String(env.payload.updatedBy ?? ""),
810
+ updatedAt: Number(env.payload.updatedAt ?? Date.now()),
811
+ });
812
+ break;
813
+ }
814
+ case "turn.credentials_response": {
815
+ this.turnCredentials = env.payload;
816
+ break;
817
+ }
818
+ case "webrtc.offer":
819
+ case "webrtc.answer":
820
+ case "webrtc.ice_candidate":
821
+ case "webrtc.ice_restart":
822
+ case "webrtc.close": {
823
+ const senderId = env.senderDeviceId;
824
+ if (!senderId)
825
+ break;
826
+ const spaceId = env.spaceId ?? this.defaultSpaceId ?? "";
827
+ if (this.revokedPeersBySpace.get(spaceId)?.has(senderId)) {
828
+ this.recordDebug("security", `Blocked signaling from revoked peer ${senderId}`);
829
+ break;
830
+ }
831
+ const session = this.getOrCreateManagedPeerSession(senderId, spaceId);
832
+ void session.handleSignalingEnvelope(env);
833
+ break;
834
+ }
835
+ default:
836
+ break;
837
+ }
838
+ }
839
+ async subscribeSpace(spaceId) {
840
+ this.subscribedSpaces.add(spaceId);
841
+ if (this.signalingState === "connected") {
842
+ await this.sendSignalingRequest({
843
+ type: "presence.subscribe",
844
+ spaceId,
845
+ payload: { spaceId },
846
+ });
847
+ }
848
+ }
849
+ getSpacePeers(spaceId) {
850
+ const sp = spaceId ?? this.defaultSpaceId ?? "";
851
+ const map = this.peersBySpace.get(sp);
852
+ if (!map)
853
+ return [];
854
+ return Array.from(map.values());
855
+ }
856
+ async updateCapabilities(capabilities) {
857
+ this.capabilities = [...capabilities];
858
+ if (this.signalingState === "connected") {
859
+ await this.sendSignalingEnvelope({
860
+ protocolVersion: PROTOCOL_VERSION,
861
+ type: "capability.update",
862
+ payload: { capabilities: this.capabilities },
863
+ });
864
+ }
865
+ }
866
+ selectBestPeer(query) {
867
+ const spaceId = query.spaceId ?? this.defaultSpaceId ?? "";
868
+ const peers = this.getSpacePeers(spaceId);
869
+ const revokedSet = this.revokedPeersBySpace.get(spaceId) ?? new Set();
870
+ const candidates = peers
871
+ .filter((p) => p.deviceId !== this.identity?.deviceId)
872
+ .map((presence) => {
873
+ const sess = this.peerSessions.get(presence.deviceId);
874
+ return {
875
+ presence,
876
+ spaceMember: true,
877
+ revoked: revokedSet.has(presence.deviceId),
878
+ hasHealthyActiveConnection: sess?.getState() === "connected",
879
+ quality: this.peerQuality.get(presence.deviceId),
880
+ };
881
+ });
882
+ const decision = this.peerSelector.selectPeer({
883
+ spaceId,
884
+ requiredPermissions: query.requiredPermissions,
885
+ requiredCapabilities: query.requiredCapabilities,
886
+ preferredDeviceId: query.preferredDeviceId,
887
+ requireOnline: query.requireOnline,
888
+ }, candidates);
889
+ this.recordDebug("selection", decision.explanation, {
890
+ selectedDeviceId: decision.selectedDeviceId,
891
+ hysteresisApplied: decision.hysteresisApplied,
892
+ });
893
+ return decision;
894
+ }
895
+ async createPairingInvite(opts) {
896
+ const spaceId = opts?.spaceId ?? this.defaultSpaceId ?? `space_${this.getIdentity().deviceId}`;
897
+ const permissions = opts?.permissions ?? ["connect", "messaging", "files", "crdt"];
898
+ const ttlMs = opts?.ttlMs ?? 300_000;
899
+ const resp = await this.sendSignalingRequest({
900
+ type: "pairing.create_invite",
901
+ spaceId,
902
+ payload: {
903
+ spaceId,
904
+ permissions,
905
+ ttlMs,
906
+ },
907
+ });
908
+ return {
909
+ pairingId: String(resp.payload.pairingId),
910
+ spaceId: String(resp.payload.spaceId ?? spaceId),
911
+ inviteToken: String(resp.payload.inviteToken),
912
+ deepLink: String(resp.payload.deepLink),
913
+ state: resp.payload.state ?? "created",
914
+ permissions: resp.payload.permissions ?? permissions,
915
+ expiresAt: Number(resp.payload.expiresAt),
916
+ };
917
+ }
918
+ async claimPairingInvite(tokenOrDeepLink) {
919
+ let inviteToken = tokenOrDeepLink.trim();
920
+ if (inviteToken.startsWith("dropspace://")) {
921
+ const url = new URL(inviteToken.replace(/^dropspace:\/\//, "https://dropspace.local/"));
922
+ inviteToken = url.searchParams.get("token") ?? inviteToken;
923
+ }
924
+ const resp = await this.sendSignalingRequest({
925
+ type: "pairing.claim_invite",
926
+ payload: { inviteToken },
927
+ });
928
+ return {
929
+ pairingId: String(resp.payload.pairingId),
930
+ spaceId: String(resp.payload.spaceId),
931
+ creatorDeviceId: String(resp.payload.creatorDeviceId),
932
+ state: resp.payload.state ?? "awaiting_approval",
933
+ requestedPermissions: resp.payload.requestedPermissions ?? [],
934
+ expiresAt: Number(resp.payload.expiresAt),
935
+ };
936
+ }
937
+ async approvePairing(pairingId, grantedPermissions) {
938
+ const resp = await this.sendSignalingRequest({
939
+ type: "pairing.approve",
940
+ payload: {
941
+ pairingId,
942
+ grantedPermissions: grantedPermissions ?? null,
943
+ },
944
+ });
945
+ const trusted = {
946
+ pairingId: String(resp.payload.pairingId),
947
+ spaceId: String(resp.spaceId ?? resp.payload.spaceId),
948
+ peerDeviceId: String(resp.payload.peerDeviceId),
949
+ peerLabel: String(resp.payload.peerLabel ?? "Trusted Device"),
950
+ peerPublicKey: String(resp.payload.peerPublicKey),
951
+ peerAlgorithm: resp.payload.peerAlgorithm ?? "ECDSA_P256_SHA256",
952
+ peerFingerprint: String(resp.payload.peerFingerprint),
953
+ permissions: resp.payload.permissions ?? [],
954
+ createdAt: Number(resp.payload.createdAt ?? Date.now()),
955
+ };
956
+ this.trustedPeers.set(trusted.peerDeviceId, trusted);
957
+ return trusted;
958
+ }
959
+ async rejectPairing(pairingId, reason) {
960
+ await this.sendSignalingRequest({
961
+ type: "pairing.reject",
962
+ payload: { pairingId, reason: reason ?? "Rejected by user" },
963
+ });
964
+ }
965
+ async cancelPairing(pairingId) {
966
+ await this.sendSignalingRequest({
967
+ type: "pairing.cancel",
968
+ payload: { pairingId },
969
+ });
970
+ }
971
+ async revokePeerAccess(opts) {
972
+ const spaceId = opts.spaceId ?? this.defaultSpaceId ?? "";
973
+ await this.sendSignalingRequest({
974
+ type: "revocation.revoke",
975
+ spaceId,
976
+ targetDeviceId: opts.targetDeviceId,
977
+ payload: {
978
+ targetDeviceId: opts.targetDeviceId,
979
+ spaceId,
980
+ pairingId: opts.pairingId ?? null,
981
+ reason: opts.reason ?? "User revoked access",
982
+ },
983
+ });
984
+ }
985
+ async listTrustedDevices() {
986
+ if (!this.sessionToken)
987
+ return Array.from(this.trustedPeers.values());
988
+ const fetchFn = this.getFetch();
989
+ const baseUrl = this.getCoreHttpUrl();
990
+ const res = await fetchFn(`${baseUrl}/api/v1/devices/trusted`, {
991
+ method: "GET",
992
+ headers: {
993
+ authorization: `Bearer ${this.sessionToken}`,
994
+ },
995
+ });
996
+ if (!res.ok) {
997
+ return Array.from(this.trustedPeers.values());
998
+ }
999
+ const data = (await res.json());
1000
+ for (const peer of data.trustedPeers ?? []) {
1001
+ this.trustedPeers.set(peer.peerDeviceId, peer);
1002
+ this.subscribedSpaces.add(peer.spaceId);
1003
+ }
1004
+ return Array.from(this.trustedPeers.values());
1005
+ }
1006
+ async setExplicitHub(spaceId, hubDeviceId) {
1007
+ await this.sendSignalingRequest({
1008
+ type: "topology.set_hub",
1009
+ spaceId,
1010
+ payload: { hubDeviceId },
1011
+ });
1012
+ }
1013
+ async getFreshIceServers() {
1014
+ const now = Date.now();
1015
+ if (this.signalingState === "connected" &&
1016
+ (!this.turnCredentials || this.turnCredentials.expiresAt - now < 60_000)) {
1017
+ try {
1018
+ const resp = await this.sendSignalingRequest({
1019
+ type: "turn.credentials_request",
1020
+ payload: {},
1021
+ });
1022
+ this.turnCredentials = resp.payload;
1023
+ }
1024
+ catch {
1025
+ // Use cached credentials if refresh fails
1026
+ }
1027
+ }
1028
+ const servers = [];
1029
+ if (this.options.staticStunUrls && this.options.staticStunUrls.length > 0) {
1030
+ servers.push({ urls: this.options.staticStunUrls });
1031
+ }
1032
+ if (this.turnCredentials && this.turnCredentials.urls.length > 0) {
1033
+ servers.push({
1034
+ urls: this.turnCredentials.urls,
1035
+ username: this.turnCredentials.username,
1036
+ credential: this.turnCredentials.credential,
1037
+ });
1038
+ }
1039
+ if (this.options.byoIceServers && this.options.byoIceServers.length > 0) {
1040
+ for (const s of this.options.byoIceServers) {
1041
+ servers.push(s);
1042
+ }
1043
+ }
1044
+ return servers;
1045
+ }
1046
+ getOrCreateManagedPeerSession(remoteDeviceId, spaceId) {
1047
+ const existing = this.peerSessions.get(remoteDeviceId);
1048
+ if (existing)
1049
+ return existing;
1050
+ const maxPeers = this.options.maxPeers ?? 16;
1051
+ if (this.peerSessions.size >= maxPeers) {
1052
+ throw new Error(`Maximum peer connection limit (${maxPeers}) reached`);
1053
+ }
1054
+ const localId = this.getIdentity().deviceId;
1055
+ const polite = localId.localeCompare(remoteDeviceId) < 0;
1056
+ const session = new ManagedPeerSession({
1057
+ localDeviceId: localId,
1058
+ remoteDeviceId,
1059
+ spaceId,
1060
+ polite,
1061
+ createPeerConnection: this.options.peerConnectionFactory ?? defaultPeerConnectionFactory,
1062
+ getIceServers: () => this.getFreshIceServers(),
1063
+ sendSignaling: async (type, connectionId, generation, payload) => {
1064
+ await this.sendSignalingEnvelope({
1065
+ protocolVersion: PROTOCOL_VERSION,
1066
+ type,
1067
+ connectionId,
1068
+ generation,
1069
+ spaceId,
1070
+ targetDeviceId: remoteDeviceId,
1071
+ payload,
1072
+ });
1073
+ },
1074
+ iceTransportPolicy: this.options.iceTransportPolicy,
1075
+ disconnectGracePeriodMs: this.options.disconnectGracePeriodMs,
1076
+ iceRestartTimeoutMs: this.options.iceRestartTimeoutMs,
1077
+ connectTimeoutMs: this.options.connectTimeoutMs,
1078
+ maxReconnectAttempts: this.options.maxReconnectAttempts,
1079
+ baseBackoffMs: this.options.baseBackoffMs,
1080
+ });
1081
+ session.events.on("stateChange", (ev) => {
1082
+ const qual = this.peerQuality.get(remoteDeviceId) ?? {
1083
+ recentSuccessCount: 0,
1084
+ recentFailureCount: 0,
1085
+ };
1086
+ if (ev.state === "connected") {
1087
+ qual.recentSuccessCount += 1;
1088
+ qual.lastConnectedAt = Date.now();
1089
+ }
1090
+ else if (ev.state === "failed") {
1091
+ qual.recentFailureCount += 1;
1092
+ }
1093
+ this.peerQuality.set(remoteDeviceId, qual);
1094
+ this.recordDebug("webrtc", `Peer ${remoteDeviceId} state ${ev.previousState} -> ${ev.state} (gen ${ev.generation})`, { reason: ev.reason, connectionId: ev.connectionId });
1095
+ this.events.emit("peerStateChange", ev);
1096
+ });
1097
+ session.events.on("routeSelected", (ev) => {
1098
+ const qual = this.peerQuality.get(remoteDeviceId) ?? {
1099
+ recentSuccessCount: 1,
1100
+ recentFailureCount: 0,
1101
+ };
1102
+ if (ev.rttMs !== undefined) {
1103
+ qual.rttMs = ev.rttMs;
1104
+ }
1105
+ this.peerQuality.set(remoteDeviceId, qual);
1106
+ this.routeByPeer.set(remoteDeviceId, {
1107
+ candidateType: ev.candidateType,
1108
+ protocol: ev.protocol,
1109
+ rttMs: ev.rttMs,
1110
+ });
1111
+ this.recordDebug("webrtc", `Selected ${ev.candidateType} route for ${remoteDeviceId}`, {
1112
+ candidateType: ev.candidateType,
1113
+ rttMs: ev.rttMs,
1114
+ });
1115
+ });
1116
+ session.multiplexer.events.on("message", (msg) => {
1117
+ if (!this.hasPeerPermission(remoteDeviceId, "messaging", spaceId)) {
1118
+ this.recordDebug("security", `Dropped incoming message from unauthorized peer ${remoteDeviceId}`);
1119
+ return;
1120
+ }
1121
+ this.events.emit("message", msg);
1122
+ });
1123
+ session.multiplexer.events.on("transferFrame", (frame) => {
1124
+ if (!this.hasPeerPermission(remoteDeviceId, "files", spaceId)) {
1125
+ this.recordDebug("security", `Dropped incoming transferFrame from unauthorized peer ${remoteDeviceId}`);
1126
+ return;
1127
+ }
1128
+ this.events.emit("transferFrame", { peerDeviceId: remoteDeviceId, frame });
1129
+ });
1130
+ session.multiplexer.events.on("crdtFrame", (frame) => {
1131
+ if (!this.hasPeerPermission(remoteDeviceId, "crdt", spaceId)) {
1132
+ this.recordDebug("security", `Dropped incoming crdtFrame from unauthorized peer ${remoteDeviceId}`);
1133
+ return;
1134
+ }
1135
+ this.events.emit("crdtFrame", { peerDeviceId: remoteDeviceId, frame });
1136
+ });
1137
+ session.multiplexer.events.on("hubRelayFrame", (frame) => {
1138
+ void this.handleHubRelayFrame(remoteDeviceId, frame);
1139
+ });
1140
+ this.peerSessions.set(remoteDeviceId, session);
1141
+ return session;
1142
+ }
1143
+ /**
1144
+ * Handles Explicit Hub forwarding or local delivery of a HUB_RELAY frame (Section 11).
1145
+ * Enforces authorization, deduplication, hop limit, and loop prevention.
1146
+ */
1147
+ async handleHubRelayFrame(fromPeerId, relayFrame) {
1148
+ const { header, payload } = relayFrame;
1149
+ const localId = this.getIdentity().deviceId;
1150
+ const targetId = header.targetDeviceId;
1151
+ const sourceId = header.sourceDeviceId ?? fromPeerId;
1152
+ const spaceId = header.spaceId ?? this.defaultSpaceId ?? "";
1153
+ const hopCount = header.hopCount ?? 0;
1154
+ const maxHops = header.maxHops ?? 2;
1155
+ if (!targetId)
1156
+ return;
1157
+ // 1. Deduplication & loop guard
1158
+ const now = Date.now();
1159
+ if (this.hubSeenRelayIds.has(header.messageId)) {
1160
+ this.recordDebug("hub", `Dropped duplicate hub relay frame ${header.messageId}`);
1161
+ return;
1162
+ }
1163
+ this.hubSeenRelayIds.set(header.messageId, now);
1164
+ if (this.hubSeenRelayIds.size > 2048) {
1165
+ const oldestKey = this.hubSeenRelayIds.keys().next().value;
1166
+ if (oldestKey)
1167
+ this.hubSeenRelayIds.delete(oldestKey);
1168
+ }
1169
+ // 2. If addressed to us, unwrap and deliver
1170
+ if (targetId === localId) {
1171
+ try {
1172
+ const inner = decodeWireFrame(payload);
1173
+ inner.header.sourceDeviceId = sourceId;
1174
+ const sourceSession = this.getOrCreateManagedPeerSession(sourceId, spaceId);
1175
+ sourceSession.multiplexer.deliverHubUnwrappedFrame(inner, fromPeerId);
1176
+ }
1177
+ catch {
1178
+ // Malformed inner frame
1179
+ }
1180
+ return;
1181
+ }
1182
+ // 3. Otherwise, we are asked to forward as an Explicit Hub
1183
+ if (!this.options.enableHubRelay) {
1184
+ this.recordDebug("hub", `Rejected relay from ${sourceId}: enableHubRelay is false`);
1185
+ return;
1186
+ }
1187
+ if (hopCount >= maxHops || sourceId === targetId) {
1188
+ this.recordDebug("hub", `Dropped relay frame ${header.messageId}: hop limit (${hopCount}/${maxHops}) or loop`);
1189
+ return;
1190
+ }
1191
+ // 4. Verify both source and target are authorized, non-revoked members of spaceId
1192
+ const revokedSet = this.revokedPeersBySpace.get(spaceId);
1193
+ if (revokedSet?.has(sourceId) || revokedSet?.has(targetId)) {
1194
+ this.recordDebug("security", `Hub blocked relay involving revoked peer in ${spaceId}`);
1195
+ return;
1196
+ }
1197
+ const spacePeers = this.peersBySpace.get(spaceId);
1198
+ const sourcePresence = spacePeers?.get(sourceId);
1199
+ const targetPresence = spacePeers?.get(targetId);
1200
+ if (sourcePresence &&
1201
+ (!sourcePresence.permissions.includes("connect") ||
1202
+ !sourcePresence.permissions.includes("messaging"))) {
1203
+ this.recordDebug("security", `Hub blocked unauthorized source ${sourceId}`);
1204
+ return;
1205
+ }
1206
+ if (targetPresence && !targetPresence.permissions.includes("connect")) {
1207
+ this.recordDebug("security", `Hub blocked unauthorized target ${targetId}`);
1208
+ return;
1209
+ }
1210
+ const targetSession = this.peerSessions.get(targetId);
1211
+ if (!targetSession || targetSession.getState() !== "connected") {
1212
+ this.recordDebug("hub", `Hub cannot forward to ${targetId}: target not connected`);
1213
+ return;
1214
+ }
1215
+ const forwardedHeader = {
1216
+ ...header,
1217
+ hopCount: hopCount + 1,
1218
+ };
1219
+ const encoded = encodeWireFrame(WireFrameType.HUB_RELAY, forwardedHeader, payload);
1220
+ await targetSession.multiplexer.sendRawWireFrame(WireFrameType.HUB_RELAY, forwardedHeader, payload);
1221
+ void encoded;
1222
+ this.recordDebug("hub", `Relayed frame ${header.messageId} from ${sourceId} to ${targetId} (hop ${hopCount + 1})`);
1223
+ }
1224
+ async connect(peerDeviceId, options) {
1225
+ const spaceId = options?.spaceId ?? this.defaultSpaceId ?? "";
1226
+ if (this.revokedPeersBySpace.get(spaceId)?.has(peerDeviceId)) {
1227
+ throw new Error(`Cannot connect to revoked device '${peerDeviceId}'`);
1228
+ }
1229
+ const session = this.getOrCreateManagedPeerSession(peerDeviceId, spaceId);
1230
+ await session.connect("sdk_connect");
1231
+ if (options?.waitForOpen) {
1232
+ await this.waitForPeerConnected(peerDeviceId, options.timeoutMs ?? 10_000);
1233
+ }
1234
+ return session;
1235
+ }
1236
+ waitForPeerConnected(peerDeviceId, timeoutMs = 10_000) {
1237
+ const sess = this.peerSessions.get(peerDeviceId);
1238
+ if (sess?.getState() === "connected") {
1239
+ return Promise.resolve();
1240
+ }
1241
+ return new Promise((resolve, reject) => {
1242
+ const timer = setTimeout(() => {
1243
+ off();
1244
+ reject(new Error(`Timed out waiting ${timeoutMs}ms for peer '${peerDeviceId}' to connect`));
1245
+ }, timeoutMs);
1246
+ const off = this.events.on("peerStateChange", (ev) => {
1247
+ if (ev.peerDeviceId === peerDeviceId && ev.state === "connected") {
1248
+ clearTimeout(timer);
1249
+ off();
1250
+ resolve();
1251
+ }
1252
+ else if (ev.peerDeviceId === peerDeviceId && ev.state === "failed") {
1253
+ clearTimeout(timer);
1254
+ off();
1255
+ reject(new Error(`Connection to peer '${peerDeviceId}' failed: ${ev.reason}`));
1256
+ }
1257
+ });
1258
+ });
1259
+ }
1260
+ getPeerSession(peerDeviceId) {
1261
+ return this.peerSessions.get(peerDeviceId);
1262
+ }
1263
+ getConnectedPeerIds() {
1264
+ const ids = [];
1265
+ for (const [peerId, sess] of this.peerSessions.entries()) {
1266
+ if (sess.getState() === "connected") {
1267
+ ids.push(peerId);
1268
+ }
1269
+ }
1270
+ return ids;
1271
+ }
1272
+ async sendBinaryMessage(peerDeviceId, payload, options = {}) {
1273
+ if (options.viaHubDeviceId) {
1274
+ const hubSess = this.peerSessions.get(options.viaHubDeviceId);
1275
+ if (!hubSess || hubSess.getState() !== "connected") {
1276
+ throw new Error(`Explicit hub '${options.viaHubDeviceId}' is not connected`);
1277
+ }
1278
+ const targetSess = this.getOrCreateManagedPeerSession(peerDeviceId, options.spaceId ?? this.defaultSpaceId ?? "");
1279
+ void targetSess;
1280
+ // Route via hub's multiplexer with targetDeviceId = peerDeviceId
1281
+ const messageId = options.messageId ?? `hubmsg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
1282
+ const innerFrame = encodeWireFrame(WireFrameType.MESSAGE_BINARY, {
1283
+ messageId,
1284
+ channel: options.channel ?? "default",
1285
+ timestamp: Date.now(),
1286
+ sourceDeviceId: this.getIdentity().deviceId,
1287
+ targetDeviceId: peerDeviceId,
1288
+ }, payload);
1289
+ await hubSess.multiplexer.sendRawWireFrame(WireFrameType.HUB_RELAY, {
1290
+ messageId: `relay_${messageId}`,
1291
+ channel: "hub_relay",
1292
+ timestamp: Date.now(),
1293
+ sourceDeviceId: this.getIdentity().deviceId,
1294
+ targetDeviceId: peerDeviceId,
1295
+ spaceId: options.spaceId ?? this.defaultSpaceId ?? "",
1296
+ hopCount: 0,
1297
+ maxHops: options.maxHops ?? 2,
1298
+ }, innerFrame, options.signal);
1299
+ return { messageId };
1300
+ }
1301
+ const sess = this.peerSessions.get(peerDeviceId);
1302
+ if (!sess || sess.getState() !== "connected") {
1303
+ throw new Error(`Peer '${peerDeviceId}' is not connected`);
1304
+ }
1305
+ return sess.multiplexer.sendBinary(payload, options);
1306
+ }
1307
+ async sendJsonMessage(peerDeviceId, value, options = {}) {
1308
+ if (options.viaHubDeviceId) {
1309
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
1310
+ const hubSess = this.peerSessions.get(options.viaHubDeviceId);
1311
+ if (!hubSess || hubSess.getState() !== "connected") {
1312
+ throw new Error(`Explicit hub '${options.viaHubDeviceId}' is not connected`);
1313
+ }
1314
+ const messageId = options.messageId ?? `hubmsg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
1315
+ const innerFrame = encodeWireFrame(WireFrameType.MESSAGE_JSON, {
1316
+ messageId,
1317
+ channel: options.channel ?? "default",
1318
+ timestamp: Date.now(),
1319
+ sourceDeviceId: this.getIdentity().deviceId,
1320
+ targetDeviceId: peerDeviceId,
1321
+ }, bytes);
1322
+ await hubSess.multiplexer.sendRawWireFrame(WireFrameType.HUB_RELAY, {
1323
+ messageId: `relay_${messageId}`,
1324
+ channel: "hub_relay",
1325
+ timestamp: Date.now(),
1326
+ sourceDeviceId: this.getIdentity().deviceId,
1327
+ targetDeviceId: peerDeviceId,
1328
+ spaceId: options.spaceId ?? this.defaultSpaceId ?? "",
1329
+ hopCount: 0,
1330
+ maxHops: options.maxHops ?? 2,
1331
+ }, innerFrame, options.signal);
1332
+ return { messageId };
1333
+ }
1334
+ const sess = this.peerSessions.get(peerDeviceId);
1335
+ if (!sess || sess.getState() !== "connected") {
1336
+ throw new Error(`Peer '${peerDeviceId}' is not connected`);
1337
+ }
1338
+ return sess.multiplexer.sendJson(value, options);
1339
+ }
1340
+ /**
1341
+ * Explicitly closes the client, all peer connections, signaling socket, timers, and listeners.
1342
+ * Disables all automatic reconnection loops.
1343
+ */
1344
+ close() {
1345
+ this.manuallyClosed = true;
1346
+ this.stopHeartbeat();
1347
+ if (this.wsReconnectTimer) {
1348
+ clearTimeout(this.wsReconnectTimer);
1349
+ this.wsReconnectTimer = null;
1350
+ }
1351
+ for (const pend of this.pendingRequests.values()) {
1352
+ clearTimeout(pend.timer);
1353
+ pend.reject(new Error("DropspaceClient closed"));
1354
+ }
1355
+ this.pendingRequests.clear();
1356
+ for (const sess of this.peerSessions.values()) {
1357
+ sess.close(false);
1358
+ sess.multiplexer.dispose();
1359
+ }
1360
+ this.peerSessions.clear();
1361
+ this.routeByPeer.clear();
1362
+ if (this.ws) {
1363
+ try {
1364
+ this.ws.close(1000, "client_closed");
1365
+ }
1366
+ catch { }
1367
+ this.ws = null;
1368
+ }
1369
+ this.transitionSignalingState("closed", "manual_close");
1370
+ this.events.removeAllListeners();
1371
+ }
1372
+ hasPeerPermission(peerDeviceId, permission, spaceId) {
1373
+ if (spaceId) {
1374
+ if (this.revokedPeersBySpace.get(spaceId)?.has(peerDeviceId)) {
1375
+ return false;
1376
+ }
1377
+ const presence = this.peersBySpace.get(spaceId)?.get(peerDeviceId);
1378
+ if (presence) {
1379
+ return presence.permissions.includes(permission);
1380
+ }
1381
+ for (const trusted of this.trustedPeers.values()) {
1382
+ if (trusted.peerDeviceId === peerDeviceId && trusted.spaceId === spaceId) {
1383
+ return trusted.permissions.includes(permission);
1384
+ }
1385
+ }
1386
+ return false;
1387
+ }
1388
+ for (const [spId, spacePeers] of this.peersBySpace.entries()) {
1389
+ if (this.revokedPeersBySpace.get(spId)?.has(peerDeviceId))
1390
+ continue;
1391
+ const presence = spacePeers.get(peerDeviceId);
1392
+ if (presence && presence.permissions.includes(permission)) {
1393
+ return true;
1394
+ }
1395
+ }
1396
+ for (const trusted of this.trustedPeers.values()) {
1397
+ if (trusted.peerDeviceId === peerDeviceId &&
1398
+ !this.revokedPeersBySpace.get(trusted.spaceId)?.has(peerDeviceId) &&
1399
+ trusted.permissions.includes(permission)) {
1400
+ return true;
1401
+ }
1402
+ }
1403
+ return false;
1404
+ }
1405
+ getPeerRouteInfo(peerDeviceId) {
1406
+ return this.routeByPeer.get(peerDeviceId) ?? null;
1407
+ }
1408
+ getActiveResourceCounts() {
1409
+ return {
1410
+ listenerCount: this.events.listenerCount(),
1411
+ pendingRequests: this.pendingRequests.size,
1412
+ activePeerSessions: this.peerSessions.size,
1413
+ hasHeartbeatTimer: this.heartbeatTimer !== null,
1414
+ hasReconnectTimer: this.wsReconnectTimer !== null,
1415
+ };
1416
+ }
1417
+ }
1418
+ export function createClient(options) {
1419
+ return new DropspaceClient(options);
1420
+ }
1421
+ //# sourceMappingURL=client.js.map