import { ChildProcess } from 'child_process' import { HttpRequestEventMap } from './glossary' import { Interceptor } from './Interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest' import { handleRequest } from './utils/handleRequest' import { RequestController } from './RequestController' import { FetchResponse } from './utils/fetchUtils' export interface SerializedRequest { id: string url: string method: string headers: Array<[string, string]> credentials: RequestCredentials body: string } interface RevivedRequest extends Omit { url: URL headers: Headers } export interface SerializedResponse { status: number statusText: string headers: Array<[string, string]> body: string } export class RemoteHttpInterceptor extends BatchInterceptor< [ClientRequestInterceptor, XMLHttpRequestInterceptor] > { constructor() { super({ name: 'remote-interceptor', interceptors: [ new ClientRequestInterceptor(), new XMLHttpRequestInterceptor(), ], }) } protected setup() { super.setup() let handleParentMessage: NodeJS.MessageListener this.on('request', async ({ request, requestId, controller }) => { // Send the stringified intercepted request to // the parent process where the remote resolver is established. const serializedRequest = JSON.stringify({ id: requestId, method: request.method, url: request.url, headers: Array.from(request.headers.entries()), credentials: request.credentials, body: ['GET', 'HEAD'].includes(request.method) ? null : await request.text(), } as SerializedRequest) this.logger.info( 'sent serialized request to the child:', serializedRequest ) process.send?.(`request:${serializedRequest}`) const responsePromise = new Promise((resolve) => { handleParentMessage = (message) => { if (typeof message !== 'string') { return resolve() } if (message.startsWith(`response:${requestId}`)) { const [, serializedResponse] = message.match(/^response:.+?:(.+)$/) || [] if (!serializedResponse) { return resolve() } const responseInit = JSON.parse( serializedResponse ) as SerializedResponse const mockedResponse = new FetchResponse(responseInit.body, { url: request.url, status: responseInit.status, statusText: responseInit.statusText, headers: responseInit.headers, }) /** * @todo Support "errorWith" as well. * This response handling from the child is incomplete. */ controller.respondWith(mockedResponse) return resolve() } } }) // Listen for the mocked response message from the parent. this.logger.info( 'add "message" listener to the parent process', handleParentMessage ) process.addListener('message', handleParentMessage) return responsePromise }) this.subscriptions.push(() => { process.removeListener('message', handleParentMessage) }) } } export function requestReviver(key: string, value: any) { switch (key) { case 'url': return new URL(value) case 'headers': return new Headers(value) default: return value } } export interface RemoveResolverOptions { process: ChildProcess } export class RemoteHttpResolver extends Interceptor { static symbol = Symbol('remote-resolver') private process: ChildProcess constructor(options: RemoveResolverOptions) { super(RemoteHttpResolver.symbol) this.process = options.process } protected setup() { const logger = this.logger.extend('setup') const handleChildMessage: NodeJS.MessageListener = async (message) => { logger.info('received message from child!', message) if (typeof message !== 'string' || !message.startsWith('request:')) { logger.info('unknown message, ignoring...') return } const [, serializedRequest] = message.match(/^request:(.+)$/) || [] if (!serializedRequest) { return } const requestJson = JSON.parse( serializedRequest, requestReviver ) as RevivedRequest logger.info('parsed intercepted request', requestJson) const request = new Request(requestJson.url, { method: requestJson.method, headers: new Headers(requestJson.headers), credentials: requestJson.credentials, body: requestJson.body, }) const controller = new RequestController(request) await handleRequest({ request, requestId: requestJson.id, controller, emitter: this.emitter, onResponse: async (response) => { this.logger.info('received mocked response!', { response }) const responseClone = response.clone() const responseText = await responseClone.text() // // Send the mocked response to the child process. const serializedResponse = JSON.stringify({ status: response.status, statusText: response.statusText, headers: Array.from(response.headers.entries()), body: responseText, } as SerializedResponse) this.process.send( `response:${requestJson.id}:${serializedResponse}`, (error) => { if (error) { return } // Emit an optimistic "response" event at this point, // not to rely on the back-and-forth signaling for the sake of the event. this.emitter.emit('response', { request, requestId: requestJson.id, response: responseClone, isMockedResponse: true, }) } ) logger.info( 'sent serialized mocked response to the parent:', serializedResponse ) }, onRequestError: (response) => { this.logger.info('received a network error!', { response }) throw new Error('Not implemented') }, onError: (error) => { this.logger.info('request has errored!', { error }) throw new Error('Not implemented') }, }) } this.subscriptions.push(() => { this.process.removeListener('message', handleChildMessage) logger.info('removed the "message" listener from the child process!') }) logger.info('adding a "message" listener to the child process') this.process.addListener('message', handleChildMessage) this.process.once('error', () => this.dispose()) this.process.once('exit', () => this.dispose()) } }