UNPKG

expo-file-system

Version:

Provides access to the local file system on the device.

476 lines (440 loc) 16.2 kB
import type { EventSubscription } from 'expo-modules-core'; import { Directory } from './Directory'; import ExpoFileSystem from './ExpoFileSystem'; import { File } from './File'; import type { DownloadProgress, DownloadPauseState, DownloadTaskOptions, DownloadTaskState, UploadOptions, UploadProgress, UploadResult, UploadTaskState, } from './NetworkTasks.types'; type TaskState = UploadTaskState | DownloadTaskState; type NetworkTaskOptions<TProgress> = { signal?: AbortSignal; onProgress?: (progress: TProgress) => void; }; function createAbortError(reason?: string): Error { const error = new Error(reason ?? 'The operation was aborted.'); error.name = 'AbortError'; return error; } function assertNetworkTaskState<TState extends TaskState>( state: TState, allowedStates: readonly TState[], methodName: string ) { if (!allowedStates.includes(state)) { throw new Error(`Cannot call ${methodName}() in state "${state}"`); } } function wireNetworkTaskAbortSignal( signal: NetworkTaskOptions<never>['signal'], cancel: () => void ) { if (signal?.aborted) { throw createAbortError(); } if (signal) { const abortHandler = () => cancel(); signal.addEventListener('abort', abortHandler, { once: true }); return abortHandler; } return undefined; } function wireNetworkTaskProgress<TProgress>( onProgress: NetworkTaskOptions<TProgress>['onProgress'], addListener: (listener: (progress: TProgress) => void) => EventSubscription ) { if (onProgress) { return addListener(onProgress); } return undefined; } function cleanupNetworkTask( signal: NetworkTaskOptions<never>['signal'], subscription: EventSubscription | undefined, abortHandler: (() => void) | undefined ) { subscription?.remove(); if (abortHandler && signal) { signal.removeEventListener('abort', abortHandler); } } /** * Represents an upload task with progress tracking and cancellation support. * * Upload tasks start in the `idle` state. Calling `uploadAsync()` moves the task to `active`, * then to `completed`, `cancelled`, or `error`. */ export class UploadTask { private _state: UploadTaskState = 'idle'; private _file: File; private _url: string; private _options?: UploadOptions; private _subscription?: EventSubscription; private _abortHandler?: () => void; private readonly _nativeTask: InstanceType<typeof ExpoFileSystem.FileSystemUploadTask>; /** * Creates an upload task. * * The task does not start automatically. Call `uploadAsync()` to begin uploading. * * @param file The file to upload. * @param url The URL to upload the file to. * @param options Upload options. */ constructor(file: File, url: string, options?: UploadOptions) { this._nativeTask = new ExpoFileSystem.FileSystemUploadTask(); this._file = file; this._url = url; this._options = options; } /** * The current state of the upload task. */ get state(): UploadTaskState { return this._state; } /** * Starts the upload operation. * * This method can only be called once, while the task is `idle`. The promise resolves * with response metadata and body for completed HTTP responses, including non-2xx status codes. * It is rejected when the file cannot be read, the request fails, or the task is cancelled. * * If `options.signal` is aborted, the promise is rejected with an `AbortError`. * * @returns A promise that resolves to the upload response. */ async uploadAsync(): Promise<UploadResult> { assertNetworkTaskState(this._state, ['idle'], 'uploadAsync'); this._state = 'active'; try { this._abortHandler = wireNetworkTaskAbortSignal(this._options?.signal, () => this.cancel()); this._subscription = wireNetworkTaskProgress(this._options?.onProgress, (listener) => this.addListener('progress', listener) ); const nativeOpts = { httpMethod: this._options?.httpMethod || 'POST', uploadType: this._options?.uploadType ?? 0, headers: this._options?.headers, fieldName: this._options?.fieldName, mimeType: this._options?.mimeType, parameters: this._options?.parameters, sessionType: this._options?.sessionType, }; const result = await this._nativeTask.start(this._url, this._file, nativeOpts); this._state = 'completed'; // Emit a synthetic final progress to guarantee 100% is reported. // Native progress events may not fire for small files, and even when they do, // the event can race with promise resolution (listener removed before delivery). if (this._options?.onProgress && this._file.exists) { const size = this._file.size ?? 0; if (size > 0) { this._options.onProgress({ bytesSent: size, totalBytes: size }); } } return result; } catch (error) { if (this._options?.signal?.aborted) { this._state = 'cancelled'; throw createAbortError(); } if (this.state === 'cancelled') { throw error; } this._state = 'error'; throw error; } finally { cleanupNetworkTask(this._options?.signal, this._subscription, this._abortHandler); this._subscription = undefined; this._abortHandler = undefined; } } /** * Adds a listener for upload progress events. * * > **Note:** Prefer the `onProgress` option unless you need manual subscription control. * * @param eventName The event to listen to. Only `'progress'` is supported. * @param listener Invoked with upload progress updates. * @returns A subscription handle. Call `remove()` to stop listening. */ addListener(eventName: 'progress', listener: (data: UploadProgress) => void): EventSubscription { return this._nativeTask.addListener(eventName, listener); } /** * Releases the native task handle. * * Call this when you no longer need the task and want to release native resources manually. */ release(): void { this._nativeTask.release(); } /** * Cancels the upload operation. * * If `uploadAsync()` is pending, its promise is rejected after the native request is cancelled. * Calling this method after the task reaches `completed`, `cancelled`, or `error` has no effect. */ cancel(): void { if (['completed', 'cancelled', 'error'].includes(this._state)) return; this._state = 'cancelled'; this._nativeTask.cancel(); cleanupNetworkTask(this._options?.signal, this._subscription, this._abortHandler); this._subscription = undefined; this._abortHandler = undefined; } } /** * Represents a download task with pause/resume support and progress tracking. * * Download tasks start in the `idle` state. Calling `downloadAsync()` moves the task to `active`; * pausing moves it to `paused`, and a completed, cancelled, or failed transfer moves it to the * corresponding terminal state. */ export class DownloadTask { private _state: DownloadTaskState = 'idle'; private _url: string; private _destination: File | Directory; private _options?: DownloadTaskOptions; private _resumeData?: string; private _subscription?: EventSubscription; private _abortHandler?: () => void; private _inFlightOperation?: Promise<File | null>; private _pauseRequest?: Promise<void>; private readonly _nativeTask: InstanceType<typeof ExpoFileSystem.FileSystemDownloadTask>; /** * Creates a download task. * * The task does not start automatically. Call `downloadAsync()` to begin downloading. * * @param url The URL of the file to download. * @param destination The destination file or directory. If a directory is provided, the resulting * filename is determined from the response headers or URL. * @param options Download task options. */ constructor(url: string, destination: File | Directory, options?: DownloadTaskOptions) { this._nativeTask = new ExpoFileSystem.FileSystemDownloadTask(); this._url = url; this._destination = destination; this._options = options; } /** * The current state of the download task. */ get state(): DownloadTaskState { return this._state; } /** * Starts the download operation. * * This method can only be called once, while the task is `idle`. The promise resolves with * the downloaded file when the transfer completes, or with `null` if the task is paused before * completion. It is rejected when the request fails or the task is cancelled. * * If `options.signal` is aborted, the promise is rejected with an `AbortError`. * * @returns A promise that resolves to the downloaded file, or `null` when the task is paused. */ async downloadAsync(): Promise<File | null> { assertNetworkTaskState(this._state, ['idle'], 'downloadAsync'); this._state = 'active'; this._pauseRequest = undefined; const operation = this._runDownloadOperation(() => this._nativeTask.start(this._url, this._destination, { headers: this._options?.headers, sessionType: this._options?.sessionType, }) ); this._inFlightOperation = operation; return operation; } /** * Requests pausing the active download operation. * * The pending `downloadAsync()` or `resumeAsync()` promise resolves with `null` after native * code produces resume data and the task enters the `paused` state. Use `pauseAsync()` if you * need to wait until the task is ready to resume or save. */ pause(): void { assertNetworkTaskState(this._state, ['active'], 'pause'); this._pauseRequest = Promise.resolve(this._nativeTask.pause()).then((result) => { this._resumeData = result?.resumeData ?? undefined; }); // State transition to 'paused' happens in downloadAsync()/resumeAsync() // when the native promise resolves with null } /** * Requests pausing the active download operation and waits until the task reaches the `paused` * state. * * @returns A promise that resolves after resume data is available. */ async pauseAsync(): Promise<void> { this.pause(); await this._pauseRequest; await this._inFlightOperation; } /** * Resumes a paused download operation. * * The promise resolves with the downloaded file when the transfer completes, or with `null` * if the task is paused again before completion. It is rejected when the request fails or the task * is cancelled. * * @returns A promise that resolves to the downloaded file, or `null` when the task is paused. */ async resumeAsync(): Promise<File | null> { assertNetworkTaskState(this._state, ['paused'], 'resumeAsync'); if (!this._resumeData) { throw new Error( 'No resume data available. Was the download paused before any data was received?' ); } this._state = 'active'; this._pauseRequest = undefined; const operation = this._runDownloadOperation(() => this._nativeTask.resume(this._url, this._destination, this._resumeData!, { headers: this._options?.headers, sessionType: this._options?.sessionType, }) ); this._inFlightOperation = operation; return operation; } /** * Adds a listener for download progress events. * * > **Note:** Prefer the `onProgress` option unless you need manual subscription control. * * @param eventName The event to listen to. Only `'progress'` is supported. * @param listener Invoked with download progress updates. * @returns A subscription handle. Call `remove()` to stop listening. */ addListener( eventName: 'progress', listener: (data: DownloadProgress) => void ): EventSubscription { return this._nativeTask.addListener(eventName, listener); } /** * Releases the native task handle. * * Call this when you no longer need the task and want to release native resources manually. */ release(): void { this._nativeTask.release(); } /** * Cancels the download operation. * * If `downloadAsync()` or `resumeAsync()` is pending, its promise is rejected after the native * request is cancelled. Calling this method after the task reaches `completed`, `cancelled`, or * `error` has no effect. */ cancel(): void { if (['completed', 'cancelled', 'error'].includes(this._state)) return; this._state = 'cancelled'; this._pauseRequest = undefined; this._nativeTask.cancel(); cleanupNetworkTask(this._options?.signal, this._subscription, this._abortHandler); this._subscription = undefined; this._abortHandler = undefined; } /** * Returns the paused task state that can be persisted and restored later. * * This method can only be called while the task is `paused`. The returned state contains * platform-specific resume data and request metadata, but does not include callbacks or abort * signals. * * @returns A serializable paused download state. */ savable(): DownloadPauseState { assertNetworkTaskState(this._state, ['paused'], 'savable'); return { url: this._url, fileUri: this._destination.uri, isDirectory: this._destination instanceof Directory, headers: this._options?.headers, resumeData: this._resumeData, }; } /** * Creates a paused download task from saved state. * * Use this to continue a download after persisting the value returned by `savable()`. New options * can attach progress callbacks or an abort signal because functions and signals are not stored * in `DownloadPauseState`. If both saved state and new options include headers, the new headers * override saved headers with the same names. * * @param state The saved pause state. * @param options Optional download task options to attach to the restored task. * @returns A download task in the `paused` state. */ static fromSavable(state: DownloadPauseState, options?: DownloadTaskOptions): DownloadTask { if (!state.resumeData) { throw new Error('Cannot restore task: DownloadPauseState has no resumeData'); } const dest = state.isDirectory ? new Directory(state.fileUri) : new File(state.fileUri); const mergedOptions = options || state.headers ? { ...options, headers: { ...state.headers, ...options?.headers } } : undefined; const task = new DownloadTask(state.url, dest, mergedOptions); task._resumeData = state.resumeData; task._state = 'paused'; return task; } private async _runDownloadOperation( operation: () => Promise<string | null> ): Promise<File | null> { try { this._abortHandler = wireNetworkTaskAbortSignal(this._options?.signal, () => this.cancel()); this._subscription = wireNetworkTaskProgress(this._options?.onProgress, (listener) => this.addListener('progress', listener) ); const result = await operation(); if (result) { this._state = 'completed'; this._resumeData = undefined; const file = new File(result); this._emitFinalProgressEvent(file.size); return file; } await this._pauseRequest; this._state = 'paused'; return null; } catch (error) { if (this._options?.signal?.aborted) { this._state = 'cancelled'; throw createAbortError(); } if (this.state === 'cancelled') { throw error; } this._state = 'error'; throw error; } finally { cleanupNetworkTask(this._options?.signal, this._subscription, this._abortHandler); this._subscription = undefined; this._abortHandler = undefined; this._inFlightOperation = undefined; } } private _emitFinalProgressEvent(fileSize: number) { // Emit a synthetic final progress to guarantee 100% is reported. // Native progress events may not fire for small files, and even when they do, // the event can race with promise resolution (listener removed before delivery). if (this._options?.onProgress) { if (fileSize > 0) { this._options.onProgress({ bytesWritten: fileSize, totalBytes: fileSize }); } } } }