Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: typecheck
run: yarn typecheck

- name: js tests
run: yarn test

- name: setup java
uses: actions/setup-java@v4
with:
Expand Down
8 changes: 8 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// For Jest only. Metro/RN builds use each app's own babel config; the library
// itself ships TypeScript source (no build step).
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};
2 changes: 1 addition & 1 deletion husky.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ module.exports = {
'post-checkout': `if [[ $HUSKY_GIT_PARAMS =~ 1$ ]]; then ${runYarnLock}; fi`,
'post-merge': runYarnLock,
'post-rebase': 'yarn install',
'pre-commit': 'yarn lint-staged && yarn typecheck',
'pre-commit': 'yarn lint-staged && yarn typecheck && yarn test',
},
};
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/src/__tests__/**/*.test.ts'],
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"scripts": {
"typecheck": "tsc",
"test": "jest",
"lint": "yarn lint-root --fix && yarn lint-example --fix",
"lint:ci": "yarn lint-root && yarn lint-example",
"lint-root": "eslint --ext js,jsx,ts,tsx src --quiet",
Expand Down
33 changes: 33 additions & 0 deletions src/NativeRNFileUploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { type CodegenTypes, type TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

// Codegen TurboModule spec (New Architecture). The typed public API lives in
// ./types and is applied at the JS edge in ./index; here the dynamic-shaped
// payloads (the options dict, journaled events, upload snapshots, and the
// terminal event payloads that carry header maps + optional fields) are declared
// as UnsafeObject because codegen can't model index signatures, Partial<>, or
// intersections. index.ts casts them back to the precise ./types shapes.
export interface Spec extends TurboModule {
startUpload(options: CodegenTypes.UnsafeObject): Promise<string>;
cancelUpload(id: string): Promise<boolean>;
// iOS returns { state, bytesSent, totalBytes }; Android returns null.
getUploadStatus(id: string): Promise<CodegenTypes.UnsafeObject | null>;
getUnacknowledgedEvents(): Promise<CodegenTypes.UnsafeObject[]>;
ackEvents(ids: string[]): Promise<boolean>;
getAllUploads(): Promise<CodegenTypes.UnsafeObject[]>;

// Events. progress fires on both platforms with a fixed shape; the terminal
// events carry variant payloads (header maps, optional fields) so they're
// UnsafeObject. notification is Android-only (tapping the progress
// notification) and simply never fires on iOS.
readonly onProgress: CodegenTypes.EventEmitter<{
id: string;
progress: number;
}>;
readonly onError: CodegenTypes.EventEmitter<CodegenTypes.UnsafeObject>;
readonly onCancelled: CodegenTypes.EventEmitter<CodegenTypes.UnsafeObject>;
readonly onCompleted: CodegenTypes.EventEmitter<CodegenTypes.UnsafeObject>;
readonly onNotification: CodegenTypes.EventEmitter<CodegenTypes.UnsafeObject>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('RNFileUploader');
102 changes: 102 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Define all mocks inside the factory (no outer references) to avoid the
// import-hoisting TDZ trap, then grab handles from the mocked module below.
// The library reaches native through TurboModuleRegistry.getEnforcing, so that
// is what has to be stubbed — the codegen event emitters are plain functions
// that take a handler and return a subscription.
jest.mock('react-native', () => {
const subscription = { remove: jest.fn() };
const nativeModule = {
startUpload: jest.fn(async () => 'id-1'),
cancelUpload: jest.fn(async () => true),
getUploadStatus: jest.fn(async () => null),
getUnacknowledgedEvents: jest.fn(async () => [
{
eventId: 'e1',
id: 'u1',
type: 'completed',
timestamp: 1,
responseCode: 200,
},
]),
ackEvents: jest.fn(async () => true),
getAllUploads: jest.fn(async () => [{ id: 'u1', state: 'running' }]),
onProgress: jest.fn(() => subscription),
onError: jest.fn(() => subscription),
onCancelled: jest.fn(() => subscription),
onCompleted: jest.fn(() => subscription),
onNotification: jest.fn(() => subscription),
};
return {
Platform: { OS: 'ios' },
TurboModuleRegistry: {
getEnforcing: jest.fn(() => nativeModule),
get: jest.fn(() => nativeModule),
},
};
});

import { TurboModuleRegistry } from 'react-native';
import Upload from '../index';

/* eslint-disable @typescript-eslint/no-explicit-any */
// Same object the module captured at import time.
const native = (TurboModuleRegistry as any).getEnforcing('RNFileUploader');

describe('journal + query API', () => {
it('getUnacknowledgedEvents returns the native events', async () => {
const events = await Upload.getUnacknowledgedEvents();
expect(events[0].eventId).toBe('e1');
expect(events[0].type).toBe('completed');
});

it('ackEvents forwards the ids to native', async () => {
await Upload.ackEvents(['e1', 'e2']);
expect(native.ackEvents).toHaveBeenCalledWith(['e1', 'e2']);
});

it('getAllUploads returns the native snapshots', async () => {
const uploads = await Upload.getAllUploads();
expect(uploads[0]).toEqual({ id: 'u1', state: 'running' });
});

it('getUploadStatus maps a null result to undefined', async () => {
await expect(Upload.ios.getUploadStatus('u1')).resolves.toBeUndefined();
});
});

describe('startUpload', () => {
it('prefixes the file path on iOS and forwards options', async () => {
await Upload.startUpload({
url: 'https://example.com/up',
path: '/tmp/f.bin',
method: 'POST',
type: 'raw',
acceptStatus: [409],
});
expect(native.startUpload).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/up',
path: 'file:///tmp/f.bin',
acceptStatus: [409],
}),
);
});
});

