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:
parent
32dac7d466
commit
3a5196417d
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/errors",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.13",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<symbol, unknown>)[SERVICE_ERROR_BRAND] === true;
|
||||
}
|
||||
const proto = (this as unknown as { prototype: object }).prototype;
|
||||
return Object.prototype.isPrototypeOf.call(proto, instance);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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 () => {
|
||||
const { BadRequestError } = await import('@bytelyst/errors');
|
||||
const app = await createServiceApp({
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user