Skip to content

Commit c17ef47

Browse files
authored
fix: accept urlToPath callback for people without URL.pathname polyfill (#25)
RN's default URL polyfill might not provide `.pathname`, which we use to extract file extnensions. Instead of requiring everyone to use a polyfill in their app, we instead now take an optional `urlToPath` callback which is used to extract file extensions. If it's not provided, then files will be saved without extensions. BREAKING CHANGE: The default behavior is now changed to not save files with extensions. To make this change not-breaking would have required that this package accept and use a polyfill for URL, which feels like it could cause greater problems for people who already use other polyfills. fix #23
1 parent 87feb47 commit c17ef47

File tree

3 files changed

+101
-18
lines changed

3 files changed

+101
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ You can pass any of the following options, or nothing at all:
7171
|---|---|---|---|
7272
|handlers|DownloadQueueHandlers|undefined|For any events you'd like to receive notifications about, you'll need to pass a handler here. More details in the next table.|
7373
|domain|string|"main"|By default, AsyncStorage keys and RNFS filenames are with DownloadQueue/main". If you want to use something other than "main", pass it here. This is commonly used to manage different queues for different users (e.g. you can use userId as the domain).|
74+
|urlToPath|(url:string) => string|undefined (i.e. files will be saved without extensions)|Callback used to get a pathname from a URL. By default, files are saved without any particular extension. But if you need the server extension to be preserved (e.g. you pass the file to a media player that uses the extension to determine its data format), pass a function here that returns a path given a URL (e.g. for `https://foo.com/baz/moo.mp3?q=song`, returns `baz/moo.mp3`). The easiest way to implement this if you already have a React Native URL polyfill is: `(url) => new URL(url).pathname`. If you don't have a polyfill, you can use something like https://www.npmjs.com/package/react-native-url-polyfill|
7475
|startActive|boolean|true|Whether to start the queue in an active state where downloads will be started. If false, no downloads will begin until you call resumeAll().|
7576
|netInfoAddEventListener|(listener: (state: {isConnected: boolean \| null, type: string}) => void) => ()=> void|undefined|If you'd like DownloadQueue to pause downloads when the device is offline, pass this. Usually easiest to literally pass `NetInfo.addEventListener`.|
7677
|activeNetworkTypes| string[] | [] |The NetInfoStateType values for which downloads will be allowed. If you pass undefined or [], downloads will happen on all connection types. A common practice is to pass ["wifi", "ethernet"] if you want to help users avoid cellular data charges. As of @react-native-community/[email protected], valid values are "unknown", "none", "wifi", "cellular", "bluetooth", "ethernet", "wimax", "vpn", "other", "mixed".|

src/index.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ export interface DownloadQueueOptions {
120120
* started. If false, no downloads will begin until you call resumeAll().
121121
*/
122122
startActive?: boolean;
123+
/**
124+
* Callback used to get a pathname from a URL. By default, files are saved
125+
* without any particular extension. But if you need the server extension to
126+
* be preserved (e.g. you pass the file to a media player that uses the
127+
* extension to determine its data format), pass a function here that returns
128+
* a path given a URL (e.g. for `https://foo.com/baz/moo.mp3?q=song`, returns
129+
* `baz/moo.mp3`). The easiest way to implement this, if you already have
130+
* a React Native polyfill for URL, is:
131+
*
132+
* function urlToPath(url) {
133+
* const parsed = new URL(url);
134+
* return parsed.pathname;
135+
* }
136+
*
137+
* If you don't have a polyfill, you can use something like
138+
* https://www.npmjs.com/package/react-native-url-polyfill
139+
*/
140+
urlToPath?: (url: string) => string;
123141
}
124142

125143
/**
@@ -139,6 +157,7 @@ export default class DownloadQueue {
139157
);
140158
private handlers?: DownloadQueueHandlers = undefined;
141159
private active = true;
160+
private urlToPath?: (url: string) => string = undefined;
142161
private erroredIds = new Set<string>();
143162
private errorTimer: NodeJS.Timeout | null = null;
144163
private netInfoUnsubscriber?: () => void;
@@ -178,13 +197,15 @@ export default class DownloadQueue {
178197
netInfoAddEventListener = undefined,
179198
activeNetworkTypes = [],
180199
startActive = true,
200+
urlToPath = undefined,
181201
}: DownloadQueueOptions = {}): Promise<void> {
182202
if (this.inited) {
183203
throw new Error("DownloadQueue already initialized");
184204
}
185205

186206
this.domain = domain;
187207
this.handlers = handlers;
208+
this.urlToPath = urlToPath;
188209

189210
// This is safe to call even if it already exists. It'll also create all
190211
// necessary parent directories.
@@ -279,6 +300,7 @@ export default class DownloadQueue {
279300
this.tasks = [];
280301
this.specs = [];
281302
this.handlers = undefined;
303+
this.urlToPath = undefined;
282304
this.inited = false;
283305
this.erroredIds.clear();
284306
if (this.errorTimer) {
@@ -308,7 +330,7 @@ export default class DownloadQueue {
308330

309331
const [fileExists] = await Promise.all([
310332
RNFS.exists(
311-
this.pathFromId(curSpec.id, extensionFromUri(curSpec.url))
333+
this.pathFromId(curSpec.id, this.extensionFromUri(curSpec.url))
312334
),
313335
this.kvfs.write(this.keyFromId(curSpec.id), curSpec),
314336
]);
@@ -331,7 +353,7 @@ export default class DownloadQueue {
331353
const spec: Spec = {
332354
id,
333355
url,
334-
path: this.pathFromId(id, extensionFromUri(url)),
356+
path: this.pathFromId(id, this.extensionFromUri(url)),
335357
createTime: Date.now(),
336358
finished: false,
337359
};
@@ -760,6 +782,24 @@ export default class DownloadQueue {
760782
}
761783
}
762784

785+
private extensionFromUri(uri: string) {
786+
const path = this.urlToPath?.(uri);
787+
788+
if (path) {
789+
const filename = path.split("/").pop();
790+
791+
if (filename) {
792+
const parts = filename.split(".");
793+
794+
if (parts.length > 1) {
795+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
796+
return parts.pop()!;
797+
}
798+
}
799+
}
800+
return "";
801+
}
802+
763803
private async getDirFilenames() {
764804
try {
765805
return await RNFS.readdir(this.getDomainedBasePath());
@@ -802,17 +842,3 @@ function basePath() {
802842
return `${RNFS.DocumentDirectoryPath}/DownloadQueue`;
803843
}
804844

805-
function extensionFromUri(uri: string) {
806-
const url = new URL(uri);
807-
const filename = url.pathname.split("/").pop();
808-
809-
if (filename) {
810-
const parts = filename.split(".");
811-
812-
if (parts.length > 1) {
813-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
814-
return parts.pop()!;
815-
}
816-
}
817-
return "";
818-
}

test/index.spec.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ jest.mock("@react-native-community/netinfo", () => ({
170170
}),
171171
}));
172172

173+
function urlToPath(url: string): string {
174+
return new URL(url).pathname;
175+
}
176+
173177
describe("DownloadQueue", () => {
174178
beforeEach(() => {
175179
// restore a few commonly-used functions between tests to avoid unexpected
@@ -526,7 +530,7 @@ describe("DownloadQueue", () => {
526530
})
527531
);
528532

529-
await queue.init({ domain: "mydomain" });
533+
await queue.init({ domain: "mydomain", urlToPath });
530534
await queue.addUrl("http://foo.com/a.mp3");
531535
expect(download).toHaveBeenCalledWith(
532536
expect.objectContaining({
@@ -553,6 +557,58 @@ describe("DownloadQueue", () => {
553557
destination: `${RNFS.DocumentDirectoryPath}/DownloadQueue/mydomain/${task.id}`,
554558
})
555559
);
560+
561+
queue.terminate();
562+
await queue.init({ domain: "mydomain", urlToPath });
563+
(download as jest.Mock).mockClear(); // Clear out revival of prev url
564+
await queue.addUrl("http://foo.com/a.mp3");
565+
expect(download).toHaveBeenCalledWith(
566+
expect.objectContaining({
567+
url: "http://foo.com/a.mp3",
568+
destination: `${RNFS.DocumentDirectoryPath}/DownloadQueue/mydomain/${task.id}.mp3`,
569+
})
570+
);
571+
(download as jest.Mock).mockClear();
572+
// This tests whether paths/extensions are dealt with properly when a url
573+
// ends in a slash.
574+
await queue.addUrl("http://foo.com/abc/");
575+
expect(download).toHaveBeenCalledWith(
576+
expect.objectContaining({
577+
url: "http://foo.com/abc/",
578+
destination: `${RNFS.DocumentDirectoryPath}/DownloadQueue/mydomain/${task.id}`,
579+
})
580+
);
581+
(download as jest.Mock).mockClear();
582+
// Tests whether paths/extensions with terminating periods are handled
583+
await queue.addUrl("http://foo.com/a/bc.");
584+
expect(download).toHaveBeenCalledWith(
585+
expect.objectContaining({
586+
url: "http://foo.com/a/bc.",
587+
destination: `${RNFS.DocumentDirectoryPath}/DownloadQueue/mydomain/${task.id}`,
588+
})
589+
);
590+
(download as jest.Mock).mockClear();
591+
// Tests whether paths with no extensions are handled when urlToPath is
592+
// given by caller
593+
await queue.addUrl("http://foo.com/a/bc");
594+
expect(download).toHaveBeenCalledWith(
595+
expect.objectContaining({
596+
url: "http://foo.com/a/bc",
597+
destination: `${RNFS.DocumentDirectoryPath}/DownloadQueue/mydomain/${task.id}`,
598+
})
599+
);
600+
601+
// Now make sure termination clears out the urlPath callback
602+
queue.terminate();
603+
await queue.init({ domain: "mydomain" });
604+
(download as jest.Mock).mockClear(); // Clear out revival of prev url
605+
await queue.addUrl("http://foo.com/b.mp3");
606+
expect(download).toHaveBeenCalledWith(
607+
expect.objectContaining({
608+
url: "http://foo.com/b.mp3",
609+
destination: `${RNFS.DocumentDirectoryPath}/DownloadQueue/mydomain/${task.id}`,
610+
})
611+
);
556612
});
557613

558614
it("shouldn't add the same url twice", async () => {
@@ -1780,7 +1836,7 @@ describe("DownloadQueue", () => {
17801836
});
17811837
}
17821838
);
1783-
await queue.init({ domain: "mydomain", handlers });
1839+
await queue.init({ domain: "mydomain", handlers, urlToPath });
17841840
await queue.addUrl("http://foo.com/a.mp3");
17851841

17861842
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion

0 commit comments

Comments
 (0)