Skip to content

Commit 2afbc1e

Browse files
committed
wip
1 parent c06895b commit 2afbc1e

File tree

2 files changed

+414
-106
lines changed

2 files changed

+414
-106
lines changed

packages/expo-resource-fetcher/src/ResourceFetcherV2.ts

Lines changed: 235 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -23,140 +23,269 @@
2323
* - Pause/resume/cancel operations work only for remote resources
2424
* - Most functions accept multiple `ResourceSource` arguments (string, number, or object)
2525
* - The {@link fetch} method accepts a progress callback (0-1) and returns file paths or null if interrupted
26-
*
27-
* **Technical Implementation:**
28-
* - Maintains a `downloads` Map to track active and paused downloads
29-
* - Successful downloads are automatically removed from the Map
30-
* - Uses `ResourceSourceExtended` interface for pause/resume functionality with linked-list behavior
3126
*/
3227

28+
import {
29+
deleteAsync,
30+
readDirectoryAsync,
31+
readAsStringAsync,
32+
moveAsync,
33+
} from 'expo-file-system/legacy';
34+
import { RNEDirectory } from './constants/directories';
3335
import {
3436
ResourceSource,
3537
ResourceFetcherAdapter,
36-
DownloadStatus,
37-
SourceType,
38-
RnExecutorchError,
3938
RnExecutorchErrorCode,
39+
RnExecutorchError,
4040
} from 'react-native-executorch';
4141
import {
42-
ResourceSourceExtended,
43-
DownloadResource,
4442
ResourceFetcherUtils,
43+
HTTP_CODE,
44+
DownloadStatus,
45+
SourceType,
4546
} from './ResourceFetcherUtils';
4647
import {
47-
cacheDirectory,
48-
createDownloadResumable,
49-
DownloadResumable,
50-
} from 'expo-file-system/legacy';
51-
import { RNEDirectory } from './constants/directories';
48+
type ActiveDownload,
49+
handleObject,
50+
handleLocalFile,
51+
handleAsset,
52+
handleRemote,
53+
} from './handlers';
5254

