Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ Here are listed all methods implemented in `react-native-tcp-socket` that imitat
* [`setNoDelay([noDelay])`](https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay)
* [`setTimeout(timeout[, callback])`](https://nodejs.org/api/net.html#net_socket_settimeout_timeout_callback)
* [`write(data[, encoding][, callback])`](https://nodejs.org/api/net.html#net_socket_write_data_encoding_callback)
* **[`sendFile(path, offset, length)`](#socketsendfile----omit-in-toc)**
* **[`receiveHttpBodyToFile(path)`](#socketreceivehttpbodytofile----omit-in-toc)**
* [`pause()`](https://nodejs.org/api/net.html#net_socket_pause)
* `ref()` - _Will not have any effect_
* [`resume()`](https://nodejs.org/api/net.html#net_socket_resume)
Expand Down Expand Up @@ -362,6 +364,8 @@ Here are listed all methods implemented in `react-native-tcp-socket` that imitat
* [`'drain'`](https://nodejs.org/api/net.html#net_event_drain)
* [`'error'`](https://nodejs.org/api/net.html#net_event_error_1)
* [`'timeout'`](https://nodejs.org/api/net.html#net_event_timeout)
* **[`'fileProgress'`](#socketsendfile----omit-in-toc)**
* **[`'fileEnd'`](#socketreceivehttpbodytofile----omit-in-toc)**

##### `net.createConnection()` <!-- omit in toc -->
`net.createConnection(options[, callback])` creates a TCP connection using the given `options`. The `options` parameter must be an `object` with the following properties:
Expand All @@ -378,6 +382,54 @@ Here are listed all methods implemented in `react-native-tcp-socket` that imitat

**Note**: The platforms marked as ❌ use the default value.

##### `Socket.sendFile()` <!-- omit in toc -->
`socket.sendFile(path, offset, length)` sends `length` bytes of the file at `path`, starting at `offset`. The file is read and written to the socket natively: its bytes never go through JS, so a large file is sent much faster than with `write()` and without loading it in memory. It works on iOS/macOS and Android, on client sockets and on the sockets of a server.

| Parameter | Type | Description |
| ------------ | ---------- | -------------------------------------------------------------------- |
| **`path`** | `<string>` | Absolute path of the file, with or without the `file://` scheme. |
| **`offset`** | `<number>` | Position of the first byte to send. |
| **`length`** | `<number>` | Number of bytes to send. |

It returns a `Promise` resolved once the bytes are written out, and rejected if the file cannot be read or if the socket fails or closes before the end. The file is sent after the pending `write()` calls. Wait for the promise before writing to the socket or sending another file.

While the file is sent, `'fileProgress'` is emitted about every 250 ms with `(bytes, total)`: the number of bytes sent and `length`.

```js
server.on('connection', async (socket) => {
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();
});
```

##### `Socket.receiveHttpBodyToFile()` <!-- omit in toc -->
`socket.receiveHttpBodyToFile(path)` writes the body of an HTTP response to the file at `path`, natively. From this call on, the incoming bytes are parsed as one HTTP response instead of being emitted as `'data'` events: call it before sending the request. It works on iOS/macOS and Android.

| Response status | Behaviour |
| --------------- | ------------------------------------------------------------------------------------------ |
| `200` | The file is created, or replaced if it exists. |
| `206` | The body is appended to the file, to resume a download with a `Range` request. |
| Any other | The transfer fails and the file is left untouched. |

The body ends after `Content-Length` bytes, or with the connection when the response has no `Content-Length`. It is written as received: `Transfer-Encoding: chunked` and compressed bodies are not decoded. Only one response is read per socket, the bytes following the body are ignored.

* `'fileProgress'` is emitted about every 250 ms with `(bytes, total)`: the number of body bytes written by this call and the `Content-Length`, or `-1` when the response has none.
* `'fileEnd'` is emitted once with `(bytes, error)`. `error` is `null` when the whole body is written, or a message such as `'HTTP 404'` or `'Incomplete: 1000/2000 bytes'`.

```js
const socket = net.createConnection({ host, port }, () => {
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) => console.log(`${bytes}/${total}`));
socket.on('fileEnd', (bytes, error) => {
socket.destroy();
if (error) console.warn(`Download failed after ${bytes} bytes: ${error}`);
});
```

#### Server
* **Methods:**
* [`address()`](https://nodejs.org/api/net.html#net_server_address)
Expand Down
154 changes: 154 additions & 0 deletions __tests__/fileTransfer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect, test, jest, beforeEach } from '@jest/globals';
import net from '../src/index';
import { nativeEventEmitter } from '../src/Globals';
import { NativeModules } from 'react-native';

const Sockets = NativeModules.TcpSockets;

jest.mock('../src/Globals', () => {
const { EventEmitter } = require('events');
const emitter = new EventEmitter();
const originalAddListener = emitter.addListener.bind(emitter);
// @ts-ignore
emitter.addListener = (event, listener) => {
originalAddListener(event, listener);
return { remove: () => emitter.removeListener(event, listener) };
};

let idCounter = 2000;
return {
__esModule: true,
nativeEventEmitter: emitter,
getNextId: () => idCounter++,
};
});

const FILE_PATH = '/data/model.bin';

function createConnectedSocket() {
const socket = new net.Socket();
socket.connect({ port: 8080, host: '192.168.1.20' });
// @ts-ignore
const id = socket._id;
nativeEventEmitter.emit('connect', {
id,
connection: {
localAddress: '192.168.1.10',
localPort: 54321,
remoteAddress: '192.168.1.20',
remotePort: 8080,
remoteFamily: 'IPv4',
},
});
return { socket, id };
}

beforeEach(() => {
Sockets.sendFile.mockClear();
Sockets.receiveHttpBodyToFile.mockClear();
});

test('sendFile: rejects when the socket is not connected', async () => {
const socket = new net.Socket();

await expect(socket.sendFile(FILE_PATH, 0, 10)).rejects.toThrow('Socket is closed.');
expect(Sockets.sendFile).not.toHaveBeenCalled();
});

test('sendFile: resolves once the native side reports the file as written', async () => {
const { socket, id } = createConnectedSocket();

const sent = socket.sendFile(FILE_PATH, 100, 900);

expect(Sockets.sendFile).toHaveBeenCalledWith(id, FILE_PATH, 100, 900, 0);
expect(socket.bytesWritten).toBe(900);
nativeEventEmitter.emit('written', { id, msgId: 0 });
await expect(sent).resolves.toBeUndefined();
});

test('sendFile: stays ordered with write()', async () => {
const { socket, id } = createConnectedSocket();

socket.write('HTTP/1.1 200 OK\r\n\r\n');
const sent = socket.sendFile(FILE_PATH, 0, 10);

expect(Sockets.write).toHaveBeenLastCalledWith(id, expect.any(String), 0);
expect(Sockets.sendFile).toHaveBeenCalledWith(id, FILE_PATH, 0, 10, 1);
nativeEventEmitter.emit('written', { id, msgId: 0 });
nativeEventEmitter.emit('written', { id, msgId: 1 });
await expect(sent).resolves.toBeUndefined();
});

test('sendFile: rejects with the error of the written event', async () => {
const { socket, id } = createConnectedSocket();

const sent = socket.sendFile(FILE_PATH, 0, 10);
nativeEventEmitter.emit('written', { id, msgId: 0, err: 'Broken pipe' });

await expect(sent).rejects.toThrow('Broken pipe');
});

test('sendFile: rejects when the socket reports an error', async () => {
const { socket, id } = createConnectedSocket();

const sent = socket.sendFile(FILE_PATH, 0, 10);
nativeEventEmitter.emit('error', { id, error: 'Cannot open the file to send' });

await expect(sent).rejects.toThrow('Cannot open the file to send');
});

test('sendFile: rejects when the socket closes before the end', async () => {
const { socket, id } = createConnectedSocket();

const sent = socket.sendFile(FILE_PATH, 0, 10);
nativeEventEmitter.emit('close', { id, error: false });

await expect(sent).rejects.toThrow('Socket closed before the file was sent.');
});

test('sendFile: emits fileProgress for its own socket only', () => {
const { socket, id } = createConnectedSocket();
const onProgress = jest.fn();
socket.on('fileProgress', onProgress);

socket.sendFile(FILE_PATH, 0, 1000).catch(() => {});
nativeEventEmitter.emit('fileProgress', { id: id + 1, bytes: 1, total: 2 });
nativeEventEmitter.emit('fileProgress', { id, bytes: 250, total: 1000 });

expect(onProgress).toHaveBeenCalledTimes(1);
expect(onProgress).toHaveBeenCalledWith(250, 1000);
});

test('receiveHttpBodyToFile: throws when the socket is not connected', () => {
const socket = new net.Socket();

expect(() => socket.receiveHttpBodyToFile(FILE_PATH)).toThrow('Socket is closed.');
expect(Sockets.receiveHttpBodyToFile).not.toHaveBeenCalled();
});

test('receiveHttpBodyToFile: emits fileProgress then fileEnd without error', () => {
const { socket, id } = createConnectedSocket();
const onProgress = jest.fn();
const onEnd = jest.fn();
socket.on('fileProgress', onProgress);
socket.on('fileEnd', onEnd);

expect(socket.receiveHttpBodyToFile(FILE_PATH)).toBe(socket);
nativeEventEmitter.emit('fileProgress', { id, bytes: 500, total: 1000 });
nativeEventEmitter.emit('fileEnd', { id, bytes: 1000, error: null });

expect(Sockets.receiveHttpBodyToFile).toHaveBeenCalledWith(id, FILE_PATH);
expect(onProgress).toHaveBeenCalledWith(500, 1000);
expect(onEnd).toHaveBeenCalledWith(1000, null);
});

test('receiveHttpBodyToFile: reports the failure in fileEnd', () => {
const { socket, id } = createConnectedSocket();
const onEnd = jest.fn();
socket.on('fileEnd', onEnd);

socket.receiveHttpBodyToFile(FILE_PATH);
nativeEventEmitter.emit('fileEnd', { id, bytes: 0, error: 'HTTP 404' });

expect(onEnd).toHaveBeenCalledWith(0, 'HTTP 404');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.asterinet.react.tcpsocket;

import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;

import javax.annotation.Nullable;

/**
* Parses an HTTP response header, then streams the body straight to a file, without passing the
* bytes through JS. A 200 response truncates the file, a 206 response appends to it (resume).
* Reports progress and completion through the TcpEventListener.
*/
class HttpBodyFileReceiver {
static final long PROGRESS_INTERVAL_MS = 250;
private static final int MAX_HEADER_BYTES = 64 * 1024;
private static final int FILE_BUFFER_BYTES = 256 * 1024;

private final int socketId;
private final String path;
private final TcpEventListener listener;
private final ByteArrayOutputStream header = new ByteArrayOutputStream();
@Nullable
private OutputStream out;
private long expected = -1;
private long received = 0;
private long lastProgressAt = 0;
private boolean done = false;
private boolean isPartialContent = false;

HttpBodyFileReceiver(int socketId, String path, TcpEventListener listener) {
this.socketId = socketId;
this.path = path.startsWith("file://") ? path.substring("file://".length()) : path;
this.listener = listener;
}

void onData(byte[] buffer, int count) {
if (done) return;
try {
if (out != null) {
writeBody(buffer, 0, count);
return;
}
header.write(buffer, 0, count);
byte[] head = header.toByteArray();
int headerEnd = indexOfHeaderEnd(head);
if (headerEnd < 0) {
if (head.length > MAX_HEADER_BYTES) finish("HTTP header too large");
return;
}
if (!parseHeader(new String(head, 0, headerEnd, StandardCharsets.ISO_8859_1))) return;
// 206 Partial Content answers a Range request: resume by appending to the partial file
out = new BufferedOutputStream(new FileOutputStream(path, isPartialContent), FILE_BUFFER_BYTES);
int bodyStart = headerEnd + 4;
writeBody(head, bodyStart, head.length - bodyStart);
} catch (IOException e) {
finish(e.getMessage());
}
}

void onEnd() {
if (done) return;
if (out == null) {
finish("Connection closed before the HTTP header");
} else if (expected >= 0 && received < expected) {
finish("Incomplete: " + received + "/" + expected + " bytes");
} else {
finish(null);
}
}

void onError(@Nullable String message) {
if (!done) finish(message == null ? "Socket error" : message);
}

private void writeBody(byte[] buffer, int offset, int length) throws IOException {
int toWrite = expected >= 0 ? (int) Math.min(length, expected - received) : length;
if (toWrite > 0 && out != null) {
out.write(buffer, offset, toWrite);
received += toWrite;
}
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
lastProgressAt = now;
listener.onFileProgress(socketId, received, expected);
}
if (expected >= 0 && received >= expected) finish(null);
}

private boolean parseHeader(String head) {
String[] lines = head.split("\r\n");
String[] statusParts = lines[0].split(" ");
int status = statusParts.length > 1 ? parseIntOr(statusParts[1], -1) : -1;
if (status != 200 && status != 206) {
finish("HTTP " + status);
return false;
}
isPartialContent = status == 206;
for (String line : lines) {
if (line.toLowerCase().startsWith("content-length:")) {
expected = parseLongOr(line.substring("content-length:".length()).trim(), -1);
}
}
return true;
}

private void finish(@Nullable String error) {
done = true;
if (out != null) {
try {
out.close();
} catch (IOException e) {
if (error == null) error = e.getMessage();
}
}
listener.onFileEnd(socketId, received, error);
}

private static int indexOfHeaderEnd(byte[] data) {
for (int i = 0; i + 3 < data.length; i++) {
if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') return i;
}
return -1;
}

private static int parseIntOr(String value, int fallback) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return fallback;
}
}

private static long parseLongOr(String value, long fallback) {
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return fallback;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ public void onWritten(int id, int msgId, @Nullable Exception e) {
sendEvent("written", eventParams);
}

public void onFileProgress(int id, long bytes, long total) {
WritableMap eventParams = Arguments.createMap();
eventParams.putInt("id", id);
eventParams.putDouble("bytes", bytes);
eventParams.putDouble("total", total);
sendEvent("fileProgress", eventParams);
}

public void onFileEnd(int id, long bytes, @Nullable String error) {
WritableMap eventParams = Arguments.createMap();
eventParams.putInt("id", id);
eventParams.putDouble("bytes", bytes);
eventParams.putString("error", error);
sendEvent("fileEnd", eventParams);
}

public void onClose(int id, Exception e) {
if (e != null) {
onError(id, e);
Expand Down
Loading