@meshwhisper/sdk 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 +138 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +19 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/chaff/index.d.ts +91 -0
- package/dist/chaff/index.d.ts.map +1 -0
- package/dist/chaff/index.js +268 -0
- package/dist/chaff/index.js.map +1 -0
- package/dist/cluster/index.d.ts +159 -0
- package/dist/cluster/index.d.ts.map +1 -0
- package/dist/cluster/index.js +393 -0
- package/dist/cluster/index.js.map +1 -0
- package/dist/compliance/index.d.ts +129 -0
- package/dist/compliance/index.d.ts.map +1 -0
- package/dist/compliance/index.js +315 -0
- package/dist/compliance/index.js.map +1 -0
- package/dist/crypto/index.d.ts +65 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +146 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/group/index.d.ts +155 -0
- package/dist/group/index.d.ts.map +1 -0
- package/dist/group/index.js +560 -0
- package/dist/group/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/namespace/index.d.ts +155 -0
- package/dist/namespace/index.d.ts.map +1 -0
- package/dist/namespace/index.js +278 -0
- package/dist/namespace/index.js.map +1 -0
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +19 -0
- package/dist/node/index.js.map +1 -0
- package/dist/packet/index.d.ts +63 -0
- package/dist/packet/index.d.ts.map +1 -0
- package/dist/packet/index.js +244 -0
- package/dist/packet/index.js.map +1 -0
- package/dist/permissions/index.d.ts +107 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +282 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/persistence/idb-storage.d.ts +27 -0
- package/dist/persistence/idb-storage.d.ts.map +1 -0
- package/dist/persistence/idb-storage.js +75 -0
- package/dist/persistence/idb-storage.js.map +1 -0
- package/dist/persistence/index.d.ts +4 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +3 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/node-storage.d.ts +33 -0
- package/dist/persistence/node-storage.d.ts.map +1 -0
- package/dist/persistence/node-storage.js +90 -0
- package/dist/persistence/node-storage.js.map +1 -0
- package/dist/persistence/serialization.d.ts +4 -0
- package/dist/persistence/serialization.d.ts.map +1 -0
- package/dist/persistence/serialization.js +49 -0
- package/dist/persistence/serialization.js.map +1 -0
- package/dist/persistence/types.d.ts +29 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/persistence/types.js +5 -0
- package/dist/persistence/types.js.map +1 -0
- package/dist/ratchet/index.d.ts +80 -0
- package/dist/ratchet/index.d.ts.map +1 -0
- package/dist/ratchet/index.js +259 -0
- package/dist/ratchet/index.js.map +1 -0
- package/dist/reciprocity/index.d.ts +109 -0
- package/dist/reciprocity/index.d.ts.map +1 -0
- package/dist/reciprocity/index.js +311 -0
- package/dist/reciprocity/index.js.map +1 -0
- package/dist/relay/index.d.ts +87 -0
- package/dist/relay/index.d.ts.map +1 -0
- package/dist/relay/index.js +286 -0
- package/dist/relay/index.js.map +1 -0
- package/dist/routing/index.d.ts +136 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +478 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/sdk/index.d.ts +322 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +1530 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sybil/index.d.ts +123 -0
- package/dist/sybil/index.d.ts.map +1 -0
- package/dist/sybil/index.js +491 -0
- package/dist/sybil/index.js.map +1 -0
- package/dist/transport/browser/index.d.ts +34 -0
- package/dist/transport/browser/index.d.ts.map +1 -0
- package/dist/transport/browser/index.js +176 -0
- package/dist/transport/browser/index.js.map +1 -0
- package/dist/transport/local/index.d.ts +57 -0
- package/dist/transport/local/index.d.ts.map +1 -0
- package/dist/transport/local/index.js +442 -0
- package/dist/transport/local/index.js.map +1 -0
- package/dist/transport/negotiator/index.d.ts +79 -0
- package/dist/transport/negotiator/index.d.ts.map +1 -0
- package/dist/transport/negotiator/index.js +289 -0
- package/dist/transport/negotiator/index.js.map +1 -0
- package/dist/transport/node/index.d.ts +56 -0
- package/dist/transport/node/index.d.ts.map +1 -0
- package/dist/transport/node/index.js +209 -0
- package/dist/transport/node/index.js.map +1 -0
- package/dist/transport/noop/index.d.ts +11 -0
- package/dist/transport/noop/index.d.ts.map +1 -0
- package/dist/transport/noop/index.js +20 -0
- package/dist/transport/noop/index.js.map +1 -0
- package/dist/transport/p2p/index.d.ts +109 -0
- package/dist/transport/p2p/index.d.ts.map +1 -0
- package/dist/transport/p2p/index.js +237 -0
- package/dist/transport/p2p/index.js.map +1 -0
- package/dist/transport/websocket/index.d.ts +89 -0
- package/dist/transport/websocket/index.d.ts.map +1 -0
- package/dist/transport/websocket/index.js +498 -0
- package/dist/transport/websocket/index.js.map +1 -0
- package/dist/transport/websocket/serialize.d.ts +5 -0
- package/dist/transport/websocket/serialize.d.ts.map +1 -0
- package/dist/transport/websocket/serialize.js +55 -0
- package/dist/transport/websocket/serialize.js.map +1 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/x3dh/index.d.ts +120 -0
- package/dist/x3dh/index.d.ts.map +1 -0
- package/dist/x3dh/index.js +290 -0
- package/dist/x3dh/index.js.map +1 -0
- package/package.json +59 -0
- package/src/browser/index.ts +19 -0
- package/src/chaff/index.ts +340 -0
- package/src/cluster/index.ts +482 -0
- package/src/compliance/index.ts +407 -0
- package/src/crypto/index.ts +193 -0
- package/src/group/index.ts +719 -0
- package/src/index.ts +87 -0
- package/src/lz4js.d.ts +58 -0
- package/src/namespace/index.ts +336 -0
- package/src/node/index.ts +19 -0
- package/src/packet/index.ts +326 -0
- package/src/permissions/index.ts +405 -0
- package/src/persistence/idb-storage.ts +83 -0
- package/src/persistence/index.ts +3 -0
- package/src/persistence/node-storage.ts +96 -0
- package/src/persistence/serialization.ts +75 -0
- package/src/persistence/types.ts +33 -0
- package/src/ratchet/index.ts +363 -0
- package/src/reciprocity/index.ts +371 -0
- package/src/relay/index.ts +382 -0
- package/src/routing/index.ts +577 -0
- package/src/sdk/index.ts +1994 -0
- package/src/sybil/index.ts +661 -0
- package/src/transport/browser/index.ts +201 -0
- package/src/transport/local/index.ts +540 -0
- package/src/transport/negotiator/index.ts +397 -0
- package/src/transport/node/index.ts +234 -0
- package/src/transport/noop/index.ts +22 -0
- package/src/transport/p2p/index.ts +345 -0
- package/src/transport/websocket/index.ts +660 -0
- package/src/transport/websocket/serialize.ts +68 -0
- package/src/types.ts +275 -0
- package/src/x3dh/index.ts +388 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MeshWhisper SDK — Bearer Negotiator
|
|
3
|
+
// Bearer-agnostic transport layer that selects the best
|
|
4
|
+
// available channel automatically with hybrid fragmentation.
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Transport,
|
|
9
|
+
Packet,
|
|
10
|
+
DeviceCapability,
|
|
11
|
+
BearerType,
|
|
12
|
+
BatteryState,
|
|
13
|
+
RelayWillingness,
|
|
14
|
+
} from '../../types.js';
|
|
15
|
+
import { PacketFlags } from '../../types.js';
|
|
16
|
+
|
|
17
|
+
// --- Constants ---
|
|
18
|
+
|
|
19
|
+
const MAX_PAYLOAD_SIZE = 65535;
|
|
20
|
+
const FRAGMENT_HEADER_SIZE = 8; // fragmentId(4) + fragmentIndex(2) + totalFragments(2)
|
|
21
|
+
const MAX_FRAGMENT_PAYLOAD = MAX_PAYLOAD_SIZE - FRAGMENT_HEADER_SIZE;
|
|
22
|
+
|
|
23
|
+
/** Priority map — lower number = higher priority. */
|
|
24
|
+
const BEARER_PRIORITY: Record<BearerType, number> = {
|
|
25
|
+
platform_p2p: 0,
|
|
26
|
+
local_net: 1,
|
|
27
|
+
internet: 2,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// --- Fragment helpers ---
|
|
31
|
+
|
|
32
|
+
export interface FragmentHeader {
|
|
33
|
+
fragmentId: number;
|
|
34
|
+
fragmentIndex: number;
|
|
35
|
+
totalFragments: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encodeFragmentHeader(header: FragmentHeader): Uint8Array {
|
|
39
|
+
const buf = new Uint8Array(FRAGMENT_HEADER_SIZE);
|
|
40
|
+
const view = new DataView(buf.buffer);
|
|
41
|
+
view.setUint32(0, header.fragmentId, false);
|
|
42
|
+
view.setUint16(4, header.fragmentIndex, false);
|
|
43
|
+
view.setUint16(6, header.totalFragments, false);
|
|
44
|
+
return buf;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decodeFragmentHeader(data: Uint8Array): FragmentHeader {
|
|
48
|
+
if (data.byteLength < FRAGMENT_HEADER_SIZE) {
|
|
49
|
+
throw new Error('Fragment header too short');
|
|
50
|
+
}
|
|
51
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
52
|
+
return {
|
|
53
|
+
fragmentId: view.getUint32(0, false),
|
|
54
|
+
fragmentIndex: view.getUint16(4, false),
|
|
55
|
+
totalFragments: view.getUint16(6, false),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Receive callback type ---
|
|
60
|
+
|
|
61
|
+
export type NegotiatorReceiveCallback = (
|
|
62
|
+
packet: Packet,
|
|
63
|
+
source: string,
|
|
64
|
+
bearer: BearerType,
|
|
65
|
+
) => void;
|
|
66
|
+
|
|
67
|
+
// --- BearerNegotiator ---
|
|
68
|
+
|
|
69
|
+
export class BearerNegotiator {
|
|
70
|
+
private readonly transports: Map<BearerType, Transport> = new Map();
|
|
71
|
+
private receiveCallback: NegotiatorReceiveCallback | null = null;
|
|
72
|
+
private fragmentIdCounter = 0;
|
|
73
|
+
|
|
74
|
+
constructor(transports: Transport[] = []) {
|
|
75
|
+
for (const t of transports) {
|
|
76
|
+
this.registerTransport(t);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- Transport registration & probing ----
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Register a transport and wire its onReceive into the aggregated handler.
|
|
84
|
+
* If a transport with the same bearer type was already registered it is replaced.
|
|
85
|
+
*/
|
|
86
|
+
registerTransport(transport: Transport): void {
|
|
87
|
+
this.transports.set(transport.type, transport);
|
|
88
|
+
|
|
89
|
+
// Wire up receive aggregation — capture the bearer type at registration time.
|
|
90
|
+
const bearerType = transport.type;
|
|
91
|
+
transport.onReceive((packet: Packet, source: string) => {
|
|
92
|
+
this.receiveCallback?.(packet, source, bearerType);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Probe every registered transport for availability and build a capability
|
|
98
|
+
* advertisement that can be shared with peers.
|
|
99
|
+
*/
|
|
100
|
+
async probeAvailability(): Promise<DeviceCapability> {
|
|
101
|
+
const results = await Promise.allSettled(
|
|
102
|
+
Array.from(this.transports.entries()).map(async ([type, transport]) => ({
|
|
103
|
+
type,
|
|
104
|
+
available: await transport.isAvailable(),
|
|
105
|
+
})),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const availability: Record<BearerType, boolean> = {
|
|
109
|
+
platform_p2p: false,
|
|
110
|
+
local_net: false,
|
|
111
|
+
internet: false,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const result of results) {
|
|
115
|
+
if (result.status === 'fulfilled') {
|
|
116
|
+
availability[result.value.type] = result.value.available;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Battery state and relay willingness are computed externally — defaults
|
|
121
|
+
// here represent a reasonable "unknown" baseline.
|
|
122
|
+
return {
|
|
123
|
+
bearerPlatformP2P: availability.platform_p2p,
|
|
124
|
+
bearerLocalNet: availability.local_net,
|
|
125
|
+
bearerInternet: availability.internet,
|
|
126
|
+
inboundConnectable: availability.internet || availability.local_net,
|
|
127
|
+
batteryState: 'high',
|
|
128
|
+
relayWillingness: 'willing',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return all registered transports sorted by priority (highest first).
|
|
134
|
+
* Only includes transports whose isAvailable() resolved to true.
|
|
135
|
+
*/
|
|
136
|
+
getAvailableTransports(): Transport[] {
|
|
137
|
+
return Array.from(this.transports.values()).sort(
|
|
138
|
+
(a, b) => BEARER_PRIORITY[a.type] - BEARER_PRIORITY[b.type],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- Best-path selection ----
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Select the highest-priority transport that is currently available and can
|
|
146
|
+
* reach `destination`. Falls back through all registered transports.
|
|
147
|
+
*/
|
|
148
|
+
async selectTransport(destination: string): Promise<Transport | null> {
|
|
149
|
+
const sorted = this.getAvailableTransports();
|
|
150
|
+
|
|
151
|
+
for (const transport of sorted) {
|
|
152
|
+
try {
|
|
153
|
+
const available = await transport.isAvailable();
|
|
154
|
+
if (available) {
|
|
155
|
+
return transport;
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Transport probe failed — skip to next.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- Smart sending ----
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Send a packet via the best available transport for the given destination.
|
|
169
|
+
* Throws if no transport is available.
|
|
170
|
+
*/
|
|
171
|
+
async send(packet: Packet, destination: string): Promise<void> {
|
|
172
|
+
const transport = await this.selectTransport(destination);
|
|
173
|
+
if (!transport) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`No available transport for destination: ${destination}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
await transport.send(packet, destination);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Broadcast a packet across **all** available transports concurrently.
|
|
183
|
+
* Errors on individual transports are suppressed so a single failure
|
|
184
|
+
* does not prevent delivery on other bearers.
|
|
185
|
+
*/
|
|
186
|
+
async broadcast(packet: Packet): Promise<void> {
|
|
187
|
+
const transports = this.getAvailableTransports();
|
|
188
|
+
|
|
189
|
+
const results = await Promise.allSettled(
|
|
190
|
+
transports.map(async (t) => {
|
|
191
|
+
const available = await t.isAvailable();
|
|
192
|
+
if (available) {
|
|
193
|
+
// Broadcast destination is empty string — the transport layer
|
|
194
|
+
// is expected to fan out to all known peers.
|
|
195
|
+
await t.send(packet, '');
|
|
196
|
+
}
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// If every transport failed, surface an error.
|
|
201
|
+
const allFailed = results.every((r) => r.status === 'rejected');
|
|
202
|
+
if (allFailed && results.length > 0) {
|
|
203
|
+
throw new Error('Broadcast failed on all available transports');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- Hybrid fragmentation ----
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Fragment a large payload across available bearers. Each fragment is
|
|
211
|
+
* wrapped in its own Packet with a fragment header prepended to the
|
|
212
|
+
* encrypted payload field.
|
|
213
|
+
*/
|
|
214
|
+
async sendLargePayload(
|
|
215
|
+
destHash: Uint8Array,
|
|
216
|
+
senderEphId: Uint8Array,
|
|
217
|
+
payload: Uint8Array,
|
|
218
|
+
destination: string,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
const totalFragments = Math.ceil(payload.byteLength / MAX_FRAGMENT_PAYLOAD);
|
|
221
|
+
if (totalFragments > 0xffff) {
|
|
222
|
+
throw new Error('Payload too large: exceeds maximum fragment count');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fragmentId = this.nextFragmentId();
|
|
226
|
+
const transports = this.getAvailableTransports();
|
|
227
|
+
const availableTransports: Transport[] = [];
|
|
228
|
+
|
|
229
|
+
for (const t of transports) {
|
|
230
|
+
try {
|
|
231
|
+
if (await t.isAvailable()) {
|
|
232
|
+
availableTransports.push(t);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// skip
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (availableTransports.length === 0) {
|
|
240
|
+
throw new Error('No available transport for large payload delivery');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const sendPromises: Promise<void>[] = [];
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < totalFragments; i++) {
|
|
246
|
+
const start = i * MAX_FRAGMENT_PAYLOAD;
|
|
247
|
+
const end = Math.min(start + MAX_FRAGMENT_PAYLOAD, payload.byteLength);
|
|
248
|
+
const chunk = payload.slice(start, end);
|
|
249
|
+
|
|
250
|
+
const header = encodeFragmentHeader({
|
|
251
|
+
fragmentId,
|
|
252
|
+
fragmentIndex: i,
|
|
253
|
+
totalFragments,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Prepend fragment header to payload chunk.
|
|
257
|
+
const fragmentPayload = new Uint8Array(header.byteLength + chunk.byteLength);
|
|
258
|
+
fragmentPayload.set(header, 0);
|
|
259
|
+
fragmentPayload.set(chunk, header.byteLength);
|
|
260
|
+
|
|
261
|
+
const packet: Packet = {
|
|
262
|
+
version: 1,
|
|
263
|
+
flags: PacketFlags.DATA,
|
|
264
|
+
destHash,
|
|
265
|
+
senderEphemeralId: senderEphId,
|
|
266
|
+
ttl: 7,
|
|
267
|
+
payloadLength: fragmentPayload.byteLength,
|
|
268
|
+
encryptedPayload: fragmentPayload,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Round-robin fragments across available bearers for hybrid delivery.
|
|
272
|
+
const transport = availableTransports[i % availableTransports.length]!;
|
|
273
|
+
sendPromises.push(transport.send(packet, destination));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await Promise.all(sendPromises);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Reassemble fragments into the original payload.
|
|
281
|
+
* Returns `null` if any fragment is missing.
|
|
282
|
+
*/
|
|
283
|
+
reassembleFragments(
|
|
284
|
+
fragments: Map<number, Uint8Array>,
|
|
285
|
+
totalFragments: number,
|
|
286
|
+
): Uint8Array | null {
|
|
287
|
+
if (fragments.size !== totalFragments) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Validate all indices are present.
|
|
292
|
+
for (let i = 0; i < totalFragments; i++) {
|
|
293
|
+
if (!fragments.has(i)) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Compute total length.
|
|
299
|
+
let totalLength = 0;
|
|
300
|
+
for (let i = 0; i < totalFragments; i++) {
|
|
301
|
+
totalLength += fragments.get(i)!.byteLength;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Concatenate in order.
|
|
305
|
+
const result = new Uint8Array(totalLength);
|
|
306
|
+
let offset = 0;
|
|
307
|
+
for (let i = 0; i < totalFragments; i++) {
|
|
308
|
+
const chunk = fragments.get(i)!;
|
|
309
|
+
result.set(chunk, offset);
|
|
310
|
+
offset += chunk.byteLength;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- Relay willingness computation ----
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Compute how willing this device should be to act as a relay node based
|
|
320
|
+
* on battery state, current bearer, and reciprocity with the requesting peer.
|
|
321
|
+
*/
|
|
322
|
+
computeRelayWillingness(
|
|
323
|
+
batteryState: BatteryState,
|
|
324
|
+
currentBearer: BearerType,
|
|
325
|
+
reciprocityScore: number,
|
|
326
|
+
): RelayWillingness {
|
|
327
|
+
// Reciprocity gate: if the peer is a free-rider, refuse regardless.
|
|
328
|
+
if (reciprocityScore < 0.1) {
|
|
329
|
+
return 'unavailable';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Battery critically low — never relay.
|
|
333
|
+
if (batteryState === 'low' || batteryState === 'critical') {
|
|
334
|
+
return 'unavailable';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const onWifi = currentBearer === 'local_net' || currentBearer === 'platform_p2p';
|
|
338
|
+
|
|
339
|
+
// Charging + wifi-class bearer → eager.
|
|
340
|
+
if (batteryState === 'charging' && onWifi) {
|
|
341
|
+
return 'eager';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// High battery + wifi-class bearer → willing.
|
|
345
|
+
if (batteryState === 'high' && onWifi) {
|
|
346
|
+
return 'willing';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Medium battery OR cellular bearer → reluctant.
|
|
350
|
+
if (batteryState === 'medium' || currentBearer === 'internet') {
|
|
351
|
+
return 'reluctant';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Charging but on cellular.
|
|
355
|
+
if (batteryState === 'charging') {
|
|
356
|
+
return 'reluctant';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Fallback: willing (high battery, non-wifi, non-internet — shouldn't
|
|
360
|
+
// normally occur but safe default).
|
|
361
|
+
return 'willing';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- Event aggregation ----
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Register a single callback that receives packets from **all** registered
|
|
368
|
+
* transports, tagged with the bearer type they arrived on.
|
|
369
|
+
*
|
|
370
|
+
* Only one callback is active at a time — calling this again replaces the
|
|
371
|
+
* previous handler.
|
|
372
|
+
*/
|
|
373
|
+
onReceive(callback: NegotiatorReceiveCallback): void {
|
|
374
|
+
this.receiveCallback = callback;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---- Internal helpers ----
|
|
378
|
+
|
|
379
|
+
private nextFragmentId(): number {
|
|
380
|
+
// Wrap at 32-bit unsigned max to stay within the 4-byte header field.
|
|
381
|
+
this.fragmentIdCounter = (this.fragmentIdCounter + 1) & 0xffffffff;
|
|
382
|
+
return this.fragmentIdCounter;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- Re-exports for consumer convenience ---
|
|
387
|
+
|
|
388
|
+
export {
|
|
389
|
+
MAX_PAYLOAD_SIZE,
|
|
390
|
+
MAX_FRAGMENT_PAYLOAD,
|
|
391
|
+
FRAGMENT_HEADER_SIZE,
|
|
392
|
+
BEARER_PRIORITY,
|
|
393
|
+
encodeFragmentHeader,
|
|
394
|
+
decodeFragmentHeader,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
export type { FragmentHeader as FragmentHeaderType };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MeshWhisper SDK — Node Transport
|
|
3
|
+
// Connects the SDK to a MeshWhisper Node (relay + directory).
|
|
4
|
+
// All traffic flows through the Node WebSocket endpoint; the
|
|
5
|
+
// Node routes packets by destination hash and buffers blobs
|
|
6
|
+
// for offline recipients.
|
|
7
|
+
// ============================================================
|
|
8
|
+
|
|
9
|
+
import { WebSocket, type RawData } from 'ws';
|
|
10
|
+
import type { Transport, Packet, PushConfig } from '../../types.js';
|
|
11
|
+
import {
|
|
12
|
+
serializePacket,
|
|
13
|
+
deserializePacket,
|
|
14
|
+
HEADER_SIZE,
|
|
15
|
+
} from '../websocket/serialize.js';
|
|
16
|
+
|
|
17
|
+
// ---- Constants ----
|
|
18
|
+
|
|
19
|
+
/** Foundation-hosted relay nodes. Used when node config is "mesh". */
|
|
20
|
+
export const FOUNDATION_RELAY_NODES = [
|
|
21
|
+
'wss://relay.meshwhisper.io', // TODO: deploy actual Foundation nodes
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const RECONNECT_BASE_MS = 1_000;
|
|
25
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
// ---- NodeTransport ----
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Transport that connects to a MeshWhisper Node for relay and
|
|
31
|
+
* store-and-forward delivery.
|
|
32
|
+
*
|
|
33
|
+
* Sends a `hello` control message on connect to register the device's
|
|
34
|
+
* current destination hashes. The Node then routes incoming packets
|
|
35
|
+
* to the registered hashes and delivers any queued blobs.
|
|
36
|
+
*
|
|
37
|
+
* All outbound packets are forwarded to the Node regardless of the
|
|
38
|
+
* `destination` argument — the packet's destHash header field is what
|
|
39
|
+
* the Node uses for routing.
|
|
40
|
+
*/
|
|
41
|
+
export class NodeTransport implements Transport {
|
|
42
|
+
readonly type = 'internet' as const;
|
|
43
|
+
|
|
44
|
+
private ws: WebSocket | null = null;
|
|
45
|
+
private receiveCallbacks: Array<(packet: Packet, source: string) => void> = [];
|
|
46
|
+
private running = false;
|
|
47
|
+
private reconnectAttempt = 0;
|
|
48
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param nodeUrl - The WebSocket URL of the Node, or "mesh" for Foundation nodes.
|
|
52
|
+
* @param getDestHashes - Callback returning the device's current dest hashes
|
|
53
|
+
* as hex strings. Called on every (re)connect so the Node always has
|
|
54
|
+
* up-to-date hashes.
|
|
55
|
+
* @param pushConfig - Optional push token to register with the Node so it can
|
|
56
|
+
* wake the device via APNs/FCM when a message arrives while offline.
|
|
57
|
+
*/
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly nodeUrl: string,
|
|
60
|
+
private readonly getDestHashes: () => string[],
|
|
61
|
+
private pushConfig?: PushConfig,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
// ---- Transport interface ----
|
|
65
|
+
|
|
66
|
+
async start(): Promise<void> {
|
|
67
|
+
this.running = true;
|
|
68
|
+
await this.connect();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async stop(): Promise<void> {
|
|
72
|
+
this.running = false;
|
|
73
|
+
if (this.reconnectTimer) {
|
|
74
|
+
clearTimeout(this.reconnectTimer);
|
|
75
|
+
this.reconnectTimer = null;
|
|
76
|
+
}
|
|
77
|
+
if (this.ws) {
|
|
78
|
+
this.ws.close(1000, 'shutdown');
|
|
79
|
+
this.ws = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async isAvailable(): Promise<boolean> {
|
|
84
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async send(packet: Packet, _destination: string): Promise<void> {
|
|
88
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
89
|
+
throw new Error('NodeTransport: not connected to Node');
|
|
90
|
+
}
|
|
91
|
+
const binary = serializePacket(packet);
|
|
92
|
+
return new Promise<void>((resolve, reject) => {
|
|
93
|
+
this.ws!.send(binary, { binary: true }, (err) => {
|
|
94
|
+
if (err) reject(new Error(`NodeTransport send failed: ${err.message}`));
|
|
95
|
+
else resolve();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onReceive(callback: (packet: Packet, source: string) => void): void {
|
|
101
|
+
this.receiveCallbacks.push(callback);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Connection management ----
|
|
105
|
+
|
|
106
|
+
private resolveUrl(): string {
|
|
107
|
+
if (this.nodeUrl === 'mesh') {
|
|
108
|
+
return FOUNDATION_RELAY_NODES[0];
|
|
109
|
+
}
|
|
110
|
+
return this.nodeUrl;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private connect(): Promise<void> {
|
|
114
|
+
const url = this.resolveUrl();
|
|
115
|
+
|
|
116
|
+
return new Promise<void>((resolve, reject) => {
|
|
117
|
+
let resolved = false;
|
|
118
|
+
|
|
119
|
+
const ws = new WebSocket(url);
|
|
120
|
+
ws.binaryType = 'arraybuffer';
|
|
121
|
+
|
|
122
|
+
ws.on('open', () => {
|
|
123
|
+
this.ws = ws;
|
|
124
|
+
this.reconnectAttempt = 0;
|
|
125
|
+
resolved = true;
|
|
126
|
+
|
|
127
|
+
// Register dest hashes (and optional push token) with the Node
|
|
128
|
+
ws.send(JSON.stringify(this.buildHello()));
|
|
129
|
+
|
|
130
|
+
resolve();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
ws.on('message', (raw: RawData) => {
|
|
134
|
+
this.handleMessage(raw);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
ws.on('close', () => {
|
|
138
|
+
this.ws = null;
|
|
139
|
+
if (this.running) this.scheduleReconnect();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
ws.on('error', (err: Error) => {
|
|
143
|
+
if (!resolved) {
|
|
144
|
+
resolved = true;
|
|
145
|
+
reject(new Error(`NodeTransport: failed to connect to ${url}: ${err.message}`));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private scheduleReconnect(): void {
|
|
152
|
+
if (!this.running) return;
|
|
153
|
+
const delay = Math.min(
|
|
154
|
+
RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt),
|
|
155
|
+
RECONNECT_MAX_MS,
|
|
156
|
+
);
|
|
157
|
+
this.reconnectAttempt++;
|
|
158
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
159
|
+
if (!this.running) return;
|
|
160
|
+
try {
|
|
161
|
+
await this.connect();
|
|
162
|
+
} catch {
|
|
163
|
+
this.scheduleReconnect();
|
|
164
|
+
}
|
|
165
|
+
}, delay);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- Message handling ----
|
|
169
|
+
|
|
170
|
+
private handleMessage(raw: RawData): void {
|
|
171
|
+
// Binary messages are relay packets
|
|
172
|
+
if (raw instanceof ArrayBuffer && raw.byteLength >= HEADER_SIZE) {
|
|
173
|
+
try {
|
|
174
|
+
const packet = deserializePacket(new Uint8Array(raw));
|
|
175
|
+
const source = this.resolveUrl();
|
|
176
|
+
for (const cb of this.receiveCallbacks) {
|
|
177
|
+
try { cb(packet, source); } catch { /* swallow */ }
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Malformed packet — discard
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// JSON messages from the Node (informational; no action needed in v1)
|
|
186
|
+
if (typeof raw === 'string' || raw instanceof Buffer) {
|
|
187
|
+
try {
|
|
188
|
+
JSON.parse(raw.toString());
|
|
189
|
+
// Reserved for future Node→client control messages
|
|
190
|
+
} catch { /* not JSON */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- Dest hash refresh ----
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Re-registers the device's destination hashes with the Node.
|
|
198
|
+
* Call this when the epoch hour rolls over and hashes rotate.
|
|
199
|
+
*/
|
|
200
|
+
refreshDestHashes(): void {
|
|
201
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
202
|
+
this.ws.send(JSON.stringify(this.buildHello()));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Update the push token (e.g. after APNs/FCM issues a new token).
|
|
207
|
+
* Re-registers with the Node immediately if connected.
|
|
208
|
+
*/
|
|
209
|
+
setPushConfig(pushConfig: PushConfig | undefined): void {
|
|
210
|
+
this.pushConfig = pushConfig;
|
|
211
|
+
this.refreshDestHashes();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- Private helpers ----
|
|
215
|
+
|
|
216
|
+
private buildHello(): object {
|
|
217
|
+
const msg: Record<string, unknown> = {
|
|
218
|
+
type: 'hello',
|
|
219
|
+
destHashes: this.getDestHashes(),
|
|
220
|
+
};
|
|
221
|
+
if (this.pushConfig) {
|
|
222
|
+
msg['pushPlatform'] = this.pushConfig.platform;
|
|
223
|
+
if (this.pushConfig.platform === 'webpush') {
|
|
224
|
+
msg['pushSubscription'] = JSON.stringify(this.pushConfig.subscription);
|
|
225
|
+
} else {
|
|
226
|
+
msg['pushToken'] = this.pushConfig.token;
|
|
227
|
+
if (this.pushConfig.platform === 'apns' && this.pushConfig.topic) {
|
|
228
|
+
msg['pushTopic'] = this.pushConfig.topic;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return msg;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MeshWhisper SDK — No-Op Transport
|
|
3
|
+
// Satisfies the Transport interface but does nothing.
|
|
4
|
+
// Used as a placeholder for transports that are unavailable
|
|
5
|
+
// in the current environment (e.g. LocalTransport in a browser).
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
import type { Transport, Packet, BearerType } from '../../types.js';
|
|
9
|
+
|
|
10
|
+
export class NoOpTransport implements Transport {
|
|
11
|
+
constructor(readonly type: BearerType) {}
|
|
12
|
+
|
|
13
|
+
async start(): Promise<void> {}
|
|
14
|
+
async stop(): Promise<void> {}
|
|
15
|
+
async isAvailable(): Promise<boolean> { return false; }
|
|
16
|
+
|
|
17
|
+
async send(_packet: Packet, _destination: string): Promise<void> {
|
|
18
|
+
throw new Error(`NoOpTransport(${this.type}): not available in this environment`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onReceive(_callback: (packet: Packet, source: string) => void): void {}
|
|
22
|
+
}
|