describe('addListener', () => {
it('subscribes to the matching codegen emitter', () => {
Upload.addListener('progress', null, jest.fn());
expect(native.onProgress).toHaveBeenCalled();
});

it('only invokes the listener for the matching upload id', () => {
const cb = jest.fn();
Upload.addListener('completed', 'u1', cb);
const handler = native.onCompleted.mock.calls.at(-1)![0] as (
data: unknown,
) => void;
handler({ id: 'u1', responseCode: 200 });
handler({ id: 'someone-else', responseCode: 200 });
expect(cb).toHaveBeenCalledTimes(1);
});
});
142 changes: 96 additions & 46 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,31 @@
/**
* Handles HTTP background file uploads from an iOS or Android device.
*/
import { NativeModules, DeviceEventEmitter, Platform } from 'react-native';
import { AddListener, UploadId, UploadOptions } from './types';
import { Platform } from 'react-native';
import type { EventSubscription } from 'react-native';
import NativeRNFileUploader from './NativeRNFileUploader';
import {
AddListener,
JournaledEvent,
UploadId,
UploadOptions,
UploadSnapshot,
} from './types';

export * from './types';

const NativeModule =
NativeModules.VydiaRNFileUploader || NativeModules.RNFileUploader;
const eventPrefix = 'RNFileUploader-';
const fileURIPrefix = 'file://';

// for IOS, register event listeners or else they don't fire on DeviceEventEmitter
if (NativeModules.VydiaRNFileUploader) {
NativeModule.addListener(eventPrefix + 'progress');
NativeModule.addListener(eventPrefix + 'error');
NativeModule.addListener(eventPrefix + 'cancelled');
NativeModule.addListener(eventPrefix + 'completed');
}

