peer-client 1.0.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1086 -0
  3. package/dist/core/client.d.ts +41 -0
  4. package/dist/core/client.js +361 -0
  5. package/dist/core/emitter.d.ts +11 -0
  6. package/dist/core/emitter.js +46 -0
  7. package/dist/core/identity.d.ts +15 -0
  8. package/dist/core/identity.js +54 -0
  9. package/dist/core/index.d.ts +6 -0
  10. package/dist/core/index.js +6 -0
  11. package/dist/core/peer.d.ts +29 -0
  12. package/dist/core/peer.js +234 -0
  13. package/dist/core/transport.d.ts +35 -0
  14. package/dist/core/transport.js +174 -0
  15. package/dist/core/types.d.ts +173 -0
  16. package/dist/core/types.js +28 -0
  17. package/dist/crypto.d.ts +32 -0
  18. package/dist/crypto.js +168 -0
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.js +11 -0
  21. package/dist/media.d.ts +63 -0
  22. package/dist/media.js +275 -0
  23. package/dist/react/Audio.d.ts +6 -0
  24. package/dist/react/Audio.js +18 -0
  25. package/dist/react/PeerProvider.d.ts +17 -0
  26. package/dist/react/PeerProvider.js +46 -0
  27. package/dist/react/PeerStatus.d.ts +7 -0
  28. package/dist/react/PeerStatus.js +20 -0
  29. package/dist/react/TransferProgress.d.ts +10 -0
  30. package/dist/react/TransferProgress.js +17 -0
  31. package/dist/react/Video.d.ts +7 -0
  32. package/dist/react/Video.js +18 -0
  33. package/dist/react/index.d.ts +19 -0
  34. package/dist/react/index.js +18 -0
  35. package/dist/react/useBroadcast.d.ts +12 -0
  36. package/dist/react/useBroadcast.js +21 -0
  37. package/dist/react/useCRDT.d.ts +8 -0
  38. package/dist/react/useCRDT.js +37 -0
  39. package/dist/react/useE2E.d.ts +11 -0
  40. package/dist/react/useE2E.js +62 -0
  41. package/dist/react/useFileTransfer.d.ts +24 -0
  42. package/dist/react/useFileTransfer.js +133 -0
  43. package/dist/react/useIdentity.d.ts +9 -0
  44. package/dist/react/useIdentity.js +63 -0
  45. package/dist/react/useMatch.d.ts +11 -0
  46. package/dist/react/useMatch.js +33 -0
  47. package/dist/react/useMedia.d.ts +13 -0
  48. package/dist/react/useMedia.js +89 -0
  49. package/dist/react/useNamespace.d.ts +7 -0
  50. package/dist/react/useNamespace.js +38 -0
  51. package/dist/react/usePeer.d.ts +6 -0
  52. package/dist/react/usePeer.js +49 -0
  53. package/dist/react/usePeerClient.d.ts +7 -0
  54. package/dist/react/usePeerClient.js +5 -0
  55. package/dist/react/useRelay.d.ts +11 -0
  56. package/dist/react/useRelay.js +19 -0
  57. package/dist/react/useRoom.d.ts +17 -0
  58. package/dist/react/useRoom.js +67 -0
  59. package/dist/react/useSync.d.ts +8 -0
  60. package/dist/react/useSync.js +45 -0
  61. package/dist/room.d.ts +44 -0
  62. package/dist/room.js +246 -0
  63. package/dist/sync.d.ts +45 -0
  64. package/dist/sync.js +333 -0
  65. package/dist/transfer.d.ts +49 -0
  66. package/dist/transfer.js +454 -0
  67. package/package.json +76 -0
