diff --git a/src/index.ts b/src/index.ts index b4e6e86..0e54b0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,10 @@ import { } from './constants'; import onExit from 'signal-exit'; import { NodeRequestInterceptor } from './interceptor/NodeRequestInterceptor'; +import { IsomorphicRequest } from './interceptor/utils/IsomorphicRequest'; +import { IsomorphicResponse } from './interceptor/utils/IsomorphicResponse'; +import { BatchInterceptor } from './interceptor/BatchInterceptor'; +import { FetchInterceptor } from './interceptor/FetchInterceptor'; const Supergood = () => { let eventSinkUrl: string; @@ -42,25 +46,25 @@ const Supergood = () => { let localOnly = false; - let interceptor: NodeRequestInterceptor; + let interceptor: BatchInterceptor; const init = async ( { clientId, clientSecret, config, - metadata, + metadata }: { clientId?: string; clientSecret?: string; config?: Partial; metadata?: Partial; } = { - clientId: process.env.SUPERGOOD_CLIENT_ID as string, - clientSecret: process.env.SUPERGOOD_CLIENT_SECRET as string, - config: {} as Partial, - metadata: {} as Partial, - }, + clientId: process.env.SUPERGOOD_CLIENT_ID as string, + clientSecret: process.env.SUPERGOOD_CLIENT_SECRET as string, + config: {} as Partial, + metadata: {} as Partial + }, baseUrl = process.env.SUPERGOOD_BASE_URL || 'https://api.supergood.ai' ) => { if (!clientId) throw new Error(errors.NO_CLIENT_ID); @@ -82,11 +86,18 @@ const Supergood = () => { responseCache = new NodeCache({ stdTTL: 0 }); - interceptor = new NodeRequestInterceptor({ + const interceptorOpts = { ignoredDomains: supergoodConfig.ignoredDomains, allowLocalUrls: supergoodConfig.allowLocalUrls, baseUrl - }); + }; + + interceptor = new BatchInterceptor([ + new NodeRequestInterceptor(interceptorOpts), + ...(FetchInterceptor.checkEnvironment() + ? [new FetchInterceptor(interceptorOpts)] + : []) + ]); errorSinkUrl = `${baseUrl}${supergoodConfig.errorSinkEndpoint}`; eventSinkUrl = `${baseUrl}${supergoodConfig.eventSinkEndpoint}`; @@ -96,83 +107,92 @@ const Supergood = () => { interceptor.setup(); - interceptor.on('request', async (request: Request, requestId: string) => { - try { - const url = new URL(request.url); - // Meant for debug and testing purposes + interceptor.on( + 'request', + async (request: IsomorphicRequest, requestId: string) => { + try { + const url = new URL(request.url); + // Meant for debug and testing purposes - if (url.pathname === TestErrorPath) { - throw new Error(errors.TEST_ERROR); - } + if (url.pathname === TestErrorPath) { + throw new Error(errors.TEST_ERROR); + } - const body = await request.clone().text(); - const requestData = { - id: requestId, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - url: url.href, - path: url.pathname, - search: url.search, - body: safeParseJson(body), - requestedAt: new Date() - } as RequestType; - - cacheRequest(requestData, baseUrl); - } catch (e) { - log.error( - errors.CACHING_REQUEST, - { - config: supergoodConfig, - metadata: { - requestUrl: request.url, - payloadSize: new Blob([request as any]).size, - ...supergoodMetadata + const body = await request.clone().text(); + const requestData = { + id: requestId, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + url: url.href, + path: url.pathname, + search: url.search, + body: safeParseJson(body), + requestedAt: new Date() + } as RequestType; + + cacheRequest(requestData, baseUrl); + } catch (e) { + log.error( + errors.CACHING_REQUEST, + { + config: supergoodConfig, + metadata: { + requestUrl: request.url.toString(), + payloadSize: new Blob([request as any]).size, + ...supergoodMetadata + } + }, + e as Error, + { + reportOut: !localOnly } - }, - e as Error, - { - reportOut: !localOnly - } - ); + ); + } } - }); - - interceptor.on('response', async (response, requestId) => { - let requestData = { url: '' }; - let responseData = {}; - - try { - const requestData = requestCache.get(requestId) as { - request: RequestType; - }; - if (requestData) { - const responseData = { - response: { - headers: Object.fromEntries(response.headers.entries()), - status: response.status, - statusText: response.statusText, - body: response.body && safeParseJson(response.body), - respondedAt: new Date() + ); + + interceptor.on( + 'response', + async (response: IsomorphicResponse, requestId: string) => { + let requestData = { url: '' }; + let responseData = {}; + + try { + const requestData = requestCache.get(requestId) as { + request: RequestType; + }; + + if (requestData) { + const responseData = { + response: { + headers: Object.fromEntries(response.headers.entries()), + status: response.status, + statusText: response.statusText, + body: response.body && safeParseJson(response.body), + respondedAt: new Date() + }, + ...requestData + } as EventRequestType; + cacheResponse(responseData, baseUrl); + } + } catch (e) { + log.error( + errors.CACHING_RESPONSE, + { + config: supergoodConfig, + metadata: { + ...supergoodMetadata, + requestUrl: requestData.url, + payloadSize: responseData + ? new Blob([responseData as BlobPart]).size + : 0 + } }, - ...requestData - } as EventRequestType; - cacheResponse(responseData, baseUrl); + e as Error + ); } - } catch (e) { - log.error( - errors.CACHING_RESPONSE, - { - config: supergoodConfig, - metadata: { - ...supergoodMetadata, - requestUrl: requestData.url, - payloadSize: responseData ? new Blob([responseData as BlobPart]).size : 0 - } - }, - e as Error - ); } - }); + ); // Flushes the cache every milliseconds interval = setInterval(flushCache, supergoodConfig.flushInterval); diff --git a/src/interceptor/BatchInterceptor.ts b/src/interceptor/BatchInterceptor.ts new file mode 100644 index 0000000..1211a35 --- /dev/null +++ b/src/interceptor/BatchInterceptor.ts @@ -0,0 +1,34 @@ +import { Interceptor } from './Interceptor'; + +/** + * A batch interceptor that exposes a single interface + * to apply and operate with multiple interceptors at once. + */ +export class BatchInterceptor { + private interceptors: Interceptor[]; + private subscriptions: Array<() => void> = []; + + constructor(interceptors: Interceptor[]) { + this.interceptors = interceptors; + } + + public setup() { + for (const interceptor of this.interceptors) { + interceptor.setup(); + + this.subscriptions.push(() => interceptor.teardown()); + } + } + + public on(event: string, listener: (...args: any[]) => void): void { + for (const interceptor of this.interceptors) { + interceptor.on(event, listener); + } + } + + public teardown() { + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + } +} diff --git a/src/interceptor/FetchInterceptor.ts b/src/interceptor/FetchInterceptor.ts new file mode 100644 index 0000000..d57197b --- /dev/null +++ b/src/interceptor/FetchInterceptor.ts @@ -0,0 +1,55 @@ +import crypto from 'crypto'; + +import { IsomorphicRequest } from './utils/IsomorphicRequest'; +import { IsomorphicResponse } from './utils/IsomorphicResponse'; +import { isInterceptable } from './utils/isInterceptable'; +import { Interceptor, NodeRequestInterceptorOptions } from './Interceptor'; + +export class FetchInterceptor extends Interceptor { + constructor(options?: NodeRequestInterceptorOptions) { + super(options); + } + + public static checkEnvironment() { + return ( + typeof globalThis !== 'undefined' && + typeof globalThis.fetch !== 'undefined' + ); + } + + public setup() { + const pureFetch = globalThis.fetch; + + globalThis.fetch = async (input, init) => { + const requestId = crypto.randomUUID(); + const request = new Request(input, init); + const _isInterceptable = isInterceptable({ + url: new URL(request.url), + ignoredDomains: this.options.ignoredDomains ?? [], + baseUrl: this.options.baseUrl ?? '', + allowLocalUrls: this.options.allowLocalUrls ?? false + }); + + if (_isInterceptable) { + const isomorphicRequest = await IsomorphicRequest.fromFetchRequest( + request + ); + this.emitter.emit('request', isomorphicRequest, requestId); + } + + return pureFetch(request).then(async (response) => { + if (_isInterceptable) { + const isomorphicResponse = await IsomorphicResponse.fromFetchResponse( + response + ); + this.emitter.emit('response', isomorphicResponse, requestId); + } + return response; + }); + }; + + this.subscriptions.push(() => { + globalThis.fetch = pureFetch; + }); + } +} diff --git a/src/interceptor/Interceptor.ts b/src/interceptor/Interceptor.ts new file mode 100644 index 0000000..ac99fc2 --- /dev/null +++ b/src/interceptor/Interceptor.ts @@ -0,0 +1,37 @@ +import { EventEmitter } from 'events'; + +export interface NodeRequestInterceptorOptions { + ignoredDomains?: string[]; + allowLocalUrls?: boolean; + baseUrl?: string; +} + +export class Interceptor { + protected emitter: EventEmitter; + protected options: NodeRequestInterceptorOptions; + protected subscriptions: Array<() => void> = []; + + constructor(options?: NodeRequestInterceptorOptions) { + this.emitter = new EventEmitter(); + this.options = options ?? {}; + } + + public setup(): void { + throw new Error('Not implemented'); + } + + public teardown(): void { + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + this.emitter.removeAllListeners(); + } + + public on(event: string, listener: (...args: any[]) => void): void { + this.emitter.on(event, listener); + } + + public static checkEnvironment() { + return true; + } +} diff --git a/src/interceptor/NodeClientRequest.ts b/src/interceptor/NodeClientRequest.ts index 356ba48..ce6fb7b 100644 --- a/src/interceptor/NodeClientRequest.ts +++ b/src/interceptor/NodeClientRequest.ts @@ -8,13 +8,10 @@ import { ClientRequestWriteArgs, normalizeClientRequestWriteArgs } from './utils/normalizeClientRequestWriteArgs'; -import { - createHeadersFromIncomingHttpHeaders, - getIncomingMessageBody -} from './utils/getIncomingMessageBody'; import { IsomorphicRequest } from './utils/IsomorphicRequest'; import { getArrayBuffer } from './utils/bufferUtils'; import { isInterceptable } from './utils/isInterceptable'; +import { IsomorphicResponse } from './utils/IsomorphicResponse'; export type NodeClientOptions = { emitter: EventEmitter; @@ -115,18 +112,10 @@ export class NodeClientRequest extends ClientRequest { message: IncomingMessage, emitter: EventEmitter ) { - const response = args[0] as IncomingMessage; - const responseBody = await getIncomingMessageBody(message); - emitter.emit( - 'response', - { - status: response.statusCode || 200, - statusText: response.statusMessage || 'OK', - headers: createHeadersFromIncomingHttpHeaders(message.headers), - body: responseBody - }, - requestId + const isomorphicResponse = await IsomorphicResponse.fromIncomingMessage( + message ); + emitter.emit('response', isomorphicResponse, requestId); } if (this.isInterceptable) { diff --git a/src/interceptor/NodeRequestInterceptor.ts b/src/interceptor/NodeRequestInterceptor.ts index be5861f..6e03ee2 100644 --- a/src/interceptor/NodeRequestInterceptor.ts +++ b/src/interceptor/NodeRequestInterceptor.ts @@ -1,32 +1,21 @@ import http from 'http'; import https from 'https'; -import { EventEmitter } from 'events'; import { request } from './http.request'; import { get } from './http.get'; import { Protocol } from './NodeClientRequest'; +import { Interceptor, NodeRequestInterceptorOptions } from './Interceptor'; export type ClientRequestModules = Map; -export interface NodeRequestInterceptorOptions { - ignoredDomains?: string[]; - allowLocalUrls?: boolean; - baseUrl?: string; -} - -export class NodeRequestInterceptor { +export class NodeRequestInterceptor extends Interceptor { private modules: ClientRequestModules; - private subscriptions: Array<() => void> = []; - private emitter: EventEmitter; - private options: NodeRequestInterceptorOptions; constructor(options?: NodeRequestInterceptorOptions) { - this.emitter = new EventEmitter(); + super(options); this.modules = new Map(); this.modules.set('http', http); this.modules.set('https', https); - - this.options = options ?? {}; } public setup() { @@ -42,7 +31,7 @@ export class NodeRequestInterceptor { emitter: this.emitter, ignoredDomains: this.options.ignoredDomains, allowLocalUrls: this.options.allowLocalUrls, - baseUrl: this.options.baseUrl, + baseUrl: this.options.baseUrl }; // @ts-ignore @@ -52,15 +41,4 @@ export class NodeRequestInterceptor { requestModule.get = get(protocol, options); } } - - public on(event: string, listener: (...args: any[]) => void): void { - this.emitter.on(event, listener); - } - - public teardown() { - for (const unsubscribe of this.subscriptions) { - unsubscribe(); - } - this.emitter.removeAllListeners(); - } } diff --git a/src/interceptor/utils/IsomorphicRequest.ts b/src/interceptor/utils/IsomorphicRequest.ts index 6eb765e..017cf44 100644 --- a/src/interceptor/utils/IsomorphicRequest.ts +++ b/src/interceptor/utils/IsomorphicRequest.ts @@ -83,4 +83,17 @@ export class IsomorphicRequest { public clone(): IsomorphicRequest { return new IsomorphicRequest(this); } + + static async fromFetchRequest(request: Request): Promise { + const requestClone = request.clone(); + const url = new URL(requestClone.url); + const body = await requestClone.arrayBuffer(); + + return new IsomorphicRequest(url, { + body, + method: requestClone.method || 'GET', + credentials: 'same-origin', + headers: requestClone.headers as Headers + }); + } } diff --git a/src/interceptor/utils/IsomorphicResponse.ts b/src/interceptor/utils/IsomorphicResponse.ts new file mode 100644 index 0000000..d9678c5 --- /dev/null +++ b/src/interceptor/utils/IsomorphicResponse.ts @@ -0,0 +1,49 @@ +import { Headers } from 'headers-polyfill'; +import { IncomingMessage } from 'http'; +import { + createHeadersFromIncomingHttpHeaders, + getIncomingMessageBody +} from './getIncomingMessageBody'; + +export class IsomorphicResponse { + public readonly status: number; + public readonly statusText: string; + public readonly headers: Headers; + public readonly body: string; + + constructor( + status: number, + statusText: string, + headers: Headers, + body: string + ) { + this.status = status; + this.statusText = statusText; + this.headers = headers; + this.body = body; + } + + static async fromIncomingMessage(message: IncomingMessage) { + const responseBody = await getIncomingMessageBody(message); + + return new IsomorphicResponse( + message.statusCode || 200, + message.statusMessage || 'OK', + createHeadersFromIncomingHttpHeaders(message.headers), + responseBody + ); + } + + static async fromFetchResponse( + response: Response + ): Promise { + const responseClone = response.clone(); + const body = await responseClone.text(); + return new IsomorphicResponse( + response.status || 200, + response.statusText || 'OK', + response.headers as Headers, + body + ); + } +} diff --git a/test/e2e/native-fetch.e2e.test.ts b/test/e2e/native-fetch.e2e.test.ts new file mode 100644 index 0000000..e2dd305 --- /dev/null +++ b/test/e2e/native-fetch.e2e.test.ts @@ -0,0 +1,59 @@ +import Supergood from '../../src'; +import { + MOCK_DATA_SERVER, + SUPERGOOD_CLIENT_ID, + SUPERGOOD_CLIENT_SECRET, + SUPERGOOD_SERVER +} from '../consts'; +import { getEvents } from '../utils/function-call-args'; +import { mockApi } from '../utils/mock-api'; + +const [major] = process.versions.node.split('.').map(Number); +const describeIf = major >= 18 ? describe : describe.skip; + +describeIf('native fetch', () => { + const { postEventsMock } = mockApi(); + + beforeEach(async () => { + await Supergood.init( + { + clientId: SUPERGOOD_CLIENT_ID, + clientSecret: SUPERGOOD_CLIENT_SECRET, + config: { allowLocalUrls: true } + }, + SUPERGOOD_SERVER + ); + }); + + it('GET /posts ', async () => { + const response = await fetch(`${MOCK_DATA_SERVER}/posts`); + const responseBody = await response.json(); + expect(response.status).toEqual(200); + await Supergood.close(); + + const eventsPosted = getEvents(postEventsMock); + + expect(eventsPosted.length).toEqual(1); + expect(eventsPosted[0].response.body).toEqual(responseBody); + }); + + it('POST /posts', async () => { + const body = { + title: 'node-fetch-post', + author: 'node-fetch-author' + }; + const response = await fetch(`${MOCK_DATA_SERVER}/posts`, { + method: 'POST', + body: JSON.stringify(body) + }); + const responseBody = await response.json(); + expect(response.status).toEqual(201); + await Supergood.close(); + + const eventsPosted = getEvents(postEventsMock); + + expect(eventsPosted[0].request.body).toEqual(body); + expect(eventsPosted[0].response.body).toEqual(responseBody); + expect(eventsPosted.length).toEqual(1); + }); +});