From 6c6f83dcf3b08c86f651e37a289fe8f391a9d238 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 27 Jul 2026 10:20:33 -0400 Subject: [PATCH] feat!: define the TurboModule spec and the v8 JS surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/NativeRNFileUploader.ts, the codegen spec both platforms generate from. Six methods and five event emitters replace the hand-rolled NativeModules lookup and the DeviceEventEmitter string channels. Variant payloads (upload options, terminal events, snapshots) are typed as UnsafeObject in the spec because codegen cannot model Partial<>, intersections, or index signatures. The precise contracts live in src/types.ts and are applied at the public boundary in src/index.ts, so callers still get full types. Three new methods make terminal outcomes recoverable rather than fire-and- forget: getUnacknowledgedEvents/ackEvents read and drain the native journal, and getAllUploads enumerates what the OS still knows about. Terminal events carry eventId, type, timestamp, and — where the platform has them — responseCode, responseBody, and responseHeaders. Two behavior fixes on the JS side. A scoped addListener now drops events it cannot attribute instead of failing open and delivering another upload's event, and the iOS keep-alive hack that called native addListener on import is gone, since the codegen emitters need no such priming. Adds jest with the first test suite for the JS layer, and wires it into CI and the pre-commit hook. This PR lands the contract only. The Android and iOS implementations that back getUnacknowledgedEvents, ackEvents, getAllUploads, and the codegen emitters arrive in the next two PRs in this stack. BREAKING CHANGE: requires the New Architecture. `completed` now fires only for 2xx or a request's acceptStatus; other HTTP responses arrive as `error` with errorKind 'http' and the response attached. Co-Authored-By: Claude Opus 5 --- .github/workflows/node.yml | 3 + babel.config.js | 8 ++ husky.config.js | 2 +- jest.config.js | 4 + package.json | 1 + src/NativeRNFileUploader.ts | 33 +++++++++ src/__tests__/index.test.ts | 102 ++++++++++++++++++++++++++ src/index.ts | 142 ++++++++++++++++++++++++------------ src/types.ts | 88 +++++++++++++++++----- 9 files changed, 318 insertions(+), 65 deletions(-) create mode 100644 babel.config.js create mode 100644 jest.config.js create mode 100644 src/NativeRNFileUploader.ts create mode 100644 src/__tests__/index.test.ts 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; }