fix(errors): make ServiceError instanceof cross-instance-safe

When docker-prep packs @bytelyst/* as tarballs, a second physical copy of
@bytelyst/errors can be created, breaking prototype identity. The
fastify-core error handler's 'error instanceof ServiceError' then returned
false, mis-mapping ConflictError/BadRequestError/etc. to HTTP 500.

Fix: brand ServiceError instances with a global Symbol.for() key and add a
Symbol.hasInstance that recognizes any branded instance for the base class,
while preserving prototype-chain semantics for subclasses. Resilient to
duplicated module copies without touching call sites.

- errors: brand + custom hasInstance (+3 cross-instance unit tests)
- fastify-core: regression test (duplicated-copy ServiceError -> 409 not 500)
- bump @bytelyst/errors 0.1.11 -> 0.1.13, published to Gitea registry
This commit is contained in:
saravanakumardb1 2026-05-28 22:02:35 -07:00
parent 32dac7d466
commit 3a5196417d
5 changed files with 107 additions and 4 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@bytelyst/errors", "name": "@bytelyst/errors",
"version": "0.1.11", "version": "0.1.13",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {

View File

@ -54,3 +54,41 @@ describe('HTTP error classes', () => {
expect(err.details).toEqual({ retryAfter: 60 }); 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);
});
});

View File

@ -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. * Base error class for typed HTTP service errors.
* All specific error types extend this class. * All specific error types extend this class.
@ -10,5 +19,30 @@ export class ServiceError extends Error {
) { ) {
super(message); super(message);
this.name = 'ServiceError'; 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<symbol, unknown>)[SERVICE_ERROR_BRAND] === true;
}
const proto = (this as unknown as { prototype: object }).prototype;
return Object.prototype.isPrototypeOf.call(proto, instance);
} }
} }

View File

@ -99,6 +99,37 @@ describe('createServiceApp', () => {
await app.close(); 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<string, unknown>;
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 () => { it('handles ServiceError with details', async () => {
const { BadRequestError } = await import('@bytelyst/errors'); const { BadRequestError } = await import('@bytelyst/errors');
const app = await createServiceApp({ const app = await createServiceApp({

View File

@ -140,9 +140,9 @@
"publishedAt": "2026-05-27T08:43:49.944Z" "publishedAt": "2026-05-27T08:43:49.944Z"
}, },
"@bytelyst/errors": { "@bytelyst/errors": {
"version": "0.1.11", "version": "0.1.13",
"contentHash": "76443c39ff29478a483cb82439dcc44c5a25a1e87cf53eba3c1ba1e30b7947cf", "contentHash": "73720df322fdc53a53a27a680f1c4765ad81f611c95c9ede48808da7183a7ccc",
"publishedAt": "2026-05-27T08:43:51.483Z" "publishedAt": "2026-05-29T05:02:21.329Z"
}, },
"@bytelyst/event-store": { "@bytelyst/event-store": {
"version": "0.1.6", "version": "0.1.6",