Skip to content

Commit faeaa7d

Browse files
committed
[fix] Route beatmods.com file downloads through electron.net
1 parent 35113e6 commit faeaa7d

File tree

1 file changed

+199
-8
lines changed

1 file changed

+199
-8
lines changed

src/main/services/request.service.ts

Lines changed: 199 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,14 @@ export class RequestService {
3131

3232
private constructor() {}
3333

34+
private isBeatmodsUrl(url: string): boolean {
35+
const hostname = new URL(url).hostname;
36+
return hostname === 'beatmods.com' || hostname.endsWith('.beatmods.com');
37+
}
38+
3439
/**
35-
* Request JSON using Electron's Chromium network stack (electron.net).
36-
* This is used specifically for beatmods.com to avoid Cloudflare timeout issues
40+
* Uses Electron's Chromium network stack instead of Node's HTTP stack
41+
* to avoid Cloudflare timeout issues that occur with beatmods.com
3742
*/
3843
private async requestWithElectronNet<T = unknown>(url: string): Promise<{ data: T; headers: IncomingHttpHeaders }> {
3944
return new Promise<{ data: T; headers: IncomingHttpHeaders }>((resolve, reject) => {
@@ -47,7 +52,6 @@ export class RequestService {
4752
let responseHeaders: IncomingHttpHeaders = {};
4853
let isResolved = false;
4954

50-
// Set up timeout
5155
const timeoutId = setTimeout(() => {
5256
if (!isResolved) {
5357
isResolved = true;
@@ -61,10 +65,8 @@ export class RequestService {
6165
};
6266

6367
request.on('response', (response) => {
64-
// Collect headers
6568
responseHeaders = response.headers as IncomingHttpHeaders;
6669

67-
// Collect response body
6870
response.on('data', (chunk: Buffer) => {
6971
responseBody = Buffer.concat([responseBody, chunk]);
7072
});
@@ -95,9 +97,8 @@ export class RequestService {
9597
}
9698

9799
public async getJSON<T = unknown>(url: string): Promise<{ data: T; headers: IncomingHttpHeaders }> {
98-
// Use Electron's Chromium network stack for beatmods.com to avoid Cloudflare timeout issues
99-
const hostname = new URL(url).hostname;
100-
if (hostname === 'beatmods.com' || hostname.endsWith('.beatmods.com')) {
100+
// Node's HTTP stack has Cloudflare compatibility issues with beatmods.com
101+
if (this.isBeatmodsUrl(url)) {
101102
return await this.requestWithElectronNet<T>(url);
102103
}
103104

@@ -175,11 +176,107 @@ export class RequestService {
175176
};
176177
}
177178

179+
/**
180+
* Uses Electron's Chromium network stack instead of Node's HTTP stack
181+
* to avoid Cloudflare timeout issues that occur with beatmods.com
182+
*/
183+
private downloadFileWithElectronNet(
184+
url: string,
185+
dest: string,
186+
opt?: { preferContentDisposition?: boolean }
187+
): Observable<Progression<string>> {
188+
return new Observable<Progression<string>>((subscriber) => {
189+
const progress: Progression<string> = { current: 0, total: 0 };
190+
let file: WriteStream | undefined;
191+
let isCompleted = false;
192+
193+
const request = net.request({
194+
method: 'GET',
195+
url: url,
196+
headers: this.baseHeaders,
197+
});
198+
199+
const cleanup = () => {
200+
if (file) {
201+
file.destroy();
202+
}
203+
};
204+
205+
request.on('response', (response) => {
206+
const contentLength = response.headers['content-length'];
207+
if (contentLength) {
208+
const length = Array.isArray(contentLength) ? contentLength[0] : contentLength;
209+
progress.total = parseInt(length, 10);
210+
}
211+
212+
const filename = opt?.preferContentDisposition
213+
? this.getFilenameFromContentDisposition(response.headers['content-disposition'] as string)
214+
: null;
215+
216+
if (filename) {
217+
dest = path.join(path.dirname(dest), sanitize(filename));
218+
}
219+
220+
progress.data = dest;
221+
file = createWriteStream(dest);
222+
223+
response.on('data', (chunk: Buffer) => {
224+
if (file && !file.destroyed) {
225+
progress.current += chunk.length;
226+
subscriber.next(progress);
227+
file.write(chunk);
228+
}
229+
});
230+
231+
response.on('end', () => {
232+
if (isCompleted) return;
233+
isCompleted = true;
234+
if (file && !file.destroyed) {
235+
file.end();
236+
}
237+
subscriber.next(progress);
238+
subscriber.complete();
239+
});
240+
241+
response.on('error', (error) => {
242+
if (isCompleted) return;
243+
isCompleted = true;
244+
cleanup();
245+
tryit(() => deleteFileSync(dest));
246+
subscriber.error(error);
247+
});
248+
});
249+
250+
request.on('error', (error) => {
251+
if (isCompleted) return;
252+
isCompleted = true;
253+
cleanup();
254+
tryit(() => deleteFileSync(dest));
255+
subscriber.error(error);
256+
});
257+
258+
request.end();
259+
260+
return () => {
261+
request.abort();
262+
cleanup();
263+
};
264+
}).pipe(
265+
tap({ error: (e) => log.error(e, url, dest) }),
266+
shareReplay(1)
267+
);
268+
}
269+
178270
public downloadFile(
179271
url: string,
180272
dest: string,
181273
opt?: { preferContentDisposition?: boolean }
182274
): Observable<Progression<string>> {
275+
// Node's HTTP stack has Cloudflare compatibility issues with beatmods.com
276+
if (this.isBeatmodsUrl(url)) {
277+
return this.downloadFileWithElectronNet(url, dest, opt);
278+
}
279+
183280
return new Observable<Progression<string>>((subscriber) => {
184281
const progress: Progression<string> = { current: 0, total: 0 };
185282

@@ -256,10 +353,104 @@ export class RequestService {
256353
);
257354
}
258355

356+
/**
357+
* Uses Electron's Chromium network stack instead of Node's HTTP stack
358+
* to avoid Cloudflare timeout issues that occur with beatmods.com
359+
*/
360+
private downloadBufferWithElectronNet(
361+
url: string,
362+
options?: got.GotOptions<null>
363+
): Observable<Progression<Buffer, IncomingMessage>> {
364+
return new Observable<Progression<Buffer, IncomingMessage>>((subscriber) => {
365+
const progress: Progression<Buffer, IncomingMessage> = {
366+
current: 0,
367+
total: 0,
368+
data: null,
369+
};
370+
371+
// Convert headers to the format expected by electron.net (string | string[])
372+
const electronHeaders: Record<string, string | string[]> = { ...this.baseHeaders };
373+
if (options?.headers) {
374+
for (const [key, value] of Object.entries(options.headers)) {
375+
if (typeof value === 'string' || Array.isArray(value)) {
376+
electronHeaders[key] = value;
377+
} else if (value != null) {
378+
electronHeaders[key] = String(value);
379+
}
380+
}
381+
}
382+
383+
let data = Buffer.alloc(0);
384+
let responseHeaders: IncomingHttpHeaders = {};
385+
let isCompleted = false;
386+
387+
const request = net.request({
388+
method: 'GET',
389+
url: url,
390+
headers: electronHeaders,
391+
});
392+
393+
request.on('response', (response) => {
394+
const contentLength = response.headers['content-length'];
395+
if (contentLength) {
396+
const length = Array.isArray(contentLength) ? contentLength[0] : contentLength;
397+
progress.total = parseInt(length, 10);
398+
}
399+
400+
responseHeaders = response.headers as IncomingHttpHeaders;
401+
402+
response.on('data', (chunk: Buffer) => {
403+
data = Buffer.concat([data, chunk]);
404+
progress.current = data.length;
405+
subscriber.next(progress);
406+
});
407+
408+
response.on('end', () => {
409+
if (isCompleted) return;
410+
isCompleted = true;
411+
progress.data = data;
412+
// Required to maintain API compatibility with got-based implementation
413+
const mockResponse = {
414+
headers: responseHeaders,
415+
} as IncomingMessage;
416+
progress.extra = mockResponse;
417+
subscriber.next(progress);
418+
subscriber.complete();
419+
});
420+
421+
response.on('error', (error) => {
422+
if (isCompleted) return;
423+
isCompleted = true;
424+
subscriber.error(error);
425+
});
426+
});
427+
428+
request.on('error', (error) => {
429+
if (isCompleted) return;
430+
isCompleted = true;
431+
subscriber.error(error);
432+
});
433+
434+
request.end();
435+
436+
return () => {
437+
request.abort();
438+
};
439+
}).pipe(
440+
tap({ error: (e) => log.error(e, url) }),
441+
shareReplay(1)
442+
);
443+
}
444+
259445
public downloadBuffer(
260446
url: string,
261447
options?: got.GotOptions<null>
262448
): Observable<Progression<Buffer, IncomingMessage>> {
449+
// Node's HTTP stack has Cloudflare compatibility issues with beatmods.com
450+
if (this.isBeatmodsUrl(url)) {
451+
return this.downloadBufferWithElectronNet(url, options);
452+
}
453+
263454
return new Observable<Progression<Buffer, IncomingMessage>>((subscriber) => {
264455
const progress: Progression<Buffer, IncomingMessage> = {
265456
current: 0,

0 commit comments

Comments
 (0)