Skip to content

Commit e31d938

Browse files
committedMay 14, 2020
.
0 parents  commit e31d938

13 files changed

+9182
-0
lines changed
 

‎.editorconfig

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# EditorConfig is awesome: http://EditorConfig.org
2+
3+
root = true
4+
5+
[*]
6+
indent_size = 2
7+
indent_style = space
8+
end_of_line = lf
9+
charset = utf-8
10+
trim_trailing_whitespace = true
11+
insert_final_newline = true

‎.eslintrc.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module.exports = {
2+
parser: "@typescript-eslint/parser",
3+
extends: [
4+
"plugin:@typescript-eslint/recommended",
5+
"prettier/@typescript-eslint",
6+
"plugin:prettier/recommended"
7+
],
8+
parserOptions: {
9+
ecmaVersion: 2018,
10+
sourceType: "module"
11+
}
12+
};

‎.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
coverage/
3+
.DS_Store
4+
npm-debug.log
5+
dist/
6+
dist.es2015/

‎.travis.yml

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
language: node_js
2+
3+
notifications:
4+
email:
5+
on_success: never
6+
on_failure: change
7+
8+
node_js:
9+
- "10"
10+
- stable
11+
12+
after_script:
13+
- npm install coveralls@2
14+
- cat ./coverage/lcov.info | coveralls

‎LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020 Blake Embrey (hello@blakeembrey.com)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

