@masumraihan/idempotency-shield 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # @http-state/idempotency-shield
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Makes mutation requests (payments, orders, record creation) safe to retry. A request that carries an
4
+ `Idempotency-Key` header is processed exactly once, even if the client retries after a network failure or a
5
+ double click. The behavior follows the IETF `Idempotency-Key` HTTP header draft.
6
+
7
+ - Works with **Express** (4 and 5) and **NestJS** (tested with 11 and 12)
8
+ - Requires **Node.js 20 or newer** (tested on 20.0, 20.x, 22, 24 and 26)
9
+ - No runtime dependencies
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @http-state/idempotency-shield
15
+ ```
16
+
17
+ ## Express
18
+
19
+ ```ts
20
+ import express from "express";
21
+ import { idempotencyShield } from "@http-state/idempotency-shield";
22
+
23
+ const app = express();
24
+ app.use(express.json()); // register body parsers first so the payload is part of the check
25
+ app.use(idempotencyShield({ ttlMs: 5 * 60 * 1000 }));
26
+ ```
27
+
28
+ ## NestJS
29
+
30
+ NestJS users import from the `/nest` entry point, so Express-only apps never load `@nestjs/*`.
31
+
32
+ ```ts
33
+ import { Controller, Post, Body } from "@nestjs/common";
34
+ import { Idempotent } from "@http-state/idempotency-shield/nest";
35
+
36
+ @Controller("payments")
37
+ export class PaymentsController {
38
+ @Post()
39
+ @Idempotent({ ttlMs: 5 * 60 * 1000 }) // also registers the interceptor
40
+ create(@Body() dto: CreatePaymentDto) {
41
+ /* ... */
42
+ }
43
+ }
44
+ ```
45
+
46
+ To share one store (for example Redis) across the app, provide it under the `IDEMPOTENCY_STORAGE` token.
47
+
48
+ ## How it works
49
+
50
+ Only `POST`, `PUT` and `PATCH` are guarded. Other methods pass straight through, and so do requests
51
+ without an `Idempotency-Key` header.
52
+
53
+ ```
54
+ Idempotency-Key present?
55
+ ├── no → run the handler normally
56
+ └── yes → claim the key atomically
57
+ ├── claimed → status STARTED → run handler → save response → status COMPLETED
58
+ ├── STARTED → 409 Conflict (same request still in progress)
59
+ ├── COMPLETED → replay the saved response (header X-Cache-Lookup: HIT - Idempotent)
60
+ └── different payload for this key → 422 Unprocessable Entity
61
+ ```
62
+
63
+ 1. **Claim (lock).** The first request atomically creates a `STARTED` record. A duplicate that arrives while
64
+ the first is still running sees `STARTED` and receives `409`. Because the claim is a single atomic
65
+ `setIfAbsent`, two simultaneous requests can never both run.
66
+ 2. **Execute.** The handler runs normally. On Express the response is recorded at `res.write` / `res.end`, so
67
+ `res.send`, `res.json`, streamed and binary responses are all covered. On NestJS the handler's return value is recorded.
68
+ 3. **Memoize.** The status code and body (plus response headers on Express) are saved and the record becomes `COMPLETED` for `ttlMs`.
69
+ 4. **Replay.** A retry with the same key skips your business logic and returns the saved response with the
70
+ original status code.
71
+
72
+ ### Failures
73
+
74
+ | Situation | What happens |
75
+ | --- | --- |
76
+ | Server crashes while processing | The key stays `STARTED` until `lockTtlMs` expires (default: `ttlMs`, at most 60 s). After that the client can retry. |
77
+ | Handler throws or returns an error | The key is released immediately so the client can fix the problem and retry. |
78
+ | 4xx / 5xx response (Express) | Discarded by default. Pass `cacheErrors: true` to replay 4xx/5xx responses as well. |
79
+ | Same key, different payload | `422`. The fingerprint covers method, URL and body. |
80
+ | Key is empty, longer than 255 characters or sent twice | `400`. |
81
+ | Response larger than `maxBodyBytes` (Express, default 1 MiB) | Not cached and the key is released. |
82
+ | Storage fails while saving the result | The response is still sent and the error is passed to `onError`. |
83
+
84
+ ## Options
85
+
86
+ | Option | Default | Description |
87
+ | --- | --- | --- |
88
+ | `ttlMs` | `300000` | How long a completed response can be replayed. |
89
+ | `lockTtlMs` | `min(ttlMs, 60000)` | How long the in-progress lock is held. |
90
+ | `storage` | shared in-memory store | Any object implementing `IdempotencyStore`. |
91
+ | `scope` | none | `(req) => string`. Namespaces keys, usually by user id, so users can never read each other's cached responses. |
92
+ | `onError` | ignore | Called when the store fails after a response. |
93
+ | `cacheErrors` | `false` | Express only. Cache responses with status 400 and above. |
94
+ | `maxBodyBytes` | `1048576` | Express only. Largest response that will be cached. |
95
+
96
+ ## Production storage (Redis)
97
+
98
+ The default store lives in the memory of one process. With several instances, or after a restart, a retry can
99
+ reach a server that has never seen the key and the operation runs twice. Give every instance the same Redis
100
+ store instead. The package ships `RedisIdempotencyStore`, which works with both `ioredis` and `redis`
101
+ (node-redis v4+). Neither library is a dependency of this package: you pass in your own client.
102
+
103
+ ```ts
104
+ import Redis from "ioredis"; // or: import { createClient } from "redis";
105
+ import { idempotencyShield, RedisIdempotencyStore } from "@http-state/idempotency-shield";
106
+
107
+ const redis = new Redis(process.env.REDIS_URL);
108
+ app.use(idempotencyShield({ storage: new RedisIdempotencyStore(redis) }));
109
+ ```
110
+
111
+ With node-redis, connect the client first (`await client.connect()`).
112
+ For NestJS, provide the same store under the `IDEMPOTENCY_STORAGE` token.
113
+
114
+ The lock is one atomic `SET key value PX ttl NX`, so simultaneous requests on different servers still produce
115
+ exactly one winner. Keys are prefixed with `idempotency:` (change it with `keyPrefix`) and expire through the
116
+ Redis TTL, so nothing needs cleaning up.
117
+
118
+ To use another database, implement `IdempotencyStore`. Its `setIfAbsent` must be atomic.
119
+
120
+ ## Notes
121
+
122
+ - Register the Express middleware after body parsers.
123
+ - Replayed responses do not include `Set-Cookie`, `Date`, `Content-Length` or connection-level headers.
124
+ - The NestJS interceptor is tested on the Express platform. Fastify is not covered by the tests.
125
+
126
+ ## Running the tests
127
+
128
+ ```bash
129
+ npm install
130
+ npm test
131
+ ```
132
+
133
+ The Redis tests need a Redis server. They use an installed `redis-server` if there is one. Otherwise point them at
134
+ a running Redis with `REDIS_URL`; the tests only create and remove keys starting with `idem-test-`:
135
+
136
+ ```bash
137
+ # bash / Git Bash
138
+ REDIS_URL=redis://127.0.0.1:6379 npm test
139
+
140
+ # PowerShell
141
+ $env:REDIS_URL="redis://127.0.0.1:6379"; npm test
142
+ ```
143
+
144
+ For a disposable Redis, use `docker run --rm -p 6390:6379 redis:7-alpine` and
145
+ `REDIS_URL=redis://127.0.0.1:6390`. Without Redis, the Redis tests are skipped and everything else still runs.
package/dist/core.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { IdempotencyStore, IdempotentRecord } from "./storage";
2
+ export declare const IDEMPOTENCY_KEY_HEADER = "idempotency-key";
3
+ export declare const CACHE_HIT_HEADER = "X-Cache-Lookup";
4
+ export declare const CACHE_HIT_VALUE = "HIT - Idempotent";
5
+ /** Only methods that are not idempotent by definition are guarded. */
6
+ export declare const GUARDED_METHODS: ReadonlySet<string>;
7
+ export declare const DEFAULT_TTL_MS: number;
8
+ export declare const MESSAGES: {
9
+ readonly invalidKey: "The Idempotency-Key header must be a single non-empty value of at most 255 characters.";
10
+ readonly inProgress: "A request with this Idempotency-Key is already in progress.";
11
+ readonly mismatch: "This Idempotency-Key was already used with a different request.";
12
+ };
13
+ export interface IdempotencyOptions<Req = any> {
14
+ /** How long a completed response is kept for replay. Default: 5 minutes. */
15
+ ttlMs?: number | undefined;
16
+ /**
17
+ * How long the in-progress lock is held. It is the window during which a crashed
18
+ * request blocks retries. Default: the smaller of `ttlMs` and 60 seconds.
19
+ */
20
+ lockTtlMs?: number | undefined;
21
+ /** Where records are kept. Default: a shared in-memory store. */
22
+ storage?: IdempotencyStore | undefined;
23
+ /**
24
+ * Returns a namespace for the key, typically the authenticated user id, so that
25
+ * different users can never read each other's cached responses.
26
+ */
27
+ scope?: ((req: Req) => string | undefined) | undefined;
28
+ /** Called when the store fails while saving or releasing a record after a response. */
29
+ onError?: ((error: unknown) => void) | undefined;
30
+ }
31
+ export interface ResolvedOptions<Req = any> {
32
+ ttlMs: number;
33
+ lockTtlMs: number;
34
+ storage: IdempotencyStore;
35
+ scope: ((req: Req) => string | undefined) | undefined;
36
+ onError: (error: unknown) => void;
37
+ }
38
+ export declare function resolveOptions<Req>(options: IdempotencyOptions<Req>, fallbackStorage?: IdempotencyStore): ResolvedOptions<Req>;
39
+ export type KeyResult = {
40
+ ok: true;
41
+ key: string | undefined;
42
+ } | {
43
+ ok: false;
44
+ message: string;
45
+ };
46
+ /** Validates the raw header value. A missing header is valid and means "not idempotent". */
47
+ export declare function readIdempotencyKey(value: string | string[] | undefined): KeyResult;
48
+ /** Builds the storage key. The scope is URI-encoded so "a:b" + "c" never equals "a" + "b:c". */
49
+ export declare function buildStoreKey(scope: string | undefined, key: string): string;
50
+ /** Hashes the parts of a request that must be identical when a key is reused. */
51
+ export declare function createFingerprint(method: string, url: string, body: unknown): string;
52
+ export type BeginResult = {
53
+ kind: "acquired";
54
+ } | {
55
+ kind: "in-progress";
56
+ } | {
57
+ kind: "mismatch";
58
+ } | {
59
+ kind: "replay";
60
+ record: IdempotentRecord;
61
+ };
62
+ /**
63
+ * Atomically claims the key, or reports what is already stored for it.
64
+ * Reading and writing as two separate steps would let two simultaneous requests
65
+ * both pass the check, so the claim always goes through `setIfAbsent`.
66
+ */
67
+ export declare function beginRequest(storage: IdempotencyStore, storeKey: string, fingerprint: string, lockTtlMs: number): Promise<BeginResult>;
68
+ export declare function pickReplayHeaders(headers: Record<string, number | string | string[] | undefined>): Record<string, string | string[]>;
69
+ //# sourceMappingURL=core.d.ts.map
package/dist/core.js ADDED
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MESSAGES = exports.DEFAULT_TTL_MS = exports.GUARDED_METHODS = exports.CACHE_HIT_VALUE = exports.CACHE_HIT_HEADER = exports.IDEMPOTENCY_KEY_HEADER = void 0;
4
+ exports.resolveOptions = resolveOptions;
5
+ exports.readIdempotencyKey = readIdempotencyKey;
6
+ exports.buildStoreKey = buildStoreKey;
7
+ exports.createFingerprint = createFingerprint;
8
+ exports.beginRequest = beginRequest;
9
+ exports.pickReplayHeaders = pickReplayHeaders;
10
+ const node_crypto_1 = require("node:crypto");
11
+ const storage_1 = require("./storage");
12
+ exports.IDEMPOTENCY_KEY_HEADER = "idempotency-key";
13
+ exports.CACHE_HIT_HEADER = "X-Cache-Lookup";
14
+ exports.CACHE_HIT_VALUE = "HIT - Idempotent";
15
+ /** Only methods that are not idempotent by definition are guarded. */
16
+ exports.GUARDED_METHODS = new Set(["POST", "PUT", "PATCH"]);
17
+ exports.DEFAULT_TTL_MS = 5 * 60 * 1000;
18
+ const DEFAULT_LOCK_TTL_MS = 60 * 1000;
19
+ const MAX_KEY_LENGTH = 255;
20
+ exports.MESSAGES = {
21
+ invalidKey: `The Idempotency-Key header must be a single non-empty value of at most ${MAX_KEY_LENGTH} characters.`,
22
+ inProgress: "A request with this Idempotency-Key is already in progress.",
23
+ mismatch: "This Idempotency-Key was already used with a different request.",
24
+ };
25
+ function assertDuration(name, value) {
26
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
27
+ throw new TypeError(`${name} must be a positive number of milliseconds.`);
28
+ }
29
+ }
30
+ function resolveOptions(options, fallbackStorage) {
31
+ const ttlMs = options.ttlMs ?? exports.DEFAULT_TTL_MS;
32
+ assertDuration("ttlMs", ttlMs);
33
+ const lockTtlMs = options.lockTtlMs ?? Math.min(ttlMs, DEFAULT_LOCK_TTL_MS);
34
+ assertDuration("lockTtlMs", lockTtlMs);
35
+ return {
36
+ ttlMs,
37
+ lockTtlMs,
38
+ storage: options.storage ?? fallbackStorage ?? (0, storage_1.getDefaultStore)(),
39
+ scope: options.scope,
40
+ onError: options.onError ?? (() => undefined),
41
+ };
42
+ }
43
+ /** Validates the raw header value. A missing header is valid and means "not idempotent". */
44
+ function readIdempotencyKey(value) {
45
+ if (value === undefined)
46
+ return { ok: true, key: undefined };
47
+ if (typeof value !== "string")
48
+ return { ok: false, message: exports.MESSAGES.invalidKey };
49
+ const key = value.trim();
50
+ if (key.length === 0 || key.length > MAX_KEY_LENGTH) {
51
+ return { ok: false, message: exports.MESSAGES.invalidKey };
52
+ }
53
+ return { ok: true, key };
54
+ }
55
+ /** Builds the storage key. The scope is URI-encoded so "a:b" + "c" never equals "a" + "b:c". */
56
+ function buildStoreKey(scope, key) {
57
+ return scope ? `${encodeURIComponent(scope)}:${key}` : key;
58
+ }
59
+ /** Hashes the parts of a request that must be identical when a key is reused. */
60
+ function createFingerprint(method, url, body) {
61
+ const hash = (0, node_crypto_1.createHash)("sha256");
62
+ hash.update(`${method.toUpperCase()}\n${url}\n`);
63
+ if (Buffer.isBuffer(body) || typeof body === "string") {
64
+ hash.update(body);
65
+ }
66
+ else if (body !== undefined && body !== null) {
67
+ try {
68
+ hash.update(JSON.stringify(body) ?? "");
69
+ }
70
+ catch {
71
+ // Unserializable bodies (for example circular objects) only count by method and URL.
72
+ }
73
+ }
74
+ return hash.digest("hex");
75
+ }
76
+ /**
77
+ * Atomically claims the key, or reports what is already stored for it.
78
+ * Reading and writing as two separate steps would let two simultaneous requests
79
+ * both pass the check, so the claim always goes through `setIfAbsent`.
80
+ */
81
+ async function beginRequest(storage, storeKey, fingerprint, lockTtlMs) {
82
+ const lock = { status: "STARTED", fingerprint };
83
+ // A second attempt covers a record that expires between the claim and the read.
84
+ for (let attempt = 0; attempt < 2; attempt++) {
85
+ if (await storage.setIfAbsent(storeKey, lock, lockTtlMs))
86
+ return { kind: "acquired" };
87
+ const existing = await storage.get(storeKey);
88
+ if (!existing)
89
+ continue;
90
+ if (existing.fingerprint !== undefined && existing.fingerprint !== fingerprint) {
91
+ return { kind: "mismatch" };
92
+ }
93
+ if (existing.status === "STARTED")
94
+ return { kind: "in-progress" };
95
+ return { kind: "replay", record: existing };
96
+ }
97
+ return { kind: "in-progress" };
98
+ }
99
+ /** Headers that describe the original connection or message framing and must not be replayed. */
100
+ const SKIPPED_HEADERS = new Set([
101
+ "connection",
102
+ "content-length",
103
+ "date",
104
+ "keep-alive",
105
+ "set-cookie",
106
+ "transfer-encoding",
107
+ "x-cache-lookup",
108
+ ]);
109
+ function pickReplayHeaders(headers) {
110
+ const picked = {};
111
+ for (const [name, value] of Object.entries(headers)) {
112
+ if (value === undefined || SKIPPED_HEADERS.has(name.toLowerCase()))
113
+ continue;
114
+ picked[name] = Array.isArray(value) ? value : String(value);
115
+ }
116
+ return picked;
117
+ }
118
+ //# sourceMappingURL=core.js.map
@@ -0,0 +1,17 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+ import type { IdempotencyOptions } from "./core";
3
+ export interface ExpressIdempotencyOptions extends IdempotencyOptions<Request> {
4
+ /**
5
+ * Cache responses with a status of 400 or above too. By default they are discarded
6
+ * so the client can fix the problem and retry with the same key. Default: false.
7
+ */
8
+ cacheErrors?: boolean | undefined;
9
+ /** Responses larger than this are not cached and the key is released. Default: 1 MiB. */
10
+ maxBodyBytes?: number | undefined;
11
+ }
12
+ /**
13
+ * Express middleware that makes POST, PUT and PATCH requests safe to retry.
14
+ * Register it after body parsers so the request body is part of the fingerprint.
15
+ */
16
+ export declare function idempotencyShield(options?: ExpressIdempotencyOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
17
+ //# sourceMappingURL=express-mw.d.ts.map
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.idempotencyShield = idempotencyShield;
4
+ const core_1 = require("./core");
5
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
6
+ /**
7
+ * Express middleware that makes POST, PUT and PATCH requests safe to retry.
8
+ * Register it after body parsers so the request body is part of the fingerprint.
9
+ */
10
+ function idempotencyShield(options = {}) {
11
+ const resolved = (0, core_1.resolveOptions)(options);
12
+ const cacheErrors = options.cacheErrors ?? false;
13
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
14
+ return async function idempotencyShieldMiddleware(req, res, next) {
15
+ try {
16
+ await handle(req, res, next, resolved, cacheErrors, maxBodyBytes);
17
+ }
18
+ catch (error) {
19
+ // A failing store is treated as an error rather than skipping the protection.
20
+ next(error);
21
+ }
22
+ };
23
+ }
24
+ async function handle(req, res, next, options, cacheErrors, maxBodyBytes) {
25
+ if (!core_1.GUARDED_METHODS.has(req.method))
26
+ return next();
27
+ const parsed = (0, core_1.readIdempotencyKey)(req.headers[core_1.IDEMPOTENCY_KEY_HEADER]);
28
+ if (!parsed.ok) {
29
+ res.status(400).json({ message: parsed.message });
30
+ return;
31
+ }
32
+ if (parsed.key === undefined)
33
+ return next();
34
+ const storeKey = (0, core_1.buildStoreKey)(options.scope?.(req), parsed.key);
35
+ const fingerprint = (0, core_1.createFingerprint)(req.method, req.originalUrl || req.url, req.body);
36
+ const state = await (0, core_1.beginRequest)(options.storage, storeKey, fingerprint, options.lockTtlMs);
37
+ switch (state.kind) {
38
+ case "mismatch":
39
+ res.status(422).json({ message: core_1.MESSAGES.mismatch });
40
+ return;
41
+ case "in-progress":
42
+ res.status(409).json({ message: core_1.MESSAGES.inProgress });
43
+ return;
44
+ case "replay":
45
+ replay(res, state.record);
46
+ return;
47
+ case "acquired":
48
+ captureResponse(res, options, storeKey, fingerprint, cacheErrors, maxBodyBytes);
49
+ return next();
50
+ }
51
+ }
52
+ function replay(res, record) {
53
+ res.status(record.statusCode ?? 200);
54
+ for (const [name, value] of Object.entries(record.headers ?? {})) {
55
+ res.setHeader(name, value);
56
+ }
57
+ res.setHeader(core_1.CACHE_HIT_HEADER, core_1.CACHE_HIT_VALUE);
58
+ if (record.body === undefined) {
59
+ res.end();
60
+ }
61
+ else {
62
+ res.send(record.bodyEncoding === "base64" ? Buffer.from(record.body, "base64") : record.body);
63
+ }
64
+ }
65
+ /**
66
+ * Wraps res.write and res.end to record the response. Hooking the lowest level
67
+ * covers res.send, res.json, res.sendFile and streamed responses alike.
68
+ */
69
+ function captureResponse(res, options, storeKey, fingerprint, cacheErrors, maxBodyBytes) {
70
+ const chunks = [];
71
+ let size = 0;
72
+ let tooLarge = false;
73
+ let finished = false;
74
+ const collect = (chunk, encoding) => {
75
+ if (tooLarge || chunk === undefined || chunk === null || typeof chunk === "function")
76
+ return;
77
+ const buffer = typeof chunk === "string"
78
+ ? Buffer.from(chunk, typeof encoding === "string" ? encoding : "utf8")
79
+ : Buffer.isBuffer(chunk)
80
+ ? chunk
81
+ : Buffer.from(chunk);
82
+ size += buffer.length;
83
+ if (size > maxBodyBytes) {
84
+ tooLarge = true;
85
+ chunks.length = 0;
86
+ return;
87
+ }
88
+ chunks.push(buffer);
89
+ };
90
+ const settle = () => {
91
+ const statusCode = res.statusCode;
92
+ const cacheable = !tooLarge && (statusCode < 400 || cacheErrors);
93
+ // Fire and forget: the response must not wait on the store, and a store
94
+ // failure must never break the response that is being sent.
95
+ try {
96
+ const task = cacheable
97
+ ? options.storage.set(storeKey, buildRecord(statusCode, fingerprint, res.getHeaders(), Buffer.concat(chunks)), options.ttlMs)
98
+ : options.storage.delete(storeKey);
99
+ Promise.resolve(task).catch(options.onError);
100
+ }
101
+ catch (error) {
102
+ options.onError(error);
103
+ }
104
+ };
105
+ const originalWrite = res.write;
106
+ const originalEnd = res.end;
107
+ res.write = function (...args) {
108
+ collect(args[0], args[1]);
109
+ return originalWrite.apply(this, args);
110
+ };
111
+ res.end = function (...args) {
112
+ collect(args[0], args[1]);
113
+ if (!finished) {
114
+ finished = true;
115
+ settle();
116
+ }
117
+ return originalEnd.apply(this, args);
118
+ };
119
+ }
120
+ function buildRecord(statusCode, fingerprint, headers, body) {
121
+ const record = {
122
+ status: "COMPLETED",
123
+ fingerprint,
124
+ statusCode,
125
+ headers: (0, core_1.pickReplayHeaders)(headers),
126
+ };
127
+ if (body.length > 0) {
128
+ // Keep text readable in the store, and fall back to base64 for binary payloads.
129
+ const text = body.toString("utf8");
130
+ const isText = Buffer.from(text, "utf8").equals(body);
131
+ record.body = isText ? text : body.toString("base64");
132
+ record.bodyEncoding = isText ? "utf8" : "base64";
133
+ }
134
+ return record;
135
+ }
136
+ //# sourceMappingURL=express-mw.js.map
@@ -0,0 +1,5 @@
1
+ export * from "./storage";
2
+ export * from "./redis-store";
3
+ export * from "./express-mw";
4
+ export type { IdempotencyOptions } from "./core";
5
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // The Express entry point. NestJS users import from "<package>/nest", which keeps
18
+ // the @nestjs/* packages from being required by applications that do not use them.
19
+ __exportStar(require("./storage"), exports);
20
+ __exportStar(require("./redis-store"), exports);
21
+ __exportStar(require("./express-mw"), exports);
22
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,24 @@
1
+ import type { CallHandler, ExecutionContext, NestInterceptor } from "@nestjs/common";
2
+ import { Reflector } from "@nestjs/core";
3
+ import { Observable } from "rxjs";
4
+ import type { IdempotencyOptions } from "./core";
5
+ import type { IdempotencyStore } from "./storage";
6
+ export declare const IDEMPOTENT_KEY = "idempotent_options";
7
+ /** Injection token for providing a shared store (for example Redis) to the interceptor. */
8
+ export declare const IDEMPOTENCY_STORAGE = "IDEMPOTENCY_STORAGE";
9
+ export type IdempotentOptions = IdempotencyOptions<any>;
10
+ /**
11
+ * Enables idempotency for a controller method or a whole controller.
12
+ * It also registers the interceptor, so no separate @UseInterceptors is needed.
13
+ */
14
+ export declare const Idempotent: (options?: IdempotentOptions) => <TFunction extends Function, Y>(target: TFunction | object, propertyKey?: string | symbol, descriptor?: TypedPropertyDescriptor<Y>) => void;
15
+ export declare class IdempotencyInterceptor implements NestInterceptor {
16
+ private readonly reflector;
17
+ private readonly storage?;
18
+ constructor(reflector: Reflector, storage?: IdempotencyStore | undefined);
19
+ intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>>;
20
+ private replay;
21
+ private complete;
22
+ private release;
23
+ }
24
+ //# sourceMappingURL=nest-decorator.d.ts.map
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
9
+ return function (target, key) { decorator(target, key, paramIndex); }
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.IdempotencyInterceptor = exports.Idempotent = exports.IDEMPOTENCY_STORAGE = exports.IDEMPOTENT_KEY = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@nestjs/core");
15
+ const rxjs_1 = require("rxjs");
16
+ const core_2 = require("./core");
17
+ exports.IDEMPOTENT_KEY = "idempotent_options";
18
+ /** Injection token for providing a shared store (for example Redis) to the interceptor. */
19
+ exports.IDEMPOTENCY_STORAGE = "IDEMPOTENCY_STORAGE";
20
+ /** Metadata key Nest uses for @HttpCode(). */
21
+ const HTTP_CODE_METADATA = "__httpCode__";
22
+ /** Marks requests already handled so that registering the interceptor twice is harmless. */
23
+ const HANDLED = Symbol("idempotency-shield.handled");
24
+ /**
25
+ * Enables idempotency for a controller method or a whole controller.
26
+ * It also registers the interceptor, so no separate @UseInterceptors is needed.
27
+ */
28
+ const Idempotent = (options = {}) => (0, common_1.applyDecorators)((0, common_1.SetMetadata)(exports.IDEMPOTENT_KEY, options), (0, common_1.UseInterceptors)(IdempotencyInterceptor));
29
+ exports.Idempotent = Idempotent;
30
+ let IdempotencyInterceptor = class IdempotencyInterceptor {
31
+ reflector;
32
+ storage;
33
+ constructor(reflector, storage) {
34
+ this.reflector = reflector;
35
+ this.storage = storage;
36
+ }
37
+ async intercept(context, next) {
38
+ if (context.getType() !== "http")
39
+ return next.handle();
40
+ const http = context.switchToHttp();
41
+ const request = http.getRequest();
42
+ const response = http.getResponse();
43
+ if (!core_2.GUARDED_METHODS.has(request.method) || request[HANDLED])
44
+ return next.handle();
45
+ const parsed = (0, core_2.readIdempotencyKey)(request.headers?.[core_2.IDEMPOTENCY_KEY_HEADER]);
46
+ if (!parsed.ok)
47
+ throw new common_1.BadRequestException(parsed.message);
48
+ if (parsed.key === undefined)
49
+ return next.handle();
50
+ const key = parsed.key;
51
+ request[HANDLED] = true;
52
+ const decorated = this.reflector.getAllAndOverride(exports.IDEMPOTENT_KEY, [context.getHandler(), context.getClass()]);
53
+ const options = (0, core_2.resolveOptions)(decorated ?? {}, this.storage);
54
+ const storeKey = (0, core_2.buildStoreKey)(options.scope?.(request), key);
55
+ const fingerprint = (0, core_2.createFingerprint)(request.method, request.originalUrl ?? request.url, request.body);
56
+ const state = await (0, core_2.beginRequest)(options.storage, storeKey, fingerprint, options.lockTtlMs);
57
+ switch (state.kind) {
58
+ case "mismatch":
59
+ throw new common_1.UnprocessableEntityException(core_2.MESSAGES.mismatch);
60
+ case "in-progress":
61
+ throw new common_1.ConflictException(core_2.MESSAGES.inProgress);
62
+ case "replay":
63
+ return (0, rxjs_1.of)(this.replay(response, state.record));
64
+ case "acquired":
65
+ break;
66
+ }
67
+ // Nest applies the final status code after interceptors run, so work it out here.
68
+ const statusCode = this.reflector.get(HTTP_CODE_METADATA, context.getHandler()) ??
69
+ (response.statusCode && response.statusCode !== 200
70
+ ? response.statusCode
71
+ : request.method === "POST"
72
+ ? 201
73
+ : 200);
74
+ let emitted = false;
75
+ return next.handle().pipe((0, rxjs_1.mergeMap)((data) => {
76
+ emitted = true;
77
+ return (0, rxjs_1.from)(this.complete(options, storeKey, fingerprint, statusCode, data)).pipe((0, rxjs_1.map)(() => data));
78
+ }), (0, rxjs_1.tap)({
79
+ // A handler that completes without a value leaves nothing to replay.
80
+ complete: () => {
81
+ if (!emitted)
82
+ void this.release(options, storeKey);
83
+ },
84
+ }), (0, rxjs_1.catchError)((error) => (0, rxjs_1.from)(this.release(options, storeKey)).pipe((0, rxjs_1.mergeMap)(() => (0, rxjs_1.throwError)(() => error)))));
85
+ }
86
+ replay(response, record) {
87
+ if (typeof record.statusCode === "number")
88
+ response.status(record.statusCode);
89
+ if (typeof response.setHeader === "function") {
90
+ response.setHeader(core_2.CACHE_HIT_HEADER, core_2.CACHE_HIT_VALUE);
91
+ }
92
+ else if (typeof response.header === "function") {
93
+ response.header(core_2.CACHE_HIT_HEADER, core_2.CACHE_HIT_VALUE);
94
+ }
95
+ return record.body === undefined ? undefined : JSON.parse(record.body);
96
+ }
97
+ async complete(options, storeKey, fingerprint, statusCode, data) {
98
+ try {
99
+ const serialized = serialize(data);
100
+ if (!serialized.cacheable) {
101
+ await options.storage.delete(storeKey);
102
+ return;
103
+ }
104
+ await options.storage.set(storeKey, {
105
+ status: "COMPLETED",
106
+ fingerprint,
107
+ statusCode,
108
+ body: serialized.body,
109
+ bodyEncoding: serialized.body === undefined ? undefined : "json",
110
+ }, options.ttlMs);
111
+ }
112
+ catch (error) {
113
+ // The response must still reach the client if the store fails.
114
+ options.onError(error);
115
+ }
116
+ }
117
+ async release(options, storeKey) {
118
+ try {
119
+ await options.storage.delete(storeKey);
120
+ }
121
+ catch (error) {
122
+ options.onError(error);
123
+ }
124
+ }
125
+ };
126
+ exports.IdempotencyInterceptor = IdempotencyInterceptor;
127
+ exports.IdempotencyInterceptor = IdempotencyInterceptor = __decorate([
128
+ (0, common_1.Injectable)(),
129
+ __param(0, (0, common_1.Inject)(core_1.Reflector)),
130
+ __param(1, (0, common_1.Optional)()),
131
+ __param(1, (0, common_1.Inject)(exports.IDEMPOTENCY_STORAGE))
132
+ ], IdempotencyInterceptor);
133
+ /** Only plain JSON-serializable return values can be replayed; streams and buffers cannot. */
134
+ function serialize(data) {
135
+ if (data === undefined)
136
+ return { cacheable: true };
137
+ if (Buffer.isBuffer(data))
138
+ return { cacheable: false };
139
+ if (typeof data === "object" && data !== null) {
140
+ const candidate = data;
141
+ if (typeof candidate.pipe === "function" || typeof candidate.getStream === "function") {
142
+ return { cacheable: false };
143
+ }
144
+ }
145
+ try {
146
+ const body = JSON.stringify(data);
147
+ return body === undefined ? { cacheable: false } : { cacheable: true, body };
148
+ }
149
+ catch {
150
+ return { cacheable: false };
151
+ }
152
+ }
153
+ //# sourceMappingURL=nest-decorator.js.map
@@ -0,0 +1,37 @@
1
+ import type { IdempotencyStore, IdempotentRecord } from "./storage";
2
+ /**
3
+ * The few Redis commands the store needs. Both `ioredis` and `redis` (node-redis v4+)
4
+ * clients satisfy this shape, so neither library is a dependency of this package.
5
+ */
6
+ export interface RedisLikeClient {
7
+ get(key: string): Promise<string | null>;
8
+ del(key: string): Promise<unknown>;
9
+ set(...args: any[]): Promise<unknown>;
10
+ }
11
+ export interface RedisStoreOptions {
12
+ /** Prefix added to every key so the shield does not collide with other data. Default: "idempotency:". */
13
+ keyPrefix?: string | undefined;
14
+ /**
15
+ * Which client library is used. It is detected automatically, because the two
16
+ * libraries take their SET options in different forms.
17
+ */
18
+ client?: "ioredis" | "node-redis" | undefined;
19
+ }
20
+ /**
21
+ * Redis-backed store. Share one instance across every server process so that a retry
22
+ * is recognized no matter which instance receives it.
23
+ *
24
+ * The lock is a single `SET key value PX ttl NX`, which Redis executes atomically.
25
+ */
26
+ export declare class RedisIdempotencyStore implements IdempotencyStore {
27
+ private readonly redis;
28
+ private readonly prefix;
29
+ private readonly kind;
30
+ constructor(redis: RedisLikeClient, options?: RedisStoreOptions);
31
+ get(key: string): Promise<IdempotentRecord | null>;
32
+ setIfAbsent(key: string, record: IdempotentRecord, ttlMs: number): Promise<boolean>;
33
+ set(key: string, record: IdempotentRecord, ttlMs: number): Promise<void>;
34
+ delete(key: string): Promise<void>;
35
+ private write;
36
+ }
37
+ //# sourceMappingURL=redis-store.d.ts.map
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RedisIdempotencyStore = void 0;
4
+ /**
5
+ * Redis-backed store. Share one instance across every server process so that a retry
6
+ * is recognized no matter which instance receives it.
7
+ *
8
+ * The lock is a single `SET key value PX ttl NX`, which Redis executes atomically.
9
+ */
10
+ class RedisIdempotencyStore {
11
+ redis;
12
+ prefix;
13
+ kind;
14
+ constructor(redis, options = {}) {
15
+ this.redis = redis;
16
+ this.prefix = options.keyPrefix ?? "idempotency:";
17
+ this.kind = options.client ?? detectClient(redis);
18
+ }
19
+ async get(key) {
20
+ const raw = await this.redis.get(this.prefix + key);
21
+ if (raw === null || raw === undefined)
22
+ return null;
23
+ try {
24
+ return JSON.parse(raw);
25
+ }
26
+ catch {
27
+ // A corrupted entry is treated as missing so it cannot block the key forever.
28
+ return null;
29
+ }
30
+ }
31
+ async setIfAbsent(key, record, ttlMs) {
32
+ const reply = await this.write(key, record, ttlMs, true);
33
+ return reply === "OK";
34
+ }
35
+ async set(key, record, ttlMs) {
36
+ await this.write(key, record, ttlMs, false);
37
+ }
38
+ async delete(key) {
39
+ await this.redis.del(this.prefix + key);
40
+ }
41
+ write(key, record, ttlMs, onlyIfAbsent) {
42
+ const name = this.prefix + key;
43
+ const value = JSON.stringify(record);
44
+ // PX takes whole milliseconds and must be at least 1.
45
+ const px = Math.max(1, Math.ceil(ttlMs));
46
+ if (this.kind === "node-redis") {
47
+ return this.redis.set(name, value, onlyIfAbsent ? { PX: px, NX: true } : { PX: px });
48
+ }
49
+ return onlyIfAbsent
50
+ ? this.redis.set(name, value, "PX", px, "NX")
51
+ : this.redis.set(name, value, "PX", px);
52
+ }
53
+ }
54
+ exports.RedisIdempotencyStore = RedisIdempotencyStore;
55
+ function detectClient(redis) {
56
+ // node-redis clients expose a boolean `isOpen`, ioredis clients expose a string `status`.
57
+ return typeof redis.isOpen === "boolean" ? "node-redis" : "ioredis";
58
+ }
59
+ //# sourceMappingURL=redis-store.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A stored idempotency record. Records must stay JSON-serializable so that
3
+ * external stores (Redis, SQL, ...) can persist them as-is.
4
+ */
5
+ export interface IdempotentRecord {
6
+ status: "STARTED" | "COMPLETED";
7
+ /** Hash of the original request, used to detect key reuse with a different payload. */
8
+ fingerprint?: string | undefined;
9
+ statusCode?: number | undefined;
10
+ /** Serialized response body (see `bodyEncoding`). */
11
+ body?: string | undefined;
12
+ /** "utf8" and "base64" hold raw bytes, "json" holds a JSON-serialized value. */
13
+ bodyEncoding?: "utf8" | "base64" | "json" | undefined;
14
+ headers?: Record<string, string | string[]> | undefined;
15
+ }
16
+ /**
17
+ * Storage contract. Implement it to back the shield with Redis or a database.
18
+ * `setIfAbsent` MUST be atomic (for example `SET key value NX PX ttl` in Redis),
19
+ * because it is what prevents two concurrent requests from running the same operation.
20
+ */
21
+ export interface IdempotencyStore {
22
+ get(key: string): Promise<IdempotentRecord | null>;
23
+ /** Stores the record only if the key does not exist. Resolves to true when it was stored. */
24
+ setIfAbsent(key: string, record: IdempotentRecord, ttlMs: number): Promise<boolean>;
25
+ set(key: string, record: IdempotentRecord, ttlMs: number): Promise<void>;
26
+ delete(key: string): Promise<void>;
27
+ }
28
+ export interface MemoryStoreOptions {
29
+ /** Upper bound on stored keys. The oldest entries are evicted first. Default: 10000. */
30
+ maxEntries?: number | undefined;
31
+ /** Minimum time between sweeps of expired entries. Default: 60000. */
32
+ sweepIntervalMs?: number | undefined;
33
+ }
34
+ /**
35
+ * In-memory store. Suitable for a single process only: with several instances
36
+ * or a cluster, use a shared store instead.
37
+ */
38
+ export declare class IdempotencyStorage implements IdempotencyStore {
39
+ private readonly entries;
40
+ private readonly maxEntries;
41
+ private readonly sweepIntervalMs;
42
+ private lastSweep;
43
+ constructor(options?: MemoryStoreOptions);
44
+ /** Number of entries currently held, including expired ones not yet swept. */
45
+ get size(): number;
46
+ get(key: string): Promise<IdempotentRecord | null>;
47
+ setIfAbsent(key: string, record: IdempotentRecord, ttlMs: number): Promise<boolean>;
48
+ set(key: string, record: IdempotentRecord, ttlMs: number): Promise<void>;
49
+ delete(key: string): Promise<void>;
50
+ private live;
51
+ private write;
52
+ private sweepIfDue;
53
+ }
54
+ /** Process-wide store used when no `storage` option is given. */
55
+ export declare function getDefaultStore(): IdempotencyStorage;
56
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdempotencyStorage = void 0;
4
+ exports.getDefaultStore = getDefaultStore;
5
+ /**
6
+ * In-memory store. Suitable for a single process only: with several instances
7
+ * or a cluster, use a shared store instead.
8
+ */
9
+ class IdempotencyStorage {
10
+ entries = new Map();
11
+ maxEntries;
12
+ sweepIntervalMs;
13
+ lastSweep = Date.now();
14
+ constructor(options = {}) {
15
+ this.maxEntries = options.maxEntries ?? 10_000;
16
+ this.sweepIntervalMs = options.sweepIntervalMs ?? 60_000;
17
+ }
18
+ /** Number of entries currently held, including expired ones not yet swept. */
19
+ get size() {
20
+ return this.entries.size;
21
+ }
22
+ async get(key) {
23
+ return this.live(key)?.record ?? null;
24
+ }
25
+ async setIfAbsent(key, record, ttlMs) {
26
+ // No await between the check and the write, so this is atomic within one process.
27
+ if (this.live(key))
28
+ return false;
29
+ this.write(key, record, ttlMs);
30
+ return true;
31
+ }
32
+ async set(key, record, ttlMs) {
33
+ this.write(key, record, ttlMs);
34
+ }
35
+ async delete(key) {
36
+ this.entries.delete(key);
37
+ }
38
+ live(key) {
39
+ const entry = this.entries.get(key);
40
+ if (!entry)
41
+ return undefined;
42
+ if (Date.now() >= entry.expiresAt) {
43
+ this.entries.delete(key);
44
+ return undefined;
45
+ }
46
+ return entry;
47
+ }
48
+ write(key, record, ttlMs) {
49
+ this.sweepIfDue();
50
+ // Delete first so the key moves to the end of the insertion order.
51
+ this.entries.delete(key);
52
+ this.entries.set(key, { record, expiresAt: Date.now() + ttlMs });
53
+ while (this.entries.size > this.maxEntries) {
54
+ const oldest = this.entries.keys().next();
55
+ if (oldest.done)
56
+ break;
57
+ this.entries.delete(oldest.value);
58
+ }
59
+ }
60
+ sweepIfDue() {
61
+ const now = Date.now();
62
+ if (now - this.lastSweep < this.sweepIntervalMs)
63
+ return;
64
+ this.lastSweep = now;
65
+ for (const [key, entry] of this.entries) {
66
+ if (now >= entry.expiresAt)
67
+ this.entries.delete(key);
68
+ }
69
+ }
70
+ }
71
+ exports.IdempotencyStorage = IdempotencyStorage;
72
+ let defaultStore;
73
+ /** Process-wide store used when no `storage` option is given. */
74
+ function getDefaultStore() {
75
+ defaultStore ??= new IdempotencyStorage();
76
+ return defaultStore;
77
+ }
78
+ //# sourceMappingURL=storage.js.map
package/package.json CHANGED
@@ -1,6 +1,97 @@
1
1
  {
2
2
  "name": "@masumraihan/idempotency-shield",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "An HTTP idempotency component for Express and NestJS complying with IETF standards.",
5
+ "keywords": [
6
+ "idempotency",
7
+ "idempotency-key",
8
+ "express",
9
+ "nestjs",
10
+ "middleware",
11
+ "interceptor",
12
+ "http"
13
+ ],
14
+ "author": "",
15
+ "license": "ISC",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./express": {
24
+ "types": "./dist/express-mw.d.ts",
25
+ "default": "./dist/express-mw.js"
26
+ },
27
+ "./nest": {
28
+ "types": "./dist/nest-decorator.d.ts",
29
+ "default": "./dist/nest-decorator.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "typesVersions": {
34
+ "*": {
35
+ "express": [
36
+ "dist/express-mw.d.ts"
37
+ ],
38
+ "nest": [
39
+ "dist/nest-decorator.d.ts"
40
+ ]
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "!dist/**/*.map",
46
+ "README.md"
47
+ ],
48
+ "sideEffects": false,
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "scripts": {
56
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
57
+ "build": "npm run clean && tsc",
58
+ "test": "npm run build && node --test test/storage.test.cjs test/express.test.cjs test/nest.test.cjs test/redis.test.cjs",
59
+ "prepublishOnly": "npm test",
60
+ "demo": "npm run build && node examples/scenario.cjs",
61
+ "demo:server": "npm run build && node examples/server.cjs",
62
+ "demo:redis": "npm run build && node examples/two-servers.cjs"
63
+ },
64
+ "peerDependencies": {
65
+ "@nestjs/common": ">=9.0.0",
66
+ "@nestjs/core": ">=9.0.0",
67
+ "@types/express": ">=4.17.0",
68
+ "rxjs": ">=7.2.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@nestjs/common": {
72
+ "optional": true
73
+ },
74
+ "@nestjs/core": {
75
+ "optional": true
76
+ },
77
+ "@types/express": {
78
+ "optional": true
79
+ },
80
+ "rxjs": {
81
+ "optional": true
82
+ }
83
+ },
84
+ "devDependencies": {
85
+ "@nestjs/common": "^11.2.8",
86
+ "@nestjs/core": "^11.2.8",
87
+ "@nestjs/platform-express": "^11.2.8",
88
+ "@types/express": "^5.0.6",
89
+ "@types/node": "^20.19.43",
90
+ "express": "^5.3.0",
91
+ "ioredis": "^6.0.0",
92
+ "redis": "^6.3.1",
93
+ "reflect-metadata": "^0.2.2",
94
+ "rxjs": "^7.8.2",
95
+ "typescript": "^7.0.2"
96
+ }
97
+ }