From f78ec479876f0b987e4f818fb62c014eaf9eef53 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Feb 2022 03:14:26 +0000 Subject: [PATCH 01/12] Bump pnpm/action-setup from 2.0.1 to 2.1.0 Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2.0.1 to 2.1.0. - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v2.0.1...v2.1.0) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f4c54b..4cfe703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: key: ${{ runner.os }}-pnpm-${{ hashFiles('**/package.json') }} restore-keys: | ${{ runner.os }}-pnpm- - - uses: pnpm/action-setup@v2.0.1 + - uses: pnpm/action-setup@v2.1.0 with: version: 6.9.1 - run: pnpm install --frozen-lockfile From e8e207c74fe094f4d1e2ae6085f8dbe4e06c963f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Feb 2022 03:15:14 +0000 Subject: [PATCH 02/12] Bump pnpm/action-setup from 2.1.0 to 2.2.0 Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2.1.0 to 2.2.0. - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v2.1.0...v2.2.0) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cfe703..308d764 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: key: ${{ runner.os }}-pnpm-${{ hashFiles('**/package.json') }} restore-keys: | ${{ runner.os }}-pnpm- - - uses: pnpm/action-setup@v2.1.0 + - uses: pnpm/action-setup@v2.2.0 with: version: 6.9.1 - run: pnpm install --frozen-lockfile From 68decf82e68e1019c6f1778ba1c455715409ccd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Feb 2022 03:13:38 +0000 Subject: [PATCH 03/12] Bump actions/setup-node from 2 to 3.0.0 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 2 to 3.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v2...v3.0.0) --- updated-dependencies: - dependency-name: actions/setup-node dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 308d764..7aa2b04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2.4.0 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3.0.0 with: node-version: '14' - uses: actions/cache@v2 From 4a493aa51fffdceaaca89541fe68068c9edf4c88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Feb 2022 03:15:32 +0000 Subject: [PATCH 04/12] Bump pnpm/action-setup from 2.2.0 to 2.2.1 Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2.2.0 to 2.2.1. - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v2.2.0...v2.2.1) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aa2b04..7b0c3aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: key: ${{ runner.os }}-pnpm-${{ hashFiles('**/package.json') }} restore-keys: | ${{ runner.os }}-pnpm- - - uses: pnpm/action-setup@v2.2.0 + - uses: pnpm/action-setup@v2.2.1 with: version: 6.9.1 - run: pnpm install --frozen-lockfile From 72fea189a1ed3b82a614de9d0561298876940f9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Mar 2022 03:19:34 +0000 Subject: [PATCH 05/12] Bump actions/checkout from 2.4.0 to 3 Bumps [actions/checkout](https://github.com/actions/checkout) from 2.4.0 to 3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2.4.0...v3) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b0c3aa..e09af75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ jobs: name: Build runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v3 - uses: actions/setup-node@v3.0.0 with: node-version: '14' From addb0daa486d146902151226f605468fef40ad48 Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 03:34:34 +0000 Subject: [PATCH 06/12] Upgrade dependencies --- package.json | 4 ++-- pnpm-lock.yaml | 52 +++++++++++++++++++++++++------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index db4138f..9834e03 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "dependencies": { "content-type": "^1.0.4", "path-to-regexp": "^6.2.0", - "raw-body": "^2.4.2" + "raw-body": "^2.5.1" }, "devDependencies": { "@tsconfig/node14": "^1.0.1", @@ -40,6 +40,6 @@ "prettier": "^2.5.1", "prettier-plugin-organize-imports": "^2.3.4", "prettier-plugin-pkg": "^0.11.1", - "typescript": "^4.5.2" + "typescript": "^4.6.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c46fdbe..2e7289b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,22 +9,22 @@ specifiers: prettier: ^2.5.1 prettier-plugin-organize-imports: ^2.3.4 prettier-plugin-pkg: ^0.11.1 - raw-body: ^2.4.2 - typescript: ^4.5.2 + raw-body: ^2.5.1 + typescript: ^4.6.2 dependencies: content-type: 1.0.4 path-to-regexp: 6.2.0 - raw-body: 2.4.2 + raw-body: 2.5.1 devDependencies: '@tsconfig/node14': 1.0.1 '@types/content-type': 1.1.5 '@types/node': 16.11.11 prettier: 2.5.1 - prettier-plugin-organize-imports: 2.3.4_prettier@2.5.1+typescript@4.5.2 + prettier-plugin-organize-imports: 2.3.4_prettier@2.5.1+typescript@4.6.2 prettier-plugin-pkg: 0.11.1_prettier@2.5.1 - typescript: 4.5.2 + typescript: 4.6.2 packages: @@ -40,8 +40,8 @@ packages: resolution: {integrity: sha512-KB0sixD67CeecHC33MYn+eYARkqTheIRNuu97y2XMjR7Wu3XibO1vaY6VBV6O/a89SPI81cEUIYT87UqUWlZNw==} dev: true - /bytes/3.1.1: - resolution: {integrity: sha512-dWe4nWO/ruEOY7HkUJ5gFt1DCFV9zPRoJr8pV0/ASQermOZjtq8jMjOprC0Kd10GLN+l7xaUPvxzJFWtxGu8Fg==} + /bytes/3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} dev: false @@ -50,19 +50,19 @@ packages: engines: {node: '>= 0.6'} dev: false - /depd/1.1.2: - resolution: {integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=} - engines: {node: '>= 0.6'} + /depd/2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} dev: false - /http-errors/1.8.1: - resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} - engines: {node: '>= 0.6'} + /http-errors/2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} dependencies: - depd: 1.1.2 + depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 1.5.0 + statuses: 2.0.1 toidentifier: 1.0.1 dev: false @@ -81,14 +81,14 @@ packages: resolution: {integrity: sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==} dev: false - /prettier-plugin-organize-imports/2.3.4_prettier@2.5.1+typescript@4.5.2: + /prettier-plugin-organize-imports/2.3.4_prettier@2.5.1+typescript@4.6.2: resolution: {integrity: sha512-R8o23sf5iVL/U71h9SFUdhdOEPsi3nm42FD/oDYIZ2PQa4TNWWuWecxln6jlIQzpZTDMUeO1NicJP6lLn2TtRw==} peerDependencies: prettier: '>=2.0' typescript: '>=2.9' dependencies: prettier: 2.5.1 - typescript: 4.5.2 + typescript: 4.6.2 dev: true /prettier-plugin-pkg/0.11.1_prettier@2.5.1: @@ -105,12 +105,12 @@ packages: hasBin: true dev: true - /raw-body/2.4.2: - resolution: {integrity: sha512-RPMAFUJP19WIet/99ngh6Iv8fzAbqum4Li7AD6DtGaW2RpMB/11xDoalPiJMTbu6I3hkbMVkATvZrqb9EEqeeQ==} + /raw-body/2.5.1: + resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} engines: {node: '>= 0.8'} dependencies: - bytes: 3.1.1 - http-errors: 1.8.1 + bytes: 3.1.2 + http-errors: 2.0.0 iconv-lite: 0.4.24 unpipe: 1.0.0 dev: false @@ -123,9 +123,9 @@ packages: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false - /statuses/1.5.0: - resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=} - engines: {node: '>= 0.6'} + /statuses/2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} dev: false /toidentifier/1.0.1: @@ -133,8 +133,8 @@ packages: engines: {node: '>=0.6'} dev: false - /typescript/4.5.2: - resolution: {integrity: sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==} + /typescript/4.6.2: + resolution: {integrity: sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==} engines: {node: '>=4.2.0'} hasBin: true dev: true From 01f052b284bf105e8482cb2d923b8a3adf440369 Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 12:21:32 +0000 Subject: [PATCH 07/12] Refactor API for v2 --- README.md | 33 +++++++ src/zap.ts | 263 +++++++++++++++++++++++++++-------------------------- 2 files changed, 168 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 1c26f70..16108ba 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,39 @@ const server = http.createServer(serve(app)) server.listen(3000) ``` +## API + +### `serve(handler, options)` + +Constructs a new `http.RequestListener` out of a `Handler`. + +### `router(...routes)` + +Constructs a new `Handler` out of a list of `RouteHandlers`. + +### `route(method, path, handler)` + +Constructs a `RouteHandler` that matches a given method (`GET`, `POST`, etc) and path. + +### Body parsers + +- `buffer(req, options)` - read the request body as a `Buffer` +- `text(req, options)` - read the request body as a string +- `json(req, options)` - read the request body as parsed JSON + +## Request helpers + +- `getHeader(req, header)` - returns the requested header if it was provided +- `fromRequest(fn)` - wraps a function in the form `(req: ServerRequest, ...rest) => any` to return an equivalent function that caches its results for the provided request + +### Response helpers + +- Ordinarily you would return a `ResponseBodyType` from a `Handler` function +- `send(res, statusCode, body)` - a response with a given status code +- `notFound()` - a 404 response +- `redirect(location, statusCode)` - a redirect to another location (default status code 303) +- `httpError(code, message, metadata)` - an error response with a given code, message, and optional metadata + ## Credits Special thanks to [@nornagon](https://github.com/nornagon) for the `zap` package name. For versions of this module published before `v1.0.0`, see [nornagon/node-zap](https://github.com/nornagon/node-zap). diff --git a/src/zap.ts b/src/zap.ts index f504e68..c22eb8d 100644 --- a/src/zap.ts +++ b/src/zap.ts @@ -13,69 +13,83 @@ const IS_DEV = process.env.NODE_ENV === 'development' // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods (omitted CONNECT and TRACE) export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS' | 'PATCH' -export interface ServerRequest extends Omit { - body: RequestBody +export interface ServerRequest extends http.IncomingMessage { params: Params protocol: 'http' | 'https' - url: URL + parsedURL: URL } export interface ServerResponse extends http.ServerResponse {} -export type ResponseBodyType = string | object | number | Buffer | Stream | null -export type Next = (req: ServerRequest, res: ServerResponse) => Promise +export type ResponseBodyType = string | object | number | Buffer | Stream | Error | null export type Handler< ResponseBody extends ResponseBodyType = ResponseBodyType, Request extends ServerRequest = ServerRequest, -> = (req: Request, res: ServerResponse, next: Next) => ResponseBody | Promise +> = (req: Request, res: ServerResponse) => ResponseBody | Promise +export type ErrorHandler = ( + req: ServerRequest, + res: ServerResponse, + err: unknown, +) => ResponseBodyType | Promise // Serve ----------------------------------------------------------------------- export interface ServeOptions { trustProxy?: boolean - onError?: (err: Error) => void | Promise + errorHandler?: ErrorHandler } export function serve(handler: Handler, options: ServeOptions = {}) { return async function (req: http.IncomingMessage, res: http.ServerResponse) { + const serverRequest = requestFromHTTP(req, options) + const serverResponse = responseFromHTTP(res) + try { - const serverRequest = requestFromHTTP(req, options) - const serverResponse = responseFromHTTP(res) - await handler(serverRequest, serverResponse, async (_, res) => notFound(res)) - } catch (error: any) { - if (options.onError) await options.onError(error) - else if (!res.writableEnded) sendError(res, error) + await handler(serverRequest, serverResponse) + } catch (error) { + if (res.writableEnded) throw error + + if (error instanceof RedirectError) { + res.statusCode = error.statusCode + res.setHeader('Location', error.location) + res.end() + return + } + + const errorHandler = options.errorHandler ?? ((_, res, error) => sendError(res, error)) + errorHandler(serverRequest, serverResponse, error) } } } // Request --------------------------------------------------------------------- -function requestFromHTTP(req: http.IncomingMessage, options: ServeOptions): ServerRequest { - const originalURL = req.url! +const protocolFromRequest = fromRequest((req, options: ServeOptions) => { + const socketProtocol = Boolean((req.socket as tls.TLSSocket).encrypted) ? 'https' : 'http' + if (!options.trustProxy) return socketProtocol + const headerProtocol = getHeader(req, 'x-forwarded-proto') ?? socketProtocol + const commaIndex = headerProtocol.indexOf(',') + return commaIndex === -1 ? headerProtocol.trim() : headerProtocol.substring(0, commaIndex).trim() +}) + +const queryFromRequest = fromRequest((req) => { + return Object.fromEntries(req.parsedURL.searchParams) +}) + +const urlFromRequest = fromRequest((req) => { + return new URL(req.url!, `${req.protocol}://${req.headers.host}`) +}) +function requestFromHTTP(req: http.IncomingMessage, options: ServeOptions): ServerRequest { const serverRequest: ServerRequest = Object.defineProperties(req as unknown as ServerRequest, { - protocol: cachedGetter(req, () => { - const socketProtocol = Boolean((req.socket as tls.TLSSocket).encrypted) ? 'https' : 'http' - if (!options.trustProxy) return socketProtocol - const headerProtocol = getHeader(serverRequest, 'x-forwarded-proto') ?? socketProtocol - const commaIndex = headerProtocol.indexOf(',') - return commaIndex === -1 ? headerProtocol.trim() : headerProtocol.substring(0, commaIndex).trim() - }), - - query: cachedGetter(req, () => { - return Object.fromEntries(serverRequest.url.searchParams) - }), - - url: cachedGetter(req, () => { - return new URL(originalURL, `http://${req.headers.host}`) - }), + protocol: {get: () => protocolFromRequest(serverRequest, options), enumerable: true}, + query: {get: () => queryFromRequest(serverRequest), enumerable: true}, + parsedURL: {get: () => urlFromRequest(serverRequest), enumerable: true}, }) - return serverRequest } -export function getHeader(req: ServerRequest, header: string): string | undefined { +export function getHeader(req: http.IncomingMessage, header: string): string | undefined { const value = req.headers[header] return Array.isArray(value) ? value[0] : value } @@ -85,9 +99,9 @@ export interface RequestBodyOptions { encoding?: string } -const requestBodyMap = new WeakMap() +const requestBodyMap = new WeakMap() -export async function buffer(req: ServerRequest, {limit = '1mb', encoding}: RequestBodyOptions = {}) { +export async function buffer(req: http.IncomingMessage, {limit = '1mb', encoding}: RequestBodyOptions = {}) { const type = req.headers['content-type'] ?? 'text/plain' const length = req.headers['content-length'] @@ -104,22 +118,22 @@ export async function buffer(req: ServerRequest, {limit = '1mb', encoding}: Requ return body } catch (error: any) { if (error.type === 'entity.too.large') { - throw createError(413, `Body exceeded ${limit} limit`, error) + throw httpError(413, `Body exceeded ${limit} limit`, error) } - throw createError(400, 'Invalid body', error) + throw httpError(400, 'Invalid body', error) } } -export async function text(req: ServerRequest, options: RequestBodyOptions = {}) { +export async function text(req: http.IncomingMessage, options: RequestBodyOptions = {}) { return await buffer(req, options).then((body) => body.toString()) } -export async function json(req: ServerRequest, options: RequestBodyOptions = {}) { +export async function json(req: http.IncomingMessage, options: RequestBodyOptions = {}) { return await text(req, options).then((body) => { try { return JSON.parse(body) } catch (error: any) { - throw createError(400, 'Invalid JSON', error) + throw httpError(400, 'Invalid JSON', error) } }) } @@ -131,7 +145,7 @@ function responseFromHTTP(res: http.ServerResponse): ServerResponse { return serverResponse } -export function send(res: ServerResponse, code: number, body: ResponseBodyType = null) { +export function send(res: http.ServerResponse, code: number, body: ResponseBodyType = null) { res.statusCode = code if (body === null || body === undefined) { @@ -139,6 +153,11 @@ export function send(res: ServerResponse, code: number, body: ResponseBodyType = return } + // Throw errors so they can be handled by the error handler + if (body instanceof Error) { + throw body + } + if (body instanceof Stream || isReadableStream(body)) { if (!res.getHeader('Content-Type')) { res.setHeader('Content-Type', 'application/octet-stream') @@ -171,40 +190,47 @@ export function send(res: ServerResponse, code: number, body: ResponseBodyType = res.end(stringifiedBody) } -export function sendError(res: ServerResponse, error: HttpError) { - const statusCode = error.statusCode - const message = statusCode ? error.message : 'Internal Server Error' - send(res, statusCode ?? 500, IS_DEV ? error.stack : message) - console.error(error.stack) +function sendError(res: http.ServerResponse, error: unknown) { + if (error instanceof HttpError) { + send(res, error.statusCode, error.message) + } else if (error instanceof Error) { + send(res, 500, IS_DEV ? error.stack : error.message) + } else { + send(res, 500, `${error}`) + } } -export function notFound(res: ServerResponse) { - send(res, 404, 'Not Found') +export function notFound() { + return httpError(404, 'Not Found') } // Router ---------------------------------------------------------------------- -const notFoundMiddleware: Handler = async (_, res) => send(res, 404, 'Not Found') - -export function router(...middleware: Handler>[]) { +export function router(...handlers: RouteHandler[]) { return async function (req: ServerRequest, res: ServerResponse) { - const next = async (req: ServerRequest, res: ServerResponse, idx: number) => { - const current = middleware[idx] ?? notFoundMiddleware - await current(req, res, (req, res) => next(req, res, idx + 1)) + for (const current of handlers) { + if (req.method !== current.method) continue + const match = current.matchPath(req.parsedURL.pathname) + if (!match) continue + req.params = match.params + await current(req as ServerRequest, res) } - - await next(req, res, 0) + return send(res, 404, 'Not Found') } } -export type RouteHandler< +export interface RouteHandler< Method extends HttpMethod = HttpMethod, Route extends string = string, ResponseBody extends ResponseBodyType = ResponseBodyType, - Request extends ServerRequest = ServerRequest, -> = Handler & {method: Method; route: Route; compilePath: (params?: Request['params']) => string} +> extends Handler>> { + method: Method + route: Route + compilePath: (params?: RouteParams) => string + matchPath: (path: string) => false | {params: RouteParams; path: string; index: number} +} -// Type signature without a body validator +// Type signature export function route< ResponseBody extends ResponseBodyType, Method extends HttpMethod = HttpMethod, @@ -213,78 +239,47 @@ export function route< method: Method, path: Route, handler: Handler>>, -): RouteHandler>> - -// Type signature with a body validator -export function route< - RequestBody extends object = object, - ResponseBody extends ResponseBodyType = ResponseBodyType, - Method extends HttpMethod = HttpMethod, - Route extends string = string, ->( - method: Method, - path: Route, - handler: Handler>>, - validator: (body: object) => body is RequestBody, -): RouteHandler, RequestBody>> +): RouteHandler // Implementation -export function route< - RequestBody extends object = object, - ResponseBody extends ResponseBodyType = ResponseBodyType, - Method extends HttpMethod = HttpMethod, - Route extends string = string, ->( - method: Method, - path: Route, - handler: Handler>, - validator?: (body: object) => body is RequestBody, +export function route( + method: HttpMethod, + path: string, + handler: Handler>, ): RouteHandler { - const matchPath = match>(path) - const compilePath = compile(path) - - const routeHandler: Handler = async (req, res, next) => { - if (req.method !== method) return await next(req, res) - const pathMatch = matchPath(req.url.pathname) - if (!pathMatch) return await next(req, res) - - req.params = pathMatch.params - - let body: object | undefined = undefined - if (validator) { - body = await json(req) - if (typeof body !== 'object' || body === null || !validator(body)) { - return sendError(res, createError(422, 'Request body failed validation')) - } - } - - const responseBody = await Promise.resolve(handler(Object.assign(req, {body}), res, next)) + const routeHandler: Handler = async (req, res) => { + const responseBody = await Promise.resolve(handler(req, res)) + if (responseBody === null) return send(res, 204, null) + if (responseBody === undefined) return + send(res, res.statusCode ?? 200, responseBody) + } + return Object.assign(routeHandler, {method, route: path, compilePath: compile(path), matchPath: match(path)}) +} - if (responseBody === null) { - send(res, 204, null) - return - } +// Errors ---------------------------------------------------------------------- - if (responseBody !== undefined) { - send(res, res.statusCode ?? 200, responseBody) - } +export class HttpError extends Error { + constructor(public statusCode: number, message: string, public metadata: unknown) { + super(message) + if (Error.captureStackTrace) Error.captureStackTrace(this, RedirectError) } +} - return Object.assign(routeHandler, {method, route: path, compilePath}) +export function httpError(code: number, message: string, metadata?: unknown): HttpError { + return new HttpError(code, message, metadata) } -// Errors ---------------------------------------------------------------------- +// Redirects ------------------------------------------------------------------- -export interface HttpError extends Error { - statusCode?: number - originalError?: Error +export class RedirectError extends Error { + constructor(public statusCode: number, public location: string) { + super(`Redirect to ${location}, status code ${statusCode}`) + if (Error.captureStackTrace) Error.captureStackTrace(this, RedirectError) + } } -export function createError(code: number, message: string, original?: Error): HttpError { - const error: HttpError = new Error(message) - error.statusCode = code - error.originalError = original - return error +export function redirect(location: string, statusCode = 303) { + return new RedirectError(statusCode, location) } // Utilities ------------------------------------------------------------------- @@ -302,17 +297,29 @@ function isReadableStream(val: unknown): val is Readable { ) } -function cachedGetter(obj: object, getter: () => T) { - const cache = new WeakMap() - return { - get: (): T => { - if (cache.has(obj)) return cache.get(obj) - const value = getter() - cache.set(obj, value) +/** + * Creates a function that caches its results for a given request. Both successful responses + * and errors are cached. + * + * @param fn The function that should be cached. + * @returns The results of calling the function + */ +export function fromRequest any>(fn: Fn): Fn { + const cache = new WeakMap() + const errorCache = new WeakMap() + const cachedFn = (req: ServerRequest) => { + if (errorCache.has(req)) throw errorCache.get(req) + if (cache.has(req)) return cache.get(req) + try { + const value = fn(req) + cache.set(req, value) return value - }, - enumerable: true, + } catch (error) { + errorCache.set(req, error) + throw error + } } + return cachedFn as Fn } // TODO: can we support more param types here? From 37b979ca2fefab0175e3ad4058c16777339bd292 Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 12:31:39 +0000 Subject: [PATCH 08/12] Add TOC and recipes to README --- README.md | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 16108ba..364fcce 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,20 @@ Zap is a lightweight HTTP server framework for Node. +- [Installation](#installation) +- [Usage](#usage) +- [API](#api) +- [Recipes](#recipes) +- [Credits](#credits) +- [License](#license) + ## Installation Install with your favorite package manager: ```shell -$ yarn add zap $ pnpm add zap +$ yarn add zap $ npm install zap ``` @@ -60,6 +67,37 @@ Constructs a `RouteHandler` that matches a given method (`GET`, `POST`, etc) and - `redirect(location, statusCode)` - a redirect to another location (default status code 303) - `httpError(code, message, metadata)` - an error response with a given code, message, and optional metadata +## Recipes + +### Validating body schema + +You can use a function that throws an `httpError` to provide type-safe body payload parsing: + +```typescript +async function parseBody(req: ServerRequest) { + const body = await json(req) + if (!validate(body)) throw httpError(400, 'invalid body') + return body +} + +route('POST', '/example', (req) => { + const body = await parseBody(req) + // body is now typed according to your parseBody return type +}) +``` + +### Error handling + +The `serve()` function options accept an `errorHandler` that will replace `zap`'s built-in error handler. This allows you to report errors to services like Sentry, format the response sent to the user, etc. + +```typescript +serve(handler, { + errorHandler: (_, res, error) => { + send(res, 500, {message: 'Internal server error', details: formatError(error)}) + }, +}) +``` + ## Credits Special thanks to [@nornagon](https://github.com/nornagon) for the `zap` package name. For versions of this module published before `v1.0.0`, see [nornagon/node-zap](https://github.com/nornagon/node-zap). @@ -67,3 +105,7 @@ Special thanks to [@nornagon](https://github.com/nornagon) for the `zap` package ## License MIT License, see `LICENSE`. + +``` + +``` From b637fb1e076c917d57b3db2a02d17ce7971a1d6f Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 12:32:01 +0000 Subject: [PATCH 09/12] Allow void response from handlers --- src/zap.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zap.ts b/src/zap.ts index c22eb8d..d8cca22 100644 --- a/src/zap.ts +++ b/src/zap.ts @@ -25,12 +25,12 @@ export type ResponseBodyType = string | object | number | Buffer | Stream | Erro export type Handler< ResponseBody extends ResponseBodyType = ResponseBodyType, Request extends ServerRequest = ServerRequest, -> = (req: Request, res: ServerResponse) => ResponseBody | Promise +> = (req: Request, res: ServerResponse) => void | ResponseBody | Promise export type ErrorHandler = ( req: ServerRequest, res: ServerResponse, err: unknown, -) => ResponseBodyType | Promise +) => void | ResponseBodyType | Promise // Serve ----------------------------------------------------------------------- From 416a44488b192857429906e6d26f5d01636e8f7c Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 12:32:39 +0000 Subject: [PATCH 10/12] Fix README typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 364fcce..6529db8 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Constructs a `RouteHandler` that matches a given method (`GET`, `POST`, etc) and - `text(req, options)` - read the request body as a string - `json(req, options)` - read the request body as parsed JSON -## Request helpers +### Request helpers - `getHeader(req, header)` - returns the requested header if it was provided - `fromRequest(fn)` - wraps a function in the form `(req: ServerRequest, ...rest) => any` to return an equivalent function that caches its results for the provided request From 3bb3ad41f96a031f960a8edb4368798300299c68 Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 12:33:21 +0000 Subject: [PATCH 11/12] Remove stray code block --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 6529db8..098dcfd 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,3 @@ Special thanks to [@nornagon](https://github.com/nornagon) for the `zap` package ## License MIT License, see `LICENSE`. - -``` - -``` From 6f045b6359edd9766bc7a400c55c3f27a26c3ed7 Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 2 Mar 2022 12:35:17 +0000 Subject: [PATCH 12/12] 2.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9834e03..5ff18ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zap", - "version": "1.1.1", + "version": "2.0.0", "description": "Lightweight HTTP server framework for Node", "repository": { "type": "git",