@elefunc/send 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/core/files.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { access, mkdir, stat } from "node:fs/promises"
1
+ import { access, mkdir, open, rm, stat, type FileHandle } from "node:fs/promises"
2
2
  import { basename, extname, join, resolve } from "node:path"
3
3
  import { resolveUserPath } from "./paths"
4
4
 
@@ -8,16 +8,16 @@ export interface LocalFile {
8
8
  size: number
9
9
  type: string
10
10
  lastModified: number
11
- blob: Blob
11
+ reader?: FileHandle
12
12
  }
13
13
 
14
- export type LocalFileInfo = Omit<LocalFile, "blob">
14
+ export type LocalFileInfo = Omit<LocalFile, "reader">
15
15
  export interface LocalPathIssue {
16
16
  path: string
17
17
  error: string
18
18
  }
19
19
 
20
- const exists = async (path: string) => access(path).then(() => true, () => false)
20
+ export const pathExists = async (path: string) => access(path).then(() => true, () => false)
21
21
 
22
22
  export const inspectLocalFile = async (path: string): Promise<LocalFileInfo> => {
23
23
  const absolute = resolveUserPath(path)
@@ -52,23 +52,52 @@ export const inspectLocalPaths = async (paths: string[]) => {
52
52
 
53
53
  export const loadLocalFile = async (path: string): Promise<LocalFile> => {
54
54
  const info = await inspectLocalFile(path)
55
- return {
56
- ...info,
57
- blob: Bun.file(info.path),
58
- }
55
+ return { ...info }
59
56
  }
60
57
 
61
58
  export const loadLocalFiles = (paths: string[]) => Promise.all(paths.map(loadLocalFile))
62
59
 
63
- export const readFileChunk = async (file: LocalFile, offset: number, size: number) => Buffer.from(await file.blob.slice(offset, offset + size).arrayBuffer())
60
+ const readHandleChunk = async (handle: FileHandle, offset: number, size: number) => {
61
+ const chunk = Buffer.allocUnsafe(size)
62
+ let bytesReadTotal = 0
63
+ while (bytesReadTotal < size) {
64
+ const { bytesRead } = await handle.read(chunk, bytesReadTotal, size - bytesReadTotal, offset + bytesReadTotal)
65
+ if (!bytesRead) break
66
+ bytesReadTotal += bytesRead
67
+ }
68
+ return bytesReadTotal === size ? chunk : chunk.subarray(0, bytesReadTotal)
69
+ }
64
70
 
65
- export const uniqueOutputPath = async (directory: string, fileName: string) => {
71
+ export const readFileChunk = async (file: LocalFile, offset: number, size: number) => {
72
+ file.reader ||= await open(file.path, "r")
73
+ return readHandleChunk(file.reader, offset, size)
74
+ }
75
+
76
+ export const closeLocalFile = async (file?: LocalFile) => {
77
+ if (!file?.reader) return
78
+ const reader = file.reader
79
+ file.reader = undefined
80
+ await reader.close()
81
+ }
82
+
83
+ export const writeFileChunk = async (handle: FileHandle, data: Buffer, offset: number) => {
84
+ let bytesWrittenTotal = 0
85
+ while (bytesWrittenTotal < data.byteLength) {
86
+ const { bytesWritten } = await handle.write(data, bytesWrittenTotal, data.byteLength - bytesWrittenTotal, offset + bytesWrittenTotal)
87
+ if (!bytesWritten) throw new Error("short write")
88
+ bytesWrittenTotal += bytesWritten
89
+ }
90
+ return bytesWrittenTotal
91
+ }
92
+
93
+ export const uniqueOutputPath = async (directory: string, fileName: string, reservedPaths: ReadonlySet<string> = new Set()) => {
66
94
  await mkdir(directory, { recursive: true })
67
95
  const extension = extname(fileName)
68
96
  const stem = extension ? fileName.slice(0, -extension.length) : fileName
69
97
  for (let index = 0; ; index += 1) {
70
98
  const candidate = join(directory, index ? `${stem} (${index})${extension}` : fileName)
71
- if (!await exists(candidate)) return candidate
99
+ if (reservedPaths.has(candidate)) continue
100
+ if (!await pathExists(candidate)) return candidate
72
101
  }
73
102
  }
74
103
 
@@ -77,3 +106,7 @@ export const saveIncomingFile = async (directory: string, fileName: string, data
77
106
  await Bun.write(path, data)
78
107
  return path
79
108
  }
109
+
110
+ export const removePath = async (path: string) => {
111
+ await rm(path, { force: true }).catch(() => {})
112
+ }
@@ -1,7 +1,8 @@
1
+ import { open, rename, type FileHandle } from "node:fs/promises"
1
2
  import { resolve } from "node:path"
2
3
  import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
3
4
  import { RTCPeerConnection } from "werift"
4
- import { loadLocalFiles, readFileChunk, saveIncomingFile, type LocalFile } from "./files"
5
+ import { closeLocalFile, loadLocalFiles, pathExists, readFileChunk, removePath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
5
6
  import {
6
7
  BASE_ICE_SERVERS,
7
8
  BUFFER_HIGH,
@@ -87,12 +88,23 @@ interface TransferState {
87
88
  file?: LocalFile
88
89
  buffers?: Buffer[]
89
90
  data?: Buffer
91
+ incomingDisk?: IncomingDiskState
90
92
  inFlight: boolean
91
93
  cancel: boolean
92
94
  cancelReason?: string
93
95
  cancelSource?: "local" | "remote"
94
96
  }
95
97
 
98
+ interface IncomingDiskState {
99
+ finalPath: string
100
+ tempPath: string
101
+ handle: FileHandle
102
+ queue: Promise<void>
103
+ offset: number
104
+ error: string
105
+ closed: boolean
106
+ }
107
+
96
108
  export interface PeerSnapshot {
97
109
  id: string
98
110
  name: string
@@ -384,6 +396,7 @@ export class SendSession {
384
396
  private readonly logs: LogEntry[] = []
385
397
  private readonly subscribers = new Set<() => void>()
386
398
  private readonly eventSubscribers = new Set<(event: SessionEvent) => void>()
399
+ private readonly reservedSavePaths = new Set<string>()
387
400
 
388
401
  private rtcEpochCounter = 0
389
402
  private socket: WebSocket | null = null
@@ -674,7 +687,14 @@ export class SendSession {
674
687
  if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
675
688
  const peer = this.peers.get(transfer.peerId)
676
689
  if (!peer || !this.isPeerReady(peer)) return false
677
- if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) return false
690
+ const streamingToDisk = await this.startIncomingDiskTransfer(transfer)
691
+ transfer.data = undefined
692
+ transfer.buffers = streamingToDisk ? undefined : []
693
+ if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) {
694
+ if (streamingToDisk) this.discardIncomingDiskTransfer(transfer)
695
+ transfer.buffers = []
696
+ return false
697
+ }
678
698
  transfer.status = "accepted"
679
699
  this.noteTransfer(transfer)
680
700
  this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
@@ -738,13 +758,12 @@ export class SendSession {
738
758
  async saveTransfer(transferId: string) {
739
759
  const transfer = this.transfers.get(transferId)
740
760
  if (!transfer || transfer.direction !== "in" || transfer.status !== "complete") return null
761
+ if (transfer.savedPath) return transfer.savedPath
741
762
  if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
742
763
  if (!transfer.data) return null
743
- transfer.savedPath ||= await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
764
+ transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
744
765
  transfer.savedAt ||= Date.now()
745
- const snapshot = this.transferSnapshot(transfer)
746
- this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
747
- this.emit({ type: "saved", transfer: snapshot })
766
+ this.emitTransferSaved(transfer)
748
767
  this.notify()
749
768
  return transfer.savedPath
750
769
  }
@@ -753,6 +772,124 @@ export class SendSession {
753
772
  return this.transfers.get(transferId)
754
773
  }
755
774
 
775
+ private emitTransferSaved(transfer: TransferState) {
776
+ const snapshot = this.transferSnapshot(transfer)
777
+ this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
778
+ this.emit({ type: "saved", transfer: snapshot })
779
+ }
780
+
781
+ private autoSaveTransfer(transferId: string) {
782
+ void this.saveTransfer(transferId).catch(error => {
783
+ this.pushLog("transfer:save-error", { transferId, error: `${error}` }, "error")
784
+ this.notify()
785
+ })
786
+ }
787
+
788
+ private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
789
+ const finalPath = await uniqueOutputPath(this.saveDir, fileName || "download", this.reservedSavePaths)
790
+ this.reservedSavePaths.add(finalPath)
791
+ for (let attempt = 0; ; attempt += 1) {
792
+ const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
793
+ try {
794
+ const handle = await open(tempPath, "wx")
795
+ return {
796
+ finalPath,
797
+ tempPath,
798
+ handle,
799
+ queue: Promise.resolve(),
800
+ offset: 0,
801
+ error: "",
802
+ closed: false,
803
+ }
804
+ } catch (error) {
805
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
806
+ this.reservedSavePaths.delete(finalPath)
807
+ throw error
808
+ }
809
+ }
810
+ }
811
+
812
+ private takeIncomingDisk(transfer: TransferState) {
813
+ const disk = transfer.incomingDisk
814
+ transfer.incomingDisk = undefined
815
+ return disk
816
+ }
817
+
818
+ private async closeIncomingDiskState(disk: IncomingDiskState, removeTemp: boolean) {
819
+ if (!disk.closed) {
820
+ disk.closed = true
821
+ try { await disk.handle.close() } catch {}
822
+ }
823
+ if (removeTemp) await removePath(disk.tempPath)
824
+ this.reservedSavePaths.delete(disk.finalPath)
825
+ }
826
+
827
+ private discardIncomingDiskTransfer(transfer: TransferState) {
828
+ const disk = this.takeIncomingDisk(transfer)
829
+ if (!disk) return
830
+ void this.closeIncomingDiskState(disk, true)
831
+ }
832
+
833
+ private async startIncomingDiskTransfer(transfer: TransferState) {
834
+ if (!this.autoSaveIncoming || transfer.incomingDisk) return false
835
+ try {
836
+ transfer.incomingDisk = await this.createIncomingDiskState(transfer.name)
837
+ return true
838
+ } catch (error) {
839
+ this.pushLog("transfer:save-error", { transferId: transfer.id, error: `${error}` }, "error")
840
+ return false
841
+ }
842
+ }
843
+
844
+ private queueIncomingDiskWrite(peer: PeerState, transfer: TransferState, data: Buffer) {
845
+ const disk = transfer.incomingDisk
846
+ if (!disk) return
847
+ disk.queue = disk.queue.then(async () => {
848
+ if (disk.closed || disk.error || transfer.cancel || transfer.status !== "receiving") return
849
+ await writeFileChunk(disk.handle, data, disk.offset)
850
+ disk.offset += data.byteLength
851
+ }).catch(error => {
852
+ if (disk.error || isFinal(transfer)) return
853
+ disk.error = `${error}`
854
+ this.pushLog("transfer:save-error", { transferId: transfer.id, error: disk.error }, "error")
855
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason: disk.error })
856
+ this.completeTransfer(transfer, "error", disk.error)
857
+ })
858
+ }
859
+
860
+ private async finalizeIncomingDiskTransfer(transfer: TransferState, expectedSize: number) {
861
+ const disk = this.takeIncomingDisk(transfer)
862
+ if (!disk) return null
863
+ await disk.queue
864
+ if (disk.error) {
865
+ await this.closeIncomingDiskState(disk, true)
866
+ return null
867
+ }
868
+ if (disk.offset !== expectedSize) throw new Error(`saved size mismatch: ${disk.offset} vs ${expectedSize}`)
869
+
870
+ let finalPath = disk.finalPath
871
+ try {
872
+ if (!disk.closed) {
873
+ disk.closed = true
874
+ await disk.handle.close()
875
+ }
876
+ if (await pathExists(finalPath)) {
877
+ this.reservedSavePaths.delete(finalPath)
878
+ finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
879
+ this.reservedSavePaths.add(finalPath)
880
+ }
881
+ await rename(disk.tempPath, finalPath)
882
+ transfer.savedPath = finalPath
883
+ transfer.savedAt ||= Date.now()
884
+ return finalPath
885
+ } catch (error) {
886
+ await removePath(disk.tempPath)
887
+ throw error
888
+ } finally {
889
+ this.reservedSavePaths.delete(finalPath)
890
+ }
891
+ }
892
+
756
893
  async waitFor(predicate: () => boolean, timeoutMs: number, signal?: AbortSignal | null) {
757
894
  if (predicate()) return
758
895
  if (signal?.aborted) throw new SessionAbortedError()
@@ -930,9 +1067,11 @@ export class SendSession {
930
1067
  transfer.inFlight = false
931
1068
  transfer.endedAt = Date.now()
932
1069
  this.noteTransfer(transfer)
1070
+ if (transfer.direction === "out") void closeLocalFile(transfer.file).catch(() => {})
933
1071
  if (status !== "complete" && transfer.direction === "in") {
934
1072
  transfer.buffers = []
935
1073
  transfer.data = undefined
1074
+ this.discardIncomingDiskTransfer(transfer)
936
1075
  }
937
1076
 
938
1077
  const peer = this.peers.get(transfer.peerId)
@@ -945,7 +1084,7 @@ export class SendSession {
945
1084
 
946
1085
  const snapshot = this.transferSnapshot(transfer)
947
1086
  this.emit({ type: "transfer", transfer: snapshot })
948
- if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming) void this.saveTransfer(transfer.id)
1087
+ if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming && transfer.savedAt === 0) this.autoSaveTransfer(transfer.id)
949
1088
  this.notify()
950
1089
  }
951
1090
 
@@ -1517,8 +1656,11 @@ export class SendSession {
1517
1656
  private onBinary(peer: PeerState, data: Buffer) {
1518
1657
  const transfer = this.transfers.get(peer.activeIncoming)
1519
1658
  if (!transfer || transfer.status !== "receiving") return
1520
- transfer.buffers ||= []
1521
- transfer.buffers.push(data)
1659
+ if (transfer.incomingDisk) this.queueIncomingDiskWrite(peer, transfer, data)
1660
+ else {
1661
+ transfer.buffers ||= []
1662
+ transfer.buffers.push(data)
1663
+ }
1522
1664
  transfer.bytes += data.byteLength
1523
1665
  transfer.chunks += 1
1524
1666
  this.noteTransfer(transfer)
@@ -1579,7 +1721,8 @@ export class SendSession {
1579
1721
  peer.activeIncoming = transfer.id
1580
1722
  transfer.status = "receiving"
1581
1723
  transfer.startedAt ||= Date.now()
1582
- transfer.buffers = []
1724
+ transfer.data = undefined
1725
+ transfer.buffers = transfer.incomingDisk ? undefined : []
1583
1726
  this.noteTransfer(transfer)
1584
1727
  this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1585
1728
  }
@@ -1593,6 +1736,7 @@ export class SendSession {
1593
1736
  case "file-end": {
1594
1737
  const transfer = this.transfers.get(message.transferId)
1595
1738
  if (!transfer || transfer.direction !== "in") break
1739
+ if (isFinal(transfer)) break
1596
1740
  if (transfer.status === "cancelling") {
1597
1741
  this.completeTransfer(transfer, "cancelled", transfer.cancelReason || "cancelled")
1598
1742
  break
@@ -1601,6 +1745,27 @@ export class SendSession {
1601
1745
  this.completeTransfer(transfer, "error", `unexpected end while ${transfer.status}`)
1602
1746
  break
1603
1747
  }
1748
+ if (transfer.bytes !== message.size) {
1749
+ const reason = `size mismatch: ${transfer.bytes} vs ${message.size}`
1750
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
1751
+ this.completeTransfer(transfer, "error", reason)
1752
+ break
1753
+ }
1754
+ if (transfer.incomingDisk) {
1755
+ try {
1756
+ const savedPath = await this.finalizeIncomingDiskTransfer(transfer, message.size)
1757
+ if (!savedPath) break
1758
+ this.sendDataControl(peer, { kind: "file-done", transferId: transfer.id, size: transfer.bytes, totalChunks: transfer.chunks })
1759
+ this.completeTransfer(transfer, "complete")
1760
+ this.emitTransferSaved(transfer)
1761
+ } catch (error) {
1762
+ const reason = `${error}`
1763
+ this.pushLog("transfer:save-error", { transferId: transfer.id, error: reason }, "error")
1764
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
1765
+ this.completeTransfer(transfer, "error", reason)
1766
+ }
1767
+ break
1768
+ }
1604
1769
  const data = Buffer.concat(transfer.buffers || [])
1605
1770
  if (data.byteLength !== message.size) {
1606
1771
  const reason = `size mismatch: ${data.byteLength} vs ${message.size}`
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { resolve } from "node:path"
3
3
  import { cac, type CAC } from "cac"
4
- import { cleanRoom } from "./core/protocol"
4
+ import { cleanRoom, displayPeerName } from "./core/protocol"
5
5
  import type { SendSession, SessionConfig, SessionEvent } from "./core/session"
6
6
  import { resolvePeerTargets } from "./core/targeting"
7
7
  import { ensureSessionRuntimePatches, ensureTuiRuntimePatches } from "../runtime/install"
@@ -74,6 +74,7 @@ const TUI_TOGGLE_OPTIONS = [
74
74
  ["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
75
75
  ["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
76
76
  ] as const
77
+ export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
77
78
  const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
78
79
  definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
79
80
 
@@ -108,9 +109,10 @@ export const sessionConfigFrom = (options: Record<string, unknown>, defaults: {
108
109
  }
109
110
  }
110
111
 
111
- export const roomAnnouncement = (room: string, json = false) => json ? JSON.stringify({ type: "room", room }) : `room ${room}`
112
+ export const roomAnnouncement = (room: string, self: string, json = false) =>
113
+ json ? JSON.stringify({ type: "room", room, self }) : `room ${room}\nself ${self}`
112
114
 
113
- const printRoomAnnouncement = (room: string, json = false) => console.log(roomAnnouncement(room, json))
115
+ const printRoomAnnouncement = (room: string, self: string, json = false) => console.log(roomAnnouncement(room, self, json))
114
116
 
115
117
  const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
116
118
 
@@ -191,7 +193,7 @@ const peersCommand = async (options: Record<string, unknown>) => {
191
193
  const { SendSession } = await loadSessionRuntime()
192
194
  const session = new SendSession(sessionConfigFrom(options, {}))
193
195
  handleSignals(session)
194
- printRoomAnnouncement(session.room, !!options.json)
196
+ printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
195
197
  await session.connect()
196
198
  await Bun.sleep(numberOption(options.wait, 3000))
197
199
  const snapshot = session.snapshot()
@@ -218,7 +220,7 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
218
220
  const { SendSession } = await loadSessionRuntime()
219
221
  const session = new SendSession(sessionConfigFrom(options, {}))
220
222
  handleSignals(session)
221
- printRoomAnnouncement(session.room, !!options.json)
223
+ printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
222
224
  const detachReporter = attachReporter(session, !!options.json)
223
225
  await session.connect()
224
226
  const targets = await waitForTargets(session, selectors, timeoutMs)
@@ -232,9 +234,9 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
232
234
 
233
235
  const acceptCommand = async (options: Record<string, unknown>) => {
234
236
  const { SendSession } = await loadSessionRuntime()
235
- const session = new SendSession(sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true }))
237
+ const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
236
238
  handleSignals(session)
237
- printRoomAnnouncement(session.room, !!options.json)
239
+ printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
238
240
  const detachReporter = attachReporter(session, !!options.json)
239
241
  await session.connect()
240
242
  if (!options.json) console.log(`listening in ${session.room}`)
@@ -252,7 +254,7 @@ const acceptCommand = async (options: Record<string, unknown>) => {
252
254
  }
253
255
 
254
256
  const tuiCommand = async (options: Record<string, unknown>) => {
255
- const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
257
+ const initialConfig = sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS)
256
258
  const { clean, offer } = parseBinaryOptions(options, ["clean", "offer"] as const)
257
259
  const { startTui } = await loadTuiRuntime()
258
260
  await startTui(initialConfig, {