‎README.md

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Fetch Error Handler
2+
3+
[![NPM version][npm-image]][npm-url]
4+
[![NPM downloads][downloads-image]][downloads-url]
5+
[![Build status][travis-image]][travis-url]
6+
[![Test coverage][coveralls-image]][coveralls-url]
7+
8+
> Error handler for [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) responses, e.g. Cloudflare Workers.
9+
10+
## Installation
11+
12+
```sh
13+
npm install @borderless/fetch-error-handler --save
14+
```
15+
16+
## Usage
17+
18+
```js
19+
import { compose } from "throwback";
20+
import { errorHandler } from "@borderless/fetch-error-handler";
21+
import { finalHandler } from "@borderless/fetch-final-handler";
22+
23+
const app = compose([get(), post()]);
24+
const req = new Request("/");
25+
26+
const res = await app(req, finalHandler()).catch(
27+
errorHandler(req, { production: true })
28+
);
29+
```
30+
31+
## TypeScript
32+
33+
This project is written using [TypeScript](https://github.com/Microsoft/TypeScript) and publishes the definitions directly to NPM.
34+
35+
## License
36+
37+
MIT
38+
39+
[npm-image]: https://img.shields.io/npm/v/@borderless/fetch-error-handler.svg?style=flat
40+
[npm-url]: https://npmjs.org/package/@borderless/fetch-error-handler
41+
[downloads-image]: https://img.shields.io/npm/dm/@borderless/fetch-error-handler.svg?style=flat
42+
[downloads-url]: https://npmjs.org/package/@borderless/fetch-error-handler
43+
[travis-image]: https://img.shields.io/travis/BorderlessLabs/fetch-error-handler.svg?style=flat
44+
[travis-url]: https://travis-ci.org/BorderlessLabs/fetch-error-handler
45+
[coveralls-image]: https://img.shields.io/coveralls/BorderlessLabs/fetch-error-handler.svg?style=flat
46+
[coveralls-url]: https://coveralls.io/r/BorderlessLabs/fetch-error-handler?branch=master

‎package-lock.json

+8,708
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
{
2+
"name": "@borderless/fetch-error-handler",
3+
"version": "0.0.0",
4+
"description": "Error handler for fetch responses, e.g. Cloudflare Workers.",
5+
"main": "dist/index.js",
6+
"typings": "dist/index.d.ts",
7+
"module": "dist.es2015/index.js",
8+
"sideEffects": false,
9+
"jsnext:main": "dist.es2015/index.js",
10+
"files": [
11+
"dist/",
12+
"dist.es2015/"
13+
],
14+
"scripts": {
15+
"prettier": "prettier --write",
16+
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --quiet --fix",
17+
"format": "npm run prettier -- \"{.,src/**}/*.{js,jsx,ts,tsx,json,css,md,yml,yaml}\"",
18+
"build": "rimraf dist/ dist.es2015/ && tsc && tsc -P tsconfig.es2015.json",
19+
"specs": "jest --coverage",
20+
"test": "npm run -s lint && npm run -s build && npm run -s specs",
21+
"prepare": "npm run build"
22+
},
23+
"repository": {
24+
"type": "git",
25+
"url": "git://github.com/BorderlessLabs/fetch-error-handler.git"
26+
},
27+
"keywords": [
28+
"json",
29+
"rpc",
30+
"type",
31+
"safe",
32+
"typescript"
33+
],
34+
"author": {
35+
"name": "Blake Embrey",
36+
"email": "hello@blakeembrey.com",
37+
"url": "http://blakeembrey.me"
38+
},
39+
"license": "MIT",
40+
"bugs": {
41+
"url": "https://github.com/BorderlessLabs/fetch-error-handler/issues"
42+
},
43+
"homepage": "https://github.com/BorderlessLabs/fetch-error-handler",
44+
"jest": {
45+
"roots": [
46+
"<rootDir>/src/"
47+
],
48+
"transform": {
49+
"\\.tsx?$": "ts-jest"
50+
},
51+
"testRegex": "(/__tests__/.*|\\.(test|spec))\\.(tsx?|jsx?)$",
52+
"moduleFileExtensions": [
53+
"ts",
54+
"tsx",
55+
"js",
56+
"jsx",
57+
"json"
58+
]
59+
},
60+
"husky": {
61+
"hooks": {
62+
"pre-commit": "lint-staged"
63+
}
64+
},
65+
"lint-staged": {
66+
"*.{js,jsx,ts,tsx,json,css,md,yml,yaml}": "npm run prettier"
67+
},
68+
"publishConfig": {
69+
"access": "public"
70+
},
71+
"engines": {
72+
"node": ">=10"
73+
},
74+
"devDependencies": {
75+
"@hapi/boom": "^9.1.0",
76+
"@types/http-errors": "^1.6.3",
77+
"@types/jest": "^25.1.4",
78+
"@types/node": "^14.0.1",
79+
"@typescript-eslint/eslint-plugin": "^2.14.0",
80+
"@typescript-eslint/parser": "^2.14.0",
81+
"eslint": "^7.0.0",
82+
"eslint-config-prettier": "^6.9.0",
83+
"eslint-plugin-prettier": "^3.1.2",
84+
"http-errors": "^1.7.3",
85+
"husky": "^4.2.3",
86+
"jest": "^26.0.1",
87+
"lint-staged": "^10.0.8",
88+
"prettier": "^2.0.5",
89+
"rimraf": "^3.0.0",
90+
"throwback": "^4.1.0",
91+
"ts-jest": "^25.5.1",
92+
"typescript": "^3.7.4"
93+
},
94+
"dependencies": {
95+
"@types/escape-html": "^1.0.0",
96+
"@types/negotiator": "^0.6.1",
97+
"cross-fetch": "^3.0.4",
98+
"escape-html": "^1.0.3",
99+
"negotiator": "^0.6.2"
100+
}
101+
}

‎src/__snapshots__/index.spec.ts.snap

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`error handler accept html should fail and return html 1`] = `
4+
"<!doctype html><html lang=\\"en\\"><head><meta charset=\\"utf-8\\"><title>Error</title></head><body><pre>{
5+
&nbsp;&quot;status&quot;: 500,
6+
&nbsp;&quot;error&quot;: &quot;500 Error&quot;,
7+
&nbsp;&quot;message&quot;: &quot;Error&quot;
8+
}</pre></body></html>"
9+
`;
10+
11+
exports[`error handler accept json should fail and return json 1`] = `"{\\"status\\":500,\\"error\\":\\"500 Error\\",\\"message\\":\\"Error\\"}"`;
12+
13+
exports[`error handler default accepts should fail gracefully with empty error 1`] = `
14+
"<!doctype html><html lang=\\"en\\"><head><meta charset=\\"utf-8\\"><title>Error</title></head><body><pre>{
15+
&nbsp;&quot;status&quot;: 500,
16+
&nbsp;&quot;error&quot;: &quot;500 Error&quot;,
17+
&nbsp;&quot;message&quot;: &quot;Error&quot;
18+
}</pre></body></html>"
19+
`;
20+
21+
exports[`error handler default accepts should fail gracefully with non-error 1`] = `
22+
"<!doctype html><html lang=\\"en\\"><head><meta charset=\\"utf-8\\"><title>Error</title></head><body><pre>{
23+
&nbsp;&quot;status&quot;: 500,
24+
&nbsp;&quot;error&quot;: &quot;500 Error&quot;,
25+
&nbsp;&quot;message&quot;: &quot;Error&quot;
26+
}</pre></body></html>"
27+
`;
28+
29+
exports[`error handler default accepts should render an error 1`] = `
30+
"<!doctype html><html lang=\\"en\\"><head><meta charset=\\"utf-8\\"><title>Error</title></head><body><pre>{
31+
&nbsp;&quot;status&quot;: 500,
32+
&nbsp;&quot;error&quot;: &quot;500 Error&quot;,
33+
&nbsp;&quot;message&quot;: &quot;Error&quot;
34+
}</pre></body></html>"
35+
`;
36+
37+
exports[`error handler default accepts should render boom status errors 1`] = `
38+
"<!doctype html><html lang=\\"en\\"><head><meta charset=\\"utf-8\\"><title>Error</title></head><body><pre>{
39+
&nbsp;&quot;statusCode&quot;: 400,
40+
&nbsp;&quot;error&quot;: &quot;Bad Request&quot;,
41+
&nbsp;&quot;message&quot;: &quot;data has an issue&quot;
42+
}</pre></body></html>"
43+
`;
44+
45+
exports[`error handler default accepts should render http errors status error 1`] = `
46+
"<!doctype html><html lang=\\"en\\"><head><meta charset=\\"utf-8\\"><title>Error</title></head><body><pre>{
47+
&nbsp;&quot;status&quot;: 400,
48+
&nbsp;&quot;error&quot;: &quot;400 Error&quot;,
49+
&nbsp;&quot;message&quot;: &quot;Error&quot;
50+
}</pre></body></html>"
51+
`;

