diff --git a/README.md b/README.md index ab0b05f..94814fe 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ npm i @fastify/error The module exports a function that you can use for consistent error objects, it takes 4 parameters: -``` +```js createError(code, message [, statusCode [, Base [, captureStackTrace]]]) ``` @@ -58,6 +58,83 @@ new CustomError('world') new CustomError(1) ``` +### instanceof + +All errors created with `createError` will be instances of the base error constructor you provided, or `Error` if none was provided. + +```js +const createError = require('@fastify/error') +const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) +const customError = new CustomError('world') + +console.log(customError instanceof CustomError) // true +console.log(customError instanceof TypeError) // true +console.log(customError instanceof Error) // true +``` + +All instantiated errors are instances of the `FastifyError` class, which can be required directly from the module. + +```js +const { createError, FastifyError } = require('@fastify/error') +const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) +const customError = new CustomError('world') + +console.log(customError instanceof FastifyError) // true +``` + +A `FastifyError` created by `createError` can extend another `FastifyError` while maintaining correct `instanceof` behavior. + +```js +const { createError, FastifyError } = require('@fastify/error') + +const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) +const ChildCustomError = createError('CHILD_ERROR_CODE', 'Hello %s', 500, CustomError) + +const customError = new ChildCustomError('world') + +console.log(customError instanceof ChildCustomError) // true +console.log(customError instanceof CustomError) // true +console.log(customError instanceof FastifyError) // true +console.log(customError instanceof TypeError) // true +console.log(customError instanceof Error) // true +``` + +If `fastify-error` is installed multiple times directly or as a transitive dependency, `instanceof` checks for errors created by `createError` will still work correctly across these installations, as long as their error codes (e.g., `FST_ERR_CUSTOM_ERROR`) are identical. + +```js +const { createError, FastifyError } = require('@fastify/error') + +// CustomError from `@fastify/some-plugin` is created with `createError` and +// has its own `@fastify/error` installation as dependency. CustomError has +// FST_ERR_CUSTOM_ERROR as code. +const { CustomError: CustomErrorFromPlugin } = require('@fastify/some-plugin') + +const CustomError = createError('FST_ERR_CUSTOM_ERROR', 'Hello %s', 500) + +const customError = new CustomError('world') +const customErrorFromPlugin = new CustomErrorFromPlugin('world') + +console.log(customError instanceof CustomError) // true +console.log(customError instanceof CustomErrorFromPlugin) // true +console.log(customErrorFromPlugin instanceof CustomError) // true +console.log(customErrorFromPlugin instanceof CustomErrorFromPlugin) // true +``` + +Changing the code of an instantiated Error will not change the result of the `instanceof` operator. + +```js +const { createError, FastifyError } = require('@fastify/error') + +const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) +const AnotherCustomError = createError('ANOTHER_ERROR_CODE', 'Hello %s', 500, CustomError) + +const customError = new CustomError('world') +customError.code = 'ANOTHER_ERROR_CODE' + +console.log(customError instanceof CustomError) // true +console.log(customError instanceof AnotherCustomError) // false +``` + ## License Licensed under [MIT](./LICENSE). diff --git a/index.js b/index.js index 3e2365a..4d8da8e 100644 --- a/index.js +++ b/index.js @@ -6,13 +6,23 @@ function toString () { return `${this.name} [${this.code}]: ${this.message}` } +const FastifyGenericErrorSymbol = Symbol.for('fastify-error-generic') + function createError (code, message, statusCode = 500, Base = Error, captureStackTrace = createError.captureStackTrace) { + const shouldCreateFastifyGenericError = code === FastifyGenericErrorSymbol + + if (shouldCreateFastifyGenericError) { + code = 'FST_ERR' + } + if (!code) throw new Error('Fastify error code must not be empty') if (!message) throw new Error('Fastify error message must not be empty') code = code.toUpperCase() !statusCode && (statusCode = undefined) + const FastifySpecificErrorSymbol = Symbol.for(`fastify-error ${code}`) + function FastifyError (...args) { if (!new.target) { return new FastifyError(...args) @@ -38,9 +48,41 @@ function createError (code, message, statusCode = 500, Base = Error, captureStac enumerable: false, writable: true, configurable: true + }, + [FastifyGenericErrorSymbol]: { + value: true, + enumerable: false, + writable: false, + configurable: false + }, + [FastifySpecificErrorSymbol]: { + value: true, + enumerable: false, + writable: false, + configurable: false } }) + if (shouldCreateFastifyGenericError) { + Object.defineProperty(FastifyError, Symbol.hasInstance, { + value (instance) { + return instance && instance[FastifyGenericErrorSymbol] + }, + configurable: false, + writable: false, + enumerable: false + }) + } else { + Object.defineProperty(FastifyError, Symbol.hasInstance, { + value (instance) { + return instance && instance[FastifySpecificErrorSymbol] + }, + configurable: false, + writable: false, + enumerable: false + }) + } + FastifyError.prototype[Symbol.toStringTag] = 'Error' FastifyError.prototype.toString = toString @@ -50,6 +92,9 @@ function createError (code, message, statusCode = 500, Base = Error, captureStac createError.captureStackTrace = true +const FastifyErrorConstructor = createError(FastifyGenericErrorSymbol, 'Fastify Error', 500, Error) + module.exports = createError +module.exports.FastifyError = FastifyErrorConstructor module.exports.default = createError module.exports.createError = createError diff --git a/test/index.test.js b/test/index.test.js index beca457..3ad97f6 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,7 +1,7 @@ 'use strict' const test = require('node:test') -const createError = require('..') +const { createError, FastifyError } = require('..') test('Create error with zero parameter', (t) => { t.plan(6) @@ -221,3 +221,12 @@ test('Create an error with last argument null', (t) => { t.assert.ok(err instanceof Error) t.assert.ifError(err.cause) }) + +test('check if FastifyError is instantiable', (t) => { + t.plan(2) + + const err = new FastifyError() + + t.assert.ok(err instanceof FastifyError) + t.assert.ok(err instanceof Error) +}) diff --git a/test/instanceof.test.js b/test/instanceof.test.js new file mode 100644 index 0000000..34d56fb --- /dev/null +++ b/test/instanceof.test.js @@ -0,0 +1,263 @@ +'use strict' + +const cp = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const test = require('node:test') +const { createError, FastifyError } = require('..') + +test('Readme: All errors created with `createError` will be instances of the base error constructor you provided, or `Error` if none was provided.', (t) => { + t.plan(3) + + const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) + const customError = new CustomError('world') + + t.assert.ok(customError instanceof CustomError) + t.assert.ok(customError instanceof TypeError) + t.assert.ok(customError instanceof Error) +}) + +test('Readme: All instantiated errors will be instances of the `FastifyError` class. The `FastifyError` class can be required from the module directly.', (t) => { + t.plan(1) + + const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) + const customError = new CustomError('world') + + t.assert.ok(customError instanceof FastifyError) +}) + +test('Readme: It is possible to create a `FastifyError` that extends another `FastifyError`, created by `createError`, while instanceof working correctly.', (t) => { + t.plan(5) + + const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) + const ChildCustomError = createError('CHILD_ERROR_CODE', 'Hello %s', 500, CustomError) + + const customError = new ChildCustomError('world') + + t.assert.ok(customError instanceof ChildCustomError) + t.assert.ok(customError instanceof CustomError) + t.assert.ok(customError instanceof FastifyError) + t.assert.ok(customError instanceof TypeError) + t.assert.ok(customError instanceof Error) +}) + +test('Readme: Changing the code of an instantiated Error will not change the result of the `instanceof` operator.', (t) => { + t.plan(3) + + const CustomError = createError('ERROR_CODE', 'Hello %s', 500, TypeError) + const AnotherCustomError = createError('ANOTHER_ERROR_CODE', 'Hello %s', 500, CustomError) + + const customError = new CustomError('world') + customError.code = 'ANOTHER_ERROR_CODE' + + t.assert.ok(customError instanceof CustomError) + t.assert.ok(customError instanceof AnotherCustomError === false) + t.assert.ok(customError instanceof FastifyError) +}) + +test('check if createError creates an Error which is instanceof Error', (t) => { + t.plan(3) + + const CustomFastifyError = createError('CODE', 'Not available') + const err = CustomFastifyError() + + t.assert.ok(err instanceof Error) + t.assert.ok(err instanceof SyntaxError === false) + t.assert.ok(err instanceof TypeError === false) +}) + +test('check if createError creates an Error which is instanceof FastifyError', (t) => { + t.plan(4) + + const CustomFastifyError = createError('CODE', 'Not available') + const err = CustomFastifyError() + + t.assert.ok(err instanceof Error) + t.assert.ok(err instanceof FastifyError) + t.assert.ok(err instanceof SyntaxError === false) + t.assert.ok(err instanceof TypeError === false) +}) + +test('check if createError creates an Error with the right BaseConstructor', (t) => { + t.plan(2) + + const CustomFastifyError = createError('CODE', 'Not available', 500, TypeError) + const err = CustomFastifyError() + + t.assert.ok(err instanceof Error) + t.assert.ok(err instanceof TypeError) +}) + +test('check if createError creates an Error with the right BaseConstructor, which is a FastifyError', (t) => { + t.plan(6) + + const BaseFastifyError = createError('CODE', 'Not available', 500, TypeError) + const CustomFastifyError = createError('CODE', 'Not available', 500, BaseFastifyError) + const err = CustomFastifyError() + + t.assert.ok(err instanceof Error) + t.assert.ok(err instanceof TypeError) + t.assert.ok(err instanceof FastifyError) + t.assert.ok(err instanceof BaseFastifyError) + t.assert.ok(err instanceof CustomFastifyError) + t.assert.ok(err instanceof SyntaxError === false) +}) + +// for more information see https://github.com/fastify/fastify-error/pull/86#issuecomment-1301466407 +test('ensure that instanceof works accross different installations of the fastify-error module', async (t) => { + const assertsPlanned = 5 + t.plan(assertsPlanned) + + // We need to create a test environment where fastify-error is installed in two different locations + // and then we will check if the error created in one location is instanceof the error created in the other location + // This is done by creating a test directory with the following structure: + + // / + // ├── index.js + // └── node_modules/ + // ├── fastify-error/ + // │ └── index.js + // └── dep/ + // ├── index.js + // └── node_modules/ + // └── fastify-error/ + // └── index.js + + const testDirectoryPrefix = 'fastify-error-instanceof-test-' + + const testCwd = path.resolve(os.tmpdir(), `${testDirectoryPrefix}${Math.random().toString(36).substring(2, 15)}`) + fs.mkdirSync(testCwd, { recursive: true }) + + // Create the index.js. It will be executed as a forked process, so we need to + // use process.send to send messages back to the parent process. + fs.writeFileSync(path.resolve(testCwd, 'index.js'), ` + 'use strict' + + const path = require('node:path') + const { createError, FastifyError } = require('fastify-error') + const { foo } = require('dep') + + const actualPathOfFastifyError = require.resolve('fastify-error') + const expectedPathOfFastifyError = path.resolve('node_modules', 'fastify-error', 'index.js') + + // Ensure that fastify-error is required from the node_modules directory of the test-project + if (actualPathOfFastifyError !== expectedPathOfFastifyError) { + console.error('actualPathOfFastifyError', actualPathOfFastifyError) + console.error('expectedPathOfFastifyError', expectedPathOfFastifyError) + throw new Error('fastify-error should be required from the node_modules directory of the test-project') + } + + const Boom = createError('Boom', 'Boom', 500) + const ChildBoom = createError('ChildBoom', 'Boom', 500, Boom) + const NotChildBoom = createError('NotChildBoom', 'NotChildBoom', 500, Boom) + + try { + foo() + } catch (err) { + process.send(err instanceof Error) + process.send(err instanceof FastifyError) + process.send(err instanceof NotChildBoom) + process.send(err instanceof Boom) + process.send(err instanceof ChildBoom) + } + `) + + // Create /node_modules/fastify-error directory + // Copy the index.js file to the fastify-error directory + fs.mkdirSync(path.resolve(testCwd, 'node_modules', 'fastify-error'), { recursive: true }) + fs.copyFileSync(path.resolve(process.cwd(), 'index.js'), path.resolve(testCwd, 'node_modules', 'fastify-error', 'index.js')) + + // Create /node_modules/dep/node_modules/fastify-error directory + // Copy the index.js to the fastify-error directory + fs.mkdirSync(path.resolve(testCwd, 'node_modules', 'dep', 'node_modules', 'fastify-error'), { recursive: true }) + fs.copyFileSync(path.resolve(process.cwd(), 'index.js'), path.resolve(testCwd, 'node_modules', 'dep', 'node_modules', 'fastify-error', 'index.js')) + + // Create /node_modules/dep/index.js. It will export a function foo which will + // throw an error when called. The error will be an instance of ChildBoom, created + // by the fastify-error module in the node_modules directory of dep. + fs.writeFileSync(path.resolve(testCwd, 'node_modules', 'dep', 'index.js'), ` + 'use strict' + + const path = require('node:path') + const { createError } = require('fastify-error') + + const actualPathOfFastifyError = require.resolve('fastify-error') + const expectedPathOfFastifyError = path.resolve('node_modules', 'dep', 'node_modules', 'fastify-error', 'index.js') + + // Ensure that fastify-error is required from the node_modules directory of the test-project + if (actualPathOfFastifyError !== expectedPathOfFastifyError) { + console.error('actualPathOfFastifyError', actualPathOfFastifyError) + console.error('expectedPathOfFastifyError', expectedPathOfFastifyError) + throw new Error('fastify-error should be required from the node_modules directory of dep') + } + + const Boom = createError('Boom', 'Boom', 500) + const ChildBoom = createError('ChildBoom', 'Boom', 500, Boom) + + module.exports.foo = function foo () { + throw new ChildBoom('foo go Boom') + } + `) + + const finishedPromise = { + promise: undefined, + reject: undefined, + resolve: undefined, + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const child = cp.fork(path.resolve(testCwd, 'index.js'), { + cwd: testCwd, + stdio: 'inherit', + env: { + ...process.env, + NODE_OPTIONS: '--no-warnings' + }, + }) + + let messageCount = 0 + child.on('message', message => { + try { + switch (messageCount) { + case 0: + t.assert.strictEqual(message, true, 'instanceof Error') + break + case 1: + t.assert.strictEqual(message, true, 'instanceof FastifyError') + break + case 2: + t.assert.strictEqual(message, false, 'instanceof NotChildBoom') + break + case 3: + t.assert.strictEqual(message, true, 'instanceof Boom') + break + case 4: + t.assert.strictEqual(message, true, 'instanceof ChildBoom') + break + } + if (++messageCount === assertsPlanned) { + finishedPromise.resolve() + } + } catch (err) { + finishedPromise.reject(err) + } + }) + + child.on('error', err => { + finishedPromise.reject(err) + }) + + await finishedPromise.promise + + // Cleanup + // As we are creating the test-setup on the fly in the /tmp directory, we can remove it + // safely when we are done. It is not relevant for the test if the deletion fails. + try { + fs.rmSync(testCwd, { recursive: true, force: true }) + } catch {} +}) diff --git a/types/index.d.ts b/types/index.d.ts index 5ef39a3..2e59d9f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -40,6 +40,8 @@ declare namespace createError { readonly prototype: FastifyError & E } + export const FastifyError: FastifyErrorConstructor + export const createError: CreateError export { createError as default } } diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 31316eb..1a4398d 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -77,3 +77,16 @@ expectError(new CustomTypedArgError6('a', 'b', 'c', 'd', 'e')) const CustomErrorWithErrorConstructor = createError('ERROR_CODE', 'message', 500, TypeError) expectType>(CustomErrorWithErrorConstructor) CustomErrorWithErrorConstructor({ cause: new Error('Error') }) +const customErrorWithErrorConstructor = CustomErrorWithErrorConstructor() +if (customErrorWithErrorConstructor instanceof FastifyError) { + expectType<'ERROR_CODE'>(customErrorWithErrorConstructor.code) + expectType(customErrorWithErrorConstructor.message) + expectType<500>(customErrorWithErrorConstructor.statusCode) +} + +const error = new FastifyError('ERROR_CODE', 'message', 500) +if (error instanceof FastifyError) { + expectType(error.code) + expectType(error.message) + expectType(error.statusCode) +}