diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 07d1af4e..4e31494b 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -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: diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..7bcd7fce --- /dev/null +++ b/babel.config.js @@ -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', + ], +}; diff --git a/husky.config.js b/husky.config.js index b74152c6..a19a9276 100644 --- a/husky.config.js +++ b/husky.config.js @@ -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', }, }; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..f8508736 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/src/__tests__/**/*.test.ts'], +}; diff --git a/package.json b/package.json index f7622ff1..2a886fef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts new file mode 100644 index 00000000..ffeec3e8 --- /dev/null +++ b/src/NativeRNFileUploader.ts @@ -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; + cancelUpload(id: string): Promise; + // iOS returns { state, bytesSent, totalBytes }; Android returns null. + getUploadStatus(id: string): Promise; + getUnacknowledgedEvents(): Promise; + ackEvents(ids: string[]): Promise; + getAllUploads(): Promise; + + // 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; + readonly onCancelled: CodegenTypes.EventEmitter; + readonly onCompleted: CodegenTypes.EventEmitter; + readonly onNotification: CodegenTypes.EventEmitter; +} + +export default TurboModuleRegistry.getEnforcing('RNFileUploader'); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 00000000..c81cb5db --- /dev/null +++ b/src/__tests__/index.test.ts @@ -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); + }); +}); diff --git a/src/index.ts b/src/index.ts index 4710e914..8a0b21e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 => { if (!path.startsWith(fileURIPrefix)) { @@ -49,7 +36,7 @@ const startUpload = ({ path = path.replace(fileURIPrefix, ''); } - return NativeModule.startUpload({ ...options, ...android, ...ios, path }); + return NativeRNFileUploader.startUpload({ ...options, ...android, path }); }; /** @@ -58,27 +45,77 @@ 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 => - 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 => + (await NativeRNFileUploader.getUnacknowledgedEvents()) as JournaledEvent[]; + +/** Removes journaled events by eventId once you've processed them. */ +const ackEvents = (eventIds: string[]): Promise => + 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 => + (await NativeRNFileUploader.getAllUploads()) as UploadSnapshot[]; const ios = { /** @@ -86,32 +123,45 @@ const ios = { * 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, }; diff --git a/src/types.ts b/src/types.ts index 2498be65..a5f15b66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,16 +8,72 @@ export interface ProgressData extends EventData { progress: number; } -export interface ErrorData extends EventData { +export type ErrorKind = 'http' | 'network' | 'file' | 'unknown'; + +export type CancelReason = 'user' | 'system'; + +export type UploadId = string; + +/** + * Fields carried by every terminal event (`completed` / `error` / `cancelled`). + * + * The native side emits the journal entry itself, so a live terminal event is + * the very same object `getUnacknowledgedEvents()` returns — `eventId` included, + * which is what lets you `ackEvents([eventId])` immediately after handling a + * live event instead of waiting to rediscover it on the next launch. + */ +export interface TerminalEventData extends EventData { + eventId: string; + type: 'completed' | 'error' | 'cancelled'; + /** Epoch milliseconds, stamped natively when the outcome occurred. */ + timestamp: number; + /** + * The response, when one was received. Absent for a transport failure (the + * request never reached the server), so always narrow before using it. + */ + responseCode?: number; + responseBody?: string; + /** True when `responseBody` hit the 64KB cap and was truncated. */ + responseBodyTruncated?: boolean; + responseHeaders?: Record; +} + +/** A 2xx response, or one whose status was listed in the request's `acceptStatus`. */ +export interface CompletedData extends TerminalEventData { + type: 'completed'; +} + +export interface ErrorData extends TerminalEventData { + type: 'error'; error: string; + /** + * Why it failed. `http` means the server responded and the status was not + * accepted (the response fields above are populated). `file` means the payload + * is missing or unreadable on disk, so retrying can never succeed. + */ + errorKind?: ErrorKind; } -export interface CompletedData extends EventData { - responseCode: number; - responseBody: string; +export interface CancelledData extends TerminalEventData { + type: 'cancelled'; + /** `user` for an explicit `cancelUpload`; `system` for an OS-initiated stop. */ + cancelReason?: CancelReason; } -export type UploadId = string; +/** + * A terminal event journaled natively before being emitted, so it survives app + * death and JS reloads. Read via `getUnacknowledgedEvents`, process, then + * acknowledge via `ackEvents`. Discriminate on `type`. + */ +export type JournaledEvent = CompletedData | ErrorData | CancelledData; + +/** A snapshot of an upload the OS still knows about (from getAllUploads). */ +export interface UploadSnapshot { + id: UploadId; + state: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'; + bytesSent?: number; // iOS only + totalBytes?: number; // iOS only +} export type UploadOptions = { url: string; @@ -29,11 +85,15 @@ export type UploadOptions = { }; // Whether the upload should wait for wifi before starting wifiOnly?: boolean; - android: AndroidOnlyUploadOptions; - ios?: IOSOnlyUploadOptions; + // Non-2xx statuses to treat as a successful completion (e.g. [409] when + // duplicate-create conflicts are expected). Anything else non-2xx emits an + // 'error' event with errorKind 'http'. + acceptStatus?: number[]; + // Optional: the library supplies notification defaults and creates its own channel. + android?: Partial; } & RawUploadOptions; -type AndroidOnlyUploadOptions = { +export type AndroidOnlyUploadOptions = { notificationId: string; notificationTitle: string; notificationTitleNoWifi: string; @@ -45,15 +105,7 @@ type AndroidOnlyUploadOptions = { maxRetries?: number; }; -type IOSOnlyUploadOptions = { - /** - * AppGroup defined in XCode for extensions. Necessary when trying to upload things via this library - * in the context of ShareExtension. - */ - appGroup?: string; -}; - -type RawUploadOptions = { +export type RawUploadOptions = { type: 'raw'; }; @@ -88,6 +140,6 @@ export interface AddListener { ( event: 'cancelled', uploadId: UploadId | null, - callback: (data: EventData) => void, + callback: (data: CancelledData) => void, ): EventSubscription; }