@@ -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