import assertNever from "assert-never" ; import chalk from "chalk" ; import http from "http" ; import { ensureBuffer } from "./buffer" ; import { findNextRecordToReplay , findRecordMatches } from "./matcher" ; import { Mode } from "./modes" ; import { Persistence } from "./persistence" ; import { Request , send } from "./sender" ; import { TapeRecord } from "./tape" ; /**
 * A server that proxies or replays requests depending on the mode.
 */ export class RecordReplayServer { private server : http . Server ; private persistence : Persistence ; private mode : Mode ; private proxiedHost ? : string ; private timeout : number ; private currentTapeRecords : TapeRecord [ ] = [ ] ; private currentTape ! : string ; private loggingEnabled : boolean ; private defaultTape : string ; private replayedTapes : Set < TapeRecord > = new Set ( ) ; constructor ( options ) { this . currentTapeRecords = [ ] ; this . mode = options . initialMode ; this . proxiedHost = options . host ; this . timeout = options . timeout || 5000 ; this . loggingEnabled = options . enableLogging || false ; const redactHeaders = options . redactHeaders || [ ] ; this . persistence = new Persistence ( options . tapeDir , redactHeaders ) ; this . defaultTape = options . defaultTapeName ; this . loadTape ( this . defaultTape ) ; this . server = http . createServer ( async ( req , res ) => { if ( ! req . url ) { if ( this . loggingEnabled ) { console . error ( chalk . red ( "Received a request without URL." ) ) ; } return ; } if ( ! req . method ) { if ( this . loggingEnabled ) { console . error ( chalk . red ( "Received a request without HTTP method." ) ) ; } return ; } try { const request = { method : req . method , path : extractPath ( req . url ) , headers : req . headers , body : await receiveRequestBody ( req ) , } ; if ( request . path === "/__proxay" || request . path . startsWith ( "/__proxay/" ) ) { this . handleProxayApi ( request , res ) ; return ; } const record = await this . fetchResponse ( request ) ; if ( record ) { this . sendResponse ( record , res ) ; } else { res . statusCode = 500 ; res . end ( ) ; } } catch ( e ) { if ( this . loggingEnabled ) { console . error ( chalk . red ( "Unexpected error:" ) , e ) ; } res . statusCode = 500 ; res . end ( ) ; } } ) ; } /**
     * Starts the server.
     */ async start ( port ) { await new Promise ( ( resolve ) => this . server . listen ( port , resolve ) ) ; } /**
     * Stops the server.
     */ async stop ( ) { await new Promise ( ( resolve ) => this . server . close ( resolve ) ) ; } /**
     * Handles requests that are intended for Proxay itself.
     */ private handleProxayApi ( request , res ) { if ( request . method . toLowerCase ( ) === "get" && request . path === "/__proxay" ) { res . end ( "Proxay!" ) ; } if ( request . method . toLowerCase ( ) === "post" && request . path === "/__proxay/tape" ) { const json = request . body . toString ( "utf8" ) ; let tape ; let mode ; try { const body = JSON . parse ( json ) ; tape = body . tape || this . currentTape ; mode = body . mode || this . mode ; } catch { tape = null ; mode = this . mode ; } if ( mode !== this . mode ) { this . mode = mode ; } if ( tape ) { if ( ! this . persistence . isTapeNameValid ( tape ) ) { const errorMessage = `Invalid tape name: ${ tape }` ; if ( this . loggingEnabled ) { console . error ( chalk . red ( errorMessage ) ) ; } res . statusCode = 403 ; res . end ( errorMessage ) ; return ; } if ( this . loadTape ( tape ) ) { res . end ( `Updated tape: ${ tape }` ) ; } else { res . statusCode = 404 ; res . end ( `Missing tape: ${ tape }` ) ; } } else { this . unloadTape ( ) ; res . end ( `Unloaded tape` ) ; } } } private async fetchResponse ( request ) { switch ( this . mode ) { case "replay" : return this . fetchReplayResponse ( request ) ; case "record" : return this . fetchRecordResponse ( request ) ; case "mimic" : return this . fetchMimicResponse ( request ) ; case "passthrough" : return this . fetchPassthroughResponse ( request ) ; default : throw assertNever ( this . mode ) ; } } /**
     * Fetches the response from the tape, returning null otherwise.
     */ private async fetchReplayResponse ( request ) { const record = findNextRecordToReplay ( findRecordMatches ( this . currentTapeRecords , request . method , request . path , request . headers , request . body ) , this . replayedTapes ) ; if ( record ) { this . replayedTapes . add ( record ) ; if ( this . loggingEnabled ) { console . log ( `Replayed: ${ request . method } ${ request . path }` ) ; } } else { if ( this . loggingEnabled ) { console . warn ( chalk . yellow ( `Unexpected request ${ request . method } ${ request . path } has no matching record in tapes.` ) ) ; } } return record ; } /**
     * Fetches the response directly from the proxied host and records it.
     */ private async fetchRecordResponse ( request ) { if ( ! this . proxiedHost ) { throw new Error ( "Missing proxied host" ) ; } const record = await send ( { host : this . proxiedHost , method : request . method , path : request . path , headers : request . headers , body : request . body , } , { loggingEnabled : this . loggingEnabled , timeout : this . timeout , } ) ; this . addRecordToTape ( record ) ; if ( this . loggingEnabled ) { console . log ( `Recorded: ${ request . method } ${ request . path }` ) ; } return record ; } /**
     * Fetches the response from the tape if present, otherwise from the proxied host.
     */ private async fetchMimicResponse ( request ) { let record = findNextRecordToReplay ( findRecordMatches ( this . currentTapeRecords , request . method , request . path , request . headers , request . body ) , this . replayedTapes ) ; if ( record ) { this . replayedTapes . add ( record ) ; if ( this . loggingEnabled ) { console . log ( `Replayed: ${ request . method } ${ request . path }` ) ; } } else { if ( ! this . proxiedHost ) { throw new Error ( "Missing proxied host" ) ; } record = await send ( { host : this . proxiedHost , method : request . method , path : request . path , headers : request . headers , body : request . body , } , { loggingEnabled : this . loggingEnabled , timeout : this . timeout , } ) ; this . addRecordToTape ( record ) ; if ( this . loggingEnabled ) { console . log ( `Recorded: ${ request . method } ${ request . path }` ) ; } } return record ; } /**
     * Fetches the response directly from the proxied host without recording it.
     */ private async fetchPassthroughResponse ( request ) { if ( ! this . proxiedHost ) { throw new Error ( "Missing proxied host" ) ; } const record = await send ( { host : this . proxiedHost , method : request . method , path : request . path , headers : request . headers , body : request . body , } , { loggingEnabled : this . loggingEnabled , timeout : this . timeout , } ) ; if ( this . loggingEnabled ) { console . log ( `Proxied: ${ request . method } ${ request . path }` ) ; } return record ; } returns private loadTape ( tapeName ) { this . currentTape = tapeName ; if ( this . loggingEnabled ) { console . log ( chalk . blueBright ( `Loaded tape: ${ tapeName }` ) ) ; } switch ( this . mode ) { case "record" : this . currentTapeRecords = [ ] ; this . persistence . saveTapeToDisk ( this . currentTape , [ ] ) ; return true ; case "replay" : try { this . currentTapeRecords = this . persistence . loadTapeFromDisk ( this . currentTape ) ; return true ; } catch ( e ) { if ( this . loggingEnabled ) { console . warn ( chalk . yellow ( e . message ) ) ; } return false ; } case "mimic" : try { this . currentTapeRecords = this . persistence . loadTapeFromDisk ( this . currentTape ) ; } catch ( e ) { this . currentTapeRecords = [ ] ; this . persistence . saveTapeToDisk ( this . currentTape , [ ] ) ; } return true ; case "passthrough" : return true ; default : throw assertNever ( this . mode ) ; } } /**
     * Unloads the current tape, falling back to the default.
     */ private unloadTape ( ) { this . loadTape ( this . defaultTape ) ; } /**
     * Adds a new record to the current tape and saves to disk.
     */ private addRecordToTape ( record ) { this . currentTapeRecords . push ( record ) ; this . persistence . saveTapeToDisk ( this . currentTape , this . currentTapeRecords ) ; } /**
     * Sends a particular response to the client.
     */ private sendResponse ( record , res ) { res . statusCode = record . response . status . code ; Object . keys ( record . response . headers ) . forEach ( ( headerName ) => { const headerValue = record . response . headers [ headerName ] ; if ( headerValue ) { res . setHeader ( headerName , headerValue ) ; } } ) ; res . end ( record . response . body ) ; } } function receiveRequestBody ( req ) { const requestChunks = [ ] ; req . on ( "data" , ( chunk ) => { requestChunks . push ( ensureBuffer ( chunk ) ) ; } ) ; return new Promise ( ( resolve ) => { req . on ( "end" , ( ) => resolve ( Buffer . concat ( requestChunks ) ) ) ; } ) ; } function extractPath ( url ) { const schemePosition = url . indexOf ( "://" ) ; let path ; if ( schemePosition !== - 1 ) { const pathPosition = url . indexOf ( "/" , schemePosition + 3 ) ; path = url . substr ( pathPosition ) ; } else { path = url ; } return path ; }