Summary
Drop all three runtime dependencies (autobind-decorator, deep-equal, http-status-codes) in favor of native Node.js / TypeScript equivalents, making express-idempotency a zero-runtime-dependency middleware.
For a widely installed Express middleware this is a meaningful win: smaller install footprint, a smaller supply-chain surface, and fewer transitive breakages to inherit. The biggest supply-chain reduction comes from deep-equal (18 direct dependencies plus their subtree); the other two ship with 0 transitive dependencies.
Note: these replacements were validated by an adversarial review before scoping this issue. One of them (deep-equal) is not a safe naive swap — see the ⚠️ section under proposal 2. The plan below reflects the corrected approach.
Current usage
| Dependency |
Where it's used |
What it actually does |
http-status-codes |
src/errors/idempotencyErrors.ts, src/services/idempotencyService.ts |
Only two numeric constants: CONFLICT (409) and EXPECTATION_FAILED (417). |
deep-equal |
src/defaults/defaultIntentValidator.ts |
Two calls comparing req.query and req.body against the original request (loose mode, no {strict:true}). |
autobind-decorator |
src/services/idempotencyService.ts (@boundClass) |
Binds provideMiddlewareFunction, which is returned detached from its instance by the idempotency() factory. |
Proposed replacements
1. http-status-codes → local constants — ✅ low risk
Replace HttpStatus.CONFLICT (409) and HttpStatus.EXPECTATION_FAILED (417) with local constants, ideally exported once from errors/idempotencyErrors.ts (already imported by the service) to avoid duplicated magic numbers.
- Caveat: the test files still import
http-status-codes, including a third constant HttpStatus.OK (200) at idempotencyService.test.ts:388. Either move http-status-codes to devDependencies (runtime goal still met) or refactor the tests onto local constants (and add OK = 200). Removing it from dependencies without one of these breaks npm test.
2. deep-equal → ⚠️ prototype-agnostic helper (NOT util.isDeepStrictEqual)
A naive swap to node:util.isDeepStrictEqual is a silent breaking change and must be avoided.
Why: the current call uses loose comparison, which does not compare prototypes. isDeepStrictEqual does. In Express 5 the default query parser is 'simple' (querystring.parse), so req.query is a null-prototype object (Object.create(null)). Any data adapter that serializes (Mongo, Redis, SQL — i.e. the production targets this library exists for) reads the stored query/body back as a plain Object.prototype object. At replay time:
isDeepStrictEqual(Object.create(null), {}) // → false ❌ (even for an EMPTY query)
deepEqual(Object.create(null), {}) // → true ✅ (current behavior)
Result: isDeepStrictEqual returns false for genuinely identical requests → the middleware raises 417 on every legitimate retry (idempotencyService.ts:136) → idempotency is broken in production. The existing test suite stays green (the InMemoryDataAdapter stores by reference and node-mocks-http uses Object.prototype), so the regression would ship undetected.
Correct approach:
- Implement a small (~20 lines, zero-dep) prototype-agnostic deep-equal that compares keys/values like the current loose behavior, without inspecting
[[Prototype]].
- Add a regression test that round-trips the stored request through
JSON.parse(JSON.stringify(...)) (simulating Mongo/Redis) before intent validation, running on Express 5 — otherwise this regression class reappears unnoticed.
3. autobind-decorator → explicit .bind() — ✅ low risk
@boundClass binds all public methods. Only provideMiddlewareFunction is actually returned detached (middleware/idempotency.ts:36), so a single bind makes the middleware work. However, to preserve the public contract (a consumer destructuring const { isHit } = getSharedIdempotencyService() works today), bind all three public methods in the constructor:
constructor(options: IdempotencyOptions = {}) {
// ...
this.provideMiddlewareFunction = this.provideMiddlewareFunction.bind(this);
this.isHit = this.isHit.bind(this);
this.reportError = this.reportError.bind(this);
}
The res.send monkey-patch (sendHook, ~idempotencyService.ts:378) and all internal callbacks are arrow functions capturing this lexically, so no extra binding is needed there.
4. Drop experimentalDecorators — ✅ depends on #3
@boundClass is the only decorator in the repo (no reflect-metadata, emitDecoratorMetadata already off). The flag lives once in tsconfig.json:63 and is inherited by tsconfig.dist.json / tsconfig.test.json / tsconfig.e2e.json via extends. Remove it after #3 lands (removing it while @boundClass is still present fails compilation) — ideally in the same PR.
Acceptance criteria
Summary
Drop all three runtime dependencies (
autobind-decorator,deep-equal,http-status-codes) in favor of native Node.js / TypeScript equivalents, makingexpress-idempotencya zero-runtime-dependency middleware.For a widely installed Express middleware this is a meaningful win: smaller install footprint, a smaller supply-chain surface, and fewer transitive breakages to inherit. The biggest supply-chain reduction comes from
deep-equal(18 direct dependencies plus their subtree); the other two ship with 0 transitive dependencies.Current usage
http-status-codessrc/errors/idempotencyErrors.ts,src/services/idempotencyService.tsCONFLICT(409) andEXPECTATION_FAILED(417).deep-equalsrc/defaults/defaultIntentValidator.tsreq.queryandreq.bodyagainst the original request (loose mode, no{strict:true}).autobind-decoratorsrc/services/idempotencyService.ts(@boundClass)provideMiddlewareFunction, which is returned detached from its instance by theidempotency()factory.Proposed replacements
1.
http-status-codes→ local constants — ✅ low riskReplace
HttpStatus.CONFLICT(409) andHttpStatus.EXPECTATION_FAILED(417) with local constants, ideally exported once fromerrors/idempotencyErrors.ts(already imported by the service) to avoid duplicated magic numbers.http-status-codes, including a third constantHttpStatus.OK(200) atidempotencyService.test.ts:388. Either movehttp-status-codestodevDependencies(runtime goal still met) or refactor the tests onto local constants (and addOK = 200). Removing it fromdependencieswithout one of these breaksnpm test.2.⚠️ prototype-agnostic helper (NOT
deep-equal→util.isDeepStrictEqual)A naive swap to
node:util.isDeepStrictEqualis a silent breaking change and must be avoided.Why: the current call uses loose comparison, which does not compare prototypes.
isDeepStrictEqualdoes. In Express 5 the default query parser is'simple'(querystring.parse), soreq.queryis a null-prototype object (Object.create(null)). Any data adapter that serializes (Mongo, Redis, SQL — i.e. the production targets this library exists for) reads the storedquery/bodyback as a plainObject.prototypeobject. At replay time:Result:
isDeepStrictEqualreturnsfalsefor genuinely identical requests → the middleware raises417on every legitimate retry (idempotencyService.ts:136) → idempotency is broken in production. The existing test suite stays green (theInMemoryDataAdapterstores by reference andnode-mocks-httpusesObject.prototype), so the regression would ship undetected.Correct approach:
[[Prototype]].JSON.parse(JSON.stringify(...))(simulating Mongo/Redis) before intent validation, running on Express 5 — otherwise this regression class reappears unnoticed.3.
autobind-decorator→ explicit.bind()— ✅ low risk@boundClassbinds all public methods. OnlyprovideMiddlewareFunctionis actually returned detached (middleware/idempotency.ts:36), so a single bind makes the middleware work. However, to preserve the public contract (a consumer destructuringconst { isHit } = getSharedIdempotencyService()works today), bind all three public methods in the constructor:The
res.sendmonkey-patch (sendHook, ~idempotencyService.ts:378) and all internal callbacks are arrow functions capturingthislexically, so no extra binding is needed there.4. Drop
experimentalDecorators— ✅ depends on #3@boundClassis the only decorator in the repo (noreflect-metadata,emitDecoratorMetadataalready off). The flag lives once intsconfig.json:63and is inherited bytsconfig.dist.json/tsconfig.test.json/tsconfig.e2e.jsonviaextends. Remove it after #3 lands (removing it while@boundClassis still present fails compilation) — ideally in the same PR.Acceptance criteria
http-status-codesno longer a runtime dependency; 409/417 surfaced via shared local constants. Tests updated orhttp-status-codesmoved todevDependencies.deep-equalreplaced by a prototype-agnostic helper (NOT a naiveisDeepStrictEqualswap);@types/deep-equalremoved from devDependencies.JSON.parse(JSON.stringify(...))before intent validation, on Express 5, proving identical requests (including emptyquery) still match.autobind-decoratorreplaced by explicit.bind()of the three public methods (provideMiddlewareFunction,isHit,reportError);experimentalDecoratorsremoved fromtsconfig.json.dependenciesblock inpackage.jsonis empty (zero runtime dependencies).