‎src/index.spec.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Request } from "cross-fetch";
2+
import * as boom from "@hapi/boom";
3+
import * as httpErrors from "http-errors";
4+
import { errorHandler } from "./index";
5+
6+
describe("error handler", () => {
7+
describe("default accepts", () => {
8+
const req = new Request("/");
9+
const handler = errorHandler(req);
10+
11+
it("should fail gracefully with non-error", async () => {
12+
const res = handler("test");
13+
14+
expect(await res.text()).toMatchSnapshot();
15+
});
16+
17+
it("should fail gracefully with empty error", async () => {
18+
const res = handler(undefined);
19+
20+
expect(await res.text()).toMatchSnapshot();
21+
});
22+
23+
it("should render an error", async () => {
24+
const res = handler(new Error("boom!"));
25+
26+
expect(await res.text()).toMatchSnapshot();
27+
});
28+
29+
it("should render boom status errors", async () => {
30+
const res = handler(boom.badRequest("data has an issue"));
31+
32+
expect(await res.text()).toMatchSnapshot();
33+
});
34+
35+
it("should render http errors status error", async () => {
36+
const res = handler(new httpErrors.BadRequest("data has an issue"));
37+
38+
expect(await res.text()).toMatchSnapshot();
39+
});
40+
});
41+
42+
describe("accept html", () => {
43+
const req = new Request("/", { headers: { accept: "text/html" } });
44+
const handler = errorHandler(req);
45+
46+
it("should fail and return html", async () => {
47+
const res = handler(new Error("boom!"));
48+
49+
expect(res.headers.get("content-type")).toEqual("text/html");
50+
expect(await res.text()).toMatchSnapshot();
51+
});
52+
});
53+
54+
describe("accept json", () => {
55+
const req = new Request("/", { headers: { accept: "application/json" } });
56+
const handler = errorHandler(req);
57+
58+
it("should fail and return json", async () => {
59+
const res = handler(new Error("boom!"));
60+
61+
expect(res.headers.get("content-type")).toEqual("application/json");
62+
expect(await res.text()).toMatchSnapshot();
63+
});
64+
});
65+
});