/**
* Starts uploading a file to an HTTP endpoint.
* Options object:
```
{
url: string. url to post to.
path: string. path to the file on the device
headers: hash of name/value header pairs
method: HTTP method to use. Default is "POST"
notification: hash for customizing tray notifiaction
enabled: boolean to enable/disabled notifications, true by default.
}
```
* Returns a promise with the string ID of the upload. Will reject if there is a connection problem, the file doesn't exist, or there is some other problem.
* It is recommended to add listeners in the .then of this promise.
*/
* Starts uploading a file to an HTTP endpoint. See UploadOptions for the full
* option set (url, path, method, headers, wifiOnly, acceptStatus, android).
* Returns a promise resolving to the upload's string id. Rejects only on a bad
* option (e.g. missing/invalid url or path); transport failures and HTTP error
* responses surface later as 'error' events, not a rejection here.
*/
const startUpload = ({
path,
android,
ios,
...options
}: UploadOptions): Promise<UploadId> => {
if (!path.startsWith(fileURIPrefix)) {
Expand All @@ -49,7 +36,7 @@ const startUpload = ({
path = path.replace(fileURIPrefix, '');
}

return NativeModule.startUpload({ ...options, ...android, ...ios, path });
return NativeRNFileUploader.startUpload({ ...options, ...android, path });
};

/**
Expand All @@ -58,60 +45,123 @@ const startUpload = ({
* Upload ID is returned in a promise after a call to startUpload method,
* use it to cancel started upload.
* Event "cancelled" will be fired when upload is cancelled.
* Returns a promise with boolean true if operation was successfully completed.
* Will reject if there was an internal error or ID format is invalid.
* On iOS, resolves true if a matching in-flight upload was found and cancelled,
* false if there was nothing to cancel. Android always resolves true — the
* WorkManager cancel is fire-and-forget and does not report whether it matched.
*/
const cancelUpload = (cancelUploadId: string): Promise<boolean> =>
NativeModule.cancelUpload(cancelUploadId);
NativeRNFileUploader.cancelUpload(cancelUploadId);

/**
* Listens for the given event on the given upload ID (resolved from startUpload).
* If you don't supply a value for uploadId, the event will fire for all uploads.
* Events (id is always the upload ID):
* progress - { id: string, progress: int (0-100) }
* error - { id: string, error: string }
* cancelled - { id: string, error: string }
* completed - { id: string }
* progress - { id, progress: 0-100 }
* error - { id, error, errorKind?, responseCode?, responseBody?, responseHeaders? }
* cancelled - { id, cancelReason?: 'user' | 'system' }
* completed - { id, responseCode, responseBody, responseHeaders?, eventId? }
*/
const addListener: AddListener = (eventType, uploadId, listener) =>
DeviceEventEmitter.addListener(eventPrefix + eventType, (data) => {
if (!uploadId || !data || !data.id || data.id === uploadId) {
const addListener = ((
eventType: 'progress' | 'error' | 'completed' | 'cancelled',
uploadId: UploadId | null,
// The payload shape varies per event; the public AddListener overloads carry
// the precise contract, so the internal forwarder stays untyped.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listener: (data: any) => void,
): EventSubscription => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const forMatchingUpload = (data: any) => {
// A scoped subscription drops anything it can't attribute, rather than
// failing open and delivering another upload's (or an unidentified) event.
if (!uploadId || data?.id === uploadId) {
listener(data);
}
});
};

switch (eventType) {
case 'progress':
return NativeRNFileUploader.onProgress(forMatchingUpload);
case 'error':
return NativeRNFileUploader.onError(forMatchingUpload);
case 'cancelled':
return NativeRNFileUploader.onCancelled(forMatchingUpload);
case 'completed':
return NativeRNFileUploader.onCompleted(forMatchingUpload);
default:
throw new Error(`Unknown upload event: ${eventType}`);
}
}) as AddListener;

/**
* Terminal events (completed/error/cancelled) are journaled natively before being
* emitted, so they survive the app being killed or JS reloading. Read them on
* startup, process each, then acknowledge — unacknowledged events are re-delivered
* here on every call until you ack them.
*
* Note: `completed` fires only for 2xx (or a request's `acceptStatus`); other HTTP
* responses arrive as `error` with `errorKind: 'http'` and the response attached.
*/
const getUnacknowledgedEvents = async (): Promise<JournaledEvent[]> =>
(await NativeRNFileUploader.getUnacknowledgedEvents()) as JournaledEvent[];

/** Removes journaled events by eventId once you've processed them. */
const ackEvents = (eventIds: string[]): Promise<boolean> =>
NativeRNFileUploader.ackEvents(eventIds);

/**
* Enumerates uploads the OS still knows about, for reconciling in-flight work on
* boot. Terminal outcomes come from getUnacknowledgedEvents (durable), not here:
* on Android finished work is pruned after ~a day, and on iOS only live tasks are
* listed.
*/
const getAllUploads = async (): Promise<UploadSnapshot[]> =>
(await NativeRNFileUploader.getAllUploads()) as UploadSnapshot[];

const ios = {
/**
* Directly check the state of a single upload task without using event listeners.
* Note that this method has no way of distinguishing between a task being completed, errored, or non-existent.
* They're all `undefined`. You will need to either rely on the listeners or
* check with the API service you're using to upload.
*
* Android always resolves `undefined`.
*/
getUploadStatus: async (
jobId: string,
): Promise<
| {
state: 'running' | 'suspended' | 'canceling';
state: 'running' | 'suspended' | 'canceling' | 'completed';
bytesSent: number;
totalBytes: number;
}
| undefined
> => await NativeModule.getUploadStatus?.(jobId),
> =>
((await NativeRNFileUploader.getUploadStatus(jobId)) as
| {
state: 'running' | 'suspended' | 'canceling' | 'completed';
bytesSent: number;
totalBytes: number;
}
| null) ?? undefined,
};

const android = {
/**
* When the upload progress notification is pressed, it will open the app and fire this event
* When the upload progress notification is pressed, it will open the app and fire this event.
* Android only — never fires on iOS.
* @param listener
*/
addNotificationListener: (listener: () => void) =>
DeviceEventEmitter.addListener(eventPrefix + 'notification', listener),
addNotificationListener: (listener: () => void): EventSubscription =>
NativeRNFileUploader.onNotification(() => listener()),
};

export default {
startUpload,
cancelUpload,
addListener,
getUnacknowledgedEvents,
ackEvents,
getAllUploads,
ios,
android,
};
Loading
Loading