diff --git a/README.md b/README.md index 928bc3a..0d9f6b2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,55 @@ const { GET } = compose({ export { GET }; ``` +## Error handling + +Handling errors both in middleware and in the main handler is as simple as providing `sharedErrorHandler` to the `compose` function's second parameter _(a.k.a compose settings)_. Main goal of the shared error handler is to provide clear and easy way to e.g. send the error metadata to Sentry or other error tracking service. + +By default, shared error handler looks like this: + +```ts +sharedErrorHandler: { + handler: undefined; + // ^^^^ This is the handler function. By default there is no handler, so the error is being just thrown. + includeRouteHandler: false; + // ^^^^^^^^^^^^^^^^ This toggles whether the route handler itself should be included in a error handled area. + // By default only middlewares are being caught by the sharedErrorHandler +} +``` + +... and some usage example: + +```ts +// [...] +function errorMiddleware() { + throw new Error("foo"); +} + +const { GET } = compose( + { + GET: [ + [errorMiddleware], + () => { + // Unreachable code due to errorMiddleware throwing an error and halting the chain + return new Response(JSON.stringify({ foo: "bar" })); + }, + ], + }, + { + sharedErrorHandler: { + handler: (_method, error) => { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + }); + }, + }, + } +); +// [...] +``` + +will return `{"error": "foo"}` along with `500` status code instead of throwing an error. + ## Theory and caveats 1. Unfortunately there is no way to dynamically export named ESModules _(or at least I did not find a way)_ so you have to use `export { GET, POST }` syntax instead of something like `export compose(...)` if you're composing GET and POST methods :( @@ -113,4 +162,4 @@ The project is licensed under The MIT License. Thanks for all the contributions! [next-api-route-handlers]: https://nextjs.org/docs/app/building-your-application/routing/route-handlers [next-app-router-intro]: https://nextjs.org/docs/app/building-your-application/routing#the-app-router [next-app-router]: https://nextjs.org/docs/app -[next-pages-router]: https://nextjs.org/docs/pages \ No newline at end of file +[next-pages-router]: https://nextjs.org/docs/pages diff --git a/packages/next-api-compose/jest.config.js b/packages/next-api-compose/jest.config.js index 615edc6..e785c80 100644 --- a/packages/next-api-compose/jest.config.js +++ b/packages/next-api-compose/jest.config.js @@ -1,9 +1,9 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ -module.exports = { +module.exports = { preset: 'ts-jest', testEnvironment: 'node', collectCoverageFrom: ['src/*'], coverageReporters: ['html', 'json', 'lcov'], setupFilesAfterEnv: ['./jest.setup.js'], - coverageProvider: 'v8' + coverageProvider: 'v8', } diff --git a/packages/next-api-compose/jest.setup.js b/packages/next-api-compose/jest.setup.js index 540b887..13d1fe1 100644 --- a/packages/next-api-compose/jest.setup.js +++ b/packages/next-api-compose/jest.setup.js @@ -1,6 +1,3 @@ -global.Response = class MockedResponse { - constructor(body, status) { - this.body = body - this.status = status - } -} +const { Response } = require('undici') + +global.Response = Response diff --git a/packages/next-api-compose/package.json b/packages/next-api-compose/package.json index afa9224..e014466 100644 --- a/packages/next-api-compose/package.json +++ b/packages/next-api-compose/package.json @@ -60,7 +60,8 @@ "ts-toolbelt": "^9.6.0", "tsup": "^7.2.0", "type-fest": "^4.2.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "undici": "^5.28.2" }, "prettier": { "printWidth": 90, diff --git a/packages/next-api-compose/src/app.ts b/packages/next-api-compose/src/app.ts index c545e9d..b4ec619 100644 --- a/packages/next-api-compose/src/app.ts +++ b/packages/next-api-compose/src/app.ts @@ -1,4 +1,4 @@ -import type { Promisable } from 'type-fest' +import type { Promisable, PartialDeep } from 'type-fest' import type { NextApiRequest, NextApiResponse } from 'next' import type { NextResponse } from 'next/server' @@ -16,6 +16,22 @@ type NextApiMethodHandler = ( request: NextApiRequest ) => Promisable | Promisable +type ComposeSettings = PartialDeep<{ + sharedErrorHandler: { + /** + * @param {NextApiRouteMethod} method HTTP method of the composed route handler that failed. + * @param {Error} error Error that was thrown by the middleware or the handler. + */ + handler: (method: NextApiRouteMethod, error: Error) => Promisable + /** + * Whether to include the route handler in the error handled area. + * + * By default only middlewares are included (being caught by the sharedErrorHandler). + */ + includeRouteHandler: boolean + } +}> + type ComposeParameters< Methods extends NextApiRouteMethod, MiddlewareChain extends Array< @@ -39,6 +55,7 @@ type ComposeParameters< * Function that allows to define complex API structure in Next.js App router's Route Handlers. * * @param {ComposeParameters} parameters Middlewares array **(order matters)** or options object with previously mentioned middlewares array as `middlewareChain` property and error handler shared by every middleware in the array as `sharedErrorHandler` property. + * @param {ComposeSettings} composeSettings Settings object that allows to configure the compose function. * @returns Method handlers with applied middleware. */ export function compose< @@ -51,7 +68,22 @@ export function compose< | Promisable | Promisable > ->(parameters: ComposeParameters) { +>( + parameters: ComposeParameters, + composeSettings?: ComposeSettings +) { + const defaultComposeSettings = { + sharedErrorHandler: { + handler: undefined, + includeRouteHandler: false + } + } + + const mergedComposeSettings = { + ...defaultComposeSettings, + ...composeSettings + } + const modified = Object.entries(parameters).map( ([method, composeForMethodData]: [ UsedMethods, @@ -66,12 +98,50 @@ export function compose< [method]: async (request: any) => { if (typeof composeForMethodData === 'function') { const handler = composeForMethodData + if ( + mergedComposeSettings.sharedErrorHandler.includeRouteHandler && + mergedComposeSettings.sharedErrorHandler.handler != null + ) { + try { + return await handler(request) + } catch (error) { + const composeSharedErrorHandlerResult = + await mergedComposeSettings.sharedErrorHandler.handler(method, error) + + if ( + composeSharedErrorHandlerResult != null && + composeSharedErrorHandlerResult instanceof Response + ) { + return composeSharedErrorHandlerResult + } + } + } + return await handler(request) } const [middlewareChain, handler] = composeForMethodData for (const middleware of middlewareChain) { + if (mergedComposeSettings.sharedErrorHandler.handler != null) { + try { + const abortedMiddleware = await middleware(request) + + if (abortedMiddleware != null && abortedMiddleware instanceof Response) + return abortedMiddleware + } catch (error) { + const composeSharedErrorHandlerResult = + await mergedComposeSettings.sharedErrorHandler.handler(method, error) + + if ( + composeSharedErrorHandlerResult != null && + composeSharedErrorHandlerResult instanceof Response + ) { + return composeSharedErrorHandlerResult + } + } + } + const abortedMiddleware = await middleware(request) if (abortedMiddleware != null && abortedMiddleware instanceof Response) diff --git a/packages/next-api-compose/test/app.test.ts b/packages/next-api-compose/test/app.test.ts index d2d9798..1fca2b1 100644 --- a/packages/next-api-compose/test/app.test.ts +++ b/packages/next-api-compose/test/app.test.ts @@ -8,27 +8,32 @@ import type { IncomingMessage } from 'http' import { compose } from '../src/app' -class MockedResponse { - body: any = null - status: number = 200 - headers: { [key: string]: string } = {} - - constructor(body?: any, status: number = 200) { - this.body = body - this.status = status - } -} +type HandlerFunction = (req: IncomingMessage) => Promise + +async function streamToJson(stream: ReadableStream) { + const reader = stream.getReader() + let chunks: Uint8Array[] = [] -Object.setPrototypeOf(MockedResponse.prototype, Response.prototype) + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value as any) + } -type HandlerFunction = (req: IncomingMessage) => Promise + const string = new TextDecoder().decode( + Uint8Array.from(chunks.flatMap((chunk) => Array.from(chunk))) + ) + return JSON.parse(string) +} function createTestServer(handler: HandlerFunction) { return createServer(async (req, res) => { - const response: MockedResponse = await handler(req) - - res.writeHead(response.status, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(response.body)) + const response = await handler(req) + if (response.body) { + const body = await streamToJson(response.body) + res.writeHead(response.status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) + } }) } @@ -38,7 +43,7 @@ describe("composed route handler's http functionality", () => { GET: [ [], () => { - return new MockedResponse({ foo: 'bar' }) + return new Response(JSON.stringify({ foo: 'bar' })) } ] }) @@ -62,7 +67,7 @@ describe("composed route handler's http functionality", () => { GET: [ [setFooMiddleware, appendBarToFooMiddleware], (request) => { - return new MockedResponse({ foo: request.foo }) + return new Response(JSON.stringify({ foo: request.foo })) } ] }) @@ -74,10 +79,99 @@ describe("composed route handler's http functionality", () => { expect(response.body.foo).toBe('foobar') }) + it("should handle errors thrown by handler when no middleware is provided and return a 500 response with the error's message", async () => { + const { GET } = compose( + { + GET: () => { + throw new Error('foo') + } + }, + { + sharedErrorHandler: { + includeRouteHandler: true, + handler: (_method, error) => { + return new Response(JSON.stringify({ error: error.message }), { status: 500 }) + } + } + } + ) + + const app = createTestServer(GET) + const response = await request(app).get('/') + + expect(response.status).toBe(500) + expect(response.body.error).toBe('foo') + }) + + it("should handle errors thrown by middlewares and return a 500 response with the error's message", async () => { + function errorMiddleware() { + throw new Error('foo') + } + + const { GET } = compose( + { + GET: [ + [errorMiddleware], + () => { + return new Response(JSON.stringify({ foo: 'bar' })) + } + ] + }, + { + sharedErrorHandler: { + handler: (_method, error) => { + return new Response(JSON.stringify({ error: error.message }), { + status: 500 + }) + } + } + } + ) + + const app = createTestServer(GET) + const response = await request(app).get('/') + + expect(response.status).toBe(500) + expect(response.body.error).toBe('foo') + }) + + + it("should abort (halt) further middleware and handler execution with no error scenario when shared error handler is provided", async () => { + function haltingMiddleware() { + return new Response(JSON.stringify({ foo: 'bar' })) + } + + const { GET } = compose( + { + GET: [ + [haltingMiddleware], + () => { + return new Response(JSON.stringify({ foo: 'bar' })) + } + ] + }, + { + sharedErrorHandler: { + handler: (_method, error) => { + return new Response(JSON.stringify({ error: error.message }), { + status: 500 + }) + } + } + } + ) + + const app = createTestServer(GET) + const response = await request(app).get('/') + + expect(response.status).toBe(200) + expect(response.body.foo).toBe('bar') + }) + it('should correctly execute handler without middleware chain provided', async () => { const { GET } = compose({ GET: (request) => { - return new MockedResponse({ foo: 'bar' }) as any + return new Response(JSON.stringify({ foo: 'bar' })) } }) @@ -101,7 +195,7 @@ describe("composed route handler's http functionality", () => { GET: [ [setFooAsyncMiddleware, appendBarToFooMiddleware], (request) => { - return new MockedResponse({ foo: request.foo }) + return new Response(JSON.stringify({ foo: request.foo })) } ] }) @@ -113,10 +207,10 @@ describe("composed route handler's http functionality", () => { expect(response.body.foo).toBe('foobar') }) - it('should abort further middleware execution and return the response if a middleware returns a Response instance.', async () => { + it('should abort (halt) further middleware and handler execution and return the response if a middleware returns a Response instance.', async () => { function abortMiddleware(request) { request.foo = 'bar' - return new MockedResponse({ foo: request.foo }, 418) + return new Response(JSON.stringify({ foo: request.foo }), { status: 418 }) } function setFooMiddleware(request) { @@ -127,7 +221,7 @@ describe("composed route handler's http functionality", () => { GET: [ [abortMiddleware, setFooMiddleware], () => { - return new MockedResponse({ foo: 'unreachable fizz' }) + return new Response(JSON.stringify({ foo: 'unreachable fizz' })) } ] }) @@ -144,15 +238,15 @@ describe("composed route handler's code features", () => { it("should correctly return multiple method handlers when they're composed", async () => { const composedMethods = compose({ GET: (request) => { - return new MockedResponse({ foo: 'bar' }) as any + return new Response(JSON.stringify({ foo: 'bar' })) }, POST: (request) => { - return new MockedResponse({ fizz: 'buzz' }) as any + return new Response(JSON.stringify({ fizz: 'buzz' })) } }) - expect(composedMethods).toHaveProperty("GET") - expect(composedMethods).toHaveProperty("POST") + expect(composedMethods).toHaveProperty('GET') + expect(composedMethods).toHaveProperty('POST') }) }) diff --git a/packages/next-api-compose/tsconfig.json b/packages/next-api-compose/tsconfig.json index beb1f4f..2ba7d3c 100644 --- a/packages/next-api-compose/tsconfig.json +++ b/packages/next-api-compose/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es6", + "lib": ["es6", "dom"], "strictNullChecks": true, "moduleResolution": "node", "rootDir": "src", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 942d5c6..a00e1a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 18.2.7 next: specifier: 13.4.13 - version: 13.4.13(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0) + version: 13.4.13(react-dom@18.2.0)(react@18.2.0) next-api-compose: specifier: workspace:* version: link:../../packages/next-api-compose @@ -124,6 +124,9 @@ importers: typescript: specifier: ^5.1.6 version: 5.1.6 + undici: + specifier: ^5.28.2 + version: 5.28.2 packages: @@ -671,6 +674,11 @@ packages: dev: true optional: true + /@fastify/busboy@2.1.0: + resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} + engines: {node: '>=14'} + dev: true + /@hapi/accept@5.0.2: resolution: {integrity: sha512-CmzBx/bXUR8451fnZRuZAJRlzgm0Jgu5dltTX/bszmR2lheb9BpyN47Q1RbaGTsvFzn0PXAEs+lXDKfshccYZw==} dependencies: @@ -3893,6 +3901,46 @@ packages: - babel-plugin-macros dev: false + /next@13.4.13(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-A3YVbVDNeXLhWsZ8Nf6IkxmNlmTNz0yVg186NJ97tGZqPDdPzTrHotJ+A1cuJm2XfuWPrKOUZILl5iBQkIf8Jw==} + engines: {node: '>=16.8.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 13.4.13 + '@swc/helpers': 0.5.1 + busboy: 1.6.0 + caniuse-lite: 1.0.30001520 + postcss: 8.4.14 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) + watchpack: 2.4.0 + zod: 3.21.4 + optionalDependencies: + '@next/swc-darwin-arm64': 13.4.13 + '@next/swc-darwin-x64': 13.4.13 + '@next/swc-linux-arm64-gnu': 13.4.13 + '@next/swc-linux-arm64-musl': 13.4.13 + '@next/swc-linux-x64-gnu': 13.4.13 + '@next/swc-linux-x64-musl': 13.4.13 + '@next/swc-win32-arm64-msvc': 13.4.13 + '@next/swc-win32-ia32-msvc': 13.4.13 + '@next/swc-win32-x64-msvc': 13.4.13 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /node-fetch@2.6.1: resolution: {integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==} engines: {node: 4.x || >=6.0.0} @@ -4722,6 +4770,23 @@ packages: react: 18.2.0 dev: false + /styled-jsx@5.1.1(react@18.2.0): + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.2.0 + dev: false + /stylis-rule-sheet@0.0.10(stylis@3.5.4): resolution: {integrity: sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==} peerDependencies: @@ -5131,6 +5196,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + /undici@5.28.2: + resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.1.0 + dev: true + /universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'}