@@ -0,0 +1,454 @@
1
+ import { Emitter } from './core/emitter';
2
+ import { LIMITS } from './core/types';
3
+ function uid() {
4
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
5
+ }
6
+ function encodeChunk(index, data) {
7
+ const header = new ArrayBuffer(4);
8
+ new DataView(header).setUint32(0, index, true);
9
+ const combined = new Uint8Array(4 + data.byteLength);
10
+ combined.set(new Uint8Array(header), 0);
11
+ combined.set(new Uint8Array(data), 4);
12
+ return combined.buffer;
13
+ }
14
+ function decodeChunk(buffer) {
15
+ const view = new DataView(buffer);
16
+ const index = view.getUint32(0, true);
17
+ const data = buffer.slice(4);
18
+ return { index, data };
19
+ }
20
+ export class FileTransfer extends Emitter {
21
+ client;
22
+ sending = new Map();
23
+ receiving = new Map();
24
+ channelListeners = new Map();
25
+ constructor(client) {
26
+ super();
27
+ this.client = client;
28
+ }
29
+ async send(peer, file, filename) {
30
+ if (peer.connectionState !== 'connected') {
31
+ throw new Error('Peer not connected — P2P required for file transfer');
32
+ }
33
+ const id = uid();
34
+ const name = filename ?? (file instanceof File ? file.name : 'file');
35
+ const chunkSize = LIMITS.CHUNK_SIZE;
36
+ const totalChunks = Math.ceil(file.size / chunkSize);
37
+ const channelLabel = `${LIMITS.TRANSFER_CHANNEL_PREFIX}${id}`;
38
+ const channel = peer.createDataChannel({
39
+ label: channelLabel,
40
+ ordered: true,
41
+ });
42
+ const offer = {
43
+ _ft: true,
44
+ type: 'offer',
45
+ id,
46
+ filename: name,
47
+ size: file.size,
48
+ mime: file.type || 'application/octet-stream',
49
+ chunkSize,
50
+ totalChunks,
51
+ };
52
+ peer.send(offer, 'data');
53
+ return new Promise((resolve, reject) => {
54
+ const active = {
55
+ id,
56
+ file,
57
+ filename: name,
58
+ peer,
59
+ channel,
60
+ totalChunks,
61
+ chunkSize,
62
+ currentIndex: 0,
63
+ cancelled: false,
64
+ paused: false,
65
+ startTime: Date.now(),
66
+ bytesSent: 0,
67
+ resolve,
68
+ reject,
69
+ };
70
+ this.sending.set(id, active);
71
+ const offData = peer.on('data', (data) => {
72
+ if (!data?._ft || data.id !== id)
73
+ return;
74
+ if (data.type === 'accept') {
75
+ this.startSending(active);
76
+ }
77
+ else if (data.type === 'cancel') {
78
+ this.cancelSend(id);
79
+ }
80
+ else if (data.type === 'ack') {
81
+ this.handleAck(id, data.index);
82
+ }
83
+ else if (data.type === 'resume') {
84
+ active.currentIndex = data.lastIndex + 1;
85
+ this.startSending(active);
86
+ }
87
+ });
88
+ this.channelListeners.set(id, offData);
89
+ const timeout = setTimeout(() => {
90
+ if (this.sending.has(id) && active.currentIndex === 0 && !active.cancelled) {
91
+ this.cleanupSend(id);
92
+ reject(new Error('Transfer offer timeout — peer did not accept'));
93
+ }
94
+ }, 30000);
95
+ const origResolve = active.resolve;
96
+ active.resolve = (rid) => {
97
+ clearTimeout(timeout);
98
+ origResolve(rid);
99
+ };
100
+ const origReject = active.reject;
101
+ active.reject = (e) => {
102
+ clearTimeout(timeout);
103
+ origReject(e);
104
+ };
105
+ });
106
+ }
107
+ accept(id) {
108
+ const recv = this.receiving.get(id);
109
+ if (!recv)
110
+ return;
111
+ const peer = this.client.getPeer(recv.from);
112
+ if (!peer)
113
+ return;
114
+ const control = { _ft: true, type: 'accept', id };
115
+ peer.send(control, 'data');
116
+ }
117
+ reject(id) {
118
+ const recv = this.receiving.get(id);
119
+ if (!recv)
120
+ return;
121
+ const peer = this.client.getPeer(recv.from);
122
+ if (peer) {
123
+ const control = { _ft: true, type: 'cancel', id };
124
+ peer.send(control, 'data');
125
+ }
126
+ this.receiving.delete(id);
127
+ }
128
+ cancel(id) {
129
+ if (this.sending.has(id)) {
130
+ this.cancelSend(id);
131
+ }
132
+ if (this.receiving.has(id)) {
133
+ const recv = this.receiving.get(id);
134
+ const peer = this.client.getPeer(recv.from);
135
+ if (peer) {
136
+ const control = { _ft: true, type: 'cancel', id };
137
+ try {
138
+ peer.send(control, 'data');
139
+ }
140
+ catch { }
141
+ }
142
+ this.receiving.delete(id);
143
+ this.emit('cancelled', id);
144
+ }
145
+ }
146
+ handleIncoming(peer) {
147
+ const offData = peer.on('data', (data, channel) => {
148
+ if (data instanceof ArrayBuffer) {
149
+ this.handleBinaryChunk(data, peer.fingerprint, channel);
150
+ return;
151
+ }
152
+ if (data?._ft) {
153
+ this.handleControl(data, peer.fingerprint);
154
+ }
155
+ });
156
+ return offData;
157
+ }
158
+ requestResume(id, lastIndex) {
159
+ const recv = this.receiving.get(id);
160
+ if (!recv)
161
+ return;
162
+ const peer = this.client.getPeer(recv.from);
163
+ if (peer) {
164
+ const control = { _ft: true, type: 'resume', id, lastIndex };
165
+ peer.send(control, 'data');
166
+ }
167
+ }
168
+ getReceiveProgress(id) {
169
+ const recv = this.receiving.get(id);
170
+ if (!recv)
171
+ return null;
172
+ return {
173
+ id,
174
+ sent: recv.received,
175
+ total: recv.meta.totalChunks,
176
+ percentage: Math.round((recv.received / recv.meta.totalChunks) * 100),
177
+ };
178
+ }
179
+ handleControl(data, from) {
180
+ if (data.type === 'offer') {
181
+ const meta = {
182
+ id: data.id,
183
+ filename: data.filename,
184
+ size: data.size,
185
+ mime: data.mime,
186
+ totalChunks: data.totalChunks,
187
+ chunkSize: data.chunkSize,
188
+ };
189
+ const recv = {
190
+ id: data.id,
191
+ meta,
192
+ chunks: new Array(data.totalChunks).fill(null),
193
+ received: 0,
194
+ from,
195
+ startTime: Date.now(),
196
+ };
197
+ this.receiving.set(data.id, recv);
198
+ this.emit('incoming', meta, from);
199
+ }
200
+ else if (data.type === 'complete') {
201
+ this.assembleFile(data.id);
202
+ }
203
+ else if (data.type === 'cancel') {
204
+ if (this.sending.has(data.id)) {
205
+ this.cancelSend(data.id);
206
+ }
207
+ if (this.receiving.has(data.id)) {
208
+ this.receiving.delete(data.id);
209
+ this.emit('cancelled', data.id);
210
+ }
211
+ }
212
+ }
213
+ handleBinaryChunk(buffer, from, channel) {
214
+ if (!channel.startsWith(LIMITS.TRANSFER_CHANNEL_PREFIX))
215
+ return;
216
+ const transferId = channel.slice(LIMITS.TRANSFER_CHANNEL_PREFIX.length);
217
+ const recv = this.receiving.get(transferId);
218
+ if (!recv || recv.from !== from)
219
+ return;
220
+ const { index, data } = decodeChunk(buffer);
221
+ if (index >= recv.meta.totalChunks)
222
+ return;
223
+ if (recv.chunks[index] === null) {
224
+ recv.chunks[index] = data;
225
+ recv.received++;
226
+ }
227
+ const elapsed = (Date.now() - recv.startTime) / 1000 || 1;
228
+ const bytesReceived = recv.received * recv.meta.chunkSize;
229
+ const progress = {
230
+ id: transferId,
231
+ sent: recv.received,
232
+ total: recv.meta.totalChunks,
233
+ percentage: Math.round((recv.received / recv.meta.totalChunks) * 100),
234
+ bytesPerSecond: Math.round(bytesReceived / elapsed),
235
+ };
236
+ this.emit('progress', progress);
237
+ if (recv.received % LIMITS.ACK_INTERVAL === 0) {
238
+ const peer = this.client.getPeer(from);
239
+ if (peer) {
240
+ const ack = { _ft: true, type: 'ack', id: transferId, index };
241
+ try {
242
+ peer.send(ack, 'data');
243
+ }
244
+ catch { }
245
+ }
246
+ }
247
+ }
248
+ async startSending(active) {
249
+ const { channel } = active;
250
+ if (channel.readyState !== 'open') {
251
+ await new Promise((resolve, reject) => {
252
+ const timeout = setTimeout(() => reject(new Error('Transfer channel open timeout')), 15000);
253
+ channel.onopen = () => {
254
+ clearTimeout(timeout);
255
+ resolve();
256
+ };
257
+ });
258
+ }
259
+ channel.binaryType = 'arraybuffer';
260
+ channel.bufferedAmountLowThreshold = LIMITS.BUFFERED_AMOUNT_LOW;
261
+ const sendNext = async () => {
262
+ while (active.currentIndex < active.totalChunks && !active.cancelled) {
263
+ if (channel.bufferedAmount > LIMITS.BUFFERED_AMOUNT_HIGH) {
264
+ await new Promise((resolve) => {
265
+ channel.onbufferedamountlow = () => {
266
+ channel.onbufferedamountlow = null;
267
+ resolve();
268
+ };
269
+ });
270
+ if (active.cancelled)
271
+ break;
272
+ }
273
+ const start = active.currentIndex * active.chunkSize;
274
+ const end = Math.min(start + active.chunkSize, active.file.size);
275
+ const slice = active.file.slice(start, end);
276
+ const buffer = await new Response(slice).arrayBuffer();
277
+ const encoded = encodeChunk(active.currentIndex, buffer);
278
+ try {
279
+ channel.send(encoded);
280
+ }
281
+ catch (e) {
282
+ this.cleanupSend(active.id);
283
+ active.reject(new Error(`Send failed at chunk ${active.currentIndex}: ${e}`));
284
+ return;
285
+ }
286
+ active.bytesSent += buffer.byteLength;
287
+ active.currentIndex++;
288
+ const elapsed = (Date.now() - active.startTime) / 1000 || 1;
289
+ const progress = {
290
+ id: active.id,
291
+ sent: active.currentIndex,
292
+ total: active.totalChunks,
293
+ percentage: Math.round((active.currentIndex / active.totalChunks) * 100),
294
+ bytesPerSecond: Math.round(active.bytesSent / elapsed),
295
+ };
296
+ this.emit('progress', progress);
297
+ if (active.currentIndex % 50 === 0) {
298
+ await new Promise((r) => setTimeout(r, 0));
299
+ }
300
+ }
301
+ if (!active.cancelled && active.currentIndex >= active.totalChunks) {
302
+ // Ensure the buffer is empty before closing the channel and sending completion.
303
+ // This prevents race conditions where 'complete' message arrives before the last chunk.
304
+ try {
305
+ while (channel.bufferedAmount > 0) {
306
+ await new Promise((r) => setTimeout(r, 10));
307
+ }
308
+ }
309
+ catch (e) { }
310
+ // Small safety delay to ensure underlying transport flush
311
+ await new Promise((r) => setTimeout(r, 10));
312
+ const complete = { _ft: true, type: 'complete', id: active.id };
313
+ try {
314
+ active.peer.send(complete, 'data');
315
+ }
316
+ catch { }
317
+ this.emit('complete', active.id, 'sent');
318
+ this.cleanupSend(active.id);
319
+ active.resolve(active.id);
320
+ }
321
+ };
322
+ sendNext().catch((e) => {
323
+ this.cleanupSend(active.id);
324
+ active.reject(e);
325
+ });
326
+ }
327
+ handleAck(_id, _index) {
328
+ }
329
+ assembleFile(id) {
330
+ const recv = this.receiving.get(id);
331
+ if (!recv)
332
+ return;
333
+ const missing = recv.chunks.findIndex((c) => c === null);
334
+ if (missing !== -1 && missing < recv.meta.totalChunks) {
335
+ this.emit('error', { id, message: `Missing chunk ${missing}` });
336
+ return;
337
+ }
338
+ const parts = recv.chunks.filter((c) => c !== null);
339
+ const blob = new Blob(parts, { type: recv.meta.mime });
340
+ this.receiving.delete(id);
341
+ this.emit('complete', id, blob, recv.meta, recv.from);
342
+ }
343
+ cancelSend(id) {
344
+ const active = this.sending.get(id);
345
+ if (!active)
346
+ return;
347
+ active.cancelled = true;
348
+ this.cleanupSend(id);
349
+ this.emit('cancelled', id);
350
+ active.reject(new Error('Transfer cancelled'));
351
+ }
352
+ cleanupSend(id) {
353
+ const active = this.sending.get(id);
354
+ if (active) {
355
+ try {
356
+ active.channel.close();
357
+ }
358
+ catch { }
359
+ }
360
+ this.sending.delete(id);
361
+ const offData = this.channelListeners.get(id);
362
+ if (offData) {
363
+ offData();
364
+ this.channelListeners.delete(id);
365
+ }
366
+ }
367
+ destroy() {
368
+ this.sending.forEach((_, id) => {
369
+ const active = this.sending.get(id);
370
+ if (active)
371
+ active.cancelled = true;
372
+ });
373
+ this.sending.forEach((_, id) => this.cleanupSend(id));
374
+ this.receiving.clear();
375
+ this.removeAllListeners();
376
+ }
377
+ }
378
+ export class JSONTransfer {
379
+ client;
380
+ constructor(client) {
381
+ this.client = client;
382
+ }
383
+ sendToPeer(fingerprint, data, channel = 'data') {
384
+ const peer = this.client.getPeer(fingerprint);
385
+ if (peer && peer.connectionState === 'connected') {
386
+ try {
387
+ peer.send({ _json_transfer: true, data }, channel);
388
+ return;
389
+ }
390
+ catch { }
391
+ }
392
+ this.client.relay(fingerprint, { _json_transfer: true, data });
393
+ }
394
+ sendToRoom(roomId, data) {
395
+ this.client.broadcast(roomId, { _json_transfer: true, data });
396
+ }
397
+ onReceive(peer, callback) {
398
+ return peer.on('data', (raw) => {
399
+ if (raw?._json_transfer) {
400
+ callback(raw.data, peer.fingerprint);
401
+ }
402
+ });
403
+ }
404
+ onRelayReceive(callback) {
405
+ return this.client.on('relay', (from, payload) => {
406
+ if (payload?._json_transfer) {
407
+ callback(payload.data, from);
408
+ }
409
+ });
410
+ }
411
+ onBroadcastReceive(roomId, callback) {
412
+ return this.client.on('broadcast', (from, ns, payload) => {
413
+ if (ns === roomId && payload?._json_transfer) {
414
+ callback(payload.data, from);
415
+ }
416
+ });
417
+ }
418
+ }
419
+ export class ImageTransfer extends Emitter {
420
+ ft;
421
+ client;
422
+ constructor(client) {
423
+ super();
424
+ this.client = client;
425
+ this.ft = new FileTransfer(client);
426
+ this.ft.on('progress', (...args) => this.emit('progress', ...args));
427
+ this.ft.on('incoming', (...args) => this.emit('incoming', ...args));
428
+ this.ft.on('cancelled', (...args) => this.emit('cancelled', ...args));
429
+ this.ft.on('complete', (...args) => this.emit('complete', ...args));
430
+ this.ft.on('error', (...args) => this.emit('error', ...args));
431
+ }
432
+ getFileTransfer() {
433
+ return this.ft;
434
+ }
435
+ async send(peer, image, filename) {
436
+ return this.ft.send(peer, image, filename);
437
+ }
438
+ accept(id) {
439
+ this.ft.accept(id);
440
+ }
441
+ reject(id) {
442
+ this.ft.reject(id);
443
+ }
444
+ cancel(id) {
445
+ this.ft.cancel(id);
446
+ }
447
+ handleIncoming(peer) {
448
+ return this.ft.handleIncoming(peer);
449
+ }
450
+ destroy() {
451
+ this.ft.destroy();
452
+ this.removeAllListeners();
453
+ }
454
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "peer-client",
3
+ "version": "1.0.0",
4
+ "description": "Universal WebRTC peer-to-peer library with signaling, rooms, file transfer, state sync, and E2E encryption",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./react": {
15
+ "import": "./dist/react/index.js",
16
+ "types": "./dist/react/index.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "webrtc",
26
+ "p2p",
27
+ "peer-to-peer",
28
+ "signaling",
29
+ "file-transfer",
30
+ "crdt",
31
+ "e2e-encryption",
32
+ "data-channel",
33
+ "rooms",
34
+ "state-sync"
35
+ ],
36
+ "author": "",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": ""
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "peerDependencies": {
46
+ "react": ">=18.0.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "react": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "@testing-library/dom": "^10.4.1",
55
+ "@testing-library/react": "^16.3.2",
56
+ "@types/react": "^19.2.14",
57
+ "@types/react-dom": "^19.2.3",
58
+ "@types/ws": "^8.18.1",
59
+ "@vitest/browser": "^4.0.18",
60
+ "jsdom": "^28.0.0",
61
+ "node-datachannel": "^0.32.1",
62
+ "react": "^19.2.4",
63
+ "react-dom": "^19.2.4",
64
+ "typescript": "^5.7.3",
65
+ "vitest": "^4.0.18",
66
+ "ws": "^8.19.0"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "test:coverage": "vitest run --coverage",
73
+ "test:integration": "vitest run --config vitest.integration.config.ts",
74
+ "test:react": "vitest run --config vitest.react.config.ts"
75
+ }
76
+ }