Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
fredriklindberg committed Jan 30, 2024
1 parent 2998cda commit 5107e9d
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 55 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-json": "^6.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/content-type": "^1.1.8",
"@types/koa-joi-router": "^8.0.5",
"@types/mocha": "^10.0.1",
"@types/node": "^20.5.0",
Expand Down
195 changes: 140 additions & 55 deletions src/utils/http-captor.js → src/utils/http-captor.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
import contentTypeParser from 'content-type';
import net from 'net';
import http from 'http';

class CaptureError extends Error {
constructor(err) {
public readonly err: Error;
public readonly code: string;
public readonly message: string;

constructor(err: any) {
super();
this.message = err.message;
this.code = err.code;
this.err = err;
}
}

const captureData = (chunk, capturedLength, limit, encoding) => {
const captureData = (chunk: Buffer, capturedLength: number, limit: number, encoding: BufferEncoding): Buffer => {
const avail = limit - capturedLength;
const c = avail < chunk.length ? chunk.slice(0, avail) : chunk;
const c = avail < chunk.length ? chunk.subarray(0, avail) : chunk;
return Buffer.isBuffer(c) ? c : Buffer.from(c, encoding || 'utf-8');
};

const capturable = (contentType) => {
const capturable = (contentType: string | undefined): boolean => {
const types = [
'application/json',
'text/plain',
'text/html'
];
return types.includes(contentType);
return contentType != undefined && types.includes(contentType);
}

const parseContentType = (contentType) => {
const parseContentType = (contentType?: string): contentTypeParser.ParsedMediaType | undefined => {
if (!contentType) {
return undefined
}

try {
return contentTypeParser.parse(contentType);
} catch (e) {
} catch (e: any) {
return undefined;
}
};
Expand All @@ -42,8 +51,79 @@ const requestClientIp = (req) => {
return net.isIP(ip) ? ip : req.socket.remoteAddress;
}

export type HttpCaptorOpts = {
request: http.IncomingMessage;
response: http.ServerResponse<http.IncomingMessage>;
opts?: {
limit?: number;
captureRequestBody?: boolean;
captureResponseBody?: boolean;
}
};

type CaptureRequest = {
length: number;
capturedLength: number;
payload: Buffer;
captured: boolean;
};

type CaptureResponse = {
length: number;
capturedLength: number;
payload: Buffer;
captured: boolean;
headers: http.OutgoingHttpHeaders;
};

type CaptureResult = {
client: {
remoteAddr: string | undefined;
remoteFamily: string | undefined;
ip: string | undefined;
};
meta: {
requestBody: {
length: number;
capturedLength: number;
captured: boolean;
};
responseBody: {
length: number;
capturedLength: number;
captured: boolean;
};
};
version: string;
duration: number;
request: {
method: string;
path: string;
headers: http.IncomingHttpHeaders;
length: number;
body: string | undefined;
error: string | undefined;
};
response?: {
status: number;
message: string;
headers: http.OutgoingHttpHeaders;
length: number;
body: string | undefined;
error: string | undefined;
}
};

class HttpCaptor {
constructor(args) {

public captureRequestBody: boolean;
public captureResponseBody: boolean;
private limit: number;

private _request: http.IncomingMessage;
private _response: http.ServerResponse<http.IncomingMessage>;

constructor(args: HttpCaptorOpts) {
const {request, response, opts} = args;
this._request = request;
this._response = response;
Expand All @@ -53,44 +133,45 @@ class HttpCaptor {
this.captureResponseBody = opts?.captureResponseBody || false;
}

_captureRequest() {
const data = [];
private async _captureRequest(): Promise<CaptureRequest> {
const data: Array<Buffer> = [];
let length = 0;
let capturedLength = 0;

let contentType;
const getContentType = () => {
let contentType: contentTypeParser.ParsedMediaType | undefined;
const getContentType = (): contentTypeParser.ParsedMediaType => {
if (contentType) {
return contentType;
}
contentType = parseContentType(this._request.headers['content-type']);
return contentType || {};
const ct = parseContentType(this._request.headers['content-type']);
contentType = ct;
return contentType || {type: <any>undefined, parameters: {}};
};

const handleData = (chunk) => {
const {type, params} = getContentType();
const handleData = (chunk: Buffer): void => {
const {type, parameters} = getContentType();
if (this.captureRequestBody && capturable(type) && capturedLength < this.limit) {
const c = captureData(chunk, capturedLength, this.limit, params?.charset);
const c = captureData(chunk, capturedLength, this.limit, <any>parameters["charset"]);
data.push(c);
capturedLength += c.length;
}
length += chunk.length;
};

const assemble = () => {
const assemble = (): Buffer => {
return Buffer.concat(data, capturedLength);
};

return new Promise((resolve, reject) => {

const done = (err) => {
const done = (err: Error) => {
this._request.off('data', handleData);
this._request.off('end', done);
this._request.off('error', done);
if (err) {
reject(new CaptureError(err));
} else {
const {type, _} = getContentType();
const {type, parameters} = getContentType();
resolve({
length,
capturedLength,
Expand All @@ -100,7 +181,7 @@ class HttpCaptor {
}
};

const onListener = (event, listener) => {
const onListener = (event: string, listener: (...args: any[]) => void) => {
if (event === 'data') {
this._request.off('newListener', onListener);
this._request.on('data', handleData);
Expand All @@ -112,19 +193,19 @@ class HttpCaptor {
});
}

_captureResponse = () => {
const data = [];
let headers;
private async _captureResponse(): Promise<CaptureResponse> {
const data: Array<Buffer> = [];
let length = 0;
let capturedLength = 0;

if (!this._response) {
return new Promise((resolve, reject) => {
resolve({});
})
reject(new CaptureError(new Error('No response to capture')));
});
}

const getHeaders = () => {
let headers: http.OutgoingHttpHeaders | undefined;
const getHeaders = (): http.OutgoingHttpHeaders => {
if (headers) {
return headers;
}
Expand All @@ -133,44 +214,49 @@ class HttpCaptor {
}
}

let contentType;
const getContentType = () => {
let contentType: contentTypeParser.ParsedMediaType | undefined;
const getContentType = (): contentTypeParser.ParsedMediaType => {
if (contentType) {
return contentType;
}
contentType = parseContentType(getHeaders()['content-type']);
return contentType || {};
const ct = getHeaders()['content-type'];
if (typeof ct == 'string') {
contentType = parseContentType(ct);
}
return contentType || {type: <any>undefined, parameters: {}};
};

const saveChunk = (chunk) => {
const saveChunk = (chunk: Buffer): void => {
if (!chunk) {
return;
}
const {type, params} = getContentType();
const {type, parameters} = getContentType();
if (this.captureResponseBody && capturable(type) && capturedLength < this.limit) {
const c = captureData(chunk, capturedLength, this.limit, params?.charset);
const c = captureData(chunk, capturedLength, this.limit, <any>parameters["charset"]);
data.push(c);
capturedLength += c.length;
}
length += chunk.length;
};

const canonicalWrite = this._response.write;
this._response.write = (chunk) => {
saveChunk(chunk);
return canonicalWrite.apply(this._response, [chunk]);
this._response.write = (...args: Array<any>) => {
saveChunk(args[0]);
return canonicalWrite.apply(this._response, <any>args);
};

const canonicalEnd = this._response.end;
this._response.end = (chunk) => {
saveChunk(chunk);
return canonicalEnd.apply(this._response, [chunk]);
this._response.end = (...args: Array<any>) => {
if (args[0] instanceof Buffer) {
saveChunk(args[0]);
}
return canonicalEnd.apply(this._response, <any>args);
};

const canonicalWriteHead = this._response.writeHead;
this._response.writeHead = (...args) => {
headers = args[1];
return canonicalWriteHead.apply(this._response, args)
this._response.writeHead = (...args: Array<any>) => {
headers = args[1] as http.OutgoingHttpHeaders;
return canonicalWriteHead.apply(this._response, <any>args)
};

const assemble = () => {
Expand All @@ -179,7 +265,7 @@ class HttpCaptor {

return new Promise((resolve, reject) => {

const done = (err) => {
const done = (err: Error) => {
this._response.write = canonicalWrite;
this._response.end = canonicalEnd;
this._response.writeHead = canonicalWriteHead;
Expand All @@ -191,7 +277,7 @@ class HttpCaptor {
reject(new CaptureError(err));
} else {

const {type, _} = getContentType();
const {type, parameters} = getContentType();
resolve({
length,
capturedLength,
Expand All @@ -206,10 +292,9 @@ class HttpCaptor {
this._response.once('close', done);
this._response.once('error', done);
});
}

};

capture() {
public async capture(): Promise<CaptureResult> {
return new Promise(async (resolve, reject) => {
const startTime = process.hrtime.bigint();

Expand All @@ -218,15 +303,15 @@ class HttpCaptor {
this._captureResponse()
]);

const capturedRequest = requestResult.value;
const capturedRequest = requestResult.status == 'fulfilled' ? requestResult.value : undefined;
const requestError = requestResult.reason?.message;

const capturedResponse = responseResult.value;
const capturedResponse = responseResult.status == 'fulfilled' ? responseResult.value : undefined;
const responseError = responseResult?.reason?.message;

const elapsedMs = Math.round(Number((process.hrtime.bigint() - BigInt(startTime))) / 1e6);

const formatBody = (body, shouldCapture) => {
const formatBody = (body: CaptureRequest | CaptureResponse, shouldCapture: boolean) => {
let payload;
if (body?.captured) {
payload = body.capturedLength > 0 ? body.payload.toString('utf-8') : undefined;
Expand All @@ -239,7 +324,7 @@ class HttpCaptor {
return payload;
};

const result = {
const result: CaptureResult = {
client: {
remoteAddr: this._request.socket.remoteAddress,
remoteFamily: this._request.socket.remoteFamily,
Expand All @@ -254,14 +339,14 @@ class HttpCaptor {
responseBody: {
length: capturedResponse?.length || 0,
capturedLength: capturedResponse?.capturedLength || 0,
captured: capturedResponse?.captured || false,
captured: capturedResponse?.captured || false,
}
},
version: this._request.httpVersion,
duration: elapsedMs,
request: {
method: this._request.method,
path: this._request.url,
method: this._request.method || "UNKNOWN",
path: this._request.url || "",
headers: this._request.headers,
length: capturedRequest?.length || 0,
body: formatBody(capturedRequest, this.captureRequestBody),
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@
resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==

"@types/content-type@^1.1.8":
version "1.1.8"
resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.8.tgz#319644d07ee6b4bfc734483008393b89b99f0219"
integrity sha512-1tBhmVUeso3+ahfyaKluXe38p+94lovUZdoVfQ3OnJo9uJC42JT7CBoN3k9HYhAae+GwiBYmHu+N9FZhOG+2Pg==

"@types/cookies@*":
version "0.7.8"
resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
Expand Down

0 comments on commit 5107e9d

Please sign in to comment.