@@ -6,6 +6,7 @@ import type {
66} from 'openid-client' ;
77import { MongoDBOIDCError , type OIDCAbortSignal } from './types' ;
88import { createHash , randomBytes } from 'crypto' ;
9+ import type { Readable } from 'stream' ;
910
1011class AbortError extends Error {
1112 constructor ( ) {
@@ -236,44 +237,67 @@ export class TokenSet {
236237 }
237238}
238239
240+ function getCause ( err : unknown ) : Record < string , unknown > | undefined {
241+ if (
242+ err &&
243+ typeof err === 'object' &&
244+ 'cause' in err &&
245+ err . cause &&
246+ typeof err . cause === 'object'
247+ ) {
248+ return err . cause as Record < string , unknown > ;
249+ }
250+ }
251+
239252// openid-client@6.x has reduced error messages for HTTP errors significantly, reducing e.g.
240253// an HTTP error to just a simple 'unexpect HTTP response status code' message, without
241254// further diagnostic information. So if the `cause` of an `err` object is a fetch `Response`
242255// object, we try to throw a more helpful error.
243256export async function improveHTTPResponseBasedError < T > (
244257 err : T
245258) : Promise < T | MongoDBOIDCError > {
246- if (
247- err &&
248- typeof err === 'object' &&
249- 'cause' in err &&
250- err . cause &&
251- typeof err . cause === 'object' &&
252- 'status' in err . cause &&
253- 'statusText' in err . cause &&
254- 'text' in err . cause &&
255- typeof err . cause . text === 'function'
256- ) {
259+ // Note: `err.cause` can either be an `Error` object itself, or a `Response`, or a JSON HTTP response body
260+ const cause = getCause ( err ) ;
261+ if ( cause ) {
257262 try {
263+ const statusObject =
264+ 'status' in cause ? cause : ( err as Record < string , unknown > ) ;
265+ if ( ! statusObject . status ) return err ;
266+
258267 let body = '' ;
259268 try {
260- body = await err . cause . text ( ) ;
269+ if ( 'text' in cause && typeof cause . text === 'function' )
270+ body = await cause . text ( ) ; // Handle the `Response` case
261271 } catch {
262272 // ignore
263273 }
264274 let errorMessageFromBody = '' ;
265275 try {
266- const parsed = JSON . parse ( body ) ;
276+ let parsed : Record < string , unknown > = cause ;
277+ try {
278+ parsed = JSON . parse ( body ) ;
279+ } catch {
280+ // ignore, and maybe `parsed` already contains the parsed JSON body anyway
281+ }
267282 errorMessageFromBody =
268- ': ' + String ( parsed . error_description || parsed . error || '' ) ;
283+ ': ' +
284+ [ parsed . error , parsed . error_description ]
285+ . filter ( Boolean )
286+ . map ( String )
287+ . join ( ', ' ) ;
269288 } catch {
270289 // ignore
271290 }
272291 if ( ! errorMessageFromBody ) errorMessageFromBody = `: ${ body } ` ;
292+
293+ const statusTextInsert =
294+ 'statusText' in statusObject
295+ ? `(${ String ( statusObject . statusText ) } )`
296+ : '' ;
273297 return new MongoDBOIDCError (
274298 `${ errorString ( err ) } : caused by HTTP response ${ String (
275- err . cause . status
276- ) } ( ${ String ( err . cause . statusText ) } ) ${ errorMessageFromBody } `,
299+ statusObject . status
300+ ) } ${ statusTextInsert } ${ errorMessageFromBody } `,
277301 { codeName : 'HTTPResponseError' , cause : err }
278302 ) ;
279303 } catch {
@@ -282,3 +306,76 @@ export async function improveHTTPResponseBasedError<T>(
282306 }
283307 return err ;
284308}
309+
310+ // Check whether converting a Node.js `Readable` stream to a web `ReadableStream`
311+ // is possible. We use this for compatibility with fetch() implementations that
312+ // return Node.js `Readable` streams like node-fetch.
313+ export function streamIsNodeReadable ( stream : unknown ) : stream is Readable {
314+ return ! ! (
315+ stream &&
316+ typeof stream === 'object' &&
317+ 'pipe' in stream &&
318+ typeof stream . pipe === 'function' &&
319+ ( ! ( 'cancel' in stream ) || ! stream . cancel )
320+ ) ;
321+ }
322+
323+ export function nodeFetchCompat (
324+ response : Response & { body : Readable | ReadableStream | null }
325+ ) : Response {
326+ const notImplemented = ( method : string ) =>
327+ new MongoDBOIDCError ( `Not implemented: body.${ method } ` , {
328+ codeName : 'HTTPBodyShimNotImplemented' ,
329+ } ) ;
330+ const { body, clone } = response ;
331+ if ( streamIsNodeReadable ( body ) ) {
332+ let webStream : ReadableStream | undefined ;
333+ const toWeb = ( ) =>
334+ webStream ?? ( body . constructor as typeof Readable ) . toWeb ?.( body ) ;
335+ // Provide ReadableStream methods that may be used by openid-client
336+ Object . assign (
337+ body ,
338+ {
339+ locked : false ,
340+ cancel ( ) {
341+ if ( webStream ) return webStream . cancel ( ) ;
342+ body . resume ( ) ;
343+ } ,
344+ getReader ( ...args : Parameters < ReadableStream [ 'getReader' ] > ) {
345+ if ( ( webStream = toWeb ( ) ) ) return webStream . getReader ( ...args ) ;
346+
347+ throw notImplemented ( 'getReader' ) ;
348+ } ,
349+ pipeThrough ( ...args : Parameters < ReadableStream [ 'pipeThrough' ] > ) {
350+ if ( ( webStream = toWeb ( ) ) ) return webStream . pipeThrough ( ...args ) ;
351+ throw notImplemented ( 'pipeThrough' ) ;
352+ } ,
353+ pipeTo ( ...args : Parameters < ReadableStream [ 'pipeTo' ] > ) {
354+ if ( ( webStream = toWeb ( ) ) ) return webStream . pipeTo ( ...args ) ;
355+
356+ throw notImplemented ( 'pipeTo' ) ;
357+ } ,
358+ tee ( ...args : Parameters < ReadableStream [ 'tee' ] > ) {
359+ if ( ( webStream = toWeb ( ) ) ) return webStream . tee ( ...args ) ;
360+ throw notImplemented ( 'tee' ) ;
361+ } ,
362+ values ( ...args : Parameters < ReadableStream [ 'values' ] > ) {
363+ if ( ( webStream = toWeb ( ) ) ) return webStream . values ( ...args ) ;
364+ throw notImplemented ( 'values' ) ;
365+ } ,
366+ } ,
367+ body
368+ ) ;
369+ Object . assign ( response , {
370+ clone : function ( this : Response ) : Response {
371+ // node-fetch replaces `.body` on `.clone()` on *both*
372+ // the original and the cloned Response objects
373+ const cloned = clone . call ( this ) ;
374+ nodeFetchCompat ( this ) ;
375+ return nodeFetchCompat ( cloned ) ;
376+ } ,
377+ } ) ;
378+ }
379+
380+ return response ;
381+ }
0 commit comments