53-
export interface DownloadMetadata {
54-
source: ResourceSource;
55-
type: SourceType;
56-
remoteUri?: string;
57-
localUri: string;
58-
onDownloadProgress?: (downloadProgress: number) => void;
59-
}
55+
class ExpoResourceFetcherClass implements ResourceFetcherAdapter {
56+
private downloads = new Map<ResourceSource, ActiveDownload>();
6057

61-
export interface DownloadAsset {
62-
downloadObj: DownloadResumable;
63-
status: DownloadStatus;
64-
metadata: DownloadMetadata;
65-
}
58+
/**
59+
* Fetches resources (remote URLs, local files or embedded assets), downloads or stores them locally
60+
* for use by React Native ExecuTorch.
61+
*
62+
* @param callback - Optional callback to track progress of all downloads, reported between 0 and 1.
63+
* @param sources - Multiple resources that can be strings, asset references, or objects.
64+
* @returns If the fetch was successful, resolves to an array of local file paths for the
65+
* downloaded/stored resources (without file:// prefix).
66+
* If the fetch was interrupted by `pauseFetching` or `cancelFetching`, resolves to `null`.
67+
*/
68+
async fetch(
69+
callback: (downloadProgress: number) => void = () => {},
70+
...sources: ResourceSource[]
71+
): Promise<string[] | null> {
72+
if (sources.length === 0) {
73+
throw new RnExecutorchError(
74+
RnExecutorchErrorCode.InvalidUserInput,
75+
'Empty list given as an argument to Resource Fetcher'
76+
);
77+
}
6678

67-
interface ExpoResourceFetcherInterface extends ResourceFetcherAdapter {
68-
downloadAssets: Record<string, DownloadAsset>;
69-
singleFetch(source: ResourceSource): Promise<DownloadAsset>;
70-
returnOrStartNext(
71-
sourceExtended: ResourceSourceExtended,
72-
result: string | string[]
73-
): string[] | Promise<string[] | null>;
74-
pause(source: ResourceSource): Promise<void>;
75-
resume(source: ResourceSource): Promise<string[] | null>;
76-
cancel(source: ResourceSource): Promise<void>;
77-
findActive(sources: ResourceSource[]): ResourceSource;
78-
pauseFetching(...sources: ResourceSource[]): Promise<void>;
79-
resumeFetching(...sources: ResourceSource[]): Promise<void>;
80-
cancelFetching(...sources: ResourceSource[]): Promise<void>;
81-
listDownloadedFiles(): Promise<string[]>;
82-
listDownloadedModels(): Promise<string[]>;
83-
deleteResources(...sources: ResourceSource[]): Promise<void>;
84-
getFilesTotalSize(...sources: ResourceSource[]): Promise<number>;
85-
handleObject(source: ResourceSource): Promise<string>;
86-
handleLocalFile(source: ResourceSource): string;
87-
handleReleaseModeFile(source: DownloadAsset): Promise<string>;
88-
handleDevModeFile(source: DownloadAsset): Promise<string>;
89-
handleRemoteFile(source: DownloadAsset): Promise<string>;
90-
handleCachedDownload(source: ResourceSource): Promise<string>;
91-
}
79+
const { results: info, totalLength } =
80+
await ResourceFetcherUtils.getFilesSizes(sources);
81+
// Key by source so we can look up progress info without relying on index alignment
82+
// (getFilesSizes skips sources whose HEAD request fails)
83+
const infoMap = new Map(info.map((entry) => [entry.source, entry]));
84+
const results: string[] = [];
9285

93-
export const ExpoResourceFetcher: ExpoResourceFetcherInterface = {
94-
downloadAssets: {},
86+
for (const source of sources) {
87+
const fileInfo = infoMap.get(source);
88+
const progressCallback =
89+
fileInfo?.type === SourceType.REMOTE_FILE
90+
? ResourceFetcherUtils.calculateDownloadProgress(
91+
totalLength,
92+
fileInfo.previousFilesTotalLength,
93+
fileInfo.length,
94+
callback
95+
)
96+
: () => {};
9597

96-
async singleFetch(source: DownloadAsset): Promise<DownloadAsset> {
97-
switch (source.metadata.type) {
98-
case SourceType.OBJECT: {
99-
return await this.handleObject(source);
100-
}
101-
case SourceType.LOCAL_FILE: {
102-
return this.handleLocalFile(source);
103-
}
104-
case SourceType.RELEASE_MODE_FILE: {
105-
return this.handleReleaseModeFile(source);
106-
}
107-
case SourceType.DEV_MODE_FILE: {
108-
return this.handleDevModeFile(source);
109-
}
110-
case SourceType.REMOTE_FILE: {
111-
return this.handleRemoteFile(source);
112-
}
98+
const path = await this.fetchOne(source, progressCallback);
99+
if (path === null) return null;
100+
results.push(path);
113101
}
114-
},
115102

116-
async handleCachedDownload(source: ResourceSource) {},
103+
return results;
104+
}
117105

118-
async handleRemoteFile(source: ResourceSource) {
119-
if (typeof source === 'object') {
120-
throw new RnExecutorchError(
121-
RnExecutorchErrorCode.InvalidModelSource,
122-
'Model source is expected to be a string or a number.'
106+
/**
107+
* Reads the contents of a file as a string.
108+
*
109+
* @param path - Absolute file path or file URI to read.
110+
* @returns A promise that resolves to the file contents as a string.
111+
*/
112+
async readAsString(path: string): Promise<string> {
113+
const uri = path.startsWith('file://') ? path : `file://${path}`;
114+
return readAsStringAsync(uri);
115+
}
116+
117+
/**
118+
* Pauses an ongoing download of files.
119+
*
120+
* @param sources - The resource identifiers used when calling `fetch`.
121+
* @returns A promise that resolves once the download is paused.
122+
*/
123+
async pauseFetching(...sources: ResourceSource[]): Promise<void> {
124+
const source = this.findActive(sources);
125+
await this.pause(source);
126+
}
127+
128+
/**
129+
* Resumes a paused download of files.
130+
*
131+
* The result of the resumed download flows back through the original `fetch` promise.
132+
*
133+
* @param sources - The resource identifiers used when calling `fetch`.
134+
* @returns A promise that resolves once the resume handoff is complete.
135+
*/
136+
async resumeFetching(...sources: ResourceSource[]): Promise<void> {
137+
const source = this.findActive(sources);
138+
await this.resume(source);
139+
}
140+
141+
/**
142+
* Cancels an ongoing/paused download of files.
143+
*
144+
* @param sources - The resource identifiers used when calling `fetch()`.
145+
* @returns A promise that resolves once the download is canceled.
146+
*/
147+
async cancelFetching(...sources: ResourceSource[]): Promise<void> {
148+
const source = this.findActive(sources);
149+
await this.cancel(source);
150+
}
151+
152+
/**
153+
* Lists all the downloaded files used by React Native ExecuTorch.
154+
*
155+
* @returns A promise that resolves to an array of URIs for all the downloaded files.
156+
*/
157+
async listDownloadedFiles(): Promise<string[]> {
158+
const files = await readDirectoryAsync(RNEDirectory);
159+
return files.map((file: string) => `${RNEDirectory}${file}`);
160+
}
161+
162+
/**
163+
* Lists all the downloaded models used by React Native ExecuTorch.
164+
*
165+
* @returns A promise that resolves to an array of URIs for all the downloaded models.
166+
*/
167+
async listDownloadedModels(): Promise<string[]> {
168+
const files = await this.listDownloadedFiles();
169+
return files.filter((file: string) => file.endsWith('.pte'));
170+
}
171+
172+
/**
173+
* Deletes downloaded resources from the local filesystem.
174+
*
175+
* @param sources - The resource identifiers used when calling `fetch`.
176+
* @returns A promise that resolves once all specified resources have been removed.
177+
*/
178+
async deleteResources(...sources: ResourceSource[]): Promise<void> {
179+
for (const source of sources) {
180+
const filename = ResourceFetcherUtils.getFilenameFromUri(
181+
source as string
123182
);
183+
const fileUri = `${RNEDirectory}${filename}`;
184+
if (await ResourceFetcherUtils.checkFileExists(fileUri)) {
185+
await deleteAsync(fileUri);
186+
}
124187
}
125-
if (this.downloadAssets[source]) {
126-
return this.handleCachedDownload(source);
127-
}
188+
}
128189

129-
const uri = 'asda';
130-
const targetFilename = ResourceFetcherUtils.getFilenameFromUri(uri);
131-
const fileUri = `${RNEDirectory}${targetFilename}`;
132-
const cacheFileUri = `${cacheDirectory}${targetFilename}`;
190+
/**
191+
* Fetches the total size of remote files. Works only for remote files.
192+
*
193+
* @param sources - The resource identifiers (URLs).
194+
* @returns A promise that resolves to the combined size of files in bytes.
195+
*/
196+
async getFilesTotalSize(...sources: ResourceSource[]): Promise<number> {
197+
return (await ResourceFetcherUtils.getFilesSizes(sources)).totalLength;
198+
}
133199

134-
if (await ResourceFetcherUtils.checkFileExists(fileUri)) {
135-
return ResourceFetcherUtils.removeFilePrefix(fileUri);
200+
private async fetchOne(
201+
source: ResourceSource,
202+
progressCallback: (progress: number) => void
203+
): Promise<string | null> {
204+
const type = ResourceFetcherUtils.getType(source);
205+
switch (type) {
206+
case SourceType.OBJECT:
207+
return handleObject(source as object);
208+
case SourceType.LOCAL_FILE:
209+
return handleLocalFile(source as string);
210+
case SourceType.RELEASE_MODE_FILE:
211+
case SourceType.DEV_MODE_FILE:
212+
return handleAsset(source as number, progressCallback, this.downloads);
213+
default: // REMOTE_FILE
214+
return handleRemote(
215+
source as string,
216+
source,
217+
progressCallback,
218+
this.downloads
219+
);
136220
}
221+
}
137222

138-
await ResourceFetcherUtils.createDirectoryIfNoExists();
139-
140-
const downloadResumable = createDownloadResumable(uri, fileUri);
141-
},
223+
private async pause(source: ResourceSource): Promise<void> {
224+
const dl = this.downloads.get(source)!;
225+
if (dl.status === DownloadStatus.PAUSED) {
226+
throw new RnExecutorchError(
227+
RnExecutorchErrorCode.ResourceFetcherAlreadyPaused,
228+
"The file download is currently paused. Can't pause the download of the same file twice."
229+
);
230+
}
231+
dl.status = DownloadStatus.PAUSED;
232+
await dl.downloadResumable.pauseAsync();
233+
}
142234

143-
async fetch(
144-
callback: (downloadProgress: number) => void = () => {},
145-
...sources: ResourceSource[]
146-
) {
147-
if (sources.length === 0) {
235+
private async resume(source: ResourceSource): Promise<void> {
236+
const dl = this.downloads.get(source)!;
237+
if (dl.status === DownloadStatus.ONGOING) {
148238
throw new RnExecutorchError(
149-
RnExecutorchErrorCode.InvalidUserInput,
150-
"Empty list given as an argument to Resource Fetcher's fetch() function!"
239+
RnExecutorchErrorCode.ResourceFetcherAlreadyOngoing,
240+
"The file download is currently ongoing. Can't resume the ongoing download."
151241
);
152242
}
243+
dl.status = DownloadStatus.ONGOING;
244+
const result = await dl.downloadResumable.resumeAsync();
245+
const current = this.downloads.get(source);
246+
// Paused again or canceled during resume — settle/reject handled elsewhere.
247+
if (!current || current.status === DownloadStatus.PAUSED) return;
153248

154-
const totalFilesLength = (await ResourceFetcherUtils.getFilesSizes(sources))
155-
.totalLength;
156-
const downloadedBytesCounter = 0;
157-
for (let source of sources) {
158-
const downloadResult = await this.singleFetch(source);
249+
if (
250+
!result ||
251+
(result.status !== HTTP_CODE.OK &&
252+
result.status !== HTTP_CODE.PARTIAL_CONTENT)
253+
) {
254+
this.downloads.delete(source);
255+
// Propagate the failure through the original fetch() promise.
256+
dl.reject(
257+
new RnExecutorchError(
258+
RnExecutorchErrorCode.ResourceFetcherDownloadFailed,
259+
`Failed to resume download from '${dl.uri}', status: ${result?.status}`
260+
)
261+
);
262+
return;
159263
}
160-
return [''];
161-
},
162-
};
264+
265+
await moveAsync({ from: dl.cacheFileUri, to: dl.fileUri });
266+
this.downloads.delete(source);
267+
ResourceFetcherUtils.triggerHuggingFaceDownloadCounter(dl.uri);
268+
dl.settle(ResourceFetcherUtils.removeFilePrefix(dl.fileUri));
269+
}
270+
271+
private async cancel(source: ResourceSource): Promise<void> {
272+
const dl = this.downloads.get(source)!;
273+
await dl.downloadResumable.cancelAsync();
274+
this.downloads.delete(source);
275+
dl.settle(null);
276+
}
277+
278+
private findActive(sources: ResourceSource[]): ResourceSource {
279+
for (const source of sources) {
280+
if (this.downloads.has(source)) {
281+
return source;
282+
}
283+
}
284+
throw new RnExecutorchError(
285+
RnExecutorchErrorCode.ResourceFetcherNotActive,
286+
'None of given sources are currently during downloading process.'
287+
);
288+
}
289+
}
290+
291+
export const ExpoResourceFetcher = new ExpoResourceFetcherClass();

0 commit comments

Comments
 (0)