‎src/index.ts

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Request, Response } from "cross-fetch";
2+
import Negotiator from "negotiator";
3+
import escapeHTML from "escape-html";
4+
5+
/**
6+
* Used to properly space HTML output.
7+
*/
8+
const DOUBLE_SPACE_REGEXP = /\x20{2}/g;
9+
10+
/**
11+
* Boom-compatible output.
12+
*/
13+
interface Output {
14+
status: number;
15+
headers: Record<string, string>;
16+
payload: object;
17+
}
18+
19+
/**
20+
* Convert an error into an "output" object.
21+
*/
22+
function toOutput(err: any, production: boolean): Output {
23+
const error: {
24+
output?: {
25+
statusCode?: number;
26+
headers?: Record<string, string>;
27+
payload?: any;
28+
};
29+
statusCode?: number;
30+
status?: number;
31+
headers?: Record<string, string>;
32+
message?: string;
33+
} =
34+
err == null
35+
? { message: `Empty error: ${err}` }
36+
: typeof err === "object"
37+
? err
38+
: { message: String(err) };
39+
40+
const output = error.output || {};
41+
const status =
42+
Number(output.statusCode || error.statusCode || error.status) || 500;
43+
const headers = output.headers || error.headers || {};
44+
const payload = output.payload || {
45+
status,
46+
error: `${status} Error`,
47+
message: (production ? undefined : error.message) || "Error",
48+
};
49+
50+
return { status, headers, payload };
51+
}
52+
53+
/**
54+
* Render HTML response.
55+
*/
56+
function renderHTML(req: Request, output: Output) {
57+
const content = escapeHTML(JSON.stringify(output.payload, null, 2));
58+
59+
return new Response(
60+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Error</title></head><body><pre>${content.replace(
61+
DOUBLE_SPACE_REGEXP,
62+
" &nbsp;"
63+
)}</pre></body></html>`,
64+
{
65+
status: output.status,
66+
headers: {
67+
"Content-Type": "text/html",
68+
"X-Content-Type-Options": "nosniff",
69+
"Content-Security-Policy": "default-src 'self'",
70+
...output.headers,
71+
},
72+
}
73+
);
74+
}
75+
76+
/**
77+
* Send JSON response.
78+
*/
79+
function renderJSON(req: Request, output: Output) {
80+
return new Response(JSON.stringify(output.payload), {
81+
status: output.status,
82+
headers: {
83+
"Content-Type": "application/json",
84+
"X-Content-Type-Options": "nosniff",
85+
...output.headers,
86+
},
87+
});
88+
}
89+
90+
/**
91+
* Render HTTP response.
92+
*/
93+
function render(req: Request, output: Output) {
94+
const negotiator = new Negotiator({
95+
headers: {
96+
accept: req.headers.get("accept") || undefined,
97+
},
98+
});
99+
100+
const type = negotiator.mediaType(["text/html", "application/json"]);
101+
if (type === "text/html") return renderHTML(req, output);
102+
return renderJSON(req, output);
103+
}
104+
105+
/**
106+
* Error handler options.
107+
*/
108+
export interface Options {
109+
production?: boolean;
110+
}
111+
112+
/**
113+
* Render errors into a response object.
114+
*/
115+
export function errorHandler(
116+
req: Request,
117+
options: Options = {}
118+
): (err: any) => Response {
119+
const production = options.production !== false;
120+
121+
return function errorMiddleware(err: unknown) {
122+
return render(req, toOutput(err, production));
123+
};
124+
}

‎tsconfig.es2015.json

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "dist.es2015",
5+
"module": "ES2015",
6+
"declaration": false
7+
}
8+
}

‎tsconfig.json

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2018",
4+
"lib": ["ESNext"],
5+
"outDir": "dist",
6+
"rootDir": "src",
7+
"module": "CommonJS",
8+
"moduleResolution": "Node",
9+
"esModuleInterop": true,
10+
"strict": true,
11+
"declaration": true,
12+
"sourceMap": true,
13+
"inlineSources": true
14+
}
15+
}

0 commit comments

Comments
 (0)
Please sign in to comment.