Skip to content

Commit

Permalink
feat: initial version (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
gr2m committed Dec 21, 2021
1 parent 3e9d81e commit ea3e93a
Show file tree
Hide file tree
Showing 11 changed files with 4,800 additions and 1 deletion.
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
@@ -0,0 +1,23 @@
name: Release
"on":
push:
branches:
- "*.x"
- main
- next
- beta
jobs:
release:
name: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16
cache: npm
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
38 changes: 38 additions & 0 deletions .github/workflows/test.yml
@@ -0,0 +1,38 @@
name: Test
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize]

jobs:
test_matrix:
runs-on: ubuntu-latest
strategy:
matrix:
node_version: ["14", "16", "17"]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}
cache: npm
- run: npm ci
- run: npm run test:code

test:
runs-on: ubuntu-latest
needs: test_matrix
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}
cache: npm
- run: npm ci
- run: npm run test:tsc
- run: npm run test:tsd
- run: npm run lint
2 changes: 2 additions & 0 deletions .gitignore
@@ -0,0 +1,2 @@
coverage
node_modules
50 changes: 50 additions & 0 deletions CONTRIBUTING.md
Expand Up @@ -4,3 +4,53 @@ Thank you for considering to contribute to `http-record` 💖

Please note that this project is released with a [Contributor Code of Conduct][./code_of_conduct.md].
By participating you agree to abide by its terms.

## Setup

Node.js 14 or higher is required. Install it from https://nodejs.org/en/. [GitHub's `gh` CLI](https://cli.github.com/) is recommended for the initial setup

1. Fork this repository and clone it to your local machine. Using `gh` you can do this

```
gh repo fork gr2m/http-recorder
```

2. After cloning and changing into the `http-recorder` directory, install dependencies and run the tests

```
npm install
npm test
```

Few notes

- `npm test` runs all kind of tests. You can run the code tests in isolation with `npm run test:code`. Use `npm run` to see all available scripts.
- If coverage drops, run `npm run coverage` to open a coverage report in your browser.
- Make sure that update types in `index.d.ts` that reflect any features / fixes you might have implemented.

## Issues before pull requests

