diff --git a/src/main/services/request.service.ts b/src/main/services/request.service.ts index a98a2301..db4067ff 100644 --- a/src/main/services/request.service.ts +++ b/src/main/services/request.service.ts @@ -10,7 +10,7 @@ import path from 'path'; import { pipeline } from 'stream/promises'; import sanitize from 'sanitize-filename'; import internal from 'stream'; -import { app } from 'electron'; +import { app, net } from 'electron'; import { CookieJar } from 'tough-cookie'; export class RequestService { @@ -31,7 +31,93 @@ export class RequestService { private constructor() {} + private isBeatmodsUrl(url: string): boolean { + const { hostname } = new URL(url); + return hostname === 'beatmods.com' || hostname.endsWith('.beatmods.com'); + } + + /** + * Uses Electron's Chromium network stack instead of Node's HTTP stack + * to avoid Cloudflare timeout issues that occur with beatmods.com + */ + private async requestWithElectronNet(url: string): Promise<{ data: T; headers: IncomingHttpHeaders }> { + return new Promise<{ data: T; headers: IncomingHttpHeaders }>((resolve, reject) => { + const request = net.request({ + method: 'GET', + url, + headers: this.baseHeaders, + }); + + let responseBody = Buffer.alloc(0); + let responseHeaders: IncomingHttpHeaders = {}; + let isResolved = false; + + const timeoutId = setTimeout(() => { + if (!isResolved) { + isResolved = true; + request.abort(); + reject(new Error(`Request timeout for ${url}`)); + } + }, 15000); + + const cleanup = () => { + clearTimeout(timeoutId); + }; + + request.on('response', (response) => { + responseHeaders = response.headers as IncomingHttpHeaders; + + // Validate HTTP status code (got throws on non-2xx by default) + const { statusCode } = response; + if (statusCode < 200 || statusCode >= 300) { + isResolved = true; + cleanup(); + reject(new Error(`Request failed with status ${statusCode} for ${url}`)); + return; + } + + response.on('data', (chunk: Buffer) => { + responseBody = Buffer.concat([responseBody, chunk]); + }); + + response.on('end', () => { + if (isResolved) return; + isResolved = true; + cleanup(); + try { + const bodyText = responseBody.toString('utf-8'); + const data = JSON.parse(bodyText) as T; + resolve({ data, headers: responseHeaders }); + } catch (parseError) { + reject(new Error(`Failed to parse JSON response from ${url}: ${parseError}`)); + } + }); + + response.on('error', (error) => { + if (isResolved) return; + isResolved = true; + cleanup(); + reject(new Error(`Response stream error for ${url}: ${error.message}`)); + }); + }); + + request.on('error', (error) => { + if (isResolved) return; + isResolved = true; + cleanup(); + reject(new Error(`Network error requesting ${url}: ${error.message}`)); + }); + + request.end(); + }); + } + public async getJSON(url: string): Promise<{ data: T; headers: IncomingHttpHeaders }> { + // Node's HTTP stack has Cloudflare compatibility issues with beatmods.com + if (this.isBeatmodsUrl(url)) { + return this.requestWithElectronNet(url); + } + const domain = (new URL(url)).hostname; const cachedFamily = this.preferredFamilyCache[domain]; if (cachedFamily) { @@ -106,11 +192,132 @@ export class RequestService { }; } + /** + * Uses Electron's Chromium network stack instead of Node's HTTP stack + * to avoid Cloudflare timeout issues that occur with beatmods.com + */ + private downloadFileWithElectronNet( + url: string, + dest: string, + opt?: { preferContentDisposition?: boolean } + ): Observable> { + return new Observable>((subscriber) => { + const progress: Progression = { current: 0, total: 0 }; + let file: WriteStream | undefined; + let isCompleted = false; + + const request = net.request({ + method: 'GET', + url, + headers: this.baseHeaders, + }); + + const cleanup = () => { + if (file) { + file.destroy(); + } + }; + + request.on('response', (response) => { + // Validate HTTP status code (got throws on non-2xx by default) + const { statusCode } = response; + if (statusCode < 200 || statusCode >= 300) { + isCompleted = true; + cleanup(); + subscriber.error(new Error(`Download failed with status ${statusCode} for ${url}`)); + return; + } + + const contentLength = response.headers['content-length']; + if (contentLength) { + const length = Array.isArray(contentLength) ? contentLength[0] : contentLength; + progress.total = parseInt(length, 10); + } + + const filename = opt?.preferContentDisposition + ? this.getFilenameFromContentDisposition(response.headers['content-disposition'] as string) + : null; + + if (filename) { + dest = path.join(path.dirname(dest), sanitize(filename)); + } + + progress.data = dest; + file = createWriteStream(dest); + + file.on('error', (error) => { + cleanup(); + tryit(() => deleteFileSync(dest)); + if (!isCompleted) { + isCompleted = true; + subscriber.error(new Error(`File write error for ${dest}: ${error.message}`)); + } + }); + + response.on('data', (chunk: Buffer) => { + if (file && !file.destroyed) { + progress.current += chunk.length; + subscriber.next(progress); + file.write(chunk); + } + }); + + response.on('end', () => { + if (isCompleted) return; + if (file && !file.destroyed) { + file.once('finish', () => { + if (isCompleted) return; + isCompleted = true; + subscriber.next(progress); + subscriber.complete(); + }); + file.end(); + } else { + isCompleted = true; + subscriber.next(progress); + subscriber.complete(); + } + }); + + response.on('error', (error) => { + if (isCompleted) return; + isCompleted = true; + cleanup(); + tryit(() => deleteFileSync(dest)); + subscriber.error(error); + }); + }); + + request.on('error', (error) => { + if (isCompleted) return; + isCompleted = true; + cleanup(); + tryit(() => deleteFileSync(dest)); + subscriber.error(error); + }); + + request.end(); + + return () => { + request.abort(); + cleanup(); + }; + }).pipe( + tap({ error: (e) => log.error(e, url, dest) }), + shareReplay(1) + ); + } + public downloadFile( url: string, dest: string, opt?: { preferContentDisposition?: boolean } ): Observable> { + // Node's HTTP stack has Cloudflare compatibility issues with beatmods.com + if (this.isBeatmodsUrl(url)) { + return this.downloadFileWithElectronNet(url, dest, opt); + } + return new Observable>((subscriber) => { const progress: Progression = { current: 0, total: 0 }; @@ -187,10 +394,112 @@ export class RequestService { ); } + /** + * Uses Electron's Chromium network stack instead of Node's HTTP stack + * to avoid Cloudflare timeout issues that occur with beatmods.com + */ + private downloadBufferWithElectronNet( + url: string, + options?: got.GotOptions + ): Observable> { + return new Observable>((subscriber) => { + const progress: Progression = { + current: 0, + total: 0, + data: null, + }; + + // Convert headers to the format expected by electron.net (string | string[]) + const electronHeaders: Record = { ...this.baseHeaders }; + if (options?.headers) { + for (const [key, value] of Object.entries(options.headers)) { + if (typeof value === 'string' || Array.isArray(value)) { + electronHeaders[key] = value; + } else if (value != null) { + electronHeaders[key] = String(value); + } + } + } + + let data = Buffer.alloc(0); + let responseHeaders: IncomingHttpHeaders = {}; + let isCompleted = false; + + const request = net.request({ + method: 'GET', + url, + headers: electronHeaders, + }); + + request.on('response', (response) => { + // Validate HTTP status code (got throws on non-2xx by default) + const { statusCode } = response; + if (statusCode < 200 || statusCode >= 300) { + isCompleted = true; + subscriber.error(new Error(`Download failed with status ${statusCode} for ${url}`)); + return; + } + + const contentLength = response.headers['content-length']; + if (contentLength) { + const length = Array.isArray(contentLength) ? contentLength[0] : contentLength; + progress.total = parseInt(length, 10); + } + + responseHeaders = response.headers as IncomingHttpHeaders; + + response.on('data', (chunk: Buffer) => { + data = Buffer.concat([data, chunk]); + progress.current = data.length; + subscriber.next(progress); + }); + + response.on('end', () => { + if (isCompleted) return; + isCompleted = true; + progress.data = data; + // Required to maintain API compatibility with got-based implementation + const mockResponse = { + headers: responseHeaders, + } as IncomingMessage; + progress.extra = mockResponse; + subscriber.next(progress); + subscriber.complete(); + }); + + response.on('error', (error) => { + if (isCompleted) return; + isCompleted = true; + subscriber.error(error); + }); + }); + + request.on('error', (error) => { + if (isCompleted) return; + isCompleted = true; + subscriber.error(error); + }); + + request.end(); + + return () => { + request.abort(); + }; + }).pipe( + tap({ error: (e) => log.error(e, url) }), + shareReplay(1) + ); + } + public downloadBuffer( url: string, options?: got.GotOptions ): Observable> { + // Node's HTTP stack has Cloudflare compatibility issues with beatmods.com + if (this.isBeatmodsUrl(url)) { + return this.downloadBufferWithElectronNet(url, options); + } + return new Observable>((subscriber) => { const progress: Progression = { current: 0,