{"_id":"express-idempotency-middleware","_rev":"3-6e4921896118d284fadea131b9db64bc","name":"express-idempotency-middleware","dist-tags":{"latest":"1.0.2"},"versions":{"1.0.0":{"name":"express-idempotency-middleware","version":"1.0.0","keywords":["express","middleware","idempotency","idempotent","api","webhooks","payments"],"author":{"name":"Yevhen Mykhailenko"},"license":"MIT","_id":"express-idempotency-middleware@1.0.0","maintainers":[{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"}],"homepage":"https://github.com/YevhenMykhailenko/idempotency-express#readme","bugs":{"url":"https://github.com/YevhenMykhailenko/idempotency-express/issues"},"dist":{"shasum":"fbafaca9fb84cc2f92c68364feddae1863e7310c","tarball":"https://registry.npmjs.org/express-idempotency-middleware/-/express-idempotency-middleware-1.0.0.tgz","fileCount":59,"integrity":"sha512-zzSMq13T2cz7RPrn0y2WUtgAJETrX+qL3oqGb4puSBsPQW4Sa0AVDqy39P+Bq3yBnUmGCm04EtWy5l89GVo8dw==","signatures":[{"sig":"MEYCIQDckdJXQpzGdP0rHsU5rQFGNiXRMXU1+rw0Llb/Cah5ywIhAITXkhu4TZMUqe4tTzKwJm+/bac8LXr9Oqane1KPWNJH","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":88824},"main":"./dist/src/index.js","type":"module","types":"./dist/src/index.d.ts","module":"./dist/src/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"types":"./dist/src/index.d.ts","default":"./dist/src/index.js"}},"scripts":{"lint":"eslint .","test":"vitest run","build":"tsc -p tsconfig.json","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"},"repository":{"url":"git+https://github.com/YevhenMykhailenko/idempotency-express.git","type":"git"},"_npmVersion":"10.9.2","description":"Express middleware for idempotent POSTs via Idempotency-Key with in-flight control and pluggable stores.","directories":{},"_nodeVersion":"22.13.0","_hasShrinkwrap":false,"devDependencies":{"eslint":"^9.0.0","vitest":"^2.0.0","prettier":"^3.0.0","supertest":"^7.0.0","typescript":"^5.6.0","@types/node":"^20.0.0","@types/express":"^4.17.21","eslint-config-prettier":"^9.0.0","@typescript-eslint/parser":"^7.0.0","@typescript-eslint/eslint-plugin":"^7.0.0"},"peerDependencies":{"express":"^4.18.0 || ^5.0.0"},"_npmOperationalInternal":{"tmp":"tmp/express-idempotency-middleware_1.0.0_1759541856406_0.48662176657032963","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"express-idempotency-middleware","version":"1.0.1","keywords":["express","middleware","idempotency","idempotent","api","webhooks","payments"],"author":{"name":"Yevhen Mykhailenko"},"license":"MIT","_id":"express-idempotency-middleware@1.0.1","maintainers":[{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"}],"homepage":"https://github.com/YevhenMykhailenko/idempotency-express#readme","bugs":{"url":"https://github.com/YevhenMykhailenko/idempotency-express/issues"},"dist":{"shasum":"a41560c1583544a69074c86dd70fb4d9b4e90308","tarball":"https://registry.npmjs.org/express-idempotency-middleware/-/express-idempotency-middleware-1.0.1.tgz","fileCount":59,"integrity":"sha512-TxvoLA+vrO2kdDL5/b3J+97yOTuXT6Iif1AvILDAh4S+rogsVnFdvzwpVAlm0yZNcOSLXH2v3GQM4bWidvsAfQ==","signatures":[{"sig":"MEYCIQC9jt/maaxHr5/vY4hcHEQsPz1MfgGhlTiQBJH4Aox9ewIhAIBwzznizKaAVp9PvuGNPNJbQxJYsOvdH2kBZ7KnM1wq","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":88585},"main":"./dist/src/index.js","type":"module","types":"./dist/src/index.d.ts","module":"./dist/src/index.js","engines":{"node":">=18.0.0"},"exports":{".":{"types":"./dist/src/index.d.ts","default":"./dist/src/index.js"}},"gitHead":"f66e440e6a87de8b28308adadb64c0dcc210e82d","scripts":{"lint":"eslint .","test":"vitest run","build":"tsc -p tsconfig.json","prepublishOnly":"npm run build && npm test"},"_npmUser":{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"},"repository":{"url":"git+https://github.com/YevhenMykhailenko/idempotency-express.git","type":"git"},"_npmVersion":"10.9.2","description":"Express middleware for idempotent POSTs via Idempotency-Key with in-flight control and pluggable stores.","directories":{},"_nodeVersion":"22.13.0","_hasShrinkwrap":false,"devDependencies":{"eslint":"^9.0.0","vitest":"^2.0.0","prettier":"^3.0.0","supertest":"^7.0.0","typescript":"^5.6.0","@types/node":"^20.0.0","@types/express":"^4.17.21","eslint-config-prettier":"^9.0.0","@typescript-eslint/parser":"^7.0.0","@typescript-eslint/eslint-plugin":"^7.0.0"},"peerDependencies":{"express":"^4.18.0 || ^5.0.0"},"_npmOperationalInternal":{"tmp":"tmp/express-idempotency-middleware_1.0.1_1759542463906_0.4753413002126192","host":"s3://npm-registry-packages-npm-production"}},"1.0.2":{"name":"express-idempotency-middleware","version":"1.0.2","description":"Express middleware for idempotent POSTs via Idempotency-Key with in-flight control and pluggable stores.","type":"module","main":"./dist/src/index.js","module":"./dist/src/index.js","types":"./dist/src/index.d.ts","exports":{".":{"types":"./dist/src/index.d.ts","default":"./dist/src/index.js"}},"scripts":{"build":"tsc -p tsconfig.json","lint":"eslint .","test":"vitest run","prepublishOnly":"npm run build && npm test"},"keywords":["express","middleware","idempotency","idempotent","api","webhooks","payments"],"author":{"name":"Yevhen Mykhailenko"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/YevhenMykhailenko/idempotency-express.git"},"bugs":{"url":"https://github.com/YevhenMykhailenko/idempotency-express/issues"},"homepage":"https://github.com/YevhenMykhailenko/idempotency-express#readme","peerDependencies":{"express":"^4.18.0 || ^5.0.0"},"engines":{"node":">=18.0.0"},"devDependencies":{"@types/express":"^4.17.21","@types/node":"^20.0.0","@typescript-eslint/eslint-plugin":"^7.0.0","@typescript-eslint/parser":"^7.0.0","eslint":"^9.0.0","eslint-config-prettier":"^9.0.0","prettier":"^3.0.0","supertest":"^7.0.0","typescript":"^5.6.0","vitest":"^2.0.0"},"gitHead":"5b8b777255e5bfbb58255a9a2268900c6ba25649","_id":"express-idempotency-middleware@1.0.2","_nodeVersion":"22.13.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-4p+326Zv5ZejBFYgw9OXI7WTqx2NrkWOY8DQdpF3dDvBae0leO/LKMG+gpeYUS9kGKDvUm807n7PQUOkJRVjKg==","shasum":"856b1941749909e69fcb4f036f502c0d2ae09d32","tarball":"https://registry.npmjs.org/express-idempotency-middleware/-/express-idempotency-middleware-1.0.2.tgz","fileCount":59,"unpackedSize":88581,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIECMuDFDEellZEYKbU9sXyZqsBbEfdSfDcj+2VgoMk93AiEAjEFT8D5DjMWk8oKEqNK5feWfzaLRGis2F/7lPsRFjSo="}]},"_npmUser":{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"},"directories":{},"maintainers":[{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/express-idempotency-middleware_1.0.2_1767500513603_0.4093885598710263"},"_hasShrinkwrap":false}},"time":{"created":"2025-10-04T01:37:36.405Z","modified":"2026-01-04T04:21:53.969Z","1.0.0":"2025-10-04T01:37:36.599Z","1.0.1":"2025-10-04T01:47:44.106Z","1.0.2":"2026-01-04T04:21:53.754Z"},"bugs":{"url":"https://github.com/YevhenMykhailenko/idempotency-express/issues"},"author":{"name":"Yevhen Mykhailenko"},"license":"MIT","homepage":"https://github.com/YevhenMykhailenko/idempotency-express#readme","keywords":["express","middleware","idempotency","idempotent","api","webhooks","payments"],"repository":{"type":"git","url":"git+https://github.com/YevhenMykhailenko/idempotency-express.git"},"description":"Express middleware for idempotent POSTs via Idempotency-Key with in-flight control and pluggable stores.","maintainers":[{"name":"yevhen_mykhailenko","email":"yvn.mykh@gmail.com"}],"readme":"# express-idempotency-middleware\n\n[![npm version](https://img.shields.io/npm/v/express-idempotency-middleware.svg)](https://www.npmjs.com/package/express-idempotency-middleware)\n\nExpress middleware that makes **unsafe** HTTP requests (mainly `POST`) **idempotent** using an `Idempotency-Key`.\nThe first request executes your handler and caches `{status, body, headers(whitelist)}` for a TTL. Identical retries return the cached response. Conflicting payloads get `409 Conflict`. Concurrency is handled via `wait` or `reject` strategies.\n\n---\n\n## Highlights\n\n- **Drop-in** per-route middleware\n- **TypeScript-first**, ESM-only, Node ≥ 18\n- Pluggable **stores**: built-in Memory (dev). Example Redis/Postgres stores in `examples/`\n- **In-flight control**: `wait` (with timeout) or `reject`\n- **Safe replay** with **header whitelist** (never replays cookies/auth)\n- **Stable fingerprint**: method + path + normalized body + optional tenant/user\n- Designed for payments, orders, webhooks, and similar at-least-once scenarios\n\n---\n\n## Install\n\n```bash\nnpm i express-idempotency-middleware\n# peer\nnpm i express\n```\n\n> **ESM-only:** your project should use `\"type\": \"module\"` or native ESM (Node 18+).\n\n---\n\n## Quick Start\n\n```ts\nimport express from \"express\";\nimport { idempotencyMiddleware, MemoryStore } from \"express-idempotency-middleware\";\n\nconst app = express();\napp.use(express.json());\n\nconst store = new MemoryStore();\n\napp.post(\n  \"/payments\",\n  idempotencyMiddleware({\n    store,\n    ttlMs: 24 * 60 * 60 * 1000,\n    inFlight: { strategy: \"wait\", waitTimeoutMs: 3000, pollMs: 100 },\n    replay: { headerWhitelist: [\"location\"] }\n  }),\n  async (req, res) => {\n    // your business logic\n    const orderId = \"ord_\" + Math.random().toString(36).slice(2);\n    res.setHeader(\"Location\", `/orders/${orderId}`);\n    res.status(201).json({ orderId });\n  }\n);\n\napp.listen(3000);\n```\n\n**Client header:**\n\n```\nIdempotency-Key: <uuid-v4>\n```\n\n---\n\n## Examples / Usage\n\nThis repo ships with runnable examples under `examples/`. The quickest way to try the middleware is the **basic Express server**.\n\n### Run the example (from this repo)\n\n```bash\nnpm i\nnpm run build\nnode dist/examples/server-basic.js\n# Server: http://localhost:3000\n```\n\n### 1) First request (create)\n\n```bash\ncurl -i -X POST http://localhost:3000/payments   -H \"Content-Type: application/json\"   -H \"Idempotency-Key: key-123\"   -d '{\"amount\":100}'\n```\n\n**Expected:**\n\n- `HTTP/1.1 201 Created`\n- `Idempotency-Status: created`\n- Body: `{\"orderId\":\"...\"}`\n\n### 2) Replay — same key & same payload\n\n```bash\ncurl -i -X POST http://localhost:3000/payments   -H \"Content-Type: application/json\"   -H \"Idempotency-Key: key-123\"   -d '{\"amount\":100}'\n```\n\n**Expected:**\n\n- `HTTP/1.1 201 Created` (same status as the first response)\n- `Idempotency-Status: cached`\n- `Idempotency-Replayed: true`\n- `Content-Type: application/json; charset=utf-8`\n- Body: **identical** to the first response (same `orderId`)\n\n### 3) Conflict — same key, different payload\n\n```bash\ncurl -i -X POST http://localhost:3000/payments   -H \"Content-Type: application/json\"   -H \"Idempotency-Key: key-123\"   -d '{\"amount\":200}'\n```\n\n**Expected:**\n\n- `HTTP/1.1 409 Conflict`\n- `Idempotency-Status: conflict`\n\n### 4) In-flight duplicates (concurrency)\n\nThe example route simulates ~300 ms of work and uses `inFlight: { strategy: \"wait\", waitTimeoutMs: 3000 }`.\nOpen two terminals and run the same request with the same key as fast as possible.\nThe second request will **wait** and return the cached result:\n\n- `Idempotency-Status: cached`\n- `Idempotency-Replayed: true`\n\n---\n\n## API\n\n```ts\nimport type { RequestHandler, Request } from \"express\";\n\nfunction idempotencyMiddleware(options: IdemOptions): RequestHandler;\n\nexport type IdemOptions = {\n  store: Store;\n  ttlMs?: number;                // default 24h\n  methods?: string[];            // default [\"POST\"]\n  keyHeader?: string;            // default \"Idempotency-Key\"\n  requireKey?: boolean;          // default false (400 if true and missing)\n  inFlight?: {                   // default {strategy: \"reject\"}\n    strategy: \"wait\" | \"reject\";\n    waitTimeoutMs?: number;      // default 5000\n    pollMs?: number;             // default 100\n  };\n  fingerprint?: {\n    includeQuery?: boolean;      // default false\n    maxBodyBytes?: number;       // default 64KB\n    custom?: (req: Request) => string | undefined; // e.g., tenant/user id\n  };\n  replay?: {\n    headerWhitelist?: string[];  // lowercase names, e.g., [\"location\"]\n  };\n};\n```\n\n### Store Interface\n\n```ts\nexport type CachedResponse = {\n  status: number;\n  body: string | Buffer;\n  headers: Record<string, string | string[]>;\n  fingerprint: string;\n  createdAt: number;\n};\n\nexport interface Store {\n  begin(key: string, fp: string, ttlMs: number): Promise<\n    | { kind: \"started\" }\n    | { kind: \"replay\"; cached: CachedResponse }\n    | { kind: \"conflict\" }\n    | { kind: \"inflight\" }\n  >;\n  commit(key: string, data: CachedResponse): Promise<void>;\n  get(key: string): Promise<CachedResponse | null>;\n}\n```\n\n---\n\n## Behavior & Headers\n\n- Adds response headers:\n  - `Idempotency-Key`: echoes the key\n  - `Idempotency-Status`: `created | cached | conflict | inflight | inflight-timeout | missing-key`\n  - `Idempotency-Replayed`: `true | false`\n  - On in-flight timeout or reject: `Retry-After: 1`\n- **Replay headers**: only those in `replay.headerWhitelist` are replayed, plus `content-type` is always replayed.\n  Sensitive headers (`set-cookie`, `authorization`, `www-authenticate`, `proxy-*`) are **never** replayed.\n\n---\n\n## Using Redis / Postgres (examples)\n\n`Redis` and `Postgres` stores are provided as **examples** (no hard deps).\nSee `examples/redis-store.ts` and `examples/postgres-store.ts` for sketches.\n\n**Typical approach (Redis sketch):**\n```ts\n// requires: npm i redis\n// import { createClient } from \"redis\";\n// const client = createClient({ url: process.env.REDIS_URL });\n// await client.connect();\n\nimport type { Store, CachedResponse } from \"express-idempotency-middleware\";\n\nclass RedisStore implements Store {\n  async begin(key: string, fp: string, ttlMs: number) {\n    // Use SETNX + PX (or a Lua script) to atomically claim the key\n    // Return: {kind:\"started\"} | {kind:\"replay\", cached} | {kind:\"conflict\"} | {kind:\"inflight\"}\n    return { kind: \"started\" };\n  }\n  async commit(key: string, data: CachedResponse) {\n    // Persist final response (JSON + keep TTL)\n  }\n  async get(key: string) {\n    // Read cached response (if any)\n    return null;\n  }\n}\n```\n\n**Typical approach (Postgres sketch):**\n```sql\n-- One possible schema (sketch)\nCREATE TABLE idem_keys (\n  key text PRIMARY KEY,\n  fp text NOT NULL,\n  state text NOT NULL CHECK (state IN ('inflight','done')),\n  created_at timestamptz NOT NULL DEFAULT now(),\n  expiry timestamptz NOT NULL,\n  status int,\n  headers jsonb,\n  body bytea\n);\nCREATE INDEX ON idem_keys (expiry);\n```\n```ts\n// Use INSERT ... ON CONFLICT to claim/update atomically inside a transaction.\n```\n\n> Keep TTL moderate (hours). Store only safe headers. Avoid caching 5xx responses.\n\n---\n\n## Best Practices\n\n- Generate the key **client-side** (UUID v4) per unsafe request\n- Fingerprint only what’s necessary (method, path, normalized body, tenant/user)\n- Use a **centralized store** (Redis/PG) in production; MemoryStore is for dev/tests\n- Whitelist only **safe headers** to replay (e.g., `location`); `content-type` is always replayed\n- Keep TTL short (hours, not days). Consider background cleanup for SQL stores\n- For webhooks, prefer provider event IDs (e.g., Stripe `event.id`) as the idempotency key\n\n---\n\n## Limitations\n\n- Not designed for streaming responses or long-running jobs\n  For multi-minute operations, prefer queues/outbox + status resources\n- MemoryStore is single-process only and volatile; use Redis/PG in production\n\n---\n\n## Troubleshooting\n\n- **`ERR_MODULE_NOT_FOUND` after build**\n  Ensure compiled imports include explicit `.js` extensions and your `package.json` `exports` point to `./dist/src/index.js`.\n\n- **Second request shows `created` instead of `cached`**\n  Ensure you’re on a version where the MemoryStore uses a sane fallback TTL and the middleware commits on `finish`.\n\n- **`r2.body` is `{}` or a JSON string in tests**\n  Make sure `Content-Type: application/json` is set and that replay includes `content-type` (the middleware always replays it by default).\n\n---\n\n## Development (this repo)\n\n```bash\nnpm i\nnpm run build\nnpm test\n# Example server (after build):\nnode dist/examples/server-basic.js\n```\n\n---\n\n## License\n\nMIT\n","readmeFilename":"README.md"}