Skip to content
644 changes: 100 additions & 544 deletions packages/bare-resource-fetcher/src/ResourceFetcher.ts

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions packages/bare-resource-fetcher/src/ResourceFetcherUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ import {
HTTP_CODE,
DownloadStatus,
SourceType,
ResourceSourceExtended,
RnExecutorchError,
RnExecutorchErrorCode,
} from 'react-native-executorch';
import { Image } from 'react-native';
import * as RNFS from '@dr.pogodin/react-native-fs';

export { HTTP_CODE, DownloadStatus, SourceType };
export type { ResourceSourceExtended };

/**
* Utility functions for fetching and managing resources.
Expand Down
234 changes: 234 additions & 0 deletions packages/bare-resource-fetcher/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import {
createDownloadTask,
completeHandler,
DownloadTask,
BeginHandlerParams,
ProgressHandlerParams,
} from '@kesha-antonov/react-native-background-downloader';
import * as RNFS from '@dr.pogodin/react-native-fs';
import { Image, Platform } from 'react-native';
import {
ResourceSource,
RnExecutorchErrorCode,
RnExecutorchError,
} from 'react-native-executorch';
import { RNEDirectory } from './constants/directories';
import { ResourceFetcherUtils, DownloadStatus } from './ResourceFetcherUtils';

export interface ActiveDownload {
status: DownloadStatus;
uri: string;
fileUri: string;
cacheFileUri: string;
// settle and reject are the resolve/reject of the Promise returned by handleRemote.
// They are stored here so that cancel() and resume() in the fetcher class can
// unblock the fetch() loop from outside the download flow.
settle: (path: string) => void;
reject: (error: unknown) => void;
// iOS only: background downloader task, used for pause/resume/cancel
task?: DownloadTask;
// Android only: RNFS job ID, used for cancel via RNFS.stopDownload
jobId?: number;
}

export async function handleObject(source: object): Promise<string> {
const jsonString = JSON.stringify(source);
const digest = ResourceFetcherUtils.hashObject(jsonString);
const path = `${RNEDirectory}${digest}.json`;

if (await ResourceFetcherUtils.checkFileExists(path)) {
return ResourceFetcherUtils.removeFilePrefix(path);
}

await ResourceFetcherUtils.createDirectoryIfNoExists();
await RNFS.writeFile(path, jsonString, 'utf8');
return ResourceFetcherUtils.removeFilePrefix(path);
}

export function handleLocalFile(source: string): string {
return ResourceFetcherUtils.removeFilePrefix(source);
}

export async function handleAsset(
source: number,
progressCallback: (progress: number) => void,
downloads: Map<ResourceSource, ActiveDownload>
): Promise<string> {
const assetSource = Image.resolveAssetSource(source);
const uri = assetSource.uri;

if (uri.startsWith('http')) {
// Dev mode: asset served from Metro dev server.
// uri is the resolved HTTP URL; source is the original require() number the
// user holds, so it must be used as the downloads map key for pause/cancel to work.
return handleRemote(uri, source, progressCallback, downloads);
}

// Release mode: asset bundled locally, copy to RNEDirectory
const filename = ResourceFetcherUtils.getFilenameFromUri(uri);
const fileUri = `${RNEDirectory}${filename}`;

if (await ResourceFetcherUtils.checkFileExists(fileUri)) {
return ResourceFetcherUtils.removeFilePrefix(fileUri);
}

await ResourceFetcherUtils.createDirectoryIfNoExists();
if (uri.startsWith('file')) {
await RNFS.copyFile(uri, fileUri);
}
return ResourceFetcherUtils.removeFilePrefix(fileUri);
}

// uri and source are separate parameters because for asset sources (dev mode),
// source is the require() number the user holds (used as the downloads map key),
// while uri is the resolved HTTP URL needed for the actual download.
// For plain remote strings they are the same value.
export async function handleRemote(
Comment thread
chmjkb marked this conversation as resolved.
uri: string,
source: ResourceSource,
progressCallback: (progress: number) => void,
downloads: Map<ResourceSource, ActiveDownload>
): Promise<string> {
if (downloads.has(source)) {
throw new RnExecutorchError(
RnExecutorchErrorCode.ResourceFetcherDownloadInProgress,
'Already downloading this file'
Comment thread
chmjkb marked this conversation as resolved.
);
}

const filename = ResourceFetcherUtils.getFilenameFromUri(uri);
const fileUri = `${RNEDirectory}${filename}`;
const cacheFileUri = `${RNFS.CachesDirectoryPath}/${filename}`;

if (await ResourceFetcherUtils.checkFileExists(fileUri)) {
return ResourceFetcherUtils.removeFilePrefix(fileUri);
}

await ResourceFetcherUtils.createDirectoryIfNoExists();

// We need a Promise whose resolution can be triggered from outside this function —
// by cancel() or resume() in the fetcher class. A plain async function can't do that,
// so we create the Promise manually and store settle/reject in the downloads map.
let settle: (path: string) => void = () => {};
let reject: (error: unknown) => void = () => {};
Comment thread
chmjkb marked this conversation as resolved.
Outdated
const promise = new Promise<string>((res, rej) => {
settle = res;
reject = rej;
});

if (Platform.OS === 'android') {
const rnfsDownload = RNFS.downloadFile({
fromUrl: uri,
toFile: cacheFileUri,
progress: (res: { bytesWritten: number; contentLength: number }) => {
if (res.contentLength > 0) {
progressCallback(res.bytesWritten / res.contentLength);
}
},
progressInterval: 500,
});

downloads.set(source, {
status: DownloadStatus.ONGOING,
uri,
fileUri,
cacheFileUri,
settle,
reject,
jobId: rnfsDownload.jobId,
});

rnfsDownload.promise
.then(async (result: { statusCode: number }) => {
if (!downloads.has(source)) return; // canceled externally via cancel()

if (result.statusCode < 200 || result.statusCode >= 300) {
downloads.delete(source);
reject(
new RnExecutorchError(
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
`Failed to fetch resource from '${uri}', status: ${result.statusCode}`
)
);
return;
}

try {
await RNFS.moveFile(cacheFileUri, fileUri);
} catch (error) {
downloads.delete(source);
reject(error);
return;
}

downloads.delete(source);
ResourceFetcherUtils.triggerHuggingFaceDownloadCounter(uri);
Comment thread
chmjkb marked this conversation as resolved.
Outdated
settle(ResourceFetcherUtils.removeFilePrefix(fileUri));
})
.catch((error: unknown) => {
if (!downloads.has(source)) return; // canceled externally
downloads.delete(source);
reject(
new RnExecutorchError(
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
`Failed to fetch resource from '${uri}', context: ${error}`
)
);
});
} else {
const task = createDownloadTask({
id: filename,
url: uri,
destination: cacheFileUri,
})
.begin((_: BeginHandlerParams) => progressCallback(0))
.progress((progress: ProgressHandlerParams) => {
progressCallback(progress.bytesDownloaded / progress.bytesTotal);
})
.done(async () => {
const dl = downloads.get(source);
// If paused or canceled, settle/reject will be called externally — do nothing here.
if (!dl || dl.status === DownloadStatus.PAUSED) return;

try {
await RNFS.moveFile(cacheFileUri, fileUri);
// Required by the background downloader library to signal iOS that the
// background download session is complete.
const fn = fileUri.split('/').pop();
if (fn) await completeHandler(fn);
} catch (error) {
downloads.delete(source);
reject(error);
return;
}

downloads.delete(source);
ResourceFetcherUtils.triggerHuggingFaceDownloadCounter(uri);
settle(ResourceFetcherUtils.removeFilePrefix(fileUri));
})
.error((error: any) => {
if (!downloads.has(source)) return; // canceled externally
downloads.delete(source);
reject(
new RnExecutorchError(
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
`Failed to fetch resource from '${uri}', context: ${error}`
)
);
});

task.start();

downloads.set(source, {
status: DownloadStatus.ONGOING,
uri,
fileUri,
cacheFileUri,
settle,
reject,
task,
});
}

return promise;
}
Loading
Loading