diff --git a/packages/errors/package.json b/packages/errors/package.json index 913b8693..f2adac91 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/errors", - "version": "0.1.11", + "version": "0.1.13", "type": "module", "exports": { ".": { diff --git a/packages/errors/src/__tests__/errors.test.ts b/packages/errors/src/__tests__/errors.test.ts index b9aa6a04..1724c422 100644 --- a/packages/errors/src/__tests__/errors.test.ts +++ b/packages/errors/src/__tests__/errors.test.ts @@ -54,3 +54,41 @@ describe('HTTP error classes', () => { expect(err.details).toEqual({ retryAfter: 60 }); }); }); + +describe('cross-instance detection (duplicated module copy)', () => { + // Simulate a SECOND physical copy of @bytelyst/errors: a structurally + // identical class that brands itself with the same global symbol but has a + // different prototype chain (so plain prototype-based instanceof would fail). + const BRAND = Symbol.for('@bytelyst/errors.ServiceError'); + class DuplicatedServiceError extends Error { + constructor( + public statusCode: number, + message: string + ) { + super(message); + this.name = 'ServiceError'; + Object.defineProperty(this, BRAND, { value: true, enumerable: false }); + } + } + + it('recognizes a ServiceError created by a duplicated copy', () => { + const err = new DuplicatedServiceError(409, 'conflict'); + expect(err).toBeInstanceOf(ServiceError); + // Sanity: prototype chain genuinely differs (this is the bug scenario). + expect(Object.getPrototypeOf(err)).not.toBe(ServiceError.prototype); + }); + + it('does not treat plain objects or non-branded errors as ServiceError', () => { + expect(new Error('plain') instanceof ServiceError).toBe(false); + expect({ statusCode: 500 } instanceof ServiceError).toBe(false); + expect((null as unknown) instanceof ServiceError).toBe(false); + }); + + it('preserves subclass instanceof semantics', () => { + const notFound = new NotFoundError(); + expect(notFound instanceof NotFoundError).toBe(true); + expect(notFound instanceof ServiceError).toBe(true); + // A NotFoundError must NOT be an instance of an unrelated sibling subclass. + expect(notFound instanceof BadRequestError).toBe(false); + }); +}); diff --git a/packages/errors/src/service-error.ts b/packages/errors/src/service-error.ts index 1bc957f4..0b9fc35f 100644 --- a/packages/errors/src/service-error.ts +++ b/packages/errors/src/service-error.ts @@ -1,3 +1,12 @@ +/** + * Cross-realm brand for ServiceError instances. + * + * `Symbol.for()` returns the *same* symbol across every copy of this module, + * so even if a bundler/packer (e.g. docker-prep tarballs) produces a second + * physical copy of `@bytelyst/errors`, branded instances stay recognizable. + */ +const SERVICE_ERROR_BRAND = Symbol.for('@bytelyst/errors.ServiceError'); + /** * Base error class for typed HTTP service errors. * All specific error types extend this class. @@ -10,5 +19,30 @@ export class ServiceError extends Error { ) { super(message); this.name = 'ServiceError'; + // Brand the instance so `instanceof ServiceError` works even when this + // module has been duplicated and prototype identity no longer matches. + Object.defineProperty(this, SERVICE_ERROR_BRAND, { + value: true, + enumerable: false, + writable: false, + configurable: false, + }); + } + + /** + * Custom `instanceof` that is resilient to duplicated module copies. + * + * - For the base `ServiceError`, recognize any brand-carrying instance + * regardless of which physical copy of this module created it. + * - For subclasses, fall back to the standard prototype-chain check so that + * semantics like `notFound instanceof BadRequestError === false` hold. + */ + static [Symbol.hasInstance](instance: unknown): boolean { + if (typeof instance !== 'object' || instance === null) return false; + if (this === ServiceError) { + return (instance as Record)[SERVICE_ERROR_BRAND] === true; + } + const proto = (this as unknown as { prototype: object }).prototype; + return Object.prototype.isPrototypeOf.call(proto, instance); } } diff --git a/packages/fastify-core/src/__tests__/fastify-core.test.ts b/packages/fastify-core/src/__tests__/fastify-core.test.ts index 77e6233d..f95ac930 100644 --- a/packages/fastify-core/src/__tests__/fastify-core.test.ts +++ b/packages/fastify-core/src/__tests__/fastify-core.test.ts @@ -99,6 +99,37 @@ describe('createServiceApp', () => { await app.close(); }); + it('handles a ServiceError from a DUPLICATED module copy (docker-prep tarball scenario)', async () => { + // Simulate a second physical copy of @bytelyst/errors: a structurally + // identical, brand-carrying class with a different prototype chain. The + // handler must still map it to its statusCode instead of falling to 500. + const BRAND = Symbol.for('@bytelyst/errors.ServiceError'); + class DuplicatedConflictError extends Error { + statusCode = 409; + details?: Record; + constructor(message: string) { + super(message); + this.name = 'ServiceError'; + Object.defineProperty(this, BRAND, { value: true, enumerable: false }); + } + } + const app = await createServiceApp({ + name: 'test', + version: '0.1.0', + logger: false, + }); + + app.get('/conflict', async () => { + throw new DuplicatedConflictError('Version conflict'); + }); + + const res = await app.inject({ method: 'GET', url: '/conflict' }); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.payload).error).toBe('Version conflict'); + + await app.close(); + }); + it('handles ServiceError with details', async () => { const { BadRequestError } = await import('@bytelyst/errors'); const app = await createServiceApp({ diff --git a/scripts/gitea/.publish-manifest.json b/scripts/gitea/.publish-manifest.json index fd9de96d..b7c831fc 100644 --- a/scripts/gitea/.publish-manifest.json +++ b/scripts/gitea/.publish-manifest.json @@ -140,9 +140,9 @@ "publishedAt": "2026-05-27T08:43:49.944Z" }, "@bytelyst/errors": { - "version": "0.1.11", - "contentHash": "76443c39ff29478a483cb82439dcc44c5a25a1e87cf53eba3c1ba1e30b7947cf", - "publishedAt": "2026-05-27T08:43:51.483Z" + "version": "0.1.13", + "contentHash": "73720df322fdc53a53a27a680f1c4765ad81f611c95c9ede48808da7183a7ccc", + "publishedAt": "2026-05-29T05:02:21.329Z" }, "@bytelyst/event-store": { "version": "0.1.6",