33 * All Rights Reserved. SPDX-License-Identifier: Apache-2.0
44 */
55
6- import fetch , { type RequestInit } from 'node-fetch'
6+ import type { RequestInit } from 'node-fetch'
77import * as crypto from 'crypto'
88import * as path from 'path'
99import { spawn } from 'child_process'
@@ -56,11 +56,16 @@ export class OAuthClient {
5656 const port = Number ( new URL ( savedReg . redirect_uri ) . port )
5757 server = http . createServer ( )
5858 try {
59- await new Promise < void > ( res => server . listen ( port , '127.0.0.1' , res ) )
59+ await this . listen ( server , port )
6060 redirectUri = savedReg . redirect_uri
6161 this . logger . info ( `OAuth: reusing redirect URI ${ redirectUri } ` )
6262 } catch ( e : any ) {
6363 if ( e . code === 'EADDRINUSE' ) {
64+ try {
65+ server . close ( )
66+ } catch {
67+ /* ignore */
68+ }
6469 this . logger . warn ( `Port ${ port } in use; falling back to new random port` )
6570 ; ( { server, redirectUri } = await this . buildCallbackServer ( ) )
6671 this . logger . info ( `OAuth: new redirect URI ${ redirectUri } ` )
@@ -131,17 +136,16 @@ export class OAuthClient {
131136 // Spin up a one‑time HTTP listener on localhost:randomPort */
132137 private static async buildCallbackServer ( ) : Promise < { server : http . Server ; redirectUri : string } > {
133138 const server = http . createServer ( )
134- const port = await new Promise < number > ( res =>
135- server . listen ( 0 , '127.0.0.1' , ( ) => res ( ( server . address ( ) as any ) . port ) )
136- )
139+ await this . listen ( server , 0 )
140+ const port = ( server . address ( ) as any ) . port as number
137141 return { server, redirectUri : `http://localhost:${ port } ` }
138142 }
139143
140144 /** Discover OAuth endpoints by HEAD/WWW‑Authenticate, well‑known, or fallback */
141145 private static async discoverAS ( rs : URL ) : Promise < Meta > {
142146 // a) HEAD → WWW‑Authenticate → resource_metadata
143147 try {
144- const h = await fetch ( rs . toString ( ) , { method : 'HEAD' } )
148+ const h = await this . fetchCompat ( rs . toString ( ) , { method : 'HEAD' } )
145149 const header = h . headers . get ( 'www-authenticate' ) || ''
146150 const m = / r e s o u r c e _ m e t a d a t a = (?: " ( [ ^ " ] + ) " | ( [ ^ , \s ] + ) ) / i. exec ( header )
147151 if ( m ) {
@@ -259,7 +263,7 @@ export class OAuthClient {
259263 client_id : reg . client_id ,
260264 resource : rs . toString ( ) ,
261265 } )
262- const res = await fetch ( meta . token_endpoint , {
266+ const res = await this . fetchCompat ( meta . token_endpoint , {
263267 method : 'POST' ,
264268 headers : { 'content-type' : 'application/x-www-form-urlencoded' } ,
265269 body : form ,
@@ -332,7 +336,7 @@ export class OAuthClient {
332336 redirect_uri : redirectUri ,
333337 resource : rs . toString ( ) ,
334338 } )
335- const res2 = await fetch ( meta . token_endpoint , {
339+ const res2 = await this . fetchCompat ( meta . token_endpoint , {
336340 method : 'POST' ,
337341 headers : { 'content-type' : 'application/x-www-form-urlencoded' } ,
338342 body : form2 ,
@@ -347,7 +351,7 @@ export class OAuthClient {
347351
348352 /** Fetch + error‑check + parse JSON */
349353 private static async json < T > ( url : string , init ?: RequestInit ) : Promise < T > {
350- const r = await fetch ( url , init )
354+ const r = await this . fetchCompat ( url , init )
351355 if ( ! r . ok ) {
352356 const txt = await r . text ( ) . catch ( ( ) => '' )
353357 throw new Error ( `HTTP ${ r . status } @${ url } — ${ txt } ` )
@@ -393,4 +397,39 @@ export class OAuthClient {
393397 'sso' ,
394398 'cache'
395399 )
400+
401+ /**
402+ * Await server.listen() but reject if it emits 'error' (eg EADDRINUSE),
403+ * so callers can handle it immediately instead of hanging.
404+ */
405+ private static listen ( server : http . Server , port : number , host : string = '127.0.0.1' ) : Promise < void > {
406+ return new Promise ( ( resolve , reject ) => {
407+ const onListening = ( ) => {
408+ server . off ( 'error' , onError )
409+ resolve ( )
410+ }
411+ const onError = ( err : NodeJS . ErrnoException ) => {
412+ server . off ( 'listening' , onListening )
413+ reject ( err )
414+ }
415+ server . once ( 'listening' , onListening )
416+ server . once ( 'error' , onError )
417+ server . listen ( port , host )
418+ } )
419+ }
420+
421+ /**
422+ * Fetch compatibility: use global fetch on Node >= 18, otherwise dynamically import('node-fetch').
423+ * Using Function('return import(...)') avoids downleveling to require() in CJS builds.
424+ */
425+ private static async fetchCompat ( url : string , init ?: RequestInit ) : Promise < any > {
426+ const g = globalThis as any
427+ if ( typeof g . fetch === 'function' ) {
428+ return g . fetch ( url as any , init as any )
429+ }
430+ // Dynamic import of ESM node-fetch (only when global fetch is unavailable)
431+ const mod = await ( Function ( 'return import("node-fetch")' ) ( ) as Promise < any > )
432+ const f = mod . default ?? mod
433+ return f ( url as any , init as any )
434+ }
396435}
0 commit comments