Skip to content

Commit

Permalink
feat: allow to intercept fetch requests
Browse files Browse the repository at this point in the history
  • Loading branch information
kurpav committed Dec 8, 2023
1 parent 95bb36d commit 30e854e
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 120 deletions.
178 changes: 99 additions & 79 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ConfigType>;
metadata?: Partial<MetadataType>;
} = {
clientId: process.env.SUPERGOOD_CLIENT_ID as string,
clientSecret: process.env.SUPERGOOD_CLIENT_SECRET as string,
config: {} as Partial<ConfigType>,
metadata: {} as Partial<MetadataType>,
},
clientId: process.env.SUPERGOOD_CLIENT_ID as string,
clientSecret: process.env.SUPERGOOD_CLIENT_SECRET as string,
config: {} as Partial<ConfigType>,
metadata: {} as Partial<MetadataType>
},
baseUrl = process.env.SUPERGOOD_BASE_URL || 'https://api.supergood.ai'
) => {
if (!clientId) throw new Error(errors.NO_CLIENT_ID);
Expand All @@ -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}`;
Expand All @@ -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 <flushInterval> milliseconds
interval = setInterval(flushCache, supergoodConfig.flushInterval);
Expand Down
34 changes: 34 additions & 0 deletions src/interceptor/BatchInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
55 changes: 55 additions & 0 deletions src/interceptor/FetchInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
}
37 changes: 37 additions & 0 deletions src/interceptor/Interceptor.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
19 changes: 4 additions & 15 deletions src/interceptor/NodeClientRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 30e854e

Please sign in to comment.