Unless the change is trivial such as a type, please [open an issue first](https://github.com/gr2m/http-recorder/issues/new) before starting a pull request for a bug fix or a new feature.

After you cloned your fork, create a new branch and implement the changes in them. To start a pull request, you can use the `gh` CLI

```
gh pr create
```

## Maintainers only

### Merging the Pull Request & releasing a new version

Releases are automated using [semantic-release](https://github.com/semantic-release/semantic-release).
The following commit message conventions determine which version is released:

1. `fix: ...` or `fix(scope name): ...` prefix in subject: bumps fix version, e.g. `1.2.3``1.2.4`
2. `feat: ...` or `feat(scope name): ...` prefix in subject: bumps feature version, e.g. `1.2.3``1.3.0`
3. `BREAKING CHANGE:` in body: bumps breaking version, e.g. `1.2.3``2.0.0`

Only one version number is bumped at a time, the highest version change trumps the others.
Besides, publishing a new version to npm, semantic-release also creates a git tag and release
on GitHub, generates changelogs from the commit messages and puts them into the release notes.

If the pull request looks good but does not follow the commit conventions, update the pull request title and use the <kbd>Squash & merge</kbd> button, at which point you can set a custom commit message.
45 changes: 44 additions & 1 deletion README.md
Expand Up @@ -2,7 +2,11 @@

> Library agnostic in-process recording of http(s) requests and responses
# 🚧 Work In Progress - See [#1](https://github.com/gr2m/http-recorder/pull/1)
## Install

```
npm install @gr2m/http-recorder
```

## Usage

Expand Down Expand Up @@ -45,10 +49,49 @@ request.end();
// }
```

## API

`HttpRecorder` is a singleton API.

### `HttpRecorder.enable()`

Hooks into the request life cycle and emits `record` events for each request sent through the `http` or `https` modules.

### `HttpRecorder.disable()`

Removes the hooks. No `record` events will be emitted.

### `HttpRecorder.on("record", listener)`

Subscribe to a `record` event. The `listener` callback is called with an options object

- `options.request`: an [`http.ClientRequest` instance](https://nodejs.org/api/http.html#class-httpclientrequest)
- `options.response`: an [`http.IncomingMessage` instance](https://nodejs.org/api/http.html#class-httpincomingmessage)
- `options.requestBody`: An array of Buffer chunks representing the request body
- `options.responseBody`: An array of Buffer chunks representing the response body

### `HttpRecorder.off("record", listener)`

Remove a `record` event listener.

### `HttpRecorder.removeAllListeners()`

Removes all `record` event listeners.

## How it works

When enabled, `HttpRecorder` hooks itself into [the `http.ClientRequest.prototype.onSocket` method](https://github.com/nodejs/node/blob/cf6996458b82ec0bdf97209bce380e1483c349fb/lib/_http_client.js#L778-L782) which is conveniently called synchronously in [the `http.ClientRequest` constructor](https://nodejs.org/api/http.html#class-httpclientrequest).

When a request is intercepted, we hook into [the `request.write` method](https://github.com/nodejs/node/blob/cf6996458b82ec0bdf97209bce380e1483c349fb/lib/_http_outgoing.js#L701-L711) in order to clone the request body, subscribe to [the `response` event](https://nodejs.org/api/http.html#event-response), read out the response body, and then emit a `record` event with the `request`, `response`, `requestBody` and `responseBody` options.

## Contributing

See [CONTRIBUTING.md](CONTRIBTING.md)

## Credit

The inspiration for hooking into `http.ClientRequest.prototype.onSocket` method comes from [Mitm.js](https://github.com/moll/node-mitm/#readme) - an http mocking library for TCP connections and http(s) requests.

## License

[MIT](LICENSE)
22 changes: 22 additions & 0 deletions index.d.ts
@@ -0,0 +1,22 @@
import http from "http";

declare const HttpRecorder: {
enable: () => typeof HttpRecorder;
disable: () => typeof HttpRecorder;
on: (event: "record", listener: RecordHandler) => typeof HttpRecorder;
off: (event: "record", listener: RecordHandler) => typeof HttpRecorder;
removeAllListeners: () => typeof HttpRecorder;
};

export default HttpRecorder;

export interface RecordHandler {
(options: RecordHandlerOptions): void | Promise<void>;
}

export type RecordHandlerOptions = {
request: http.ClientRequest;
response: http.IncomingMessage;
requestBody: Buffer[];
responseBody: Buffer[];
};
73 changes: 73 additions & 0 deletions index.js
@@ -0,0 +1,73 @@
// @ts-check

import { EventEmitter } from "node:events";
import http from "node:http";

const emitter = new EventEmitter();
let origOnSocket;

export default {
enable() {
if (origOnSocket) {
// already enabled
return;
}

origOnSocket = http.ClientRequest.prototype.onSocket;
http.ClientRequest.prototype.onSocket = function (socket) {
const interceptedRequest = this;

const requestBodyChunks = [];
const originalRequestWrite = interceptedRequest.write;
interceptedRequest.write = function (chunk) {
requestBodyChunks.push(Buffer.from(chunk));
return originalRequestWrite.call(this, chunk);
};

interceptedRequest.on("response", async (response) => {
const responseBodyChunks = [];
for await (const chunk of response) {
responseBodyChunks.push(Buffer.from(chunk));
}

emitter.emit("record", {
request: interceptedRequest,
requestBody: requestBodyChunks,
response,
responseBody: responseBodyChunks,
});
});

return origOnSocket.call(this, socket);
};

return this;
},
disable() {
if (origOnSocket) {
http.ClientRequest.prototype.onSocket = origOnSocket;
origOnSocket = undefined;
}
return this;
},
on(eventName, callback) {
if (eventName !== "record") {
throw new Error("Only 'record' events are supported");
}

emitter.on("record", callback);
return this;
},
off(eventName, callback) {
if (eventName !== "record") {
throw new Error("Only 'record' events are supported");
}

emitter.off("record", callback);
return this;
},
removeAllListeners() {
emitter.removeAllListeners();
return this;
},
};
28 changes: 28 additions & 0 deletions index.test-d.ts
@@ -0,0 +1,28 @@
import http from "node:http";

import { expectType } from "tsd";
import HttpRecorder from "./index.js";

export function smokeTest() {
expectType<typeof HttpRecorder>(HttpRecorder);
}

export function API() {
expectType<typeof HttpRecorder>(HttpRecorder.enable());
expectType<typeof HttpRecorder>(HttpRecorder.disable());
expectType<typeof HttpRecorder>(HttpRecorder.on("record", () => {}));
expectType<typeof HttpRecorder>(HttpRecorder.off("record", () => {}));
expectType<typeof HttpRecorder>(HttpRecorder.removeAllListeners());

// @ts-expect-error - only "record" is supported
HttpRecorder.on("not-record", () => {});
}

export function recordHandler() {
HttpRecorder.on("record", (options) => {
expectType<http.ClientRequest>(options.request);
expectType<http.IncomingMessage>(options.response);
expectType<Buffer[]>(options.requestBody);
expectType<Buffer[]>(options.responseBody);
});
}

0 comments on commit ea3e93a

Please sign in to comment.