@elefunc/send 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.
@@ -0,0 +1,1435 @@
1
+ import { resolve } from "node:path"
2
+ import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
3
+ import { RTCPeerConnection } from "werift"
4
+ import { loadLocalFiles, readFileChunk, saveIncomingFile, type LocalFile } from "./files"
5
+ import {
6
+ BASE_ICE_SERVERS,
7
+ BUFFER_HIGH,
8
+ CHUNK,
9
+ FINAL_STATUSES,
10
+ SENDABLE_STATUSES,
11
+ SIGNAL_WS_URL,
12
+ buildCliProfile,
13
+ cleanText,
14
+ cleanLocalId,
15
+ cleanName,
16
+ cleanRoom,
17
+ displayPeerName,
18
+ fallbackName,
19
+ formatEta,
20
+ formatRate,
21
+ signalEpoch,
22
+ stamp,
23
+ turnStateLabel,
24
+ type CandidateSignal,
25
+ type DataMessage,
26
+ type DescriptionSignal,
27
+ type Direction,
28
+ type LogEntry,
29
+ type PeerProfile,
30
+ type Presence,
31
+ type SignalMessage,
32
+ type SocketState,
33
+ type TransferStatus,
34
+ uid,
35
+ } from "./protocol"
36
+
37
+ interface PeerState {
38
+ id: string
39
+ name: string
40
+ presence: Presence
41
+ selected: boolean
42
+ polite: boolean
43
+ pc: RTCPeerConnection | null
44
+ dc: RTCDataChannel | null
45
+ rtcEpoch: number
46
+ remoteEpoch: number
47
+ makingOffer: boolean
48
+ createdAt: number
49
+ lastSeenAt: number
50
+ outgoingQueue: string[]
51
+ activeOutgoing: string
52
+ activeIncoming: string
53
+ profile?: PeerProfile
54
+ turnAvailable: boolean
55
+ terminalReason: string
56
+ lastError: string
57
+ connectivity: PeerConnectivitySnapshot
58
+ }
59
+
60
+ interface TransferState {
61
+ id: string
62
+ peerId: string
63
+ peerName: string
64
+ direction: Direction
65
+ status: TransferStatus
66
+ name: string
67
+ size: number
68
+ type: string
69
+ lastModified: number
70
+ totalChunks: number
71
+ chunkSize: number
72
+ bytes: number
73
+ chunks: number
74
+ speed: number
75
+ eta: number
76
+ error: string
77
+ createdAt: number
78
+ updatedAt: number
79
+ startedAt: number
80
+ endedAt: number
81
+ savedAt: number
82
+ savedPath?: string
83
+ file?: LocalFile
84
+ buffers?: Buffer[]
85
+ data?: Buffer
86
+ inFlight: boolean
87
+ cancel: boolean
88
+ cancelReason?: string
89
+ cancelSource?: "local" | "remote"
90
+ }
91
+
92
+ export interface PeerSnapshot {
93
+ id: string
94
+ name: string
95
+ displayName: string
96
+ presence: Presence
97
+ selected: boolean
98
+ selectable: boolean
99
+ ready: boolean
100
+ status: string
101
+ turn: string
102
+ turnState: TurnState
103
+ dataState: string
104
+ lastError: string
105
+ profile?: PeerProfile
106
+ rttMs: number
107
+ localCandidateType: string
108
+ remoteCandidateType: string
109
+ pathLabel: string
110
+ }
111
+
112
+ export interface TransferSnapshot {
113
+ id: string
114
+ peerId: string
115
+ peerName: string
116
+ direction: Direction
117
+ status: TransferStatus
118
+ name: string
119
+ size: number
120
+ bytes: number
121
+ progress: number
122
+ speedText: string
123
+ etaText: string
124
+ error: string
125
+ createdAt: number
126
+ updatedAt: number
127
+ startedAt: number
128
+ endedAt: number
129
+ savedAt: number
130
+ savedPath?: string
131
+ }
132
+
133
+ export interface SessionSnapshot {
134
+ localId: string
135
+ name: string
136
+ room: string
137
+ socketState: SocketState
138
+ turn: string
139
+ turnState: TurnState
140
+ profile?: PeerProfile
141
+ pulse: PulseSnapshot
142
+ saveDir: string
143
+ peers: PeerSnapshot[]
144
+ transfers: TransferSnapshot[]
145
+ logs: LogEntry[]
146
+ }
147
+
148
+ export type SessionEvent =
149
+ | { type: "socket"; socketState: SocketState }
150
+ | { type: "peer"; peer: PeerSnapshot }
151
+ | { type: "transfer"; transfer: TransferSnapshot }
152
+ | { type: "saved"; transfer: TransferSnapshot }
153
+ | { type: "log"; log: LogEntry }
154
+
155
+ export interface SessionConfig {
156
+ room?: string
157
+ localId?: string
158
+ name?: string
159
+ saveDir?: string
160
+ autoAcceptIncoming?: boolean
161
+ autoSaveIncoming?: boolean
162
+ turnUrls?: string[]
163
+ turnUsername?: string
164
+ turnCredential?: string
165
+ reconnectSocket?: boolean
166
+ }
167
+
168
+ const LOG_LIMIT = 200
169
+ const STATS_POLL_MS = 1000
170
+ const PROFILE_URL = "https://ip.rt.ht/"
171
+ const PULSE_URL = "https://sig.efn.kr/pulse"
172
+
173
+ type StatsEntry = { id?: string; type?: string; [key: string]: unknown }
174
+
175
+ export interface PeerConnectivitySnapshot {
176
+ rttMs: number
177
+ localCandidateType: string
178
+ remoteCandidateType: string
179
+ pathLabel: string
180
+ }
181
+
182
+ export type TurnState = "none" | "idle" | "used"
183
+ export type PulseState = "idle" | "checking" | "open" | "error"
184
+
185
+ export interface PulseSnapshot {
186
+ state: PulseState
187
+ at: number
188
+ ms: number
189
+ error: string
190
+ }
191
+
192
+ const progressOf = (transfer: TransferState) => transfer.size ? Math.max(0, Math.min(100, transfer.bytes / transfer.size * 100)) : FINAL_STATUSES.has(transfer.status as never) ? 100 : 0
193
+ const isFinal = (transfer: TransferState) => FINAL_STATUSES.has(transfer.status as never)
194
+ const candidateTypeRank = (type: string) => ({ host: 0, srflx: 1, prflx: 1, relay: 2 }[type] ?? Number.NaN)
195
+ export const candidateTypeLabel = (type: string) => ({ host: "Direct", srflx: "NAT", prflx: "NAT", relay: "TURN" }[type] || "—")
196
+ const emptyConnectivitySnapshot = (): PeerConnectivitySnapshot => ({ rttMs: Number.NaN, localCandidateType: "", remoteCandidateType: "", pathLabel: "—" })
197
+ const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", at: 0, ms: 0, error: "" })
198
+
199
+ export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
200
+ geo: {
201
+ city: cleanText(profile?.geo?.city, 48),
202
+ region: cleanText(profile?.geo?.region, 48),
203
+ country: cleanText(profile?.geo?.country, 12),
204
+ timezone: cleanText(profile?.geo?.timezone, 48),
205
+ },
206
+ network: {
207
+ colo: cleanText(profile?.network?.colo, 12),
208
+ asOrganization: cleanText(profile?.network?.asOrganization, 72),
209
+ asn: Number(profile?.network?.asn) || 0,
210
+ ip: cleanText(profile?.network?.ip, 80),
211
+ },
212
+ ua: {
213
+ browser: cleanText(profile?.ua?.browser, 32),
214
+ os: cleanText(profile?.ua?.os, 32),
215
+ device: cleanText(profile?.ua?.device, 16),
216
+ },
217
+ defaults: {
218
+ autoAcceptIncoming: typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : undefined,
219
+ autoSaveIncoming: typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : undefined,
220
+ },
221
+ ready: !!profile?.ready,
222
+ error: cleanText(profile?.error, 120),
223
+ })
224
+
225
+ export const localProfileFromResponse = (data: unknown, error = ""): PeerProfile => {
226
+ const value = data as {
227
+ cf?: { city?: unknown; region?: unknown; country?: unknown; timezone?: unknown; colo?: unknown; asOrganization?: unknown; asn?: unknown }
228
+ hs?: Record<string, unknown>
229
+ } | null
230
+ const cleaned = (input: unknown, max: number) => cleanText(input, max) || undefined
231
+ return sanitizeProfile({
232
+ geo: {
233
+ city: cleaned(value?.cf?.city, 48),
234
+ region: cleaned(value?.cf?.region, 48),
235
+ country: cleaned(value?.cf?.country, 12),
236
+ timezone: cleaned(value?.cf?.timezone, 48),
237
+ },
238
+ network: {
239
+ colo: cleaned(value?.cf?.colo, 12),
240
+ asOrganization: cleaned(value?.cf?.asOrganization, 72),
241
+ asn: Number(value?.cf?.asn) || 0,
242
+ ip: cleaned(value?.hs?.["cf-connecting-ip"] || value?.hs?.["x-real-ip"], 80),
243
+ },
244
+ ua: buildCliProfile().ua,
245
+ ready: !!value?.cf,
246
+ error,
247
+ })
248
+ }
249
+
250
+ export const turnUsageState = (
251
+ hasTurn: boolean,
252
+ peers: Iterable<{ presence?: Presence; pc?: { connectionState?: string | null } | null; connectivity?: Partial<PeerConnectivitySnapshot> | null }>,
253
+ ): TurnState => {
254
+ if (!hasTurn) return "none"
255
+ for (const peer of peers) {
256
+ if (peer?.presence !== "active") continue
257
+ if (peer?.pc?.connectionState !== "connected") continue
258
+ if (peer?.connectivity?.localCandidateType === "relay") return "used"
259
+ }
260
+ return "idle"
261
+ }
262
+
263
+ const timeoutSignal = (ms: number) => typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
264
+
265
+ const statsEntriesFromReport = (report: unknown): StatsEntry[] => {
266
+ if (!report) return []
267
+ if (Array.isArray(report)) return report as StatsEntry[]
268
+ if (typeof report === "object") {
269
+ const values = report as { values?: () => Iterable<StatsEntry> }
270
+ if (typeof values.values === "function") return [...values.values()]
271
+ const forEachable = report as { forEach?: (fn: (value: StatsEntry) => void) => void }
272
+ if (typeof forEachable.forEach === "function") {
273
+ const entries: StatsEntry[] = []
274
+ forEachable.forEach(value => entries.push(value))
275
+ return entries
276
+ }
277
+ const iterable = report as Iterable<StatsEntry>
278
+ if (typeof (iterable as { [Symbol.iterator]?: () => Iterator<StatsEntry> })[Symbol.iterator] === "function") return [...iterable]
279
+ }
280
+ return []
281
+ }
282
+
283
+ const candidatePairEntries = (entries: StatsEntry[], transportId?: string) => entries.filter(entry =>
284
+ entry.type === "candidate-pair"
285
+ && (!transportId || entry.transportId === transportId))
286
+
287
+ const preferredCandidatePair = (entries: StatsEntry[]) => {
288
+ let selectedFallback: StatsEntry | null = null
289
+ let succeededFallback: StatsEntry | null = null
290
+ for (const entry of entries) {
291
+ const selected = entry.selected === true || entry.nominated === true
292
+ const succeeded = entry.state === "succeeded"
293
+ if (selected && succeeded) return entry
294
+ if (!selectedFallback && selected) selectedFallback = entry
295
+ if (!succeededFallback && succeeded) succeededFallback = entry
296
+ }
297
+ return selectedFallback || succeededFallback
298
+ }
299
+
300
+ const selectedCandidatePair = (entries: StatsEntry[], byId: Map<string, StatsEntry>) => {
301
+ const transport = entries.find(entry => entry.type === "transport" && typeof entry.selectedCandidatePairId === "string")
302
+ const transportId = typeof transport?.id === "string" ? transport.id : undefined
303
+ if (transport && typeof transport.selectedCandidatePairId === "string") {
304
+ const pair = byId.get(transport.selectedCandidatePairId)
305
+ if (pair?.type === "candidate-pair") return pair
306
+ }
307
+ return preferredCandidatePair(candidatePairEntries(entries, transportId)) || preferredCandidatePair(candidatePairEntries(entries))
308
+ }
309
+
310
+ export const connectivitySnapshotFromReport = (report: unknown, previous: PeerConnectivitySnapshot = emptyConnectivitySnapshot()): PeerConnectivitySnapshot => {
311
+ const entries = statsEntriesFromReport(report)
312
+ const byId = new Map(entries.flatMap(entry => typeof entry.id === "string" && entry.id ? [[entry.id, entry] as const] : []))
313
+ const pair = selectedCandidatePair(entries, byId)
314
+ const rttMs = typeof pair?.currentRoundTripTime === "number" ? pair.currentRoundTripTime * 1000 : previous.rttMs
315
+ const localCandidate = typeof pair?.localCandidateId === "string" ? byId.get(pair.localCandidateId) : undefined
316
+ const remoteCandidate = typeof pair?.remoteCandidateId === "string" ? byId.get(pair.remoteCandidateId) : undefined
317
+ const localCandidateType = typeof localCandidate?.candidateType === "string" ? localCandidate.candidateType : ""
318
+ const remoteCandidateType = typeof remoteCandidate?.candidateType === "string" ? remoteCandidate.candidateType : ""
319
+ if (!pair || !Number.isFinite(candidateTypeRank(localCandidateType)) || !Number.isFinite(candidateTypeRank(remoteCandidateType))) {
320
+ return { ...previous, rttMs }
321
+ }
322
+ return {
323
+ ...previous,
324
+ rttMs,
325
+ localCandidateType,
326
+ remoteCandidateType,
327
+ pathLabel: `${candidateTypeLabel(localCandidateType)} ↔ ${candidateTypeLabel(remoteCandidateType)}`,
328
+ }
329
+ }
330
+
331
+ const sameConnectivity = (left: PeerConnectivitySnapshot, right: PeerConnectivitySnapshot) =>
332
+ left.rttMs === right.rttMs
333
+ && left.localCandidateType === right.localCandidateType
334
+ && left.remoteCandidateType === right.remoteCandidateType
335
+ && left.pathLabel === right.pathLabel
336
+
337
+ const turnServers = (urls: string[], username?: string, credential?: string): RTCIceServer[] => [...new Set(urls.filter(Boolean))].map(urls => ({ urls, ...(username ? { username } : {}), ...(credential ? { credential } : {}) }))
338
+
339
+ const messageString = async (value: unknown) => {
340
+ if (typeof value === "string") return value
341
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value)
342
+ if (value instanceof ArrayBuffer) return new TextDecoder().decode(value)
343
+ if (value instanceof Blob) return value.text()
344
+ return `${value ?? ""}`
345
+ }
346
+
347
+ export class SendSession {
348
+ readonly localId: string
349
+ profile = sanitizeProfile(buildCliProfile())
350
+ readonly saveDir: string
351
+ readonly room: string
352
+ turnAvailable: boolean
353
+ name: string
354
+ socketState: SocketState = "idle"
355
+ pulse: PulseSnapshot = emptyPulseSnapshot()
356
+
357
+ private autoAcceptIncoming: boolean
358
+ private autoSaveIncoming: boolean
359
+ private readonly reconnectSocket: boolean
360
+ private readonly iceServers: RTCIceServer[]
361
+ private readonly peers = new Map<string, PeerState>()
362
+ private readonly transfers = new Map<string, TransferState>()
363
+ private readonly logs: LogEntry[] = []
364
+ private readonly subscribers = new Set<() => void>()
365
+ private readonly eventSubscribers = new Set<(event: SessionEvent) => void>()
366
+
367
+ private rtcEpochCounter = 0
368
+ private socket: WebSocket | null = null
369
+ private socketToken = 0
370
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
371
+ private peerStatsTimer: ReturnType<typeof setInterval> | null = null
372
+ private stopped = false
373
+
374
+ constructor(config: SessionConfig) {
375
+ this.localId = cleanLocalId(config.localId)
376
+ this.room = cleanRoom(config.room)
377
+ this.name = cleanName(config.name ?? fallbackName)
378
+ this.saveDir = resolve(config.saveDir ?? resolve(process.cwd(), "downloads"))
379
+ this.autoAcceptIncoming = !!config.autoAcceptIncoming
380
+ this.autoSaveIncoming = !!config.autoSaveIncoming
381
+ this.reconnectSocket = config.reconnectSocket ?? true
382
+ const extraTurn = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
383
+ this.iceServers = [...BASE_ICE_SERVERS, ...extraTurn]
384
+ this.turnAvailable = extraTurn.length > 0
385
+ }
386
+
387
+ subscribe(listener: () => void) {
388
+ this.subscribers.add(listener)
389
+ return () => this.subscribers.delete(listener)
390
+ }
391
+
392
+ onEvent(listener: (event: SessionEvent) => void) {
393
+ this.eventSubscribers.add(listener)
394
+ return () => this.eventSubscribers.delete(listener)
395
+ }
396
+
397
+ snapshot(): SessionSnapshot {
398
+ return {
399
+ localId: this.localId,
400
+ name: this.name,
401
+ room: this.room,
402
+ socketState: this.socketState,
403
+ turn: turnStateLabel(this.turnAvailable),
404
+ turnState: this.selfTurnState(),
405
+ profile: this.advertisedProfile(),
406
+ pulse: { ...this.pulse },
407
+ saveDir: this.saveDir,
408
+ peers: [...this.peers.values()]
409
+ .map(peer => this.peerSnapshot(peer))
410
+ .sort((left, right) => left.presence === right.presence ? left.displayName.localeCompare(right.displayName) : left.presence === "active" ? -1 : 1),
411
+ transfers: [...this.transfers.values()]
412
+ .map(transfer => this.transferSnapshot(transfer))
413
+ .sort((left, right) => right.createdAt - left.createdAt),
414
+ logs: [...this.logs],
415
+ }
416
+ }
417
+
418
+ async connect(timeoutMs = 10_000) {
419
+ this.stopped = false
420
+ this.startPeerStatsPolling()
421
+ void this.loadLocalProfile()
422
+ void this.probePulse()
423
+ this.connectSocket()
424
+ await this.waitFor(() => this.socketState === "open", timeoutMs)
425
+ }
426
+
427
+ async close() {
428
+ this.stopped = true
429
+ this.stopPeerStatsPolling()
430
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
431
+ if (this.socket?.readyState === WebSocket.OPEN) this.sendSignal({ kind: "bye" })
432
+ const socket = this.socket
433
+ this.socket = null
434
+ this.socketToken += 1
435
+ if (socket) try { socket.close(1000, "normal") } catch {}
436
+ for (const peer of this.peers.values()) this.destroyPeer(peer, "session-close")
437
+ this.notify()
438
+ }
439
+
440
+ activePeers() {
441
+ return [...this.peers.values()].filter(peer => peer.presence === "active")
442
+ }
443
+
444
+ readyPeers() {
445
+ return this.activePeers().filter(peer => this.isPeerReady(peer))
446
+ }
447
+
448
+ selectedReadyPeers() {
449
+ return this.readyPeers().filter(peer => peer.selected)
450
+ }
451
+
452
+ setPeerSelected(peerId: string, selected: boolean) {
453
+ const peer = this.peers.get(peerId)
454
+ if (!peer || peer.presence !== "active") return false
455
+ peer.selected = selected
456
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
457
+ this.notify()
458
+ return true
459
+ }
460
+
461
+ togglePeerSelection(peerId: string) {
462
+ const peer = this.peers.get(peerId)
463
+ return peer ? this.setPeerSelected(peerId, !peer.selected) : false
464
+ }
465
+
466
+ clearLogs() {
467
+ this.logs.length = 0
468
+ this.notify()
469
+ }
470
+
471
+ setName(value: string) {
472
+ const next = cleanName(value)
473
+ if (next === this.name) return this.name
474
+ this.name = next
475
+ this.sendSignal({ kind: "name", name: this.name })
476
+ this.notify()
477
+ return this.name
478
+ }
479
+
480
+ async setAutoAcceptIncoming(enabled: boolean) {
481
+ const next = !!enabled
482
+ const changed = next !== this.autoAcceptIncoming
483
+ this.autoAcceptIncoming = next
484
+ if (changed) this.broadcastProfile()
485
+ if (!changed || !next) {
486
+ this.notify()
487
+ return 0
488
+ }
489
+ let accepted = 0
490
+ for (const transfer of this.transfers.values()) {
491
+ if (transfer.direction !== "in" || transfer.status !== "pending") continue
492
+ if (await this.acceptTransfer(transfer.id)) accepted += 1
493
+ }
494
+ this.notify()
495
+ return accepted
496
+ }
497
+
498
+ async setAutoSaveIncoming(enabled: boolean) {
499
+ const next = !!enabled
500
+ const changed = next !== this.autoSaveIncoming
501
+ this.autoSaveIncoming = next
502
+ if (changed) this.broadcastProfile()
503
+ if (!changed || !next) {
504
+ this.notify()
505
+ return 0
506
+ }
507
+ let saved = 0
508
+ for (const transfer of this.transfers.values()) {
509
+ if (transfer.direction !== "in" || transfer.status !== "complete" || transfer.savedAt > 0) continue
510
+ if (await this.saveTransfer(transfer.id)) saved += 1
511
+ }
512
+ this.notify()
513
+ return saved
514
+ }
515
+
516
+ cancelPendingOffers() {
517
+ let cancelled = 0
518
+ for (const transfer of this.transfers.values()) {
519
+ if (transfer.direction !== "out" || !["queued", "offered"].includes(transfer.status)) continue
520
+ if (this.cancelTransfer(transfer.id)) cancelled += 1
521
+ }
522
+ return cancelled
523
+ }
524
+
525
+ clearCompletedTransfers() {
526
+ let cleared = 0
527
+ for (const [transferId, transfer] of this.transfers.entries()) {
528
+ if (transfer.status !== "complete") continue
529
+ this.transfers.delete(transferId)
530
+ cleared += 1
531
+ }
532
+ if (cleared) {
533
+ for (const peer of this.peers.values()) peer.outgoingQueue = peer.outgoingQueue.filter(transferId => this.transfers.has(transferId))
534
+ this.notify()
535
+ }
536
+ return cleared
537
+ }
538
+
539
+ clearFailedTransfers() {
540
+ let cleared = 0
541
+ for (const [transferId, transfer] of this.transfers.entries()) {
542
+ if (!["rejected", "cancelled", "error"].includes(transfer.status)) continue
543
+ this.transfers.delete(transferId)
544
+ cleared += 1
545
+ }
546
+ if (cleared) {
547
+ for (const peer of this.peers.values()) peer.outgoingQueue = peer.outgoingQueue.filter(transferId => this.transfers.has(transferId))
548
+ this.notify()
549
+ }
550
+ return cleared
551
+ }
552
+
553
+ async queueFiles(paths: string[], peerIds: string[]) {
554
+ const files = await loadLocalFiles(paths)
555
+ const peers = peerIds.map(peerId => this.peers.get(peerId)).filter((peer): peer is PeerState => !!peer && this.isPeerReady(peer))
556
+ if (!files.length) throw new Error("no files to offer")
557
+ if (!peers.length) throw new Error("no ready peers selected")
558
+ const created: string[] = []
559
+ for (const peer of peers) {
560
+ for (const file of files) {
561
+ const transfer = this.buildOutgoingTransfer(peer, file)
562
+ this.transfers.set(transfer.id, transfer)
563
+ peer.outgoingQueue.push(transfer.id)
564
+ created.push(transfer.id)
565
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
566
+ }
567
+ this.flushOffers(peer)
568
+ }
569
+ this.notify()
570
+ return created
571
+ }
572
+
573
+ async offerToSelectedPeers(paths: string[]) {
574
+ return this.queueFiles(paths, this.selectedReadyPeers().map(peer => peer.id))
575
+ }
576
+
577
+ async acceptTransfer(transferId: string) {
578
+ const transfer = this.transfers.get(transferId)
579
+ if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
580
+ const peer = this.peers.get(transfer.peerId)
581
+ if (!peer || !this.isPeerReady(peer)) return false
582
+ if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) return false
583
+ transfer.status = "accepted"
584
+ this.noteTransfer(transfer)
585
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
586
+ this.notify()
587
+ return true
588
+ }
589
+
590
+ rejectTransfer(transferId: string, reason = "rejected") {
591
+ const transfer = this.transfers.get(transferId)
592
+ if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
593
+ const peer = this.peers.get(transfer.peerId)
594
+ if (peer) this.sendDataControl(peer, { kind: "file-reject", transferId, reason })
595
+ this.completeTransfer(transfer, "rejected", reason)
596
+ return true
597
+ }
598
+
599
+ cancelTransfer(transferId: string) {
600
+ const transfer = this.transfers.get(transferId)
601
+ if (!transfer || isFinal(transfer)) return false
602
+ const peer = this.peers.get(transfer.peerId)
603
+ transfer.cancel = true
604
+ transfer.cancelSource = "local"
605
+ transfer.cancelReason = transfer.direction === "out" ? "sender cancelled" : "receiver cancelled"
606
+
607
+ if (transfer.direction === "out") {
608
+ if (transfer.status === "queued" || transfer.status === "offered" || transfer.status === "accepted") {
609
+ if (transfer.status !== "queued" && peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
610
+ this.completeTransfer(transfer, "cancelled", transfer.cancelReason)
611
+ if (peer) this.pumpSender(peer)
612
+ return true
613
+ }
614
+ if (transfer.inFlight) {
615
+ transfer.status = "cancelling"
616
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
617
+ this.notify()
618
+ return true
619
+ }
620
+ }
621
+
622
+ if (transfer.direction === "in") {
623
+ if (transfer.status === "pending") return this.rejectTransfer(transfer.id, transfer.cancelReason)
624
+ if (transfer.status === "accepted") {
625
+ if (peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
626
+ this.completeTransfer(transfer, "cancelled", transfer.cancelReason)
627
+ return true
628
+ }
629
+ if (transfer.status === "receiving") {
630
+ if (peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
631
+ transfer.status = "cancelling"
632
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
633
+ this.notify()
634
+ return true
635
+ }
636
+ }
637
+
638
+ if (peer) this.sendDataControl(peer, { kind: "file-cancel", transferId, reason: transfer.cancelReason })
639
+ this.completeTransfer(transfer, "cancelled", transfer.cancelReason)
640
+ return true
641
+ }
642
+
643
+ async saveTransfer(transferId: string) {
644
+ const transfer = this.transfers.get(transferId)
645
+ if (!transfer || transfer.direction !== "in" || transfer.status !== "complete") return null
646
+ if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
647
+ if (!transfer.data) return null
648
+ transfer.savedPath ||= await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
649
+ transfer.savedAt ||= Date.now()
650
+ const snapshot = this.transferSnapshot(transfer)
651
+ this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
652
+ this.emit({ type: "saved", transfer: snapshot })
653
+ this.notify()
654
+ return transfer.savedPath
655
+ }
656
+
657
+ getTransfer(transferId: string) {
658
+ return this.transfers.get(transferId)
659
+ }
660
+
661
+ async waitFor(predicate: () => boolean, timeoutMs: number) {
662
+ if (predicate()) return
663
+ await new Promise<void>((resolveWait, rejectWait) => {
664
+ const timeout = setTimeout(() => {
665
+ unsubscribe()
666
+ rejectWait(new Error(`timed out after ${timeoutMs}ms`))
667
+ }, timeoutMs)
668
+ const unsubscribe = this.subscribe(() => {
669
+ if (!predicate()) return
670
+ clearTimeout(timeout)
671
+ unsubscribe()
672
+ resolveWait()
673
+ })
674
+ })
675
+ }
676
+
677
+ async waitForTransfers(transferIds: string[], timeoutMs: number) {
678
+ await this.waitFor(() => transferIds.every(transferId => {
679
+ const transfer = this.transfers.get(transferId)
680
+ return !!transfer && isFinal(transfer)
681
+ }), timeoutMs)
682
+ return transferIds.map(transferId => this.transfers.get(transferId)).filter((transfer): transfer is TransferState => !!transfer)
683
+ }
684
+
685
+ private nextRtcEpoch() {
686
+ this.rtcEpochCounter += 1
687
+ return this.rtcEpochCounter
688
+ }
689
+
690
+ private notify() {
691
+ for (const listener of this.subscribers) listener()
692
+ }
693
+
694
+ private emit(event: SessionEvent) {
695
+ for (const listener of this.eventSubscribers) listener(event)
696
+ }
697
+
698
+ private startPeerStatsPolling() {
699
+ if (this.peerStatsTimer) return
700
+ this.peerStatsTimer = setInterval(() => void this.refreshPeerStats(), STATS_POLL_MS)
701
+ }
702
+
703
+ private stopPeerStatsPolling() {
704
+ if (!this.peerStatsTimer) return
705
+ clearInterval(this.peerStatsTimer)
706
+ this.peerStatsTimer = null
707
+ }
708
+
709
+ private async refreshPeerStats() {
710
+ if (this.stopped) return
711
+ let dirty = false
712
+ for (const peer of this.peers.values()) {
713
+ if (peer.presence !== "active" || !peer.pc) continue
714
+ try {
715
+ const next = connectivitySnapshotFromReport(await peer.pc.getStats(), peer.connectivity)
716
+ if (sameConnectivity(peer.connectivity, next)) continue
717
+ peer.connectivity = next
718
+ dirty = true
719
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
720
+ } catch {}
721
+ }
722
+ if (dirty) this.notify()
723
+ }
724
+
725
+ private pushLog(kind: string, payload: unknown, level: "info" | "error" = "info") {
726
+ const log = { id: uid(6), at: Date.now(), kind, level, payload }
727
+ this.logs.unshift(log)
728
+ this.logs.length = Math.min(this.logs.length, LOG_LIMIT)
729
+ this.emit({ type: "log", log })
730
+ this.notify()
731
+ }
732
+
733
+ private peerSnapshot(peer: PeerState): PeerSnapshot {
734
+ return {
735
+ id: peer.id,
736
+ name: peer.name || fallbackName,
737
+ displayName: displayPeerName(peer.name || fallbackName, peer.id),
738
+ presence: peer.presence,
739
+ selected: peer.selected,
740
+ selectable: this.peerSelectable(peer),
741
+ ready: this.isPeerReady(peer),
742
+ status: this.peerStatus(peer),
743
+ turn: turnStateLabel(peer.turnAvailable),
744
+ turnState: this.peerTurnState(peer),
745
+ dataState: this.peerDataState(peer),
746
+ lastError: peer.lastError,
747
+ profile: peer.profile,
748
+ rttMs: peer.connectivity.rttMs,
749
+ localCandidateType: peer.connectivity.localCandidateType,
750
+ remoteCandidateType: peer.connectivity.remoteCandidateType,
751
+ pathLabel: peer.connectivity.pathLabel,
752
+ }
753
+ }
754
+
755
+ private selfTurnState(): TurnState {
756
+ return turnUsageState(this.turnAvailable, this.peers.values())
757
+ }
758
+
759
+ private transferSnapshot(transfer: TransferState): TransferSnapshot {
760
+ return {
761
+ id: transfer.id,
762
+ peerId: transfer.peerId,
763
+ peerName: transfer.peerName,
764
+ direction: transfer.direction,
765
+ status: transfer.status,
766
+ name: transfer.name,
767
+ size: transfer.size,
768
+ bytes: transfer.bytes,
769
+ progress: progressOf(transfer),
770
+ speedText: formatRate(transfer.speed),
771
+ etaText: formatEta(transfer.eta),
772
+ error: transfer.error,
773
+ createdAt: transfer.createdAt,
774
+ updatedAt: transfer.updatedAt,
775
+ startedAt: transfer.startedAt,
776
+ endedAt: transfer.endedAt,
777
+ savedAt: transfer.savedAt,
778
+ savedPath: transfer.savedPath,
779
+ }
780
+ }
781
+
782
+ private peerStatus(peer: PeerState) {
783
+ if (peer.presence === "terminal") return peer.terminalReason || "closed"
784
+ if (peer.pc) return peer.pc.connectionState
785
+ return "idle"
786
+ }
787
+
788
+ private peerDataState(peer: PeerState) {
789
+ if (peer.dc?.readyState) return peer.dc.readyState
790
+ if (peer.presence === "terminal" || peer.pc?.connectionState === "closed") return "closed"
791
+ return "—"
792
+ }
793
+
794
+ private peerTurnState(peer: PeerState): TurnState {
795
+ if (!peer.turnAvailable) return "none"
796
+ return peer.connectivity.remoteCandidateType === "relay" ? "used" : "idle"
797
+ }
798
+
799
+ private peerSelectable(peer: PeerState) {
800
+ if (peer.presence === "terminal") return false
801
+ return !["closed", "failed", "disconnected"].includes(this.peerStatus(peer))
802
+ }
803
+
804
+ private isPeerReady(peer: PeerState) {
805
+ return peer.presence === "active" && peer.pc?.connectionState === "connected" && peer.dc?.readyState === "open"
806
+ }
807
+
808
+ private noteTransfer(transfer: TransferState) {
809
+ transfer.updatedAt = Date.now()
810
+ const elapsed = Math.max((transfer.updatedAt - (transfer.startedAt || transfer.createdAt)) / 1000, 0.001)
811
+ transfer.speed = transfer.bytes / elapsed
812
+ transfer.eta = transfer.speed ? Math.max(0, (transfer.size - transfer.bytes) / transfer.speed) : Infinity
813
+ }
814
+
815
+ private completeTransfer(transfer: TransferState, status: TransferStatus, error = "") {
816
+ transfer.status = status
817
+ transfer.error = error
818
+ transfer.inFlight = false
819
+ transfer.endedAt = Date.now()
820
+ this.noteTransfer(transfer)
821
+ if (status !== "complete" && transfer.direction === "in") {
822
+ transfer.buffers = []
823
+ transfer.data = undefined
824
+ }
825
+
826
+ const peer = this.peers.get(transfer.peerId)
827
+ if (peer) {
828
+ if (peer.activeOutgoing === transfer.id) peer.activeOutgoing = ""
829
+ if (peer.activeIncoming === transfer.id) peer.activeIncoming = ""
830
+ peer.outgoingQueue = peer.outgoingQueue.filter(queuedId => queuedId !== transfer.id || SENDABLE_STATUSES.has(this.transfers.get(queuedId)?.status as never))
831
+ if (transfer.direction === "out") queueMicrotask(() => this.pumpSender(peer))
832
+ }
833
+
834
+ const snapshot = this.transferSnapshot(transfer)
835
+ this.emit({ type: "transfer", transfer: snapshot })
836
+ if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming) void this.saveTransfer(transfer.id)
837
+ this.notify()
838
+ }
839
+
840
+ private connectSocket() {
841
+ if (this.stopped) return
842
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
843
+ const token = ++this.socketToken
844
+ const socket = new WebSocket(`${SIGNAL_WS_URL}?i=${encodeURIComponent(this.room)}`)
845
+ this.socket = socket
846
+ this.socketState = "connecting"
847
+ this.emit({ type: "socket", socketState: this.socketState })
848
+ this.notify()
849
+
850
+ socket.onopen = () => {
851
+ if (token !== this.socketToken || this.stopped) return
852
+ this.socketState = "open"
853
+ this.emit({ type: "socket", socketState: this.socketState })
854
+ this.pushLog("signal:socket-open", { room: this.room, localId: this.localId })
855
+ this.sendSignal({ kind: "hello", ...this.presencePayload({}) })
856
+ this.broadcastProfile()
857
+ this.notify()
858
+ }
859
+
860
+ socket.onmessage = async event => {
861
+ if (token !== this.socketToken || this.stopped) return
862
+ await this.onSignalMessage(await messageString(event.data))
863
+ }
864
+
865
+ socket.onerror = () => {
866
+ if (token !== this.socketToken || this.stopped) return
867
+ this.socketState = "error"
868
+ this.emit({ type: "socket", socketState: this.socketState })
869
+ this.notify()
870
+ }
871
+
872
+ socket.onclose = () => {
873
+ if (token !== this.socketToken || this.stopped) return
874
+ this.socketState = "closed"
875
+ this.emit({ type: "socket", socketState: this.socketState })
876
+ this.notify()
877
+ if (this.reconnectSocket) this.reconnectTimer = setTimeout(() => this.connectSocket(), 2000)
878
+ }
879
+ }
880
+
881
+ private async loadLocalProfile() {
882
+ try {
883
+ const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000) })
884
+ if (!response.ok) throw new Error(`profile ${response.status}`)
885
+ this.profile = localProfileFromResponse(await response.json())
886
+ } catch (error) {
887
+ this.profile = localProfileFromResponse(null, `${error}`)
888
+ this.pushLog("profile:error", { error: `${error}` }, "error")
889
+ }
890
+ this.broadcastProfile()
891
+ this.notify()
892
+ }
893
+
894
+ private async probePulse() {
895
+ const startedAt = performance.now()
896
+ this.pulse = { ...this.pulse, state: "checking", error: "" }
897
+ this.notify()
898
+ try {
899
+ const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500) })
900
+ if (!response.ok) throw new Error(`pulse ${response.status}`)
901
+ this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
902
+ } catch (error) {
903
+ this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
904
+ this.pushLog("pulse:error", { error: `${error}` }, "error")
905
+ }
906
+ this.notify()
907
+ }
908
+
909
+ private presencePayload(extra: Record<string, unknown>) {
910
+ return { name: this.name, turnAvailable: this.turnAvailable, profile: this.advertisedProfile(), ...extra }
911
+ }
912
+
913
+ private advertisedProfile(profile = this.profile) {
914
+ return sanitizeProfile({
915
+ ...(profile ?? {}),
916
+ defaults: {
917
+ autoAcceptIncoming: this.autoAcceptIncoming,
918
+ autoSaveIncoming: this.autoSaveIncoming,
919
+ },
920
+ })
921
+ }
922
+
923
+ private broadcastProfile() {
924
+ this.sendSignal({ kind: "profile", profile: this.advertisedProfile(), turnAvailable: this.turnAvailable })
925
+ }
926
+
927
+ private sendSignal(payload: { kind: string; to?: string; [key: string]: unknown }) {
928
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false
929
+ const message = { room: this.room, from: this.localId, to: "*", at: stamp(), ...payload }
930
+ this.socket.send(JSON.stringify(message))
931
+ this.pushLog("signal:out", message)
932
+ return true
933
+ }
934
+
935
+ private sendPeerHello(peer: PeerState, extra: Record<string, unknown> = {}) {
936
+ return this.sendSignal({ kind: "hello", to: peer.id, ...this.presencePayload({ rtcEpoch: peer.rtcEpoch, ...extra }) })
937
+ }
938
+
939
+ private sendDataControl(peer: PeerState, payload: { kind: string; [key: string]: unknown }, channel = peer.dc, rtcEpoch = peer.rtcEpoch) {
940
+ if (!this.isCurrentPeerChannel(peer, channel, rtcEpoch)) return false
941
+ const activeChannel = channel!
942
+ const message = { room: this.room, from: this.localId, to: peer.id, at: stamp(), ...payload }
943
+ activeChannel.send(JSON.stringify(message))
944
+ this.pushLog("data:out", message)
945
+ return true
946
+ }
947
+
948
+ private isCurrentPeerChannel(peer: PeerState | undefined, channel: RTCDataChannel | null | undefined, rtcEpoch = peer?.rtcEpoch) {
949
+ return !!peer && !!channel && peer.rtcEpoch === rtcEpoch && peer.dc === channel && channel.readyState === "open"
950
+ }
951
+
952
+ private buildPeer(remoteId: string) {
953
+ const peer: PeerState = {
954
+ id: remoteId,
955
+ name: fallbackName,
956
+ presence: "active",
957
+ selected: true,
958
+ polite: this.localId > remoteId,
959
+ pc: null,
960
+ dc: null,
961
+ rtcEpoch: 0,
962
+ remoteEpoch: 0,
963
+ makingOffer: false,
964
+ createdAt: Date.now(),
965
+ lastSeenAt: Date.now(),
966
+ outgoingQueue: [],
967
+ activeOutgoing: "",
968
+ activeIncoming: "",
969
+ turnAvailable: false,
970
+ terminalReason: "",
971
+ lastError: "",
972
+ connectivity: emptyConnectivitySnapshot(),
973
+ }
974
+ this.peers.set(remoteId, peer)
975
+ this.ensurePeerConnection(peer, "create")
976
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
977
+ this.notify()
978
+ return peer
979
+ }
980
+
981
+ private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
982
+ const peer = this.peers.get(remoteId) ?? this.buildPeer(remoteId)
983
+ peer.lastSeenAt = Date.now()
984
+ peer.presence = "active"
985
+ peer.terminalReason = ""
986
+ if (name != null) {
987
+ peer.name = cleanName(name)
988
+ for (const transfer of this.transfers.values()) if (transfer.peerId === remoteId) transfer.peerName = peer.name
989
+ }
990
+ if (typeof turnAvailable === "boolean") peer.turnAvailable = turnAvailable
991
+ if (profile) peer.profile = sanitizeProfile(profile)
992
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
993
+ return peer
994
+ }
995
+
996
+ private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0) {
997
+ const epoch = signalEpoch(remoteEpoch)
998
+ if (epoch && epoch < peer.remoteEpoch) return null
999
+ if (epoch) peer.remoteEpoch = epoch
1000
+ if (kind !== "bye" && (!peer.pc || peer.pc.connectionState === "closed" || peer.dc?.readyState === "closed")) this.ensurePeerConnection(peer, `signal:${kind}`)
1001
+ return peer
1002
+ }
1003
+
1004
+ private ensurePeerConnection(peer: PeerState, reason: string) {
1005
+ const epoch = this.nextRtcEpoch()
1006
+ peer.rtcEpoch = epoch
1007
+ peer.lastError = ""
1008
+ peer.makingOffer = false
1009
+ this.closePeerRTC(peer)
1010
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers })
1011
+ peer.pc = pc
1012
+ pc.onicecandidate = ({ candidate }) => {
1013
+ if (peer.rtcEpoch !== epoch || !candidate) return
1014
+ this.sendSignal({ kind: "candidate", to: peer.id, rtcEpoch: epoch, candidate: candidate.toJSON() })
1015
+ }
1016
+ pc.ondatachannel = ({ channel }) => {
1017
+ if (peer.rtcEpoch !== epoch) return
1018
+ this.attachChannel(peer, channel, epoch)
1019
+ }
1020
+ pc.onnegotiationneeded = async () => {
1021
+ if (peer.rtcEpoch !== epoch || peer.pc !== pc) return
1022
+ try {
1023
+ peer.makingOffer = true
1024
+ await pc.setLocalDescription()
1025
+ if (peer.rtcEpoch !== epoch || peer.pc !== pc || !pc.localDescription) return
1026
+ this.sendSignal({ kind: "description", to: peer.id, rtcEpoch: epoch, description: pc.localDescription.toSdp() })
1027
+ } catch (error) {
1028
+ peer.lastError = `${error}`
1029
+ this.pushLog("rtc:negotiation-error", { peer: peer.id, reason, error: `${error}` }, "error")
1030
+ this.failPeerTransfers(peer, "failed")
1031
+ } finally {
1032
+ if (peer.rtcEpoch === epoch) peer.makingOffer = false
1033
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1034
+ this.notify()
1035
+ }
1036
+ }
1037
+ pc.onconnectionstatechange = () => {
1038
+ if (peer.rtcEpoch !== epoch) return
1039
+ peer.lastSeenAt = Date.now()
1040
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
1041
+ peer.lastError ||= pc.connectionState
1042
+ this.failPeerTransfers(peer, pc.connectionState)
1043
+ }
1044
+ if (pc.connectionState === "connected") void this.refreshPeerStats()
1045
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1046
+ this.notify()
1047
+ }
1048
+ pc.oniceconnectionstatechange = () => {
1049
+ if (peer.rtcEpoch !== epoch) return
1050
+ peer.lastSeenAt = Date.now()
1051
+ if (pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed") void this.refreshPeerStats()
1052
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1053
+ this.notify()
1054
+ }
1055
+ if (this.localId < peer.id) this.attachChannel(peer, pc.createDataChannel("data", { ordered: true }), epoch)
1056
+ this.pushLog("rtc:peer-open", { peer: peer.id, reason, rtcEpoch: epoch })
1057
+ return peer
1058
+ }
1059
+
1060
+ private attachChannel(peer: PeerState, channel: RTCDataChannel, epoch: number) {
1061
+ if (peer.rtcEpoch !== epoch) {
1062
+ try { channel.close() } catch {}
1063
+ return
1064
+ }
1065
+ channel.bufferedAmountLowThreshold = CHUNK
1066
+ peer.dc = channel
1067
+ channel.onopen = () => {
1068
+ if (peer.rtcEpoch !== epoch) return
1069
+ peer.lastSeenAt = Date.now()
1070
+ this.pushLog("dc:open", { peer: peer.id, rtcEpoch: epoch })
1071
+ this.flushOffers(peer)
1072
+ this.pumpSender(peer)
1073
+ void this.refreshPeerStats()
1074
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1075
+ this.notify()
1076
+ }
1077
+ channel.onclose = () => {
1078
+ if (peer.rtcEpoch !== epoch) return
1079
+ peer.lastSeenAt = Date.now()
1080
+ if (peer.presence === "active") peer.lastError ||= "channel closed"
1081
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1082
+ this.notify()
1083
+ }
1084
+ channel.onerror = event => {
1085
+ if (peer.rtcEpoch !== epoch) return
1086
+ peer.lastError = `${event.error ?? "channel error"}`
1087
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1088
+ this.notify()
1089
+ }
1090
+ channel.onmessage = ({ data }) => void this.onDataMessage(peer, data)
1091
+ }
1092
+
1093
+ private closePeerRTC(peer: PeerState) {
1094
+ const dc = peer.dc
1095
+ const pc = peer.pc
1096
+ peer.dc = null
1097
+ peer.pc = null
1098
+ try { dc?.close() } catch {}
1099
+ void pc?.close().catch(() => {})
1100
+ }
1101
+
1102
+ private failPeerTransfers(peer: PeerState, reason: string) {
1103
+ for (const transfer of this.transfers.values()) if (transfer.peerId === peer.id && !isFinal(transfer)) this.completeTransfer(transfer, "error", reason)
1104
+ }
1105
+
1106
+ private destroyPeer(peer: PeerState, reason: string) {
1107
+ peer.presence = "terminal"
1108
+ peer.selected = false
1109
+ peer.terminalReason = reason
1110
+ this.failPeerTransfers(peer, reason)
1111
+ this.closePeerRTC(peer)
1112
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1113
+ }
1114
+
1115
+ private async onSignalMessage(raw: string) {
1116
+ const message = JSON.parse(raw) as SignalMessage
1117
+ if (message.room !== this.room || message.from === this.localId || (message.to && message.to !== "*" && message.to !== this.localId)) return
1118
+ this.pushLog("signal:in", message)
1119
+
1120
+ if (message.kind === "hello") {
1121
+ const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "hello", message.rtcEpoch)
1122
+ if (peer && !message.reply) this.sendPeerHello(peer, { reply: true })
1123
+ this.notify()
1124
+ return
1125
+ }
1126
+ if (message.kind === "name") {
1127
+ this.syncPeerPresence(message.from, message.name)
1128
+ this.notify()
1129
+ return
1130
+ }
1131
+ if (message.kind === "profile") {
1132
+ this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "profile", message.rtcEpoch)
1133
+ this.notify()
1134
+ return
1135
+ }
1136
+ if (message.kind === "bye") {
1137
+ const peer = this.peers.get(message.from)
1138
+ if (peer) this.destroyPeer(peer, "peer-left")
1139
+ this.notify()
1140
+ return
1141
+ }
1142
+ if (message.kind === "description") {
1143
+ await this.onDescriptionSignal(message)
1144
+ return
1145
+ }
1146
+ if (message.kind === "candidate") {
1147
+ await this.onCandidateSignal(message)
1148
+ return
1149
+ }
1150
+ }
1151
+
1152
+ private async onDescriptionSignal(message: DescriptionSignal) {
1153
+ const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "description", message.rtcEpoch)
1154
+ if (!peer?.pc) return
1155
+ const offerCollision = message.description.type === "offer" && !peer.makingOffer && peer.pc.signalingState !== "stable"
1156
+ if (!peer.polite && offerCollision) return
1157
+ try {
1158
+ await peer.pc.setRemoteDescription(message.description)
1159
+ if (message.description.type === "offer") {
1160
+ await peer.pc.setLocalDescription()
1161
+ if (peer.pc.localDescription) this.sendSignal({ kind: "description", to: peer.id, rtcEpoch: peer.rtcEpoch, description: peer.pc.localDescription.toSdp() })
1162
+ }
1163
+ } catch (error) {
1164
+ peer.lastError = `${error}`
1165
+ this.pushLog("rtc:description-error", { peer: peer.id, error: `${error}` }, "error")
1166
+ this.failPeerTransfers(peer, "failed")
1167
+ }
1168
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1169
+ this.notify()
1170
+ }
1171
+
1172
+ private async onCandidateSignal(message: CandidateSignal) {
1173
+ const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "candidate", message.rtcEpoch)
1174
+ if (!peer?.pc) return
1175
+ try {
1176
+ await peer.pc.addIceCandidate(message.candidate as RTCIceCandidateInit)
1177
+ } catch (error) {
1178
+ this.pushLog("rtc:candidate-error", { peer: peer.id, error: `${error}` }, "error")
1179
+ }
1180
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1181
+ this.notify()
1182
+ }
1183
+
1184
+ private buildOutgoingTransfer(peer: PeerState, file: LocalFile): TransferState {
1185
+ return {
1186
+ id: uid(12),
1187
+ peerId: peer.id,
1188
+ peerName: peer.name,
1189
+ direction: "out",
1190
+ status: "queued",
1191
+ name: file.name,
1192
+ size: file.size,
1193
+ type: file.type,
1194
+ lastModified: file.lastModified,
1195
+ totalChunks: Math.ceil(file.size / CHUNK),
1196
+ chunkSize: CHUNK,
1197
+ bytes: 0,
1198
+ chunks: 0,
1199
+ speed: 0,
1200
+ eta: Infinity,
1201
+ error: "",
1202
+ createdAt: Date.now(),
1203
+ updatedAt: 0,
1204
+ startedAt: 0,
1205
+ endedAt: 0,
1206
+ savedAt: 0,
1207
+ file,
1208
+ inFlight: false,
1209
+ cancel: false,
1210
+ }
1211
+ }
1212
+
1213
+ private flushOffers(peer: PeerState) {
1214
+ if (!this.isCurrentPeerChannel(peer, peer.dc)) return
1215
+ const queued = peer.outgoingQueue.map(transferId => this.transfers.get(transferId)).filter((transfer): transfer is TransferState => !!transfer && transfer.status === "queued")
1216
+ for (const transfer of queued) {
1217
+ transfer.status = "offered"
1218
+ if (!this.sendDataControl(peer, {
1219
+ kind: "file-offer",
1220
+ transferId: transfer.id,
1221
+ name: transfer.name,
1222
+ size: transfer.size,
1223
+ type: transfer.type,
1224
+ lastModified: transfer.lastModified,
1225
+ chunkSize: transfer.chunkSize,
1226
+ totalChunks: transfer.totalChunks,
1227
+ })) {
1228
+ transfer.status = "queued"
1229
+ continue
1230
+ }
1231
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1232
+ }
1233
+ this.notify()
1234
+ }
1235
+
1236
+ private pumpSender(peer: PeerState) {
1237
+ if (peer.activeOutgoing || !this.isCurrentPeerChannel(peer, peer.dc)) return
1238
+ const transfer = peer.outgoingQueue.map(transferId => this.transfers.get(transferId)).find((next): next is TransferState => !!next && next.status === "accepted")
1239
+ if (!transfer || !peer.dc) return
1240
+ peer.activeOutgoing = transfer.id
1241
+ if (!this.sendDataControl(peer, { kind: "file-start", transferId: transfer.id }, peer.dc, peer.rtcEpoch)) {
1242
+ peer.activeOutgoing = ""
1243
+ this.notify()
1244
+ return
1245
+ }
1246
+ void this.sendFile(peer, transfer, peer.dc, peer.rtcEpoch)
1247
+ }
1248
+
1249
+ private async waitForDrain(peer: PeerState, transfer: TransferState, channel: RTCDataChannel, rtcEpoch: number) {
1250
+ while (channel.bufferedAmount > BUFFER_HIGH) {
1251
+ this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
1252
+ await Bun.sleep(10)
1253
+ }
1254
+ }
1255
+
1256
+ private assertSendAttempt(peer: PeerState, transfer: TransferState, channel: RTCDataChannel, rtcEpoch: number) {
1257
+ if (transfer.cancel) throw new Error(transfer.cancelReason || "cancelled")
1258
+ if (peer.activeOutgoing !== transfer.id || !this.isCurrentPeerChannel(peer, channel, rtcEpoch)) throw new Error("closed")
1259
+ }
1260
+
1261
+ private async sendFile(peer: PeerState, transfer: TransferState, channel: RTCDataChannel, rtcEpoch: number) {
1262
+ transfer.status = "sending"
1263
+ transfer.startedAt ||= Date.now()
1264
+ transfer.inFlight = true
1265
+ this.noteTransfer(transfer)
1266
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1267
+ this.notify()
1268
+ try {
1269
+ for (let offset = 0; offset < transfer.size; offset += CHUNK) {
1270
+ this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
1271
+ await this.waitForDrain(peer, transfer, channel, rtcEpoch)
1272
+ const chunk = await readFileChunk(transfer.file!, offset, CHUNK)
1273
+ this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
1274
+ channel.send(chunk)
1275
+ transfer.bytes += chunk.byteLength
1276
+ transfer.chunks += 1
1277
+ this.noteTransfer(transfer)
1278
+ this.notify()
1279
+ }
1280
+ this.assertSendAttempt(peer, transfer, channel, rtcEpoch)
1281
+ transfer.status = "awaiting-done"
1282
+ transfer.inFlight = false
1283
+ this.noteTransfer(transfer)
1284
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1285
+ if (!this.sendDataControl(peer, { kind: "file-end", transferId: transfer.id, size: transfer.size, totalChunks: transfer.totalChunks }, channel, rtcEpoch)) throw new Error("closed")
1286
+ } catch (error) {
1287
+ transfer.inFlight = false
1288
+ if (transfer.cancel) {
1289
+ const reason = transfer.cancelReason || "cancelled"
1290
+ if (transfer.cancelSource !== "remote") this.sendDataControl(peer, { kind: "file-cancel", transferId: transfer.id, reason }, channel, rtcEpoch)
1291
+ this.completeTransfer(transfer, "cancelled", reason)
1292
+ } else {
1293
+ const reason = `${error}`.includes("closed") ? "closed" : `${error}`
1294
+ if (reason !== "closed") this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason }, channel, rtcEpoch)
1295
+ this.completeTransfer(transfer, "error", reason)
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ private async onDataMessage(peer: PeerState, raw: string | Buffer) {
1301
+ peer.lastSeenAt = Date.now()
1302
+ if (typeof raw === "string") {
1303
+ const message = JSON.parse(raw) as DataMessage
1304
+ await this.handleTransferControl(peer, message)
1305
+ return
1306
+ }
1307
+ this.onBinary(peer, raw)
1308
+ }
1309
+
1310
+ private onBinary(peer: PeerState, data: Buffer) {
1311
+ const transfer = this.transfers.get(peer.activeIncoming)
1312
+ if (!transfer || transfer.status !== "receiving") return
1313
+ transfer.buffers ||= []
1314
+ transfer.buffers.push(data)
1315
+ transfer.bytes += data.byteLength
1316
+ transfer.chunks += 1
1317
+ this.noteTransfer(transfer)
1318
+ this.notify()
1319
+ }
1320
+
1321
+ private async handleTransferControl(peer: PeerState, message: DataMessage) {
1322
+ this.pushLog("data:in", message)
1323
+ if (message.to && message.to !== this.localId && message.to !== "*") return
1324
+ switch (message.kind) {
1325
+ case "file-offer": {
1326
+ if (!this.transfers.has(message.transferId)) {
1327
+ const transfer: TransferState = {
1328
+ id: message.transferId,
1329
+ peerId: peer.id,
1330
+ peerName: peer.name,
1331
+ direction: "in",
1332
+ status: "pending",
1333
+ name: message.name,
1334
+ size: message.size,
1335
+ type: message.type,
1336
+ lastModified: message.lastModified,
1337
+ totalChunks: message.totalChunks || Math.ceil(message.size / (message.chunkSize || CHUNK)),
1338
+ chunkSize: message.chunkSize || CHUNK,
1339
+ bytes: 0,
1340
+ chunks: 0,
1341
+ speed: 0,
1342
+ eta: Infinity,
1343
+ error: "",
1344
+ createdAt: Date.now(),
1345
+ updatedAt: 0,
1346
+ startedAt: 0,
1347
+ endedAt: 0,
1348
+ savedAt: 0,
1349
+ buffers: [],
1350
+ inFlight: false,
1351
+ cancel: false,
1352
+ }
1353
+ this.transfers.set(message.transferId, transfer)
1354
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1355
+ }
1356
+ if (this.autoAcceptIncoming) await this.acceptTransfer(message.transferId)
1357
+ break
1358
+ }
1359
+ case "file-accept": {
1360
+ const transfer = this.transfers.get(message.transferId)
1361
+ if (transfer && transfer.direction === "out" && transfer.status === "offered") {
1362
+ transfer.status = "accepted"
1363
+ this.noteTransfer(transfer)
1364
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1365
+ this.pumpSender(peer)
1366
+ }
1367
+ break
1368
+ }
1369
+ case "file-start": {
1370
+ const transfer = this.transfers.get(message.transferId)
1371
+ if (transfer && transfer.direction === "in" && transfer.status === "accepted") {
1372
+ peer.activeIncoming = transfer.id
1373
+ transfer.status = "receiving"
1374
+ transfer.startedAt ||= Date.now()
1375
+ transfer.buffers = []
1376
+ this.noteTransfer(transfer)
1377
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1378
+ }
1379
+ break
1380
+ }
1381
+ case "file-reject": {
1382
+ const transfer = this.transfers.get(message.transferId)
1383
+ if (transfer) this.completeTransfer(transfer, "rejected", message.reason)
1384
+ break
1385
+ }
1386
+ case "file-end": {
1387
+ const transfer = this.transfers.get(message.transferId)
1388
+ if (!transfer || transfer.direction !== "in") break
1389
+ if (transfer.status === "cancelling") {
1390
+ this.completeTransfer(transfer, "cancelled", transfer.cancelReason || "cancelled")
1391
+ break
1392
+ }
1393
+ if (transfer.status !== "receiving") {
1394
+ this.completeTransfer(transfer, "error", `unexpected end while ${transfer.status}`)
1395
+ break
1396
+ }
1397
+ const data = Buffer.concat(transfer.buffers || [])
1398
+ if (data.byteLength !== message.size) {
1399
+ const reason = `size mismatch: ${data.byteLength} vs ${message.size}`
1400
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
1401
+ this.completeTransfer(transfer, "error", reason)
1402
+ break
1403
+ }
1404
+ transfer.data = data
1405
+ transfer.buffers = []
1406
+ this.sendDataControl(peer, { kind: "file-done", transferId: transfer.id, size: transfer.bytes, totalChunks: transfer.chunks })
1407
+ this.completeTransfer(transfer, "complete")
1408
+ break
1409
+ }
1410
+ case "file-done": {
1411
+ const transfer = this.transfers.get(message.transferId)
1412
+ if (transfer && transfer.direction === "out") this.completeTransfer(transfer, "complete")
1413
+ break
1414
+ }
1415
+ case "file-cancel":
1416
+ case "file-error": {
1417
+ const transfer = this.transfers.get(message.transferId)
1418
+ if (!transfer || isFinal(transfer)) break
1419
+ const status = message.kind === "file-error" ? "error" : "cancelled"
1420
+ if (transfer.direction === "out" && transfer.inFlight) {
1421
+ transfer.cancel = true
1422
+ transfer.cancelReason = message.reason
1423
+ transfer.cancelSource = "remote"
1424
+ if (status === "cancelled") transfer.status = "cancelling"
1425
+ this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1426
+ this.notify()
1427
+ } else {
1428
+ this.completeTransfer(transfer, status, message.reason)
1429
+ }
1430
+ break
1431
+ }
1432
+ }
1433
+ this.notify()
1434
+ }
1435
+ }