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