Skip to content

feat: add sendFile() and receiveHttpBodyToFile() to stream files natively - #242

Open
HugoGresse wants to merge 1 commit into
Rapsssito:masterfrom
HugoGresse:feat/native-file-streaming
Open

HugoGresse wants to merge 1 commit into
Rapsssito:masterfrom
HugoGresse:feat/native-file-streaming

Conversation

@HugoGresse

@HugoGresse HugoGresse commented Sep 30, 2026 •

Copy link
Copy Markdown

Summary

Adds two Socket methods that move a file between the disk and the socket on the native side, so its bytes never go through JS:

  • socket.sendFile(path, offset, length) streams a range of a file to the socket. It returns a Promise.
  • socket.receiveHttpBodyToFile(path) writes the body of an HTTP response to a file. A 206 response is appended to the file, so an interrupted download can be resumed with a Range request.
  • Two events: 'fileProgress' (bytes, total) on both sides, and 'fileEnd' (bytes, error) on the receiving side.

Why: with write() and 'data', every chunk crosses the bridge as base64. Between two phones on the same Wi-Fi we could not get past 2 MB/s that way (same observation as #212). With these two methods the same transfer runs at 13 to 57 MB/s, and memory use no longer depends on the file size.

// Sender (a socket accepted by a server)
socket.write(`HTTP/1.1 200 OK\r\nContent-Length: ${size}\r\nConnection: close\r\n\r\n`);
await socket.sendFile(path, 0, size);
socket.end();

// Receiver
socket.receiveHttpBodyToFile(path);
socket.write(`GET /model.bin HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
socket.on('fileProgress', (bytes, total) => {});
socket.on('fileEnd', (bytes, error) => {});

Related to #212.

Changes

JS (src/Socket.js, types regenerated with yarn declaration:build)

  • sendFile() uses the same written message ids as write(), so it is ordered after the pending writes. It rejects on a written error, and also on 'error' / 'close', because iOS only reports a failed send as an error event.
  • 'fileProgress' resets the idle timeout set with setTimeout(): no 'data' event is emitted during a native transfer.

Android

  • TcpSocketClient.sendFile() runs on the existing write executor.
  • New HttpBodyFileReceiver: parses the response header, then writes the body to the file. The receiver task hands it the bytes instead of emitting data events.
  • data events keep their 16 KB chunks. The 64 KB read size is only used once receiveHttpBodyToFile() was called.

iOS / macOS

  • sendFile reads the file in 256 KB chunks and queues the next one from didWriteDataWithTag, so at most one chunk is in memory.
  • The NSFileHandle methods that report an NSError need iOS 13 / macOS 10.15. As the podspec declares iOS 9 and macOS 10.14, they are behind @available, with the older methods as a fallback.
  • One change reaches beyond the new methods: the socket delegate callbacks ran on the main queue, which would put the file reads and writes on the main thread. They now run on a private serial queue, and the module methods use that same queue (methodQueue). As a side effect, _clients is no longer touched from two queues.

Docs: README, API section (Socket.sendFile(), Socket.receiveHttpBodyToFile(), the two events).

Tests

  • yarn ci passes. 10 new Jest tests in __tests__/fileTransfer.test.js (20 in total): arguments sent to the native module, ordering with write(), the three rejection paths of sendFile(), event filtering by socket id, fileEnd with and without an error.
  • The native code comes from a patch on 6.4.3 that we use in our app (React Native 0.86). With that patch, on real devices and release builds: a 594 MB file sent from a Samsung S23+ to a Pixel 7, and transfers between an iPhone SE and Android phones. Resuming with a 206 was not exercised on phones, only in the harnesses below.
  • This PR is that code without the unrelated parts, plus three changes: the NSFileHandle fallback, the extra rejections in sendFile(), and the read size kept at 16 KB for data events. This exact version was not run again on phones. What I ran instead:
    • Android sources compiled with javac against android.jar and React Native 0.86.
    • Both iOS files checked with clang -fsyntax-only for an iOS 12 target, -Wall -Wunguarded-availability: no warning.
    • Two loopback harnesses driving the real TcpSocketClient classes (Java on the JVM, Objective-C on macOS with CocoaAsyncSocket), not included in this PR. Cases: 200, 206 appended to a partial file, 404, connection closed before Content-Length, no Content-Length, missing source file, and a plain socket still receiving data events. A 25 MB file is compared byte for byte. The Objective-C harness passes with both NSFileHandle code paths.
  • Not tested: TLS sockets with these two methods, and the example app.

Limits and open questions

  • receiveHttpBodyToFile() is tied to HTTP, which is unusual for this library. The reason is that the response header and the first bytes of the body arrive in the same read: if JS parsed the header and then asked the native side to switch to a file, some data events would already be on their way. I am happy to rename it or to change its shape if you prefer another API.
  • The body is written as received: no Transfer-Encoding: chunked, no decompression, one response per socket. The size is only bounded by Content-Length.
  • On iOS, a write() issued while sendFile() is still running can end up in the middle of the file. The README says to wait for the promise.

…vely

Sending a file with write() moves every chunk through JS as base64, which
caps a LAN transfer at 1-2 MB/s. Two new Socket methods keep the bytes on
the native side:

- sendFile(path, offset, length) streams a range of a file to the socket.
- receiveHttpBodyToFile(path) writes the body of an HTTP response to a
  file, and appends to it on a 206 so a download can be resumed.

Both emit 'fileProgress', and the receiver ends with 'fileEnd'.

On iOS, the socket delegate callbacks and the module methods now share a
private serial queue instead of the main queue, so that the file is not
read and written on the main thread.

Related to Rapsssito#212
@HugoGresse
HugoGresse force-pushed the feat/native-file-streaming branch from 20fbe7f to 1ae084d Compare September 30, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant