hono-idempotency 0.8.0 → 0.8.1

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.
Files changed (41) hide show
  1. package/dist/chunk-C2JZZSOP.js +9 -0
  2. package/dist/chunk-C2JZZSOP.js.map +1 -0
  3. package/dist/index.cjs +20 -8
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +2 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +19 -8
  8. package/dist/index.js.map +1 -1
  9. package/dist/stores/cloudflare-d1.cjs +22 -8
  10. package/dist/stores/cloudflare-d1.cjs.map +1 -1
  11. package/dist/stores/cloudflare-d1.d.cts +1 -1
  12. package/dist/stores/cloudflare-d1.d.ts +1 -1
  13. package/dist/stores/cloudflare-d1.js +21 -8
  14. package/dist/stores/cloudflare-d1.js.map +1 -1
  15. package/dist/stores/cloudflare-kv.cjs +9 -3
  16. package/dist/stores/cloudflare-kv.cjs.map +1 -1
  17. package/dist/stores/cloudflare-kv.d.cts +1 -1
  18. package/dist/stores/cloudflare-kv.d.ts +1 -1
  19. package/dist/stores/cloudflare-kv.js +8 -3
  20. package/dist/stores/cloudflare-kv.js.map +1 -1
  21. package/dist/stores/durable-objects.cjs +6 -1
  22. package/dist/stores/durable-objects.cjs.map +1 -1
  23. package/dist/stores/durable-objects.d.cts +1 -1
  24. package/dist/stores/durable-objects.d.ts +1 -1
  25. package/dist/stores/durable-objects.js +5 -1
  26. package/dist/stores/durable-objects.js.map +1 -1
  27. package/dist/stores/memory.cjs +8 -2
  28. package/dist/stores/memory.cjs.map +1 -1
  29. package/dist/stores/memory.d.cts +1 -1
  30. package/dist/stores/memory.d.ts +1 -1
  31. package/dist/stores/memory.js +7 -2
  32. package/dist/stores/memory.js.map +1 -1
  33. package/dist/stores/redis.cjs +12 -2
  34. package/dist/stores/redis.cjs.map +1 -1
  35. package/dist/stores/redis.d.cts +1 -1
  36. package/dist/stores/redis.d.ts +1 -1
  37. package/dist/stores/redis.js +11 -2
  38. package/dist/stores/redis.js.map +1 -1
  39. package/dist/{types-gTE2gccV.d.cts → types-7IwFeI0l.d.cts} +3 -1
  40. package/dist/{types-gTE2gccV.d.ts → types-7IwFeI0l.d.ts} +3 -1
  41. package/package.json +1 -1
@@ -0,0 +1,9 @@
1
+ // src/types.ts
2
+ var RECORD_STATUS_PROCESSING = "processing";
3
+ var RECORD_STATUS_COMPLETED = "completed";
4
+
5
+ export {
6
+ RECORD_STATUS_PROCESSING,
7
+ RECORD_STATUS_COMPLETED
8
+ };
9
+ //# sourceMappingURL=chunk-C2JZZSOP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";AAIO,IAAM,2BAA2B;AACjC,IAAM,0BAA0B;","names":[]}
package/dist/index.cjs CHANGED
@@ -31,6 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
33
  IdempotencyErrors: () => IdempotencyErrors,
34
+ RECORD_STATUS_COMPLETED: () => RECORD_STATUS_COMPLETED,
35
+ RECORD_STATUS_PROCESSING: () => RECORD_STATUS_PROCESSING,
34
36
  clampHttpStatus: () => clampHttpStatus,
35
37
  idempotency: () => idempotency,
36
38
  problemResponse: () => problemResponse
@@ -71,11 +73,12 @@ function problemResponse(problem, extraHeaders) {
71
73
  return new Response(body, {
72
74
  status,
73
75
  headers: {
74
- "Content-Type": "application/problem+json",
76
+ "Content-Type": PROBLEM_CONTENT_TYPE,
75
77
  ...extraHeaders
76
78
  }
77
79
  });
78
80
  }
81
+ var PROBLEM_CONTENT_TYPE = "application/problem+json";
79
82
  var BASE_URL = "https://hono-idempotency.dev/errors";
80
83
  var IdempotencyErrors = {
81
84
  missingKey() {
@@ -139,10 +142,17 @@ async function generateFingerprint(method, path, body) {
139
142
  return hex;
140
143
  }
141
144
 
145
+ // src/types.ts
146
+ var RECORD_STATUS_PROCESSING = "processing";
147
+ var RECORD_STATUS_COMPLETED = "completed";
148
+
142
149
  // src/middleware.ts
143
150
  var DEFAULT_METHODS = ["POST", "PATCH"];
144
151
  var DEFAULT_MAX_KEY_LENGTH = 256;
145
152
  var EXCLUDED_STORE_HEADERS = /* @__PURE__ */ new Set(["set-cookie"]);
153
+ var DEFAULT_RETRY_AFTER = "1";
154
+ var REPLAY_HEADER = "Idempotency-Replayed";
155
+ var encoder2 = new TextEncoder();
146
156
  function idempotency(options) {
147
157
  const {
148
158
  store,
@@ -192,7 +202,7 @@ function idempotency(options) {
192
202
  }
193
203
  return next();
194
204
  }
195
- if (new TextEncoder().encode(key).length > maxKeyLength) {
205
+ if (encoder2.encode(key).length > maxKeyLength) {
196
206
  return errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));
197
207
  }
198
208
  if (maxBodySize != null) {
@@ -206,7 +216,7 @@ function idempotency(options) {
206
216
  }
207
217
  const body = await c.req.text();
208
218
  if (maxBodySize != null) {
209
- const byteLength = new TextEncoder().encode(body).length;
219
+ const byteLength = encoder2.encode(body).length;
210
220
  if (byteLength > maxBodySize) {
211
221
  return errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));
212
222
  }
@@ -218,8 +228,8 @@ function idempotency(options) {
218
228
  const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;
219
229
  const existing = await store.get(storeKey);
220
230
  if (existing) {
221
- if (existing.status === "processing") {
222
- return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
231
+ if (existing.status === RECORD_STATUS_PROCESSING) {
232
+ return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
223
233
  }
224
234
  if (existing.fingerprint !== fp) {
225
235
  return errorResponse(IdempotencyErrors.fingerprintMismatch());
@@ -233,12 +243,12 @@ function idempotency(options) {
233
243
  const record = {
234
244
  key,
235
245
  fingerprint: fp,
236
- status: "processing",
246
+ status: RECORD_STATUS_PROCESSING,
237
247
  createdAt: Date.now()
238
248
  };
239
249
  const locked = await store.lock(storeKey, record);
240
250
  if (!locked) {
241
- return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
251
+ return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
242
252
  }
243
253
  c.set("idempotencyKey", key);
244
254
  await safeHook(onCacheMiss, key, c);
@@ -281,7 +291,7 @@ async function safeHook(fn, key, c) {
281
291
  }
282
292
  function replayResponse(stored) {
283
293
  const headers = new Headers(stored.headers);
284
- headers.set("Idempotency-Replayed", "true");
294
+ headers.set(REPLAY_HEADER, "true");
285
295
  return new Response(stored.body, {
286
296
  status: clampHttpStatus(stored.status),
287
297
  headers
@@ -290,6 +300,8 @@ function replayResponse(stored) {
290
300
  // Annotate the CommonJS export names for ESM import in node:
291
301
  0 && (module.exports = {
292
302
  IdempotencyErrors,
303
+ RECORD_STATUS_COMPLETED,
304
+ RECORD_STATUS_PROCESSING,
293
305
  clampHttpStatus,
294
306
  idempotency,
295
307
  problemResponse
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/compat.ts","../src/errors.ts","../src/fingerprint.ts"],"sourcesContent":["export { idempotency } from \"./middleware.js\";\nexport { IdempotencyErrors, clampHttpStatus, problemResponse } from \"./errors.js\";\nexport type {\n\tIdempotencyEnv,\n\tIdempotencyOptions,\n\tIdempotencyRecord,\n\tStoredResponse,\n} from \"./types.js\";\nexport type { IdempotencyStore } from \"./stores/types.js\";\nexport type { IdempotencyErrorCode, ProblemDetail } from \"./errors.js\";\nexport type { MemoryStore, MemoryStoreOptions } from \"./stores/memory.js\";\nexport type { RedisClientLike, RedisStoreOptions } from \"./stores/redis.js\";\nexport type { KVNamespaceLike, KVStoreOptions } from \"./stores/cloudflare-kv.js\";\nexport type {\n\tD1DatabaseLike,\n\tD1PreparedStatementLike,\n\tD1StoreOptions,\n} from \"./stores/cloudflare-d1.js\";\nexport type {\n\tDurableObjectStorageLike,\n\tDurableObjectStoreOptions,\n} from \"./stores/durable-objects.js\";\n","import { createMiddleware } from \"hono/factory\";\nimport { getHonoProblemDetails } from \"./compat.js\";\nimport {\n\tIdempotencyErrors,\n\ttype ProblemDetail,\n\tclampHttpStatus,\n\tproblemResponse,\n} from \"./errors.js\";\nimport { generateFingerprint } from \"./fingerprint.js\";\nimport type { IdempotencyEnv, IdempotencyOptions, StoredResponse } from \"./types.js\";\n\nconst DEFAULT_METHODS = [\"POST\", \"PATCH\"];\nconst DEFAULT_MAX_KEY_LENGTH = 256;\n// Headers unsafe to replay — session cookies could leak across users\nconst EXCLUDED_STORE_HEADERS = new Set([\"set-cookie\"]);\n\nexport function idempotency(options: IdempotencyOptions) {\n\tconst {\n\t\tstore,\n\t\theaderName = \"Idempotency-Key\",\n\t\tfingerprint: customFingerprint,\n\t\trequired = false,\n\t\tmethods = DEFAULT_METHODS,\n\t\tmaxKeyLength = DEFAULT_MAX_KEY_LENGTH,\n\t\tskipRequest,\n\t\tonError,\n\t\tcacheKeyPrefix,\n\t\tmaxBodySize,\n\t\tonCacheHit,\n\t\tonCacheMiss,\n\t} = options;\n\n\treturn createMiddleware<IdempotencyEnv>(async (c, next) => {\n\t\tif (!methods.includes(c.req.method)) {\n\t\t\treturn next();\n\t\t}\n\n\t\tif (skipRequest && (await skipRequest(c))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst errorResponse = async (problem: ProblemDetail, extraHeaders?: Record<string, string>) => {\n\t\t\tif (onError) return onError(problem, c);\n\t\t\tconst pd = await getHonoProblemDetails();\n\t\t\tif (pd) {\n\t\t\t\tconst response = pd\n\t\t\t\t\t.problemDetails({\n\t\t\t\t\t\ttype: problem.type,\n\t\t\t\t\t\ttitle: problem.title,\n\t\t\t\t\t\tstatus: problem.status,\n\t\t\t\t\t\tdetail: problem.detail,\n\t\t\t\t\t\textensions: { code: problem.code },\n\t\t\t\t\t})\n\t\t\t\t\t.getResponse();\n\t\t\t\tif (extraHeaders) {\n\t\t\t\t\tfor (const [key, value] of Object.entries(extraHeaders)) {\n\t\t\t\t\t\tresponse.headers.set(key, value);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t}\n\t\t\treturn problemResponse(problem, extraHeaders);\n\t\t};\n\n\t\tconst key = c.req.header(headerName);\n\n\t\tif (!key) {\n\t\t\tif (required) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.missingKey());\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tif (new TextEncoder().encode(key).length > maxKeyLength) {\n\t\t\treturn errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));\n\t\t}\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst cl = c.req.header(\"Content-Length\");\n\t\t\tif (cl) {\n\t\t\t\tconst parsed = Number.parseInt(cl, 10);\n\t\t\t\tif (parsed < 0 || parsed > maxBodySize) {\n\t\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst body = await c.req.text();\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst byteLength = new TextEncoder().encode(body).length;\n\t\t\tif (byteLength > maxBodySize) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t}\n\t\t}\n\t\tconst fp = customFingerprint\n\t\t\t? await customFingerprint(c)\n\t\t\t: await generateFingerprint(c.req.method, c.req.path, body);\n\n\t\tconst rawPrefix =\n\t\t\ttypeof cacheKeyPrefix === \"function\" ? await cacheKeyPrefix(c) : cacheKeyPrefix;\n\t\t// Encode user-controlled components to prevent delimiter injection\n\t\tconst encodedKey = encodeURIComponent(key);\n\t\tconst baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;\n\t\tconst storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;\n\n\t\tconst existing = await store.get(storeKey);\n\n\t\tif (existing) {\n\t\t\tif (existing.status === \"processing\") {\n\t\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": \"1\" });\n\t\t\t}\n\n\t\t\tif (existing.fingerprint !== fp) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.fingerprintMismatch());\n\t\t\t}\n\n\t\t\tif (existing.response) {\n\t\t\t\tawait safeHook(onCacheHit, key, c);\n\t\t\t\treturn replayResponse(existing.response);\n\t\t\t}\n\n\t\t\t// Completed but no response — corrupt record; delete so lock() can re-acquire\n\t\t\tawait store.delete(storeKey);\n\t\t}\n\n\t\tconst record = {\n\t\t\tkey,\n\t\t\tfingerprint: fp,\n\t\t\tstatus: \"processing\" as const,\n\t\t\tcreatedAt: Date.now(),\n\t\t};\n\n\t\tconst locked = await store.lock(storeKey, record);\n\t\tif (!locked) {\n\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": \"1\" });\n\t\t}\n\n\t\tc.set(\"idempotencyKey\", key);\n\t\tawait safeHook(onCacheMiss, key, c);\n\n\t\ttry {\n\t\t\tawait next();\n\t\t} catch (err) {\n\t\t\tawait store.delete(storeKey);\n\t\t\tthrow err;\n\t\t}\n\n\t\tconst res = c.res;\n\t\tif (!res.ok) {\n\t\t\t// Non-2xx: delete key (Stripe pattern) so client can retry\n\t\t\tawait store.delete(storeKey);\n\t\t\treturn;\n\t\t}\n\n\t\tconst resBody = await res.text();\n\t\tconst resHeaders: Record<string, string> = {};\n\t\tres.headers.forEach((v, k) => {\n\t\t\tif (!EXCLUDED_STORE_HEADERS.has(k.toLowerCase())) {\n\t\t\t\tresHeaders[k] = v;\n\t\t\t}\n\t\t});\n\n\t\tconst storedResponse: StoredResponse = {\n\t\t\tstatus: res.status,\n\t\t\theaders: resHeaders,\n\t\t\tbody: resBody,\n\t\t};\n\n\t\tawait store.complete(storeKey, storedResponse);\n\n\t\t// Rebuild response since we consumed body\n\t\tc.res = new Response(resBody, {\n\t\t\tstatus: res.status,\n\t\t\theaders: res.headers,\n\t\t});\n\t});\n}\n\n// Hook errors must not break idempotency guarantees\nasync function safeHook<C>(\n\tfn: ((key: string, c: C) => void | Promise<void>) | undefined,\n\tkey: string,\n\tc: C,\n): Promise<void> {\n\tif (!fn) return;\n\ttry {\n\t\tawait fn(key, c);\n\t} catch {\n\t\t// Swallow — hooks are for observability, not control flow\n\t}\n}\n\nfunction replayResponse(stored: StoredResponse) {\n\tconst headers = new Headers(stored.headers);\n\theaders.set(\"Idempotency-Replayed\", \"true\");\n\n\treturn new Response(stored.body, {\n\t\tstatus: clampHttpStatus(stored.status),\n\t\theaders,\n\t});\n}\n","type HonoProblemDetails = typeof import(\"hono-problem-details\");\n\nlet cached: HonoProblemDetails | null | undefined;\n\nexport async function getHonoProblemDetails(): Promise<HonoProblemDetails | null> {\n\tif (cached === undefined) {\n\t\ttry {\n\t\t\tcached = await import(\"hono-problem-details\");\n\t\t} catch {\n\t\t\tcached = null;\n\t\t}\n\t}\n\treturn cached;\n}\n","export type IdempotencyErrorCode =\n\t| \"MISSING_KEY\"\n\t| \"KEY_TOO_LONG\"\n\t| \"BODY_TOO_LARGE\"\n\t| \"FINGERPRINT_MISMATCH\"\n\t| \"CONFLICT\";\n\nexport interface ProblemDetail {\n\ttype: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail: string;\n\tcode: IdempotencyErrorCode;\n}\n\n/** Ensures status is a valid HTTP status code (200-599), defaults to 500. */\nexport function clampHttpStatus(status: number): number {\n\tif (Number.isNaN(status) || status < 200 || status > 599) return 500;\n\treturn status;\n}\n\nexport function problemResponse(\n\tproblem: ProblemDetail,\n\textraHeaders?: Record<string, string>,\n): Response {\n\tlet body: string;\n\tlet status: number;\n\ttry {\n\t\tbody = JSON.stringify(problem);\n\t\tstatus = clampHttpStatus(problem.status);\n\t} catch {\n\t\tbody = '{\"title\":\"Internal Server Error\",\"status\":500}';\n\t\tstatus = 500;\n\t}\n\treturn new Response(body, {\n\t\tstatus,\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/problem+json\",\n\t\t\t...extraHeaders,\n\t\t},\n\t});\n}\n\nconst BASE_URL = \"https://hono-idempotency.dev/errors\";\n\nexport const IdempotencyErrors = {\n\tmissingKey(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/missing-key`,\n\t\t\ttitle: \"Idempotency-Key header is required\",\n\t\t\tstatus: 400,\n\t\t\tdetail: \"This endpoint requires an Idempotency-Key header\",\n\t\t\tcode: \"MISSING_KEY\",\n\t\t};\n\t},\n\n\tkeyTooLong(maxLength: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/key-too-long`,\n\t\t\ttitle: \"Idempotency-Key is too long\",\n\t\t\tstatus: 400,\n\t\t\tdetail: `Idempotency-Key must be at most ${maxLength} characters`,\n\t\t\tcode: \"KEY_TOO_LONG\",\n\t\t};\n\t},\n\n\tbodyTooLarge(maxSize: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/body-too-large`,\n\t\t\ttitle: \"Request body is too large\",\n\t\t\tstatus: 413,\n\t\t\tdetail: `Request body must be at most ${maxSize} bytes`,\n\t\t\tcode: \"BODY_TOO_LARGE\",\n\t\t};\n\t},\n\n\tfingerprintMismatch(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/fingerprint-mismatch`,\n\t\t\ttitle: \"Idempotency-Key is already used with a different request\",\n\t\t\tstatus: 422,\n\t\t\tdetail:\n\t\t\t\t\"A request with the same idempotency key but different parameters was already processed\",\n\t\t\tcode: \"FINGERPRINT_MISMATCH\",\n\t\t};\n\t},\n\n\tconflict(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/conflict`,\n\t\t\ttitle: \"A request is outstanding for this idempotency key\",\n\t\t\tstatus: 409,\n\t\t\tdetail: \"A request with the same idempotency key is currently being processed\",\n\t\t\tcode: \"CONFLICT\",\n\t\t};\n\t},\n} as const;\n","const encoder = new TextEncoder();\nconst HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, \"0\"));\n\nexport async function generateFingerprint(\n\tmethod: string,\n\tpath: string,\n\tbody: string,\n): Promise<string> {\n\tconst data = `${method}:${path}:${body}`;\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", encoder.encode(data));\n\tconst bytes = new Uint8Array(hashBuffer);\n\tlet hex = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\thex += HEX_TABLE[bytes[i]];\n\t}\n\treturn hex;\n}\n"],"mappings":"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","names":["key"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/compat.ts","../src/errors.ts","../src/fingerprint.ts","../src/types.ts"],"sourcesContent":["export { idempotency } from \"./middleware.js\";\nexport { IdempotencyErrors, clampHttpStatus, problemResponse } from \"./errors.js\";\nexport { RECORD_STATUS_COMPLETED, RECORD_STATUS_PROCESSING } from \"./types.js\";\nexport type {\n\tIdempotencyEnv,\n\tIdempotencyOptions,\n\tIdempotencyRecord,\n\tStoredResponse,\n} from \"./types.js\";\nexport type { IdempotencyStore } from \"./stores/types.js\";\nexport type { IdempotencyErrorCode, ProblemDetail } from \"./errors.js\";\nexport type { MemoryStore, MemoryStoreOptions } from \"./stores/memory.js\";\nexport type { RedisClientLike, RedisStoreOptions } from \"./stores/redis.js\";\nexport type { KVNamespaceLike, KVStoreOptions } from \"./stores/cloudflare-kv.js\";\nexport type {\n\tD1DatabaseLike,\n\tD1PreparedStatementLike,\n\tD1StoreOptions,\n} from \"./stores/cloudflare-d1.js\";\nexport type {\n\tDurableObjectStorageLike,\n\tDurableObjectStoreOptions,\n} from \"./stores/durable-objects.js\";\n","import { createMiddleware } from \"hono/factory\";\nimport { getHonoProblemDetails } from \"./compat.js\";\nimport {\n\tIdempotencyErrors,\n\ttype ProblemDetail,\n\tclampHttpStatus,\n\tproblemResponse,\n} from \"./errors.js\";\nimport { generateFingerprint } from \"./fingerprint.js\";\nimport {\n\ttype IdempotencyEnv,\n\ttype IdempotencyOptions,\n\tRECORD_STATUS_PROCESSING,\n\ttype StoredResponse,\n} from \"./types.js\";\n\nconst DEFAULT_METHODS = [\"POST\", \"PATCH\"];\nconst DEFAULT_MAX_KEY_LENGTH = 256;\n// Headers unsafe to replay — session cookies could leak across users\nconst EXCLUDED_STORE_HEADERS = new Set([\"set-cookie\"]);\nconst DEFAULT_RETRY_AFTER = \"1\";\nconst REPLAY_HEADER = \"Idempotency-Replayed\";\nconst encoder = new TextEncoder();\n\nexport function idempotency(options: IdempotencyOptions) {\n\tconst {\n\t\tstore,\n\t\theaderName = \"Idempotency-Key\",\n\t\tfingerprint: customFingerprint,\n\t\trequired = false,\n\t\tmethods = DEFAULT_METHODS,\n\t\tmaxKeyLength = DEFAULT_MAX_KEY_LENGTH,\n\t\tskipRequest,\n\t\tonError,\n\t\tcacheKeyPrefix,\n\t\tmaxBodySize,\n\t\tonCacheHit,\n\t\tonCacheMiss,\n\t} = options;\n\n\treturn createMiddleware<IdempotencyEnv>(async (c, next) => {\n\t\tif (!methods.includes(c.req.method)) {\n\t\t\treturn next();\n\t\t}\n\n\t\tif (skipRequest && (await skipRequest(c))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst errorResponse = async (problem: ProblemDetail, extraHeaders?: Record<string, string>) => {\n\t\t\tif (onError) return onError(problem, c);\n\t\t\tconst pd = await getHonoProblemDetails();\n\t\t\tif (pd) {\n\t\t\t\tconst response = pd\n\t\t\t\t\t.problemDetails({\n\t\t\t\t\t\ttype: problem.type,\n\t\t\t\t\t\ttitle: problem.title,\n\t\t\t\t\t\tstatus: problem.status,\n\t\t\t\t\t\tdetail: problem.detail,\n\t\t\t\t\t\textensions: { code: problem.code },\n\t\t\t\t\t})\n\t\t\t\t\t.getResponse();\n\t\t\t\tif (extraHeaders) {\n\t\t\t\t\tfor (const [key, value] of Object.entries(extraHeaders)) {\n\t\t\t\t\t\tresponse.headers.set(key, value);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t}\n\t\t\treturn problemResponse(problem, extraHeaders);\n\t\t};\n\n\t\tconst key = c.req.header(headerName);\n\n\t\tif (!key) {\n\t\t\tif (required) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.missingKey());\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tif (encoder.encode(key).length > maxKeyLength) {\n\t\t\treturn errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));\n\t\t}\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst cl = c.req.header(\"Content-Length\");\n\t\t\tif (cl) {\n\t\t\t\tconst parsed = Number.parseInt(cl, 10);\n\t\t\t\tif (parsed < 0 || parsed > maxBodySize) {\n\t\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst body = await c.req.text();\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst byteLength = encoder.encode(body).length;\n\t\t\tif (byteLength > maxBodySize) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t}\n\t\t}\n\t\tconst fp = customFingerprint\n\t\t\t? await customFingerprint(c)\n\t\t\t: await generateFingerprint(c.req.method, c.req.path, body);\n\n\t\tconst rawPrefix =\n\t\t\ttypeof cacheKeyPrefix === \"function\" ? await cacheKeyPrefix(c) : cacheKeyPrefix;\n\t\t// Encode user-controlled components to prevent delimiter injection\n\t\tconst encodedKey = encodeURIComponent(key);\n\t\tconst baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;\n\t\tconst storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;\n\n\t\tconst existing = await store.get(storeKey);\n\n\t\tif (existing) {\n\t\t\tif (existing.status === RECORD_STATUS_PROCESSING) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": DEFAULT_RETRY_AFTER });\n\t\t\t}\n\n\t\t\tif (existing.fingerprint !== fp) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.fingerprintMismatch());\n\t\t\t}\n\n\t\t\tif (existing.response) {\n\t\t\t\tawait safeHook(onCacheHit, key, c);\n\t\t\t\treturn replayResponse(existing.response);\n\t\t\t}\n\n\t\t\t// Completed but no response — corrupt record; delete so lock() can re-acquire\n\t\t\tawait store.delete(storeKey);\n\t\t}\n\n\t\tconst record = {\n\t\t\tkey,\n\t\t\tfingerprint: fp,\n\t\t\tstatus: RECORD_STATUS_PROCESSING,\n\t\t\tcreatedAt: Date.now(),\n\t\t};\n\n\t\tconst locked = await store.lock(storeKey, record);\n\t\tif (!locked) {\n\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": DEFAULT_RETRY_AFTER });\n\t\t}\n\n\t\tc.set(\"idempotencyKey\", key);\n\t\tawait safeHook(onCacheMiss, key, c);\n\n\t\ttry {\n\t\t\tawait next();\n\t\t} catch (err) {\n\t\t\tawait store.delete(storeKey);\n\t\t\tthrow err;\n\t\t}\n\n\t\tconst res = c.res;\n\t\tif (!res.ok) {\n\t\t\t// Non-2xx: delete key (Stripe pattern) so client can retry\n\t\t\tawait store.delete(storeKey);\n\t\t\treturn;\n\t\t}\n\n\t\tconst resBody = await res.text();\n\t\tconst resHeaders: Record<string, string> = {};\n\t\tres.headers.forEach((v, k) => {\n\t\t\tif (!EXCLUDED_STORE_HEADERS.has(k.toLowerCase())) {\n\t\t\t\tresHeaders[k] = v;\n\t\t\t}\n\t\t});\n\n\t\tconst storedResponse: StoredResponse = {\n\t\t\tstatus: res.status,\n\t\t\theaders: resHeaders,\n\t\t\tbody: resBody,\n\t\t};\n\n\t\tawait store.complete(storeKey, storedResponse);\n\n\t\t// Rebuild response since we consumed body\n\t\tc.res = new Response(resBody, {\n\t\t\tstatus: res.status,\n\t\t\theaders: res.headers,\n\t\t});\n\t});\n}\n\n// Hook errors must not break idempotency guarantees\nasync function safeHook<C>(\n\tfn: ((key: string, c: C) => void | Promise<void>) | undefined,\n\tkey: string,\n\tc: C,\n): Promise<void> {\n\tif (!fn) return;\n\ttry {\n\t\tawait fn(key, c);\n\t} catch {\n\t\t// Swallow — hooks are for observability, not control flow\n\t}\n}\n\nfunction replayResponse(stored: StoredResponse) {\n\tconst headers = new Headers(stored.headers);\n\theaders.set(REPLAY_HEADER, \"true\");\n\n\treturn new Response(stored.body, {\n\t\tstatus: clampHttpStatus(stored.status),\n\t\theaders,\n\t});\n}\n","type HonoProblemDetails = typeof import(\"hono-problem-details\");\n\nlet cached: HonoProblemDetails | null | undefined;\n\nexport async function getHonoProblemDetails(): Promise<HonoProblemDetails | null> {\n\tif (cached === undefined) {\n\t\ttry {\n\t\t\tcached = await import(\"hono-problem-details\");\n\t\t} catch {\n\t\t\tcached = null;\n\t\t}\n\t}\n\treturn cached;\n}\n","export type IdempotencyErrorCode =\n\t| \"MISSING_KEY\"\n\t| \"KEY_TOO_LONG\"\n\t| \"BODY_TOO_LARGE\"\n\t| \"FINGERPRINT_MISMATCH\"\n\t| \"CONFLICT\";\n\nexport interface ProblemDetail {\n\ttype: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail: string;\n\tcode: IdempotencyErrorCode;\n}\n\n/** Ensures status is a valid HTTP status code (200-599), defaults to 500. */\nexport function clampHttpStatus(status: number): number {\n\tif (Number.isNaN(status) || status < 200 || status > 599) return 500;\n\treturn status;\n}\n\nexport function problemResponse(\n\tproblem: ProblemDetail,\n\textraHeaders?: Record<string, string>,\n): Response {\n\tlet body: string;\n\tlet status: number;\n\ttry {\n\t\tbody = JSON.stringify(problem);\n\t\tstatus = clampHttpStatus(problem.status);\n\t} catch {\n\t\tbody = '{\"title\":\"Internal Server Error\",\"status\":500}';\n\t\tstatus = 500;\n\t}\n\treturn new Response(body, {\n\t\tstatus,\n\t\theaders: {\n\t\t\t\"Content-Type\": PROBLEM_CONTENT_TYPE,\n\t\t\t...extraHeaders,\n\t\t},\n\t});\n}\n\nconst PROBLEM_CONTENT_TYPE = \"application/problem+json\";\nconst BASE_URL = \"https://hono-idempotency.dev/errors\";\n\nexport const IdempotencyErrors = {\n\tmissingKey(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/missing-key`,\n\t\t\ttitle: \"Idempotency-Key header is required\",\n\t\t\tstatus: 400,\n\t\t\tdetail: \"This endpoint requires an Idempotency-Key header\",\n\t\t\tcode: \"MISSING_KEY\",\n\t\t};\n\t},\n\n\tkeyTooLong(maxLength: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/key-too-long`,\n\t\t\ttitle: \"Idempotency-Key is too long\",\n\t\t\tstatus: 400,\n\t\t\tdetail: `Idempotency-Key must be at most ${maxLength} characters`,\n\t\t\tcode: \"KEY_TOO_LONG\",\n\t\t};\n\t},\n\n\tbodyTooLarge(maxSize: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/body-too-large`,\n\t\t\ttitle: \"Request body is too large\",\n\t\t\tstatus: 413,\n\t\t\tdetail: `Request body must be at most ${maxSize} bytes`,\n\t\t\tcode: \"BODY_TOO_LARGE\",\n\t\t};\n\t},\n\n\tfingerprintMismatch(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/fingerprint-mismatch`,\n\t\t\ttitle: \"Idempotency-Key is already used with a different request\",\n\t\t\tstatus: 422,\n\t\t\tdetail:\n\t\t\t\t\"A request with the same idempotency key but different parameters was already processed\",\n\t\t\tcode: \"FINGERPRINT_MISMATCH\",\n\t\t};\n\t},\n\n\tconflict(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/conflict`,\n\t\t\ttitle: \"A request is outstanding for this idempotency key\",\n\t\t\tstatus: 409,\n\t\t\tdetail: \"A request with the same idempotency key is currently being processed\",\n\t\t\tcode: \"CONFLICT\",\n\t\t};\n\t},\n} as const;\n","const encoder = new TextEncoder();\nconst HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, \"0\"));\n\nexport async function generateFingerprint(\n\tmethod: string,\n\tpath: string,\n\tbody: string,\n): Promise<string> {\n\tconst data = `${method}:${path}:${body}`;\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", encoder.encode(data));\n\tconst bytes = new Uint8Array(hashBuffer);\n\tlet hex = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\thex += HEX_TABLE[bytes[i]];\n\t}\n\treturn hex;\n}\n","import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,qBAAiC;;;ACEjC,IAAI;AAEJ,eAAsB,wBAA4D;AACjF,MAAI,WAAW,QAAW;AACzB,QAAI;AACH,eAAS,MAAM,OAAO,sBAAsB;AAAA,IAC7C,QAAQ;AACP,eAAS;AAAA,IACV;AAAA,EACD;AACA,SAAO;AACR;;;ACGO,SAAS,gBAAgB,QAAwB;AACvD,MAAI,OAAO,MAAM,MAAM,KAAK,SAAS,OAAO,SAAS,IAAK,QAAO;AACjE,SAAO;AACR;AAEO,SAAS,gBACf,SACA,cACW;AACX,MAAI;AACJ,MAAI;AACJ,MAAI;AACH,WAAO,KAAK,UAAU,OAAO;AAC7B,aAAS,gBAAgB,QAAQ,MAAM;AAAA,EACxC,QAAQ;AACP,WAAO;AACP,aAAS;AAAA,EACV;AACA,SAAO,IAAI,SAAS,MAAM;AAAA,IACzB;AAAA,IACA,SAAS;AAAA,MACR,gBAAgB;AAAA,MAChB,GAAG;AAAA,IACJ;AAAA,EACD,CAAC;AACF;AAEA,IAAM,uBAAuB;AAC7B,IAAM,WAAW;AAEV,IAAM,oBAAoB;AAAA,EAChC,aAA4B;AAC3B,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,WAAW,WAAkC;AAC5C,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ,mCAAmC,SAAS;AAAA,MACpD,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,aAAa,SAAgC;AAC5C,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ,gCAAgC,OAAO;AAAA,MAC/C,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,sBAAqC;AACpC,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QACC;AAAA,MACD,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,WAA0B;AACzB,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,MAAM;AAAA,IACP;AAAA,EACD;AACD;;;ACjGA,IAAM,UAAU,IAAI,YAAY;AAChC,IAAM,YAAY,MAAM,KAAK,EAAE,QAAQ,IAAI,GAAG,CAAC,GAAG,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC;AAEvF,eAAsB,oBACrB,QACA,MACA,MACkB;AAClB,QAAM,OAAO,GAAG,MAAM,IAAI,IAAI,IAAI,IAAI;AACtC,QAAM,aAAa,MAAM,OAAO,OAAO,OAAO,WAAW,QAAQ,OAAO,IAAI,CAAC;AAC7E,QAAM,QAAQ,IAAI,WAAW,UAAU;AACvC,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACtC,WAAO,UAAU,MAAM,CAAC,CAAC;AAAA,EAC1B;AACA,SAAO;AACR;;;ACZO,IAAM,2BAA2B;AACjC,IAAM,0BAA0B;;;AJWvC,IAAM,kBAAkB,CAAC,QAAQ,OAAO;AACxC,IAAM,yBAAyB;AAE/B,IAAM,yBAAyB,oBAAI,IAAI,CAAC,YAAY,CAAC;AACrD,IAAM,sBAAsB;AAC5B,IAAM,gBAAgB;AACtB,IAAMA,WAAU,IAAI,YAAY;AAEzB,SAAS,YAAY,SAA6B;AACxD,QAAM;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,aAAa;AAAA,IACb,WAAW;AAAA,IACX,UAAU;AAAA,IACV,eAAe;AAAA,IACf;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACD,IAAI;AAEJ,aAAO,iCAAiC,OAAO,GAAG,SAAS;AAC1D,QAAI,CAAC,QAAQ,SAAS,EAAE,IAAI,MAAM,GAAG;AACpC,aAAO,KAAK;AAAA,IACb;AAEA,QAAI,eAAgB,MAAM,YAAY,CAAC,GAAI;AAC1C,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,gBAAgB,OAAO,SAAwB,iBAA0C;AAC9F,UAAI,QAAS,QAAO,QAAQ,SAAS,CAAC;AACtC,YAAM,KAAK,MAAM,sBAAsB;AACvC,UAAI,IAAI;AACP,cAAM,WAAW,GACf,eAAe;AAAA,UACf,MAAM,QAAQ;AAAA,UACd,OAAO,QAAQ;AAAA,UACf,QAAQ,QAAQ;AAAA,UAChB,QAAQ,QAAQ;AAAA,UAChB,YAAY,EAAE,MAAM,QAAQ,KAAK;AAAA,QAClC,CAAC,EACA,YAAY;AACd,YAAI,cAAc;AACjB,qBAAW,CAACC,MAAK,KAAK,KAAK,OAAO,QAAQ,YAAY,GAAG;AACxD,qBAAS,QAAQ,IAAIA,MAAK,KAAK;AAAA,UAChC;AAAA,QACD;AACA,eAAO;AAAA,MACR;AACA,aAAO,gBAAgB,SAAS,YAAY;AAAA,IAC7C;AAEA,UAAM,MAAM,EAAE,IAAI,OAAO,UAAU;AAEnC,QAAI,CAAC,KAAK;AACT,UAAI,UAAU;AACb,eAAO,cAAc,kBAAkB,WAAW,CAAC;AAAA,MACpD;AACA,aAAO,KAAK;AAAA,IACb;AAEA,QAAID,SAAQ,OAAO,GAAG,EAAE,SAAS,cAAc;AAC9C,aAAO,cAAc,kBAAkB,WAAW,YAAY,CAAC;AAAA,IAChE;AAEA,QAAI,eAAe,MAAM;AACxB,YAAM,KAAK,EAAE,IAAI,OAAO,gBAAgB;AACxC,UAAI,IAAI;AACP,cAAM,SAAS,OAAO,SAAS,IAAI,EAAE;AACrC,YAAI,SAAS,KAAK,SAAS,aAAa;AACvC,iBAAO,cAAc,kBAAkB,aAAa,WAAW,CAAC;AAAA,QACjE;AAAA,MACD;AAAA,IACD;AAEA,UAAM,OAAO,MAAM,EAAE,IAAI,KAAK;AAE9B,QAAI,eAAe,MAAM;AACxB,YAAM,aAAaA,SAAQ,OAAO,IAAI,EAAE;AACxC,UAAI,aAAa,aAAa;AAC7B,eAAO,cAAc,kBAAkB,aAAa,WAAW,CAAC;AAAA,MACjE;AAAA,IACD;AACA,UAAM,KAAK,oBACR,MAAM,kBAAkB,CAAC,IACzB,MAAM,oBAAoB,EAAE,IAAI,QAAQ,EAAE,IAAI,MAAM,IAAI;AAE3D,UAAM,YACL,OAAO,mBAAmB,aAAa,MAAM,eAAe,CAAC,IAAI;AAElE,UAAM,aAAa,mBAAmB,GAAG;AACzC,UAAM,UAAU,GAAG,EAAE,IAAI,MAAM,IAAI,EAAE,IAAI,IAAI,IAAI,UAAU;AAC3D,UAAM,WAAW,YAAY,GAAG,mBAAmB,SAAS,CAAC,IAAI,OAAO,KAAK;AAE7E,UAAM,WAAW,MAAM,MAAM,IAAI,QAAQ;AAEzC,QAAI,UAAU;AACb,UAAI,SAAS,WAAW,0BAA0B;AACjD,eAAO,cAAc,kBAAkB,SAAS,GAAG,EAAE,eAAe,oBAAoB,CAAC;AAAA,MAC1F;AAEA,UAAI,SAAS,gBAAgB,IAAI;AAChC,eAAO,cAAc,kBAAkB,oBAAoB,CAAC;AAAA,MAC7D;AAEA,UAAI,SAAS,UAAU;AACtB,cAAM,SAAS,YAAY,KAAK,CAAC;AACjC,eAAO,eAAe,SAAS,QAAQ;AAAA,MACxC;AAGA,YAAM,MAAM,OAAO,QAAQ;AAAA,IAC5B;AAEA,UAAM,SAAS;AAAA,MACd;AAAA,MACA,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,WAAW,KAAK,IAAI;AAAA,IACrB;AAEA,UAAM,SAAS,MAAM,MAAM,KAAK,UAAU,MAAM;AAChD,QAAI,CAAC,QAAQ;AACZ,aAAO,cAAc,kBAAkB,SAAS,GAAG,EAAE,eAAe,oBAAoB,CAAC;AAAA,IAC1F;AAEA,MAAE,IAAI,kBAAkB,GAAG;AAC3B,UAAM,SAAS,aAAa,KAAK,CAAC;AAElC,QAAI;AACH,YAAM,KAAK;AAAA,IACZ,SAAS,KAAK;AACb,YAAM,MAAM,OAAO,QAAQ;AAC3B,YAAM;AAAA,IACP;AAEA,UAAM,MAAM,EAAE;AACd,QAAI,CAAC,IAAI,IAAI;AAEZ,YAAM,MAAM,OAAO,QAAQ;AAC3B;AAAA,IACD;AAEA,UAAM,UAAU,MAAM,IAAI,KAAK;AAC/B,UAAM,aAAqC,CAAC;AAC5C,QAAI,QAAQ,QAAQ,CAAC,GAAG,MAAM;AAC7B,UAAI,CAAC,uBAAuB,IAAI,EAAE,YAAY,CAAC,GAAG;AACjD,mBAAW,CAAC,IAAI;AAAA,MACjB;AAAA,IACD,CAAC;AAED,UAAM,iBAAiC;AAAA,MACtC,QAAQ,IAAI;AAAA,MACZ,SAAS;AAAA,MACT,MAAM;AAAA,IACP;AAEA,UAAM,MAAM,SAAS,UAAU,cAAc;AAG7C,MAAE,MAAM,IAAI,SAAS,SAAS;AAAA,MAC7B,QAAQ,IAAI;AAAA,MACZ,SAAS,IAAI;AAAA,IACd,CAAC;AAAA,EACF,CAAC;AACF;AAGA,eAAe,SACd,IACA,KACA,GACgB;AAChB,MAAI,CAAC,GAAI;AACT,MAAI;AACH,UAAM,GAAG,KAAK,CAAC;AAAA,EAChB,QAAQ;AAAA,EAER;AACD;AAEA,SAAS,eAAe,QAAwB;AAC/C,QAAM,UAAU,IAAI,QAAQ,OAAO,OAAO;AAC1C,UAAQ,IAAI,eAAe,MAAM;AAEjC,SAAO,IAAI,SAAS,OAAO,MAAM;AAAA,IAChC,QAAQ,gBAAgB,OAAO,MAAM;AAAA,IACrC;AAAA,EACD,CAAC;AACF;","names":["encoder","key"]}
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as hono from 'hono';
2
- import { I as IdempotencyOptions, a as IdempotencyEnv } from './types-gTE2gccV.cjs';
3
- export { b as IdempotencyErrorCode, c as IdempotencyErrors, d as IdempotencyRecord, e as IdempotencyStore, P as ProblemDetail, S as StoredResponse, f as clampHttpStatus, p as problemResponse } from './types-gTE2gccV.cjs';
2
+ import { I as IdempotencyOptions, a as IdempotencyEnv } from './types-7IwFeI0l.cjs';
3
+ export { b as IdempotencyErrorCode, c as IdempotencyErrors, d as IdempotencyRecord, e as IdempotencyStore, P as ProblemDetail, R as RECORD_STATUS_COMPLETED, f as RECORD_STATUS_PROCESSING, S as StoredResponse, g as clampHttpStatus, p as problemResponse } from './types-7IwFeI0l.cjs';
4
4
  export { MemoryStore, MemoryStoreOptions } from './stores/memory.cjs';
5
5
  export { RedisClientLike, RedisStoreOptions } from './stores/redis.cjs';
6
6
  export { KVNamespaceLike, KVStoreOptions } from './stores/cloudflare-kv.cjs';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as hono from 'hono';
2
- import { I as IdempotencyOptions, a as IdempotencyEnv } from './types-gTE2gccV.js';
3
- export { b as IdempotencyErrorCode, c as IdempotencyErrors, d as IdempotencyRecord, e as IdempotencyStore, P as ProblemDetail, S as StoredResponse, f as clampHttpStatus, p as problemResponse } from './types-gTE2gccV.js';
2
+ import { I as IdempotencyOptions, a as IdempotencyEnv } from './types-7IwFeI0l.js';
3
+ export { b as IdempotencyErrorCode, c as IdempotencyErrors, d as IdempotencyRecord, e as IdempotencyStore, P as ProblemDetail, R as RECORD_STATUS_COMPLETED, f as RECORD_STATUS_PROCESSING, S as StoredResponse, g as clampHttpStatus, p as problemResponse } from './types-7IwFeI0l.js';
4
4
  export { MemoryStore, MemoryStoreOptions } from './stores/memory.js';
5
5
  export { RedisClientLike, RedisStoreOptions } from './stores/redis.js';
6
6
  export { KVNamespaceLike, KVStoreOptions } from './stores/cloudflare-kv.js';
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ RECORD_STATUS_COMPLETED,
3
+ RECORD_STATUS_PROCESSING
4
+ } from "./chunk-C2JZZSOP.js";
5
+
1
6
  // src/middleware.ts
2
7
  import { createMiddleware } from "hono/factory";
3
8
 
@@ -32,11 +37,12 @@ function problemResponse(problem, extraHeaders) {
32
37
  return new Response(body, {
33
38
  status,
34
39
  headers: {
35
- "Content-Type": "application/problem+json",
40
+ "Content-Type": PROBLEM_CONTENT_TYPE,
36
41
  ...extraHeaders
37
42
  }
38
43
  });
39
44
  }
45
+ var PROBLEM_CONTENT_TYPE = "application/problem+json";
40
46
  var BASE_URL = "https://hono-idempotency.dev/errors";
41
47
  var IdempotencyErrors = {
42
48
  missingKey() {
@@ -104,6 +110,9 @@ async function generateFingerprint(method, path, body) {
104
110
  var DEFAULT_METHODS = ["POST", "PATCH"];
105
111
  var DEFAULT_MAX_KEY_LENGTH = 256;
106
112
  var EXCLUDED_STORE_HEADERS = /* @__PURE__ */ new Set(["set-cookie"]);
113
+ var DEFAULT_RETRY_AFTER = "1";
114
+ var REPLAY_HEADER = "Idempotency-Replayed";
115
+ var encoder2 = new TextEncoder();
107
116
  function idempotency(options) {
108
117
  const {
109
118
  store,
@@ -153,7 +162,7 @@ function idempotency(options) {
153
162
  }
154
163
  return next();
155
164
  }
156
- if (new TextEncoder().encode(key).length > maxKeyLength) {
165
+ if (encoder2.encode(key).length > maxKeyLength) {
157
166
  return errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));
158
167
  }
159
168
  if (maxBodySize != null) {
@@ -167,7 +176,7 @@ function idempotency(options) {
167
176
  }
168
177
  const body = await c.req.text();
169
178
  if (maxBodySize != null) {
170
- const byteLength = new TextEncoder().encode(body).length;
179
+ const byteLength = encoder2.encode(body).length;
171
180
  if (byteLength > maxBodySize) {
172
181
  return errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));
173
182
  }
@@ -179,8 +188,8 @@ function idempotency(options) {
179
188
  const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;
180
189
  const existing = await store.get(storeKey);
181
190
  if (existing) {
182
- if (existing.status === "processing") {
183
- return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
191
+ if (existing.status === RECORD_STATUS_PROCESSING) {
192
+ return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
184
193
  }
185
194
  if (existing.fingerprint !== fp) {
186
195
  return errorResponse(IdempotencyErrors.fingerprintMismatch());
@@ -194,12 +203,12 @@ function idempotency(options) {
194
203
  const record = {
195
204
  key,
196
205
  fingerprint: fp,
197
- status: "processing",
206
+ status: RECORD_STATUS_PROCESSING,
198
207
  createdAt: Date.now()
199
208
  };
200
209
  const locked = await store.lock(storeKey, record);
201
210
  if (!locked) {
202
- return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
211
+ return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
203
212
  }
204
213
  c.set("idempotencyKey", key);
205
214
  await safeHook(onCacheMiss, key, c);
@@ -242,7 +251,7 @@ async function safeHook(fn, key, c) {
242
251
  }
243
252
  function replayResponse(stored) {
244
253
  const headers = new Headers(stored.headers);
245
- headers.set("Idempotency-Replayed", "true");
254
+ headers.set(REPLAY_HEADER, "true");
246
255
  return new Response(stored.body, {
247
256
  status: clampHttpStatus(stored.status),
248
257
  headers
@@ -250,6 +259,8 @@ function replayResponse(stored) {
250
259
  }
251
260
  export {
252
261
  IdempotencyErrors,
262
+ RECORD_STATUS_COMPLETED,
263
+ RECORD_STATUS_PROCESSING,
253
264
  clampHttpStatus,
254
265
  idempotency,
255
266
  problemResponse
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/middleware.ts","../src/compat.ts","../src/errors.ts","../src/fingerprint.ts"],"sourcesContent":["import { createMiddleware } from \"hono/factory\";\nimport { getHonoProblemDetails } from \"./compat.js\";\nimport {\n\tIdempotencyErrors,\n\ttype ProblemDetail,\n\tclampHttpStatus,\n\tproblemResponse,\n} from \"./errors.js\";\nimport { generateFingerprint } from \"./fingerprint.js\";\nimport type { IdempotencyEnv, IdempotencyOptions, StoredResponse } from \"./types.js\";\n\nconst DEFAULT_METHODS = [\"POST\", \"PATCH\"];\nconst DEFAULT_MAX_KEY_LENGTH = 256;\n// Headers unsafe to replay — session cookies could leak across users\nconst EXCLUDED_STORE_HEADERS = new Set([\"set-cookie\"]);\n\nexport function idempotency(options: IdempotencyOptions) {\n\tconst {\n\t\tstore,\n\t\theaderName = \"Idempotency-Key\",\n\t\tfingerprint: customFingerprint,\n\t\trequired = false,\n\t\tmethods = DEFAULT_METHODS,\n\t\tmaxKeyLength = DEFAULT_MAX_KEY_LENGTH,\n\t\tskipRequest,\n\t\tonError,\n\t\tcacheKeyPrefix,\n\t\tmaxBodySize,\n\t\tonCacheHit,\n\t\tonCacheMiss,\n\t} = options;\n\n\treturn createMiddleware<IdempotencyEnv>(async (c, next) => {\n\t\tif (!methods.includes(c.req.method)) {\n\t\t\treturn next();\n\t\t}\n\n\t\tif (skipRequest && (await skipRequest(c))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst errorResponse = async (problem: ProblemDetail, extraHeaders?: Record<string, string>) => {\n\t\t\tif (onError) return onError(problem, c);\n\t\t\tconst pd = await getHonoProblemDetails();\n\t\t\tif (pd) {\n\t\t\t\tconst response = pd\n\t\t\t\t\t.problemDetails({\n\t\t\t\t\t\ttype: problem.type,\n\t\t\t\t\t\ttitle: problem.title,\n\t\t\t\t\t\tstatus: problem.status,\n\t\t\t\t\t\tdetail: problem.detail,\n\t\t\t\t\t\textensions: { code: problem.code },\n\t\t\t\t\t})\n\t\t\t\t\t.getResponse();\n\t\t\t\tif (extraHeaders) {\n\t\t\t\t\tfor (const [key, value] of Object.entries(extraHeaders)) {\n\t\t\t\t\t\tresponse.headers.set(key, value);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t}\n\t\t\treturn problemResponse(problem, extraHeaders);\n\t\t};\n\n\t\tconst key = c.req.header(headerName);\n\n\t\tif (!key) {\n\t\t\tif (required) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.missingKey());\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tif (new TextEncoder().encode(key).length > maxKeyLength) {\n\t\t\treturn errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));\n\t\t}\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst cl = c.req.header(\"Content-Length\");\n\t\t\tif (cl) {\n\t\t\t\tconst parsed = Number.parseInt(cl, 10);\n\t\t\t\tif (parsed < 0 || parsed > maxBodySize) {\n\t\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst body = await c.req.text();\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst byteLength = new TextEncoder().encode(body).length;\n\t\t\tif (byteLength > maxBodySize) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t}\n\t\t}\n\t\tconst fp = customFingerprint\n\t\t\t? await customFingerprint(c)\n\t\t\t: await generateFingerprint(c.req.method, c.req.path, body);\n\n\t\tconst rawPrefix =\n\t\t\ttypeof cacheKeyPrefix === \"function\" ? await cacheKeyPrefix(c) : cacheKeyPrefix;\n\t\t// Encode user-controlled components to prevent delimiter injection\n\t\tconst encodedKey = encodeURIComponent(key);\n\t\tconst baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;\n\t\tconst storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;\n\n\t\tconst existing = await store.get(storeKey);\n\n\t\tif (existing) {\n\t\t\tif (existing.status === \"processing\") {\n\t\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": \"1\" });\n\t\t\t}\n\n\t\t\tif (existing.fingerprint !== fp) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.fingerprintMismatch());\n\t\t\t}\n\n\t\t\tif (existing.response) {\n\t\t\t\tawait safeHook(onCacheHit, key, c);\n\t\t\t\treturn replayResponse(existing.response);\n\t\t\t}\n\n\t\t\t// Completed but no response — corrupt record; delete so lock() can re-acquire\n\t\t\tawait store.delete(storeKey);\n\t\t}\n\n\t\tconst record = {\n\t\t\tkey,\n\t\t\tfingerprint: fp,\n\t\t\tstatus: \"processing\" as const,\n\t\t\tcreatedAt: Date.now(),\n\t\t};\n\n\t\tconst locked = await store.lock(storeKey, record);\n\t\tif (!locked) {\n\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": \"1\" });\n\t\t}\n\n\t\tc.set(\"idempotencyKey\", key);\n\t\tawait safeHook(onCacheMiss, key, c);\n\n\t\ttry {\n\t\t\tawait next();\n\t\t} catch (err) {\n\t\t\tawait store.delete(storeKey);\n\t\t\tthrow err;\n\t\t}\n\n\t\tconst res = c.res;\n\t\tif (!res.ok) {\n\t\t\t// Non-2xx: delete key (Stripe pattern) so client can retry\n\t\t\tawait store.delete(storeKey);\n\t\t\treturn;\n\t\t}\n\n\t\tconst resBody = await res.text();\n\t\tconst resHeaders: Record<string, string> = {};\n\t\tres.headers.forEach((v, k) => {\n\t\t\tif (!EXCLUDED_STORE_HEADERS.has(k.toLowerCase())) {\n\t\t\t\tresHeaders[k] = v;\n\t\t\t}\n\t\t});\n\n\t\tconst storedResponse: StoredResponse = {\n\t\t\tstatus: res.status,\n\t\t\theaders: resHeaders,\n\t\t\tbody: resBody,\n\t\t};\n\n\t\tawait store.complete(storeKey, storedResponse);\n\n\t\t// Rebuild response since we consumed body\n\t\tc.res = new Response(resBody, {\n\t\t\tstatus: res.status,\n\t\t\theaders: res.headers,\n\t\t});\n\t});\n}\n\n// Hook errors must not break idempotency guarantees\nasync function safeHook<C>(\n\tfn: ((key: string, c: C) => void | Promise<void>) | undefined,\n\tkey: string,\n\tc: C,\n): Promise<void> {\n\tif (!fn) return;\n\ttry {\n\t\tawait fn(key, c);\n\t} catch {\n\t\t// Swallow — hooks are for observability, not control flow\n\t}\n}\n\nfunction replayResponse(stored: StoredResponse) {\n\tconst headers = new Headers(stored.headers);\n\theaders.set(\"Idempotency-Replayed\", \"true\");\n\n\treturn new Response(stored.body, {\n\t\tstatus: clampHttpStatus(stored.status),\n\t\theaders,\n\t});\n}\n","type HonoProblemDetails = typeof import(\"hono-problem-details\");\n\nlet cached: HonoProblemDetails | null | undefined;\n\nexport async function getHonoProblemDetails(): Promise<HonoProblemDetails | null> {\n\tif (cached === undefined) {\n\t\ttry {\n\t\t\tcached = await import(\"hono-problem-details\");\n\t\t} catch {\n\t\t\tcached = null;\n\t\t}\n\t}\n\treturn cached;\n}\n","export type IdempotencyErrorCode =\n\t| \"MISSING_KEY\"\n\t| \"KEY_TOO_LONG\"\n\t| \"BODY_TOO_LARGE\"\n\t| \"FINGERPRINT_MISMATCH\"\n\t| \"CONFLICT\";\n\nexport interface ProblemDetail {\n\ttype: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail: string;\n\tcode: IdempotencyErrorCode;\n}\n\n/** Ensures status is a valid HTTP status code (200-599), defaults to 500. */\nexport function clampHttpStatus(status: number): number {\n\tif (Number.isNaN(status) || status < 200 || status > 599) return 500;\n\treturn status;\n}\n\nexport function problemResponse(\n\tproblem: ProblemDetail,\n\textraHeaders?: Record<string, string>,\n): Response {\n\tlet body: string;\n\tlet status: number;\n\ttry {\n\t\tbody = JSON.stringify(problem);\n\t\tstatus = clampHttpStatus(problem.status);\n\t} catch {\n\t\tbody = '{\"title\":\"Internal Server Error\",\"status\":500}';\n\t\tstatus = 500;\n\t}\n\treturn new Response(body, {\n\t\tstatus,\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/problem+json\",\n\t\t\t...extraHeaders,\n\t\t},\n\t});\n}\n\nconst BASE_URL = \"https://hono-idempotency.dev/errors\";\n\nexport const IdempotencyErrors = {\n\tmissingKey(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/missing-key`,\n\t\t\ttitle: \"Idempotency-Key header is required\",\n\t\t\tstatus: 400,\n\t\t\tdetail: \"This endpoint requires an Idempotency-Key header\",\n\t\t\tcode: \"MISSING_KEY\",\n\t\t};\n\t},\n\n\tkeyTooLong(maxLength: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/key-too-long`,\n\t\t\ttitle: \"Idempotency-Key is too long\",\n\t\t\tstatus: 400,\n\t\t\tdetail: `Idempotency-Key must be at most ${maxLength} characters`,\n\t\t\tcode: \"KEY_TOO_LONG\",\n\t\t};\n\t},\n\n\tbodyTooLarge(maxSize: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/body-too-large`,\n\t\t\ttitle: \"Request body is too large\",\n\t\t\tstatus: 413,\n\t\t\tdetail: `Request body must be at most ${maxSize} bytes`,\n\t\t\tcode: \"BODY_TOO_LARGE\",\n\t\t};\n\t},\n\n\tfingerprintMismatch(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/fingerprint-mismatch`,\n\t\t\ttitle: \"Idempotency-Key is already used with a different request\",\n\t\t\tstatus: 422,\n\t\t\tdetail:\n\t\t\t\t\"A request with the same idempotency key but different parameters was already processed\",\n\t\t\tcode: \"FINGERPRINT_MISMATCH\",\n\t\t};\n\t},\n\n\tconflict(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/conflict`,\n\t\t\ttitle: \"A request is outstanding for this idempotency key\",\n\t\t\tstatus: 409,\n\t\t\tdetail: \"A request with the same idempotency key is currently being processed\",\n\t\t\tcode: \"CONFLICT\",\n\t\t};\n\t},\n} as const;\n","const encoder = new TextEncoder();\nconst HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, \"0\"));\n\nexport async function generateFingerprint(\n\tmethod: string,\n\tpath: string,\n\tbody: string,\n): Promise<string> {\n\tconst data = `${method}:${path}:${body}`;\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", encoder.encode(data));\n\tconst bytes = new Uint8Array(hashBuffer);\n\tlet hex = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\thex += HEX_TABLE[bytes[i]];\n\t}\n\treturn hex;\n}\n"],"mappings":"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","names":["key"]}
1
+ {"version":3,"sources":["../src/middleware.ts","../src/compat.ts","../src/errors.ts","../src/fingerprint.ts"],"sourcesContent":["import { createMiddleware } from \"hono/factory\";\nimport { getHonoProblemDetails } from \"./compat.js\";\nimport {\n\tIdempotencyErrors,\n\ttype ProblemDetail,\n\tclampHttpStatus,\n\tproblemResponse,\n} from \"./errors.js\";\nimport { generateFingerprint } from \"./fingerprint.js\";\nimport {\n\ttype IdempotencyEnv,\n\ttype IdempotencyOptions,\n\tRECORD_STATUS_PROCESSING,\n\ttype StoredResponse,\n} from \"./types.js\";\n\nconst DEFAULT_METHODS = [\"POST\", \"PATCH\"];\nconst DEFAULT_MAX_KEY_LENGTH = 256;\n// Headers unsafe to replay — session cookies could leak across users\nconst EXCLUDED_STORE_HEADERS = new Set([\"set-cookie\"]);\nconst DEFAULT_RETRY_AFTER = \"1\";\nconst REPLAY_HEADER = \"Idempotency-Replayed\";\nconst encoder = new TextEncoder();\n\nexport function idempotency(options: IdempotencyOptions) {\n\tconst {\n\t\tstore,\n\t\theaderName = \"Idempotency-Key\",\n\t\tfingerprint: customFingerprint,\n\t\trequired = false,\n\t\tmethods = DEFAULT_METHODS,\n\t\tmaxKeyLength = DEFAULT_MAX_KEY_LENGTH,\n\t\tskipRequest,\n\t\tonError,\n\t\tcacheKeyPrefix,\n\t\tmaxBodySize,\n\t\tonCacheHit,\n\t\tonCacheMiss,\n\t} = options;\n\n\treturn createMiddleware<IdempotencyEnv>(async (c, next) => {\n\t\tif (!methods.includes(c.req.method)) {\n\t\t\treturn next();\n\t\t}\n\n\t\tif (skipRequest && (await skipRequest(c))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst errorResponse = async (problem: ProblemDetail, extraHeaders?: Record<string, string>) => {\n\t\t\tif (onError) return onError(problem, c);\n\t\t\tconst pd = await getHonoProblemDetails();\n\t\t\tif (pd) {\n\t\t\t\tconst response = pd\n\t\t\t\t\t.problemDetails({\n\t\t\t\t\t\ttype: problem.type,\n\t\t\t\t\t\ttitle: problem.title,\n\t\t\t\t\t\tstatus: problem.status,\n\t\t\t\t\t\tdetail: problem.detail,\n\t\t\t\t\t\textensions: { code: problem.code },\n\t\t\t\t\t})\n\t\t\t\t\t.getResponse();\n\t\t\t\tif (extraHeaders) {\n\t\t\t\t\tfor (const [key, value] of Object.entries(extraHeaders)) {\n\t\t\t\t\t\tresponse.headers.set(key, value);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn response;\n\t\t\t}\n\t\t\treturn problemResponse(problem, extraHeaders);\n\t\t};\n\n\t\tconst key = c.req.header(headerName);\n\n\t\tif (!key) {\n\t\t\tif (required) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.missingKey());\n\t\t\t}\n\t\t\treturn next();\n\t\t}\n\n\t\tif (encoder.encode(key).length > maxKeyLength) {\n\t\t\treturn errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));\n\t\t}\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst cl = c.req.header(\"Content-Length\");\n\t\t\tif (cl) {\n\t\t\t\tconst parsed = Number.parseInt(cl, 10);\n\t\t\t\tif (parsed < 0 || parsed > maxBodySize) {\n\t\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst body = await c.req.text();\n\n\t\tif (maxBodySize != null) {\n\t\t\tconst byteLength = encoder.encode(body).length;\n\t\t\tif (byteLength > maxBodySize) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t}\n\t\t}\n\t\tconst fp = customFingerprint\n\t\t\t? await customFingerprint(c)\n\t\t\t: await generateFingerprint(c.req.method, c.req.path, body);\n\n\t\tconst rawPrefix =\n\t\t\ttypeof cacheKeyPrefix === \"function\" ? await cacheKeyPrefix(c) : cacheKeyPrefix;\n\t\t// Encode user-controlled components to prevent delimiter injection\n\t\tconst encodedKey = encodeURIComponent(key);\n\t\tconst baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;\n\t\tconst storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;\n\n\t\tconst existing = await store.get(storeKey);\n\n\t\tif (existing) {\n\t\t\tif (existing.status === RECORD_STATUS_PROCESSING) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": DEFAULT_RETRY_AFTER });\n\t\t\t}\n\n\t\t\tif (existing.fingerprint !== fp) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.fingerprintMismatch());\n\t\t\t}\n\n\t\t\tif (existing.response) {\n\t\t\t\tawait safeHook(onCacheHit, key, c);\n\t\t\t\treturn replayResponse(existing.response);\n\t\t\t}\n\n\t\t\t// Completed but no response — corrupt record; delete so lock() can re-acquire\n\t\t\tawait store.delete(storeKey);\n\t\t}\n\n\t\tconst record = {\n\t\t\tkey,\n\t\t\tfingerprint: fp,\n\t\t\tstatus: RECORD_STATUS_PROCESSING,\n\t\t\tcreatedAt: Date.now(),\n\t\t};\n\n\t\tconst locked = await store.lock(storeKey, record);\n\t\tif (!locked) {\n\t\t\treturn errorResponse(IdempotencyErrors.conflict(), { \"Retry-After\": DEFAULT_RETRY_AFTER });\n\t\t}\n\n\t\tc.set(\"idempotencyKey\", key);\n\t\tawait safeHook(onCacheMiss, key, c);\n\n\t\ttry {\n\t\t\tawait next();\n\t\t} catch (err) {\n\t\t\tawait store.delete(storeKey);\n\t\t\tthrow err;\n\t\t}\n\n\t\tconst res = c.res;\n\t\tif (!res.ok) {\n\t\t\t// Non-2xx: delete key (Stripe pattern) so client can retry\n\t\t\tawait store.delete(storeKey);\n\t\t\treturn;\n\t\t}\n\n\t\tconst resBody = await res.text();\n\t\tconst resHeaders: Record<string, string> = {};\n\t\tres.headers.forEach((v, k) => {\n\t\t\tif (!EXCLUDED_STORE_HEADERS.has(k.toLowerCase())) {\n\t\t\t\tresHeaders[k] = v;\n\t\t\t}\n\t\t});\n\n\t\tconst storedResponse: StoredResponse = {\n\t\t\tstatus: res.status,\n\t\t\theaders: resHeaders,\n\t\t\tbody: resBody,\n\t\t};\n\n\t\tawait store.complete(storeKey, storedResponse);\n\n\t\t// Rebuild response since we consumed body\n\t\tc.res = new Response(resBody, {\n\t\t\tstatus: res.status,\n\t\t\theaders: res.headers,\n\t\t});\n\t});\n}\n\n// Hook errors must not break idempotency guarantees\nasync function safeHook<C>(\n\tfn: ((key: string, c: C) => void | Promise<void>) | undefined,\n\tkey: string,\n\tc: C,\n): Promise<void> {\n\tif (!fn) return;\n\ttry {\n\t\tawait fn(key, c);\n\t} catch {\n\t\t// Swallow — hooks are for observability, not control flow\n\t}\n}\n\nfunction replayResponse(stored: StoredResponse) {\n\tconst headers = new Headers(stored.headers);\n\theaders.set(REPLAY_HEADER, \"true\");\n\n\treturn new Response(stored.body, {\n\t\tstatus: clampHttpStatus(stored.status),\n\t\theaders,\n\t});\n}\n","type HonoProblemDetails = typeof import(\"hono-problem-details\");\n\nlet cached: HonoProblemDetails | null | undefined;\n\nexport async function getHonoProblemDetails(): Promise<HonoProblemDetails | null> {\n\tif (cached === undefined) {\n\t\ttry {\n\t\t\tcached = await import(\"hono-problem-details\");\n\t\t} catch {\n\t\t\tcached = null;\n\t\t}\n\t}\n\treturn cached;\n}\n","export type IdempotencyErrorCode =\n\t| \"MISSING_KEY\"\n\t| \"KEY_TOO_LONG\"\n\t| \"BODY_TOO_LARGE\"\n\t| \"FINGERPRINT_MISMATCH\"\n\t| \"CONFLICT\";\n\nexport interface ProblemDetail {\n\ttype: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail: string;\n\tcode: IdempotencyErrorCode;\n}\n\n/** Ensures status is a valid HTTP status code (200-599), defaults to 500. */\nexport function clampHttpStatus(status: number): number {\n\tif (Number.isNaN(status) || status < 200 || status > 599) return 500;\n\treturn status;\n}\n\nexport function problemResponse(\n\tproblem: ProblemDetail,\n\textraHeaders?: Record<string, string>,\n): Response {\n\tlet body: string;\n\tlet status: number;\n\ttry {\n\t\tbody = JSON.stringify(problem);\n\t\tstatus = clampHttpStatus(problem.status);\n\t} catch {\n\t\tbody = '{\"title\":\"Internal Server Error\",\"status\":500}';\n\t\tstatus = 500;\n\t}\n\treturn new Response(body, {\n\t\tstatus,\n\t\theaders: {\n\t\t\t\"Content-Type\": PROBLEM_CONTENT_TYPE,\n\t\t\t...extraHeaders,\n\t\t},\n\t});\n}\n\nconst PROBLEM_CONTENT_TYPE = \"application/problem+json\";\nconst BASE_URL = \"https://hono-idempotency.dev/errors\";\n\nexport const IdempotencyErrors = {\n\tmissingKey(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/missing-key`,\n\t\t\ttitle: \"Idempotency-Key header is required\",\n\t\t\tstatus: 400,\n\t\t\tdetail: \"This endpoint requires an Idempotency-Key header\",\n\t\t\tcode: \"MISSING_KEY\",\n\t\t};\n\t},\n\n\tkeyTooLong(maxLength: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/key-too-long`,\n\t\t\ttitle: \"Idempotency-Key is too long\",\n\t\t\tstatus: 400,\n\t\t\tdetail: `Idempotency-Key must be at most ${maxLength} characters`,\n\t\t\tcode: \"KEY_TOO_LONG\",\n\t\t};\n\t},\n\n\tbodyTooLarge(maxSize: number): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/body-too-large`,\n\t\t\ttitle: \"Request body is too large\",\n\t\t\tstatus: 413,\n\t\t\tdetail: `Request body must be at most ${maxSize} bytes`,\n\t\t\tcode: \"BODY_TOO_LARGE\",\n\t\t};\n\t},\n\n\tfingerprintMismatch(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/fingerprint-mismatch`,\n\t\t\ttitle: \"Idempotency-Key is already used with a different request\",\n\t\t\tstatus: 422,\n\t\t\tdetail:\n\t\t\t\t\"A request with the same idempotency key but different parameters was already processed\",\n\t\t\tcode: \"FINGERPRINT_MISMATCH\",\n\t\t};\n\t},\n\n\tconflict(): ProblemDetail {\n\t\treturn {\n\t\t\ttype: `${BASE_URL}/conflict`,\n\t\t\ttitle: \"A request is outstanding for this idempotency key\",\n\t\t\tstatus: 409,\n\t\t\tdetail: \"A request with the same idempotency key is currently being processed\",\n\t\t\tcode: \"CONFLICT\",\n\t\t};\n\t},\n} as const;\n","const encoder = new TextEncoder();\nconst HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, \"0\"));\n\nexport async function generateFingerprint(\n\tmethod: string,\n\tpath: string,\n\tbody: string,\n): Promise<string> {\n\tconst data = `${method}:${path}:${body}`;\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", encoder.encode(data));\n\tconst bytes = new Uint8Array(hashBuffer);\n\tlet hex = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\thex += HEX_TABLE[bytes[i]];\n\t}\n\treturn hex;\n}\n"],"mappings":";;;;;;AAAA,SAAS,wBAAwB;;;ACEjC,IAAI;AAEJ,eAAsB,wBAA4D;AACjF,MAAI,WAAW,QAAW;AACzB,QAAI;AACH,eAAS,MAAM,OAAO,sBAAsB;AAAA,IAC7C,QAAQ;AACP,eAAS;AAAA,IACV;AAAA,EACD;AACA,SAAO;AACR;;;ACGO,SAAS,gBAAgB,QAAwB;AACvD,MAAI,OAAO,MAAM,MAAM,KAAK,SAAS,OAAO,SAAS,IAAK,QAAO;AACjE,SAAO;AACR;AAEO,SAAS,gBACf,SACA,cACW;AACX,MAAI;AACJ,MAAI;AACJ,MAAI;AACH,WAAO,KAAK,UAAU,OAAO;AAC7B,aAAS,gBAAgB,QAAQ,MAAM;AAAA,EACxC,QAAQ;AACP,WAAO;AACP,aAAS;AAAA,EACV;AACA,SAAO,IAAI,SAAS,MAAM;AAAA,IACzB;AAAA,IACA,SAAS;AAAA,MACR,gBAAgB;AAAA,MAChB,GAAG;AAAA,IACJ;AAAA,EACD,CAAC;AACF;AAEA,IAAM,uBAAuB;AAC7B,IAAM,WAAW;AAEV,IAAM,oBAAoB;AAAA,EAChC,aAA4B;AAC3B,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,WAAW,WAAkC;AAC5C,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ,mCAAmC,SAAS;AAAA,MACpD,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,aAAa,SAAgC;AAC5C,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ,gCAAgC,OAAO;AAAA,MAC/C,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,sBAAqC;AACpC,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QACC;AAAA,MACD,MAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,WAA0B;AACzB,WAAO;AAAA,MACN,MAAM,GAAG,QAAQ;AAAA,MACjB,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,MAAM;AAAA,IACP;AAAA,EACD;AACD;;;ACjGA,IAAM,UAAU,IAAI,YAAY;AAChC,IAAM,YAAY,MAAM,KAAK,EAAE,QAAQ,IAAI,GAAG,CAAC,GAAG,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC;AAEvF,eAAsB,oBACrB,QACA,MACA,MACkB;AAClB,QAAM,OAAO,GAAG,MAAM,IAAI,IAAI,IAAI,IAAI;AACtC,QAAM,aAAa,MAAM,OAAO,OAAO,OAAO,WAAW,QAAQ,OAAO,IAAI,CAAC;AAC7E,QAAM,QAAQ,IAAI,WAAW,UAAU;AACvC,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACtC,WAAO,UAAU,MAAM,CAAC,CAAC;AAAA,EAC1B;AACA,SAAO;AACR;;;AHAA,IAAM,kBAAkB,CAAC,QAAQ,OAAO;AACxC,IAAM,yBAAyB;AAE/B,IAAM,yBAAyB,oBAAI,IAAI,CAAC,YAAY,CAAC;AACrD,IAAM,sBAAsB;AAC5B,IAAM,gBAAgB;AACtB,IAAMA,WAAU,IAAI,YAAY;AAEzB,SAAS,YAAY,SAA6B;AACxD,QAAM;AAAA,IACL;AAAA,IACA,aAAa;AAAA,IACb,aAAa;AAAA,IACb,WAAW;AAAA,IACX,UAAU;AAAA,IACV,eAAe;AAAA,IACf;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACD,IAAI;AAEJ,SAAO,iBAAiC,OAAO,GAAG,SAAS;AAC1D,QAAI,CAAC,QAAQ,SAAS,EAAE,IAAI,MAAM,GAAG;AACpC,aAAO,KAAK;AAAA,IACb;AAEA,QAAI,eAAgB,MAAM,YAAY,CAAC,GAAI;AAC1C,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,gBAAgB,OAAO,SAAwB,iBAA0C;AAC9F,UAAI,QAAS,QAAO,QAAQ,SAAS,CAAC;AACtC,YAAM,KAAK,MAAM,sBAAsB;AACvC,UAAI,IAAI;AACP,cAAM,WAAW,GACf,eAAe;AAAA,UACf,MAAM,QAAQ;AAAA,UACd,OAAO,QAAQ;AAAA,UACf,QAAQ,QAAQ;AAAA,UAChB,QAAQ,QAAQ;AAAA,UAChB,YAAY,EAAE,MAAM,QAAQ,KAAK;AAAA,QAClC,CAAC,EACA,YAAY;AACd,YAAI,cAAc;AACjB,qBAAW,CAACC,MAAK,KAAK,KAAK,OAAO,QAAQ,YAAY,GAAG;AACxD,qBAAS,QAAQ,IAAIA,MAAK,KAAK;AAAA,UAChC;AAAA,QACD;AACA,eAAO;AAAA,MACR;AACA,aAAO,gBAAgB,SAAS,YAAY;AAAA,IAC7C;AAEA,UAAM,MAAM,EAAE,IAAI,OAAO,UAAU;AAEnC,QAAI,CAAC,KAAK;AACT,UAAI,UAAU;AACb,eAAO,cAAc,kBAAkB,WAAW,CAAC;AAAA,MACpD;AACA,aAAO,KAAK;AAAA,IACb;AAEA,QAAID,SAAQ,OAAO,GAAG,EAAE,SAAS,cAAc;AAC9C,aAAO,cAAc,kBAAkB,WAAW,YAAY,CAAC;AAAA,IAChE;AAEA,QAAI,eAAe,MAAM;AACxB,YAAM,KAAK,EAAE,IAAI,OAAO,gBAAgB;AACxC,UAAI,IAAI;AACP,cAAM,SAAS,OAAO,SAAS,IAAI,EAAE;AACrC,YAAI,SAAS,KAAK,SAAS,aAAa;AACvC,iBAAO,cAAc,kBAAkB,aAAa,WAAW,CAAC;AAAA,QACjE;AAAA,MACD;AAAA,IACD;AAEA,UAAM,OAAO,MAAM,EAAE,IAAI,KAAK;AAE9B,QAAI,eAAe,MAAM;AACxB,YAAM,aAAaA,SAAQ,OAAO,IAAI,EAAE;AACxC,UAAI,aAAa,aAAa;AAC7B,eAAO,cAAc,kBAAkB,aAAa,WAAW,CAAC;AAAA,MACjE;AAAA,IACD;AACA,UAAM,KAAK,oBACR,MAAM,kBAAkB,CAAC,IACzB,MAAM,oBAAoB,EAAE,IAAI,QAAQ,EAAE,IAAI,MAAM,IAAI;AAE3D,UAAM,YACL,OAAO,mBAAmB,aAAa,MAAM,eAAe,CAAC,IAAI;AAElE,UAAM,aAAa,mBAAmB,GAAG;AACzC,UAAM,UAAU,GAAG,EAAE,IAAI,MAAM,IAAI,EAAE,IAAI,IAAI,IAAI,UAAU;AAC3D,UAAM,WAAW,YAAY,GAAG,mBAAmB,SAAS,CAAC,IAAI,OAAO,KAAK;AAE7E,UAAM,WAAW,MAAM,MAAM,IAAI,QAAQ;AAEzC,QAAI,UAAU;AACb,UAAI,SAAS,WAAW,0BAA0B;AACjD,eAAO,cAAc,kBAAkB,SAAS,GAAG,EAAE,eAAe,oBAAoB,CAAC;AAAA,MAC1F;AAEA,UAAI,SAAS,gBAAgB,IAAI;AAChC,eAAO,cAAc,kBAAkB,oBAAoB,CAAC;AAAA,MAC7D;AAEA,UAAI,SAAS,UAAU;AACtB,cAAM,SAAS,YAAY,KAAK,CAAC;AACjC,eAAO,eAAe,SAAS,QAAQ;AAAA,MACxC;AAGA,YAAM,MAAM,OAAO,QAAQ;AAAA,IAC5B;AAEA,UAAM,SAAS;AAAA,MACd;AAAA,MACA,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,WAAW,KAAK,IAAI;AAAA,IACrB;AAEA,UAAM,SAAS,MAAM,MAAM,KAAK,UAAU,MAAM;AAChD,QAAI,CAAC,QAAQ;AACZ,aAAO,cAAc,kBAAkB,SAAS,GAAG,EAAE,eAAe,oBAAoB,CAAC;AAAA,IAC1F;AAEA,MAAE,IAAI,kBAAkB,GAAG;AAC3B,UAAM,SAAS,aAAa,KAAK,CAAC;AAElC,QAAI;AACH,YAAM,KAAK;AAAA,IACZ,SAAS,KAAK;AACb,YAAM,MAAM,OAAO,QAAQ;AAC3B,YAAM;AAAA,IACP;AAEA,UAAM,MAAM,EAAE;AACd,QAAI,CAAC,IAAI,IAAI;AAEZ,YAAM,MAAM,OAAO,QAAQ;AAC3B;AAAA,IACD;AAEA,UAAM,UAAU,MAAM,IAAI,KAAK;AAC/B,UAAM,aAAqC,CAAC;AAC5C,QAAI,QAAQ,QAAQ,CAAC,GAAG,MAAM;AAC7B,UAAI,CAAC,uBAAuB,IAAI,EAAE,YAAY,CAAC,GAAG;AACjD,mBAAW,CAAC,IAAI;AAAA,MACjB;AAAA,IACD,CAAC;AAED,UAAM,iBAAiC;AAAA,MACtC,QAAQ,IAAI;AAAA,MACZ,SAAS;AAAA,MACT,MAAM;AAAA,IACP;AAEA,UAAM,MAAM,SAAS,UAAU,cAAc;AAG7C,MAAE,MAAM,IAAI,SAAS,SAAS;AAAA,MAC7B,QAAQ,IAAI;AAAA,MACZ,SAAS,IAAI;AAAA,IACd,CAAC;AAAA,EACF,CAAC;AACF;AAGA,eAAe,SACd,IACA,KACA,GACgB;AAChB,MAAI,CAAC,GAAI;AACT,MAAI;AACH,UAAM,GAAG,KAAK,CAAC;AAAA,EAChB,QAAQ;AAAA,EAER;AACD;AAEA,SAAS,eAAe,QAAwB;AAC/C,QAAM,UAAU,IAAI,QAAQ,OAAO,OAAO;AAC1C,UAAQ,IAAI,eAAe,MAAM;AAEjC,SAAO,IAAI,SAAS,OAAO,MAAM;AAAA,IAChC,QAAQ,gBAAgB,OAAO,MAAM;AAAA,IACrC;AAAA,EACD,CAAC;AACF;","names":["encoder","key"]}
@@ -23,6 +23,11 @@ __export(cloudflare_d1_exports, {
23
23
  d1Store: () => d1Store
24
24
  });
25
25
  module.exports = __toCommonJS(cloudflare_d1_exports);
26
+
27
+ // src/types.ts
28
+ var RECORD_STATUS_COMPLETED = "completed";
29
+
30
+ // src/stores/cloudflare-d1.ts
26
31
  var DEFAULT_TABLE = "idempotency_keys";
27
32
  var DEFAULT_TTL = 86400;
28
33
  var TABLE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
@@ -48,13 +53,22 @@ function d1Store(options) {
48
53
  const ttlThreshold = () => {
49
54
  return Date.now() - ttl * 1e3;
50
55
  };
51
- const toRecord = (row) => ({
52
- key: row.key,
53
- fingerprint: row.fingerprint,
54
- status: row.status,
55
- response: row.response ? JSON.parse(row.response) : void 0,
56
- createdAt: row.created_at
57
- });
56
+ const toRecord = (row) => {
57
+ let response;
58
+ if (row.response) {
59
+ try {
60
+ response = JSON.parse(row.response);
61
+ } catch {
62
+ }
63
+ }
64
+ return {
65
+ key: row.key,
66
+ fingerprint: row.fingerprint,
67
+ status: row.status,
68
+ response,
69
+ createdAt: row.created_at
70
+ };
71
+ };
58
72
  return {
59
73
  async get(key) {
60
74
  await ensureTable();
@@ -79,7 +93,7 @@ function d1Store(options) {
79
93
  } catch {
80
94
  return;
81
95
  }
82
- await db.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`).bind("completed", serialized, key).run();
96
+ await db.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`).bind(RECORD_STATUS_COMPLETED, serialized, key).run();
83
97
  },
84
98
  async delete(key) {
85
99
  await ensureTable();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/cloudflare-d1.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TABLE = \"idempotency_keys\";\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\nconst TABLE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\n/** Minimal D1Database subset used by d1Store (avoids @cloudflare/workers-types dependency). */\nexport interface D1DatabaseLike {\n\tprepare(sql: string): D1PreparedStatementLike;\n}\n\nexport interface D1PreparedStatementLike {\n\tbind(...params: unknown[]): D1PreparedStatementLike;\n\trun(): Promise<{ success: boolean; meta: { changes: number } }>;\n\tfirst(): Promise<Record<string, unknown> | null>;\n}\n\nexport interface D1StoreOptions {\n\t/** Cloudflare D1 database binding. */\n\tdatabase: D1DatabaseLike;\n\t/** Table name (default: \"idempotency_keys\"). Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/. */\n\ttableName?: string;\n\t/** TTL in seconds (default: 86400 = 24h). Expired rows are ignored by get/lock. */\n\tttl?: number;\n}\n\nexport function d1Store(options: D1StoreOptions): IdempotencyStore {\n\tconst { database: db, tableName = DEFAULT_TABLE, ttl = DEFAULT_TTL } = options;\n\n\tif (!TABLE_NAME_RE.test(tableName)) {\n\t\tthrow new Error(`Invalid table name: \"${tableName}\". Must match ${TABLE_NAME_RE}`);\n\t}\n\n\tlet initialized = false;\n\n\tconst ensureTable = async (): Promise<void> => {\n\t\tif (initialized) return;\n\t\tawait db\n\t\t\t.prepare(\n\t\t\t\t`CREATE TABLE IF NOT EXISTS ${tableName} (\n\t\t\t\tkey TEXT PRIMARY KEY,\n\t\t\t\tfingerprint TEXT NOT NULL,\n\t\t\t\tstatus TEXT NOT NULL,\n\t\t\t\tresponse TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t)`,\n\t\t\t)\n\t\t\t.run();\n\t\tinitialized = true;\n\t};\n\n\tconst ttlThreshold = (): number => {\n\t\treturn Date.now() - ttl * 1000;\n\t};\n\n\tconst toRecord = (row: Record<string, unknown>): IdempotencyRecord => ({\n\t\tkey: row.key as string,\n\t\tfingerprint: row.fingerprint as string,\n\t\tstatus: row.status as \"processing\" | \"completed\",\n\t\tresponse: row.response ? (JSON.parse(row.response as string) as StoredResponse) : undefined,\n\t\tcreatedAt: row.created_at as number,\n\t});\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tawait ensureTable();\n\t\t\tconst row = await db\n\t\t\t\t.prepare(`SELECT * FROM ${tableName} WHERE key = ? AND created_at >= ?`)\n\t\t\t\t.bind(key, ttlThreshold())\n\t\t\t\t.first();\n\t\t\tif (!row) return undefined;\n\t\t\treturn toRecord(row);\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(\n\t\t\t\t\t`INSERT OR IGNORE INTO ${tableName} (key, fingerprint, status, response, created_at)\n\t\t\t\t\tSELECT ?, ?, ?, ?, ?\n\t\t\t\t\tWHERE NOT EXISTS (SELECT 1 FROM ${tableName} WHERE key = ? AND created_at >= ?)`,\n\t\t\t\t)\n\t\t\t\t.bind(key, record.fingerprint, record.status, null, record.createdAt, key, ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes > 0;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tawait ensureTable();\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(response);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait db\n\t\t\t\t.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`)\n\t\t\t\t.bind(\"completed\", serialized, key)\n\t\t\t\t.run();\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait ensureTable();\n\t\t\tawait db.prepare(`DELETE FROM ${tableName} WHERE key = ?`).bind(key).run();\n\t\t},\n\n\t\tasync purge() {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(`DELETE FROM ${tableName} WHERE created_at < ?`)\n\t\t\t\t.bind(ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,IAAM,gBAAgB;AACtB,IAAM,cAAc;AACpB,IAAM,gBAAgB;AAsBf,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,UAAU,IAAI,YAAY,eAAe,MAAM,YAAY,IAAI;AAEvE,MAAI,CAAC,cAAc,KAAK,SAAS,GAAG;AACnC,UAAM,IAAI,MAAM,wBAAwB,SAAS,iBAAiB,aAAa,EAAE;AAAA,EAClF;AAEA,MAAI,cAAc;AAElB,QAAM,cAAc,YAA2B;AAC9C,QAAI,YAAa;AACjB,UAAM,GACJ;AAAA,MACA,8BAA8B,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOxC,EACC,IAAI;AACN,kBAAc;AAAA,EACf;AAEA,QAAM,eAAe,MAAc;AAClC,WAAO,KAAK,IAAI,IAAI,MAAM;AAAA,EAC3B;AAEA,QAAM,WAAW,CAAC,SAAqD;AAAA,IACtE,KAAK,IAAI;AAAA,IACT,aAAa,IAAI;AAAA,IACjB,QAAQ,IAAI;AAAA,IACZ,UAAU,IAAI,WAAY,KAAK,MAAM,IAAI,QAAkB,IAAuB;AAAA,IAClF,WAAW,IAAI;AAAA,EAChB;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,YAAY;AAClB,YAAM,MAAM,MAAM,GAChB,QAAQ,iBAAiB,SAAS,oCAAoC,EACtE,KAAK,KAAK,aAAa,CAAC,EACxB,MAAM;AACR,UAAI,CAAC,IAAK,QAAO;AACjB,aAAO,SAAS,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB;AAAA,QACA,yBAAyB,SAAS;AAAA;AAAA,uCAEA,SAAS;AAAA,MAC5C,EACC,KAAK,KAAK,OAAO,aAAa,OAAO,QAAQ,MAAM,OAAO,WAAW,KAAK,aAAa,CAAC,EACxF,IAAI;AACN,aAAO,OAAO,KAAK,UAAU;AAAA,IAC9B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,YAAY;AAClB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GACJ,QAAQ,UAAU,SAAS,6CAA6C,EACxE,KAAK,aAAa,YAAY,GAAG,EACjC,IAAI;AAAA,IACP;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,YAAY;AAClB,YAAM,GAAG,QAAQ,eAAe,SAAS,gBAAgB,EAAE,KAAK,GAAG,EAAE,IAAI;AAAA,IAC1E;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB,QAAQ,eAAe,SAAS,uBAAuB,EACvD,KAAK,aAAa,CAAC,EACnB,IAAI;AACN,aAAO,OAAO,KAAK;AAAA,IACpB;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/cloudflare-d1.ts","../../src/types.ts"],"sourcesContent":["import {\n\ttype IdempotencyRecord,\n\tRECORD_STATUS_COMPLETED,\n\ttype RECORD_STATUS_PROCESSING,\n\ttype StoredResponse,\n} from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TABLE = \"idempotency_keys\";\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\nconst TABLE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\n/** Minimal D1Database subset used by d1Store (avoids @cloudflare/workers-types dependency). */\nexport interface D1DatabaseLike {\n\tprepare(sql: string): D1PreparedStatementLike;\n}\n\nexport interface D1PreparedStatementLike {\n\tbind(...params: unknown[]): D1PreparedStatementLike;\n\trun(): Promise<{ success: boolean; meta: { changes: number } }>;\n\tfirst(): Promise<Record<string, unknown> | null>;\n}\n\nexport interface D1StoreOptions {\n\t/** Cloudflare D1 database binding. */\n\tdatabase: D1DatabaseLike;\n\t/** Table name (default: \"idempotency_keys\"). Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/. */\n\ttableName?: string;\n\t/** TTL in seconds (default: 86400 = 24h). Expired rows are ignored by get/lock. */\n\tttl?: number;\n}\n\nexport function d1Store(options: D1StoreOptions): IdempotencyStore {\n\tconst { database: db, tableName = DEFAULT_TABLE, ttl = DEFAULT_TTL } = options;\n\n\tif (!TABLE_NAME_RE.test(tableName)) {\n\t\tthrow new Error(`Invalid table name: \"${tableName}\". Must match ${TABLE_NAME_RE}`);\n\t}\n\n\tlet initialized = false;\n\n\tconst ensureTable = async (): Promise<void> => {\n\t\tif (initialized) return;\n\t\tawait db\n\t\t\t.prepare(\n\t\t\t\t`CREATE TABLE IF NOT EXISTS ${tableName} (\n\t\t\t\tkey TEXT PRIMARY KEY,\n\t\t\t\tfingerprint TEXT NOT NULL,\n\t\t\t\tstatus TEXT NOT NULL,\n\t\t\t\tresponse TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t)`,\n\t\t\t)\n\t\t\t.run();\n\t\tinitialized = true;\n\t};\n\n\tconst ttlThreshold = (): number => {\n\t\treturn Date.now() - ttl * 1000;\n\t};\n\n\tconst toRecord = (row: Record<string, unknown>): IdempotencyRecord => {\n\t\tlet response: StoredResponse | undefined;\n\t\tif (row.response) {\n\t\t\ttry {\n\t\t\t\tresponse = JSON.parse(row.response as string) as StoredResponse;\n\t\t\t} catch {\n\t\t\t\t// Corrupt JSON in storage — degrade gracefully like other stores\n\t\t\t}\n\t\t}\n\t\treturn {\n\t\t\tkey: row.key as string,\n\t\t\tfingerprint: row.fingerprint as string,\n\t\t\tstatus: row.status as typeof RECORD_STATUS_PROCESSING | typeof RECORD_STATUS_COMPLETED,\n\t\t\tresponse,\n\t\t\tcreatedAt: row.created_at as number,\n\t\t};\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tawait ensureTable();\n\t\t\tconst row = await db\n\t\t\t\t.prepare(`SELECT * FROM ${tableName} WHERE key = ? AND created_at >= ?`)\n\t\t\t\t.bind(key, ttlThreshold())\n\t\t\t\t.first();\n\t\t\tif (!row) return undefined;\n\t\t\treturn toRecord(row);\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(\n\t\t\t\t\t`INSERT OR IGNORE INTO ${tableName} (key, fingerprint, status, response, created_at)\n\t\t\t\t\tSELECT ?, ?, ?, ?, ?\n\t\t\t\t\tWHERE NOT EXISTS (SELECT 1 FROM ${tableName} WHERE key = ? AND created_at >= ?)`,\n\t\t\t\t)\n\t\t\t\t.bind(key, record.fingerprint, record.status, null, record.createdAt, key, ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes > 0;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tawait ensureTable();\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(response);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait db\n\t\t\t\t.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`)\n\t\t\t\t.bind(RECORD_STATUS_COMPLETED, serialized, key)\n\t\t\t\t.run();\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait ensureTable();\n\t\t\tawait db.prepare(`DELETE FROM ${tableName} WHERE key = ?`).bind(key).run();\n\t\t},\n\n\t\tasync purge() {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(`DELETE FROM ${tableName} WHERE created_at < ?`)\n\t\t\t\t.bind(ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes;\n\t\t},\n\t};\n}\n","import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,IAAM,0BAA0B;;;ADGvC,IAAM,gBAAgB;AACtB,IAAM,cAAc;AACpB,IAAM,gBAAgB;AAsBf,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,UAAU,IAAI,YAAY,eAAe,MAAM,YAAY,IAAI;AAEvE,MAAI,CAAC,cAAc,KAAK,SAAS,GAAG;AACnC,UAAM,IAAI,MAAM,wBAAwB,SAAS,iBAAiB,aAAa,EAAE;AAAA,EAClF;AAEA,MAAI,cAAc;AAElB,QAAM,cAAc,YAA2B;AAC9C,QAAI,YAAa;AACjB,UAAM,GACJ;AAAA,MACA,8BAA8B,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOxC,EACC,IAAI;AACN,kBAAc;AAAA,EACf;AAEA,QAAM,eAAe,MAAc;AAClC,WAAO,KAAK,IAAI,IAAI,MAAM;AAAA,EAC3B;AAEA,QAAM,WAAW,CAAC,QAAoD;AACrE,QAAI;AACJ,QAAI,IAAI,UAAU;AACjB,UAAI;AACH,mBAAW,KAAK,MAAM,IAAI,QAAkB;AAAA,MAC7C,QAAQ;AAAA,MAER;AAAA,IACD;AACA,WAAO;AAAA,MACN,KAAK,IAAI;AAAA,MACT,aAAa,IAAI;AAAA,MACjB,QAAQ,IAAI;AAAA,MACZ;AAAA,MACA,WAAW,IAAI;AAAA,IAChB;AAAA,EACD;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,YAAY;AAClB,YAAM,MAAM,MAAM,GAChB,QAAQ,iBAAiB,SAAS,oCAAoC,EACtE,KAAK,KAAK,aAAa,CAAC,EACxB,MAAM;AACR,UAAI,CAAC,IAAK,QAAO;AACjB,aAAO,SAAS,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB;AAAA,QACA,yBAAyB,SAAS;AAAA;AAAA,uCAEA,SAAS;AAAA,MAC5C,EACC,KAAK,KAAK,OAAO,aAAa,OAAO,QAAQ,MAAM,OAAO,WAAW,KAAK,aAAa,CAAC,EACxF,IAAI;AACN,aAAO,OAAO,KAAK,UAAU;AAAA,IAC9B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,YAAY;AAClB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GACJ,QAAQ,UAAU,SAAS,6CAA6C,EACxE,KAAK,yBAAyB,YAAY,GAAG,EAC7C,IAAI;AAAA,IACP;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,YAAY;AAClB,YAAM,GAAG,QAAQ,eAAe,SAAS,gBAAgB,EAAE,KAAK,GAAG,EAAE,IAAI;AAAA,IAC1E;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB,QAAQ,eAAe,SAAS,uBAAuB,EACvD,KAAK,aAAa,CAAC,EACnB,IAAI;AACN,aAAO,OAAO,KAAK;AAAA,IACpB;AAAA,EACD;AACD;","names":[]}
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.cjs';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.cjs';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal D1Database subset used by d1Store (avoids @cloudflare/workers-types dependency). */
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.js';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.js';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal D1Database subset used by d1Store (avoids @cloudflare/workers-types dependency). */
@@ -1,3 +1,7 @@
1
+ import {
2
+ RECORD_STATUS_COMPLETED
3
+ } from "../chunk-C2JZZSOP.js";
4
+
1
5
  // src/stores/cloudflare-d1.ts
2
6
  var DEFAULT_TABLE = "idempotency_keys";
3
7
  var DEFAULT_TTL = 86400;
@@ -24,13 +28,22 @@ function d1Store(options) {
24
28
  const ttlThreshold = () => {
25
29
  return Date.now() - ttl * 1e3;
26
30
  };
27
- const toRecord = (row) => ({
28
- key: row.key,
29
- fingerprint: row.fingerprint,
30
- status: row.status,
31
- response: row.response ? JSON.parse(row.response) : void 0,
32
- createdAt: row.created_at
33
- });
31
+ const toRecord = (row) => {
32
+ let response;
33
+ if (row.response) {
34
+ try {
35
+ response = JSON.parse(row.response);
36
+ } catch {
37
+ }
38
+ }
39
+ return {
40
+ key: row.key,
41
+ fingerprint: row.fingerprint,
42
+ status: row.status,
43
+ response,
44
+ createdAt: row.created_at
45
+ };
46
+ };
34
47
  return {
35
48
  async get(key) {
36
49
  await ensureTable();
@@ -55,7 +68,7 @@ function d1Store(options) {
55
68
  } catch {
56
69
  return;
57
70
  }
58
- await db.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`).bind("completed", serialized, key).run();
71
+ await db.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`).bind(RECORD_STATUS_COMPLETED, serialized, key).run();
59
72
  },
60
73
  async delete(key) {
61
74
  await ensureTable();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/cloudflare-d1.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TABLE = \"idempotency_keys\";\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\nconst TABLE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\n/** Minimal D1Database subset used by d1Store (avoids @cloudflare/workers-types dependency). */\nexport interface D1DatabaseLike {\n\tprepare(sql: string): D1PreparedStatementLike;\n}\n\nexport interface D1PreparedStatementLike {\n\tbind(...params: unknown[]): D1PreparedStatementLike;\n\trun(): Promise<{ success: boolean; meta: { changes: number } }>;\n\tfirst(): Promise<Record<string, unknown> | null>;\n}\n\nexport interface D1StoreOptions {\n\t/** Cloudflare D1 database binding. */\n\tdatabase: D1DatabaseLike;\n\t/** Table name (default: \"idempotency_keys\"). Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/. */\n\ttableName?: string;\n\t/** TTL in seconds (default: 86400 = 24h). Expired rows are ignored by get/lock. */\n\tttl?: number;\n}\n\nexport function d1Store(options: D1StoreOptions): IdempotencyStore {\n\tconst { database: db, tableName = DEFAULT_TABLE, ttl = DEFAULT_TTL } = options;\n\n\tif (!TABLE_NAME_RE.test(tableName)) {\n\t\tthrow new Error(`Invalid table name: \"${tableName}\". Must match ${TABLE_NAME_RE}`);\n\t}\n\n\tlet initialized = false;\n\n\tconst ensureTable = async (): Promise<void> => {\n\t\tif (initialized) return;\n\t\tawait db\n\t\t\t.prepare(\n\t\t\t\t`CREATE TABLE IF NOT EXISTS ${tableName} (\n\t\t\t\tkey TEXT PRIMARY KEY,\n\t\t\t\tfingerprint TEXT NOT NULL,\n\t\t\t\tstatus TEXT NOT NULL,\n\t\t\t\tresponse TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t)`,\n\t\t\t)\n\t\t\t.run();\n\t\tinitialized = true;\n\t};\n\n\tconst ttlThreshold = (): number => {\n\t\treturn Date.now() - ttl * 1000;\n\t};\n\n\tconst toRecord = (row: Record<string, unknown>): IdempotencyRecord => ({\n\t\tkey: row.key as string,\n\t\tfingerprint: row.fingerprint as string,\n\t\tstatus: row.status as \"processing\" | \"completed\",\n\t\tresponse: row.response ? (JSON.parse(row.response as string) as StoredResponse) : undefined,\n\t\tcreatedAt: row.created_at as number,\n\t});\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tawait ensureTable();\n\t\t\tconst row = await db\n\t\t\t\t.prepare(`SELECT * FROM ${tableName} WHERE key = ? AND created_at >= ?`)\n\t\t\t\t.bind(key, ttlThreshold())\n\t\t\t\t.first();\n\t\t\tif (!row) return undefined;\n\t\t\treturn toRecord(row);\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(\n\t\t\t\t\t`INSERT OR IGNORE INTO ${tableName} (key, fingerprint, status, response, created_at)\n\t\t\t\t\tSELECT ?, ?, ?, ?, ?\n\t\t\t\t\tWHERE NOT EXISTS (SELECT 1 FROM ${tableName} WHERE key = ? AND created_at >= ?)`,\n\t\t\t\t)\n\t\t\t\t.bind(key, record.fingerprint, record.status, null, record.createdAt, key, ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes > 0;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tawait ensureTable();\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(response);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait db\n\t\t\t\t.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`)\n\t\t\t\t.bind(\"completed\", serialized, key)\n\t\t\t\t.run();\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait ensureTable();\n\t\t\tawait db.prepare(`DELETE FROM ${tableName} WHERE key = ?`).bind(key).run();\n\t\t},\n\n\t\tasync purge() {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(`DELETE FROM ${tableName} WHERE created_at < ?`)\n\t\t\t\t.bind(ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes;\n\t\t},\n\t};\n}\n"],"mappings":";AAGA,IAAM,gBAAgB;AACtB,IAAM,cAAc;AACpB,IAAM,gBAAgB;AAsBf,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,UAAU,IAAI,YAAY,eAAe,MAAM,YAAY,IAAI;AAEvE,MAAI,CAAC,cAAc,KAAK,SAAS,GAAG;AACnC,UAAM,IAAI,MAAM,wBAAwB,SAAS,iBAAiB,aAAa,EAAE;AAAA,EAClF;AAEA,MAAI,cAAc;AAElB,QAAM,cAAc,YAA2B;AAC9C,QAAI,YAAa;AACjB,UAAM,GACJ;AAAA,MACA,8BAA8B,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOxC,EACC,IAAI;AACN,kBAAc;AAAA,EACf;AAEA,QAAM,eAAe,MAAc;AAClC,WAAO,KAAK,IAAI,IAAI,MAAM;AAAA,EAC3B;AAEA,QAAM,WAAW,CAAC,SAAqD;AAAA,IACtE,KAAK,IAAI;AAAA,IACT,aAAa,IAAI;AAAA,IACjB,QAAQ,IAAI;AAAA,IACZ,UAAU,IAAI,WAAY,KAAK,MAAM,IAAI,QAAkB,IAAuB;AAAA,IAClF,WAAW,IAAI;AAAA,EAChB;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,YAAY;AAClB,YAAM,MAAM,MAAM,GAChB,QAAQ,iBAAiB,SAAS,oCAAoC,EACtE,KAAK,KAAK,aAAa,CAAC,EACxB,MAAM;AACR,UAAI,CAAC,IAAK,QAAO;AACjB,aAAO,SAAS,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB;AAAA,QACA,yBAAyB,SAAS;AAAA;AAAA,uCAEA,SAAS;AAAA,MAC5C,EACC,KAAK,KAAK,OAAO,aAAa,OAAO,QAAQ,MAAM,OAAO,WAAW,KAAK,aAAa,CAAC,EACxF,IAAI;AACN,aAAO,OAAO,KAAK,UAAU;AAAA,IAC9B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,YAAY;AAClB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GACJ,QAAQ,UAAU,SAAS,6CAA6C,EACxE,KAAK,aAAa,YAAY,GAAG,EACjC,IAAI;AAAA,IACP;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,YAAY;AAClB,YAAM,GAAG,QAAQ,eAAe,SAAS,gBAAgB,EAAE,KAAK,GAAG,EAAE,IAAI;AAAA,IAC1E;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB,QAAQ,eAAe,SAAS,uBAAuB,EACvD,KAAK,aAAa,CAAC,EACnB,IAAI;AACN,aAAO,OAAO,KAAK;AAAA,IACpB;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/cloudflare-d1.ts"],"sourcesContent":["import {\n\ttype IdempotencyRecord,\n\tRECORD_STATUS_COMPLETED,\n\ttype RECORD_STATUS_PROCESSING,\n\ttype StoredResponse,\n} from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TABLE = \"idempotency_keys\";\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\nconst TABLE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\n/** Minimal D1Database subset used by d1Store (avoids @cloudflare/workers-types dependency). */\nexport interface D1DatabaseLike {\n\tprepare(sql: string): D1PreparedStatementLike;\n}\n\nexport interface D1PreparedStatementLike {\n\tbind(...params: unknown[]): D1PreparedStatementLike;\n\trun(): Promise<{ success: boolean; meta: { changes: number } }>;\n\tfirst(): Promise<Record<string, unknown> | null>;\n}\n\nexport interface D1StoreOptions {\n\t/** Cloudflare D1 database binding. */\n\tdatabase: D1DatabaseLike;\n\t/** Table name (default: \"idempotency_keys\"). Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/. */\n\ttableName?: string;\n\t/** TTL in seconds (default: 86400 = 24h). Expired rows are ignored by get/lock. */\n\tttl?: number;\n}\n\nexport function d1Store(options: D1StoreOptions): IdempotencyStore {\n\tconst { database: db, tableName = DEFAULT_TABLE, ttl = DEFAULT_TTL } = options;\n\n\tif (!TABLE_NAME_RE.test(tableName)) {\n\t\tthrow new Error(`Invalid table name: \"${tableName}\". Must match ${TABLE_NAME_RE}`);\n\t}\n\n\tlet initialized = false;\n\n\tconst ensureTable = async (): Promise<void> => {\n\t\tif (initialized) return;\n\t\tawait db\n\t\t\t.prepare(\n\t\t\t\t`CREATE TABLE IF NOT EXISTS ${tableName} (\n\t\t\t\tkey TEXT PRIMARY KEY,\n\t\t\t\tfingerprint TEXT NOT NULL,\n\t\t\t\tstatus TEXT NOT NULL,\n\t\t\t\tresponse TEXT,\n\t\t\t\tcreated_at INTEGER NOT NULL\n\t\t\t)`,\n\t\t\t)\n\t\t\t.run();\n\t\tinitialized = true;\n\t};\n\n\tconst ttlThreshold = (): number => {\n\t\treturn Date.now() - ttl * 1000;\n\t};\n\n\tconst toRecord = (row: Record<string, unknown>): IdempotencyRecord => {\n\t\tlet response: StoredResponse | undefined;\n\t\tif (row.response) {\n\t\t\ttry {\n\t\t\t\tresponse = JSON.parse(row.response as string) as StoredResponse;\n\t\t\t} catch {\n\t\t\t\t// Corrupt JSON in storage — degrade gracefully like other stores\n\t\t\t}\n\t\t}\n\t\treturn {\n\t\t\tkey: row.key as string,\n\t\t\tfingerprint: row.fingerprint as string,\n\t\t\tstatus: row.status as typeof RECORD_STATUS_PROCESSING | typeof RECORD_STATUS_COMPLETED,\n\t\t\tresponse,\n\t\t\tcreatedAt: row.created_at as number,\n\t\t};\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tawait ensureTable();\n\t\t\tconst row = await db\n\t\t\t\t.prepare(`SELECT * FROM ${tableName} WHERE key = ? AND created_at >= ?`)\n\t\t\t\t.bind(key, ttlThreshold())\n\t\t\t\t.first();\n\t\t\tif (!row) return undefined;\n\t\t\treturn toRecord(row);\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(\n\t\t\t\t\t`INSERT OR IGNORE INTO ${tableName} (key, fingerprint, status, response, created_at)\n\t\t\t\t\tSELECT ?, ?, ?, ?, ?\n\t\t\t\t\tWHERE NOT EXISTS (SELECT 1 FROM ${tableName} WHERE key = ? AND created_at >= ?)`,\n\t\t\t\t)\n\t\t\t\t.bind(key, record.fingerprint, record.status, null, record.createdAt, key, ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes > 0;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tawait ensureTable();\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(response);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait db\n\t\t\t\t.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`)\n\t\t\t\t.bind(RECORD_STATUS_COMPLETED, serialized, key)\n\t\t\t\t.run();\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait ensureTable();\n\t\t\tawait db.prepare(`DELETE FROM ${tableName} WHERE key = ?`).bind(key).run();\n\t\t},\n\n\t\tasync purge() {\n\t\t\tawait ensureTable();\n\t\t\tconst result = await db\n\t\t\t\t.prepare(`DELETE FROM ${tableName} WHERE created_at < ?`)\n\t\t\t\t.bind(ttlThreshold())\n\t\t\t\t.run();\n\t\t\treturn result.meta.changes;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;AAQA,IAAM,gBAAgB;AACtB,IAAM,cAAc;AACpB,IAAM,gBAAgB;AAsBf,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,UAAU,IAAI,YAAY,eAAe,MAAM,YAAY,IAAI;AAEvE,MAAI,CAAC,cAAc,KAAK,SAAS,GAAG;AACnC,UAAM,IAAI,MAAM,wBAAwB,SAAS,iBAAiB,aAAa,EAAE;AAAA,EAClF;AAEA,MAAI,cAAc;AAElB,QAAM,cAAc,YAA2B;AAC9C,QAAI,YAAa;AACjB,UAAM,GACJ;AAAA,MACA,8BAA8B,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOxC,EACC,IAAI;AACN,kBAAc;AAAA,EACf;AAEA,QAAM,eAAe,MAAc;AAClC,WAAO,KAAK,IAAI,IAAI,MAAM;AAAA,EAC3B;AAEA,QAAM,WAAW,CAAC,QAAoD;AACrE,QAAI;AACJ,QAAI,IAAI,UAAU;AACjB,UAAI;AACH,mBAAW,KAAK,MAAM,IAAI,QAAkB;AAAA,MAC7C,QAAQ;AAAA,MAER;AAAA,IACD;AACA,WAAO;AAAA,MACN,KAAK,IAAI;AAAA,MACT,aAAa,IAAI;AAAA,MACjB,QAAQ,IAAI;AAAA,MACZ;AAAA,MACA,WAAW,IAAI;AAAA,IAChB;AAAA,EACD;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,YAAY;AAClB,YAAM,MAAM,MAAM,GAChB,QAAQ,iBAAiB,SAAS,oCAAoC,EACtE,KAAK,KAAK,aAAa,CAAC,EACxB,MAAM;AACR,UAAI,CAAC,IAAK,QAAO;AACjB,aAAO,SAAS,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB;AAAA,QACA,yBAAyB,SAAS;AAAA;AAAA,uCAEA,SAAS;AAAA,MAC5C,EACC,KAAK,KAAK,OAAO,aAAa,OAAO,QAAQ,MAAM,OAAO,WAAW,KAAK,aAAa,CAAC,EACxF,IAAI;AACN,aAAO,OAAO,KAAK,UAAU;AAAA,IAC9B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,YAAY;AAClB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GACJ,QAAQ,UAAU,SAAS,6CAA6C,EACxE,KAAK,yBAAyB,YAAY,GAAG,EAC7C,IAAI;AAAA,IACP;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,YAAY;AAClB,YAAM,GAAG,QAAQ,eAAe,SAAS,gBAAgB,EAAE,KAAK,GAAG,EAAE,IAAI;AAAA,IAC1E;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,YAAY;AAClB,YAAM,SAAS,MAAM,GACnB,QAAQ,eAAe,SAAS,uBAAuB,EACvD,KAAK,aAAa,CAAC,EACnB,IAAI;AACN,aAAO,OAAO,KAAK;AAAA,IACpB;AAAA,EACD;AACD;","names":[]}
@@ -23,6 +23,11 @@ __export(cloudflare_kv_exports, {
23
23
  kvStore: () => kvStore
24
24
  });
25
25
  module.exports = __toCommonJS(cloudflare_kv_exports);
26
+
27
+ // src/types.ts
28
+ var RECORD_STATUS_COMPLETED = "completed";
29
+
30
+ // src/stores/cloudflare-kv.ts
26
31
  var DEFAULT_TTL = 86400;
27
32
  function kvStore(options) {
28
33
  const { namespace: kv, ttl = DEFAULT_TTL } = options;
@@ -49,9 +54,10 @@ function kvStore(options) {
49
54
  return stored?.lockId === lockId;
50
55
  },
51
56
  async complete(key, response) {
52
- const record = await kv.get(key, { type: "json" });
53
- if (!record) return;
54
- record.status = "completed";
57
+ const raw = await kv.get(key, { type: "json" });
58
+ if (!raw) return;
59
+ const { lockId: _, ...record } = raw;
60
+ record.status = RECORD_STATUS_COMPLETED;
55
61
  record.response = response;
56
62
  const elapsed = Math.floor((Date.now() - record.createdAt) / 1e3);
57
63
  const remaining = Math.max(1, ttl - elapsed);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/cloudflare-kv.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal KVNamespace subset used by kvStore (avoids @cloudflare/workers-types dependency). */\nexport interface KVNamespaceLike {\n\tget(key: string, options: { type: \"json\" }): Promise<unknown>;\n\tput(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n\tdelete(key: string): Promise<void>;\n}\n\nexport interface KVStoreOptions {\n\t/** Cloudflare Workers KV namespace binding. */\n\tnamespace: KVNamespaceLike;\n\t/** TTL in seconds (default: 86400 = 24h). KV minimum is 60 seconds. */\n\tttl?: number;\n}\n\n/**\n * KV is eventually consistent — lock() uses write-first with read-back\n * verification for best-effort race detection but cannot guarantee atomicity.\n * For strict concurrency guarantees, use d1Store or durableObjectStore.\n */\nexport function kvStore(options: KVStoreOptions): IdempotencyStore {\n\tconst { namespace: kv, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\tif (!raw) return undefined;\n\t\t\t// Strip internal lockId before returning to consumers\n\t\t\tconst { lockId: _, ...record } = raw;\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = (await kv.get(key, { type: \"json\" })) as IdempotencyRecord | null;\n\t\t\tif (existing) return false;\n\n\t\t\t// Embed a unique lockId to distinguish concurrent writers with the same fingerprint\n\t\t\tconst lockId = crypto.randomUUID();\n\t\t\tconst withLock = { ...record, lockId };\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(withLock);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: ttl });\n\n\t\t\t// Read-back verification using lockId (not fingerprint) for reliable race detection\n\t\t\tconst stored = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\treturn stored?.lockId === lockId;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = (await kv.get(key, { type: \"json\" })) as IdempotencyRecord | null;\n\t\t\tif (!record) return;\n\t\t\trecord.status = \"completed\";\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait kv.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// KV handles expiration automatically via expirationTtl — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,IAAM,cAAc;AAqBb,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,WAAW,IAAI,MAAM,YAAY,IAAI;AAE7C,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAO,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAG/C,UAAI,CAAC,IAAK,QAAO;AAEjB,YAAM,EAAE,QAAQ,GAAG,GAAG,OAAO,IAAI;AACjC,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAY,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AACpD,UAAI,SAAU,QAAO;AAGrB,YAAM,SAAS,OAAO,WAAW;AACjC,YAAM,WAAW,EAAE,GAAG,QAAQ,OAAO;AACrC,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,IAAI,CAAC;AAGpD,YAAM,SAAU,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAGlD,aAAO,QAAQ,WAAW;AAAA,IAC3B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAU,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAClD,UAAI,CAAC,OAAQ;AACb,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,UAAU,CAAC;AAAA,IAC3D;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,GAAG,OAAO,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/cloudflare-kv.ts","../../src/types.ts"],"sourcesContent":["import { type IdempotencyRecord, RECORD_STATUS_COMPLETED, type StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal KVNamespace subset used by kvStore (avoids @cloudflare/workers-types dependency). */\nexport interface KVNamespaceLike {\n\tget(key: string, options: { type: \"json\" }): Promise<unknown>;\n\tput(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n\tdelete(key: string): Promise<void>;\n}\n\nexport interface KVStoreOptions {\n\t/** Cloudflare Workers KV namespace binding. */\n\tnamespace: KVNamespaceLike;\n\t/** TTL in seconds (default: 86400 = 24h). KV minimum is 60 seconds. */\n\tttl?: number;\n}\n\n/**\n * KV is eventually consistent — lock() uses write-first with read-back\n * verification for best-effort race detection but cannot guarantee atomicity.\n * For strict concurrency guarantees, use d1Store or durableObjectStore.\n */\nexport function kvStore(options: KVStoreOptions): IdempotencyStore {\n\tconst { namespace: kv, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\tif (!raw) return undefined;\n\t\t\t// Strip internal lockId before returning to consumers\n\t\t\tconst { lockId: _, ...record } = raw;\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = (await kv.get(key, { type: \"json\" })) as IdempotencyRecord | null;\n\t\t\tif (existing) return false;\n\n\t\t\t// Embed a unique lockId to distinguish concurrent writers with the same fingerprint\n\t\t\tconst lockId = crypto.randomUUID();\n\t\t\tconst withLock = { ...record, lockId };\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(withLock);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: ttl });\n\n\t\t\t// Read-back verification using lockId (not fingerprint) for reliable race detection\n\t\t\tconst stored = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\treturn stored?.lockId === lockId;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst raw = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\tif (!raw) return;\n\t\t\tconst { lockId: _, ...record } = raw;\n\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait kv.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// KV handles expiration automatically via expirationTtl — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n","import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,IAAM,0BAA0B;;;ADFvC,IAAM,cAAc;AAqBb,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,WAAW,IAAI,MAAM,YAAY,IAAI;AAE7C,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAO,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAG/C,UAAI,CAAC,IAAK,QAAO;AAEjB,YAAM,EAAE,QAAQ,GAAG,GAAG,OAAO,IAAI;AACjC,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAY,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AACpD,UAAI,SAAU,QAAO;AAGrB,YAAM,SAAS,OAAO,WAAW;AACjC,YAAM,WAAW,EAAE,GAAG,QAAQ,OAAO;AACrC,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,IAAI,CAAC;AAGpD,YAAM,SAAU,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAGlD,aAAO,QAAQ,WAAW;AAAA,IAC3B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,MAAO,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAG/C,UAAI,CAAC,IAAK;AACV,YAAM,EAAE,QAAQ,GAAG,GAAG,OAAO,IAAI;AACjC,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,UAAU,CAAC;AAAA,IAC3D;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,GAAG,OAAO,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.cjs';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.cjs';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal KVNamespace subset used by kvStore (avoids @cloudflare/workers-types dependency). */
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.js';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.js';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal KVNamespace subset used by kvStore (avoids @cloudflare/workers-types dependency). */
@@ -1,3 +1,7 @@
1
+ import {
2
+ RECORD_STATUS_COMPLETED
3
+ } from "../chunk-C2JZZSOP.js";
4
+
1
5
  // src/stores/cloudflare-kv.ts
2
6
  var DEFAULT_TTL = 86400;
3
7
  function kvStore(options) {
@@ -25,9 +29,10 @@ function kvStore(options) {
25
29
  return stored?.lockId === lockId;
26
30
  },
27
31
  async complete(key, response) {
28
- const record = await kv.get(key, { type: "json" });
29
- if (!record) return;
30
- record.status = "completed";
32
+ const raw = await kv.get(key, { type: "json" });
33
+ if (!raw) return;
34
+ const { lockId: _, ...record } = raw;
35
+ record.status = RECORD_STATUS_COMPLETED;
31
36
  record.response = response;
32
37
  const elapsed = Math.floor((Date.now() - record.createdAt) / 1e3);
33
38
  const remaining = Math.max(1, ttl - elapsed);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/cloudflare-kv.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal KVNamespace subset used by kvStore (avoids @cloudflare/workers-types dependency). */\nexport interface KVNamespaceLike {\n\tget(key: string, options: { type: \"json\" }): Promise<unknown>;\n\tput(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n\tdelete(key: string): Promise<void>;\n}\n\nexport interface KVStoreOptions {\n\t/** Cloudflare Workers KV namespace binding. */\n\tnamespace: KVNamespaceLike;\n\t/** TTL in seconds (default: 86400 = 24h). KV minimum is 60 seconds. */\n\tttl?: number;\n}\n\n/**\n * KV is eventually consistent — lock() uses write-first with read-back\n * verification for best-effort race detection but cannot guarantee atomicity.\n * For strict concurrency guarantees, use d1Store or durableObjectStore.\n */\nexport function kvStore(options: KVStoreOptions): IdempotencyStore {\n\tconst { namespace: kv, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\tif (!raw) return undefined;\n\t\t\t// Strip internal lockId before returning to consumers\n\t\t\tconst { lockId: _, ...record } = raw;\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = (await kv.get(key, { type: \"json\" })) as IdempotencyRecord | null;\n\t\t\tif (existing) return false;\n\n\t\t\t// Embed a unique lockId to distinguish concurrent writers with the same fingerprint\n\t\t\tconst lockId = crypto.randomUUID();\n\t\t\tconst withLock = { ...record, lockId };\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(withLock);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: ttl });\n\n\t\t\t// Read-back verification using lockId (not fingerprint) for reliable race detection\n\t\t\tconst stored = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\treturn stored?.lockId === lockId;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = (await kv.get(key, { type: \"json\" })) as IdempotencyRecord | null;\n\t\t\tif (!record) return;\n\t\t\trecord.status = \"completed\";\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait kv.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// KV handles expiration automatically via expirationTtl — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n"],"mappings":";AAGA,IAAM,cAAc;AAqBb,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,WAAW,IAAI,MAAM,YAAY,IAAI;AAE7C,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAO,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAG/C,UAAI,CAAC,IAAK,QAAO;AAEjB,YAAM,EAAE,QAAQ,GAAG,GAAG,OAAO,IAAI;AACjC,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAY,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AACpD,UAAI,SAAU,QAAO;AAGrB,YAAM,SAAS,OAAO,WAAW;AACjC,YAAM,WAAW,EAAE,GAAG,QAAQ,OAAO;AACrC,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,IAAI,CAAC;AAGpD,YAAM,SAAU,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAGlD,aAAO,QAAQ,WAAW;AAAA,IAC3B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAU,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAClD,UAAI,CAAC,OAAQ;AACb,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,UAAU,CAAC;AAAA,IAC3D;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,GAAG,OAAO,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/cloudflare-kv.ts"],"sourcesContent":["import { type IdempotencyRecord, RECORD_STATUS_COMPLETED, type StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal KVNamespace subset used by kvStore (avoids @cloudflare/workers-types dependency). */\nexport interface KVNamespaceLike {\n\tget(key: string, options: { type: \"json\" }): Promise<unknown>;\n\tput(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;\n\tdelete(key: string): Promise<void>;\n}\n\nexport interface KVStoreOptions {\n\t/** Cloudflare Workers KV namespace binding. */\n\tnamespace: KVNamespaceLike;\n\t/** TTL in seconds (default: 86400 = 24h). KV minimum is 60 seconds. */\n\tttl?: number;\n}\n\n/**\n * KV is eventually consistent — lock() uses write-first with read-back\n * verification for best-effort race detection but cannot guarantee atomicity.\n * For strict concurrency guarantees, use d1Store or durableObjectStore.\n */\nexport function kvStore(options: KVStoreOptions): IdempotencyStore {\n\tconst { namespace: kv, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\tif (!raw) return undefined;\n\t\t\t// Strip internal lockId before returning to consumers\n\t\t\tconst { lockId: _, ...record } = raw;\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = (await kv.get(key, { type: \"json\" })) as IdempotencyRecord | null;\n\t\t\tif (existing) return false;\n\n\t\t\t// Embed a unique lockId to distinguish concurrent writers with the same fingerprint\n\t\t\tconst lockId = crypto.randomUUID();\n\t\t\tconst withLock = { ...record, lockId };\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(withLock);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: ttl });\n\n\t\t\t// Read-back verification using lockId (not fingerprint) for reliable race detection\n\t\t\tconst stored = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\treturn stored?.lockId === lockId;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst raw = (await kv.get(key, { type: \"json\" })) as\n\t\t\t\t| (IdempotencyRecord & { lockId?: string })\n\t\t\t\t| null;\n\t\t\tif (!raw) return;\n\t\t\tconst { lockId: _, ...record } = raw;\n\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait kv.put(key, serialized, { expirationTtl: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait kv.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// KV handles expiration automatically via expirationTtl — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;AAGA,IAAM,cAAc;AAqBb,SAAS,QAAQ,SAA2C;AAClE,QAAM,EAAE,WAAW,IAAI,MAAM,YAAY,IAAI;AAE7C,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAO,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAG/C,UAAI,CAAC,IAAK,QAAO;AAEjB,YAAM,EAAE,QAAQ,GAAG,GAAG,OAAO,IAAI;AACjC,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAY,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AACpD,UAAI,SAAU,QAAO;AAGrB,YAAM,SAAS,OAAO,WAAW;AACjC,YAAM,WAAW,EAAE,GAAG,QAAQ,OAAO;AACrC,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,QAAQ;AAAA,MACrC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,IAAI,CAAC;AAGpD,YAAM,SAAU,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAGlD,aAAO,QAAQ,WAAW;AAAA,IAC3B;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,MAAO,MAAM,GAAG,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC;AAG/C,UAAI,CAAC,IAAK;AACV,YAAM,EAAE,QAAQ,GAAG,GAAG,OAAO,IAAI;AACjC,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,GAAG,IAAI,KAAK,YAAY,EAAE,eAAe,UAAU,CAAC;AAAA,IAC3D;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,GAAG,OAAO,GAAG;AAAA,IACpB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -23,6 +23,11 @@ __export(durable_objects_exports, {
23
23
  durableObjectStore: () => durableObjectStore
24
24
  });
25
25
  module.exports = __toCommonJS(durable_objects_exports);
26
+
27
+ // src/types.ts
28
+ var RECORD_STATUS_COMPLETED = "completed";
29
+
30
+ // src/stores/durable-objects.ts
26
31
  var DEFAULT_TTL = 24 * 60 * 60 * 1e3;
27
32
  function durableObjectStore(options) {
28
33
  const { storage, ttl = DEFAULT_TTL } = options;
@@ -50,7 +55,7 @@ function durableObjectStore(options) {
50
55
  async complete(key, response) {
51
56
  const record = await storage.get(key);
52
57
  if (!record) return;
53
- record.status = "completed";
58
+ record.status = RECORD_STATUS_COMPLETED;
54
59
  record.response = response;
55
60
  await storage.put(key, record);
56
61
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/durable-objects.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms\n\n/** Minimal DurableObjectStorage subset (avoids @cloudflare/workers-types dependency). */\nexport interface DurableObjectStorageLike {\n\tget<T>(key: string): Promise<T | undefined>;\n\tput<T>(key: string, value: T): Promise<void>;\n\tdelete(key: string): Promise<boolean>;\n\tlist(options?: { prefix?: string }): Promise<Map<string, unknown>>;\n}\n\nexport interface DurableObjectStoreOptions {\n\t/** Durable Object storage instance (from `this.ctx.storage` inside a DO class). */\n\tstorage: DurableObjectStorageLike;\n\t/** TTL in milliseconds (default: 86400000 = 24h). */\n\tttl?: number;\n}\n\nexport function durableObjectStore(options: DurableObjectStoreOptions): IdempotencyStore {\n\tconst { storage, ttl = DEFAULT_TTL } = options;\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tawait storage.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait storage.put(key, record);\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return;\n\t\t\trecord.status = \"completed\";\n\t\t\trecord.response = response;\n\t\t\tawait storage.put(key, record);\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait storage.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tconst entries = await storage.list();\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, value] of entries) {\n\t\t\t\tconst record = value as IdempotencyRecord;\n\t\t\t\tif (record.createdAt !== undefined && isExpired(record)) {\n\t\t\t\t\tawait storage.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAiB5B,SAAS,mBAAmB,SAAsD;AACxF,QAAM,EAAE,SAAS,MAAM,YAAY,IAAI;AAEvC,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,cAAM,QAAQ,OAAO,GAAG;AACxB,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAW,MAAM,QAAQ,IAAuB,GAAG;AACzD,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,YAAM,QAAQ,IAAI,KAAK,MAAM;AAC7B,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ;AACb,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,QAAQ,IAAI,KAAK,MAAM;AAAA,IAC9B;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,QAAQ,OAAO,GAAG;AAAA,IACzB;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,UAAU,MAAM,QAAQ,KAAK;AACnC,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,KAAK,KAAK,SAAS;AACnC,cAAM,SAAS;AACf,YAAI,OAAO,cAAc,UAAa,UAAU,MAAM,GAAG;AACxD,gBAAM,QAAQ,OAAO,GAAG;AACxB;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/durable-objects.ts","../../src/types.ts"],"sourcesContent":["import { type IdempotencyRecord, RECORD_STATUS_COMPLETED, type StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms\n\n/** Minimal DurableObjectStorage subset (avoids @cloudflare/workers-types dependency). */\nexport interface DurableObjectStorageLike {\n\tget<T>(key: string): Promise<T | undefined>;\n\tput<T>(key: string, value: T): Promise<void>;\n\tdelete(key: string): Promise<boolean>;\n\tlist(options?: { prefix?: string }): Promise<Map<string, unknown>>;\n}\n\nexport interface DurableObjectStoreOptions {\n\t/** Durable Object storage instance (from `this.ctx.storage` inside a DO class). */\n\tstorage: DurableObjectStorageLike;\n\t/** TTL in milliseconds (default: 86400000 = 24h). */\n\tttl?: number;\n}\n\nexport function durableObjectStore(options: DurableObjectStoreOptions): IdempotencyStore {\n\tconst { storage, ttl = DEFAULT_TTL } = options;\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tawait storage.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait storage.put(key, record);\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return;\n\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\trecord.response = response;\n\t\t\tawait storage.put(key, record);\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait storage.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tconst entries = await storage.list();\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, value] of entries) {\n\t\t\t\tconst record = value as IdempotencyRecord;\n\t\t\t\tif (record.createdAt !== undefined && isExpired(record)) {\n\t\t\t\t\tawait storage.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n","import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,IAAM,0BAA0B;;;ADFvC,IAAM,cAAc,KAAK,KAAK,KAAK;AAiB5B,SAAS,mBAAmB,SAAsD;AACxF,QAAM,EAAE,SAAS,MAAM,YAAY,IAAI;AAEvC,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,cAAM,QAAQ,OAAO,GAAG;AACxB,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAW,MAAM,QAAQ,IAAuB,GAAG;AACzD,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,YAAM,QAAQ,IAAI,KAAK,MAAM;AAC7B,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ;AACb,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,QAAQ,IAAI,KAAK,MAAM;AAAA,IAC9B;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,QAAQ,OAAO,GAAG;AAAA,IACzB;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,UAAU,MAAM,QAAQ,KAAK;AACnC,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,KAAK,KAAK,SAAS;AACnC,cAAM,SAAS;AACf,YAAI,OAAO,cAAc,UAAa,UAAU,MAAM,GAAG;AACxD,gBAAM,QAAQ,OAAO,GAAG;AACxB;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.cjs';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.cjs';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal DurableObjectStorage subset (avoids @cloudflare/workers-types dependency). */
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.js';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.js';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal DurableObjectStorage subset (avoids @cloudflare/workers-types dependency). */
@@ -1,3 +1,7 @@
1
+ import {
2
+ RECORD_STATUS_COMPLETED
3
+ } from "../chunk-C2JZZSOP.js";
4
+
1
5
  // src/stores/durable-objects.ts
2
6
  var DEFAULT_TTL = 24 * 60 * 60 * 1e3;
3
7
  function durableObjectStore(options) {
@@ -26,7 +30,7 @@ function durableObjectStore(options) {
26
30
  async complete(key, response) {
27
31
  const record = await storage.get(key);
28
32
  if (!record) return;
29
- record.status = "completed";
33
+ record.status = RECORD_STATUS_COMPLETED;
30
34
  record.response = response;
31
35
  await storage.put(key, record);
32
36
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/durable-objects.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms\n\n/** Minimal DurableObjectStorage subset (avoids @cloudflare/workers-types dependency). */\nexport interface DurableObjectStorageLike {\n\tget<T>(key: string): Promise<T | undefined>;\n\tput<T>(key: string, value: T): Promise<void>;\n\tdelete(key: string): Promise<boolean>;\n\tlist(options?: { prefix?: string }): Promise<Map<string, unknown>>;\n}\n\nexport interface DurableObjectStoreOptions {\n\t/** Durable Object storage instance (from `this.ctx.storage` inside a DO class). */\n\tstorage: DurableObjectStorageLike;\n\t/** TTL in milliseconds (default: 86400000 = 24h). */\n\tttl?: number;\n}\n\nexport function durableObjectStore(options: DurableObjectStoreOptions): IdempotencyStore {\n\tconst { storage, ttl = DEFAULT_TTL } = options;\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tawait storage.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait storage.put(key, record);\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return;\n\t\t\trecord.status = \"completed\";\n\t\t\trecord.response = response;\n\t\t\tawait storage.put(key, record);\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait storage.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tconst entries = await storage.list();\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, value] of entries) {\n\t\t\t\tconst record = value as IdempotencyRecord;\n\t\t\t\tif (record.createdAt !== undefined && isExpired(record)) {\n\t\t\t\t\tawait storage.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n"],"mappings":";AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAiB5B,SAAS,mBAAmB,SAAsD;AACxF,QAAM,EAAE,SAAS,MAAM,YAAY,IAAI;AAEvC,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,cAAM,QAAQ,OAAO,GAAG;AACxB,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAW,MAAM,QAAQ,IAAuB,GAAG;AACzD,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,YAAM,QAAQ,IAAI,KAAK,MAAM;AAC7B,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ;AACb,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,QAAQ,IAAI,KAAK,MAAM;AAAA,IAC9B;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,QAAQ,OAAO,GAAG;AAAA,IACzB;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,UAAU,MAAM,QAAQ,KAAK;AACnC,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,KAAK,KAAK,SAAS;AACnC,cAAM,SAAS;AACf,YAAI,OAAO,cAAc,UAAa,UAAU,MAAM,GAAG;AACxD,gBAAM,QAAQ,OAAO,GAAG;AACxB;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/durable-objects.ts"],"sourcesContent":["import { type IdempotencyRecord, RECORD_STATUS_COMPLETED, type StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms\n\n/** Minimal DurableObjectStorage subset (avoids @cloudflare/workers-types dependency). */\nexport interface DurableObjectStorageLike {\n\tget<T>(key: string): Promise<T | undefined>;\n\tput<T>(key: string, value: T): Promise<void>;\n\tdelete(key: string): Promise<boolean>;\n\tlist(options?: { prefix?: string }): Promise<Map<string, unknown>>;\n}\n\nexport interface DurableObjectStoreOptions {\n\t/** Durable Object storage instance (from `this.ctx.storage` inside a DO class). */\n\tstorage: DurableObjectStorageLike;\n\t/** TTL in milliseconds (default: 86400000 = 24h). */\n\tttl?: number;\n}\n\nexport function durableObjectStore(options: DurableObjectStoreOptions): IdempotencyStore {\n\tconst { storage, ttl = DEFAULT_TTL } = options;\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tawait storage.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tconst existing = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait storage.put(key, record);\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = await storage.get<IdempotencyRecord>(key);\n\t\t\tif (!record) return;\n\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\trecord.response = response;\n\t\t\tawait storage.put(key, record);\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait storage.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tconst entries = await storage.list();\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, value] of entries) {\n\t\t\t\tconst record = value as IdempotencyRecord;\n\t\t\t\tif (record.createdAt !== undefined && isExpired(record)) {\n\t\t\t\t\tawait storage.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAiB5B,SAAS,mBAAmB,SAAsD;AACxF,QAAM,EAAE,SAAS,MAAM,YAAY,IAAI;AAEvC,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,cAAM,QAAQ,OAAO,GAAG;AACxB,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM,WAAW,MAAM,QAAQ,IAAuB,GAAG;AACzD,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,YAAM,QAAQ,IAAI,KAAK,MAAM;AAC7B,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,MAAM,QAAQ,IAAuB,GAAG;AACvD,UAAI,CAAC,OAAQ;AACb,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,QAAQ,IAAI,KAAK,MAAM;AAAA,IAC9B;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,QAAQ,OAAO,GAAG;AAAA,IACzB;AAAA,IAEA,MAAM,QAAQ;AACb,YAAM,UAAU,MAAM,QAAQ,KAAK;AACnC,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,KAAK,KAAK,SAAS;AACnC,cAAM,SAAS;AACf,YAAI,OAAO,cAAc,UAAa,UAAU,MAAM,GAAG;AACxD,gBAAM,QAAQ,OAAO,GAAG;AACxB;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -23,6 +23,12 @@ __export(memory_exports, {
23
23
  memoryStore: () => memoryStore
24
24
  });
25
25
  module.exports = __toCommonJS(memory_exports);
26
+
27
+ // src/types.ts
28
+ var RECORD_STATUS_PROCESSING = "processing";
29
+ var RECORD_STATUS_COMPLETED = "completed";
30
+
31
+ // src/stores/memory.ts
26
32
  var DEFAULT_TTL = 24 * 60 * 60 * 1e3;
27
33
  function memoryStore(options = {}) {
28
34
  const ttl = options.ttl ?? DEFAULT_TTL;
@@ -62,7 +68,7 @@ function memoryStore(options = {}) {
62
68
  while (map.size > maxSize) {
63
69
  let evicted = false;
64
70
  for (const [k, r] of map) {
65
- if (r.status !== "processing") {
71
+ if (r.status !== RECORD_STATUS_PROCESSING) {
66
72
  map.delete(k);
67
73
  evicted = true;
68
74
  break;
@@ -76,7 +82,7 @@ function memoryStore(options = {}) {
76
82
  async complete(key, response) {
77
83
  const record = map.get(key);
78
84
  if (record) {
79
- record.status = "completed";
85
+ record.status = RECORD_STATUS_COMPLETED;
80
86
  record.response = response;
81
87
  }
82
88
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/memory.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours\n\nexport interface MemoryStoreOptions {\n\tttl?: number;\n\t/** Maximum number of entries. Oldest entries are evicted when exceeded. */\n\tmaxSize?: number;\n}\n\nexport interface MemoryStore extends IdempotencyStore {\n\t/** Number of entries currently in the store (including expired but not yet swept). */\n\treadonly size: number;\n}\n\nexport function memoryStore(options: MemoryStoreOptions = {}): MemoryStore {\n\tconst ttl = options.ttl ?? DEFAULT_TTL;\n\tconst maxSize = options.maxSize;\n\tconst map = new Map<string, IdempotencyRecord>();\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\tconst sweep = (): void => {\n\t\tfor (const [key, record] of map) {\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t}\n\t\t}\n\t};\n\n\treturn {\n\t\tget size() {\n\t\t\treturn map.size;\n\t\t},\n\n\t\tasync get(key) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tsweep();\n\t\t\tconst existing = map.get(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tmap.set(key, record);\n\t\t\tif (maxSize !== undefined) {\n\t\t\t\twhile (map.size > maxSize) {\n\t\t\t\t\tlet evicted = false;\n\t\t\t\t\tfor (const [k, r] of map) {\n\t\t\t\t\t\tif (r.status !== \"processing\") {\n\t\t\t\t\t\t\tmap.delete(k);\n\t\t\t\t\t\t\tevicted = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (!evicted) break;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (record) {\n\t\t\t\trecord.status = \"completed\";\n\t\t\t\trecord.response = response;\n\t\t\t}\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tmap.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, record] of map) {\n\t\t\t\tif (isExpired(record)) {\n\t\t\t\t\tmap.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAa5B,SAAS,YAAY,UAA8B,CAAC,GAAgB;AAC1E,QAAM,MAAM,QAAQ,OAAO;AAC3B,QAAM,UAAU,QAAQ;AACxB,QAAM,MAAM,oBAAI,IAA+B;AAE/C,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,QAAM,QAAQ,MAAY;AACzB,eAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AAAA,MACf;AAAA,IACD;AAAA,EACD;AAEA,SAAO;AAAA,IACN,IAAI,OAAO;AACV,aAAO,IAAI;AAAA,IACZ;AAAA,IAEA,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AACd,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM;AACN,YAAM,WAAW,IAAI,IAAI,GAAG;AAC5B,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,UAAI,IAAI,KAAK,MAAM;AACnB,UAAI,YAAY,QAAW;AAC1B,eAAO,IAAI,OAAO,SAAS;AAC1B,cAAI,UAAU;AACd,qBAAW,CAAC,GAAG,CAAC,KAAK,KAAK;AACzB,gBAAI,EAAE,WAAW,cAAc;AAC9B,kBAAI,OAAO,CAAC;AACZ,wBAAU;AACV;AAAA,YACD;AAAA,UACD;AACA,cAAI,CAAC,QAAS;AAAA,QACf;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,QAAQ;AACX,eAAO,SAAS;AAChB,eAAO,WAAW;AAAA,MACnB;AAAA,IACD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,UAAI,OAAO,GAAG;AAAA,IACf;AAAA,IAEA,MAAM,QAAQ;AACb,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,YAAI,UAAU,MAAM,GAAG;AACtB,cAAI,OAAO,GAAG;AACd;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/memory.ts","../../src/types.ts"],"sourcesContent":["import {\n\ttype IdempotencyRecord,\n\tRECORD_STATUS_COMPLETED,\n\tRECORD_STATUS_PROCESSING,\n\ttype StoredResponse,\n} from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours\n\nexport interface MemoryStoreOptions {\n\tttl?: number;\n\t/** Maximum number of entries. Oldest entries are evicted when exceeded. */\n\tmaxSize?: number;\n}\n\nexport interface MemoryStore extends IdempotencyStore {\n\t/** Number of entries currently in the store (including expired but not yet swept). */\n\treadonly size: number;\n}\n\nexport function memoryStore(options: MemoryStoreOptions = {}): MemoryStore {\n\tconst ttl = options.ttl ?? DEFAULT_TTL;\n\tconst maxSize = options.maxSize;\n\tconst map = new Map<string, IdempotencyRecord>();\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\tconst sweep = (): void => {\n\t\tfor (const [key, record] of map) {\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t}\n\t\t}\n\t};\n\n\treturn {\n\t\tget size() {\n\t\t\treturn map.size;\n\t\t},\n\n\t\tasync get(key) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tsweep();\n\t\t\tconst existing = map.get(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tmap.set(key, record);\n\t\t\tif (maxSize !== undefined) {\n\t\t\t\twhile (map.size > maxSize) {\n\t\t\t\t\tlet evicted = false;\n\t\t\t\t\tfor (const [k, r] of map) {\n\t\t\t\t\t\tif (r.status !== RECORD_STATUS_PROCESSING) {\n\t\t\t\t\t\t\tmap.delete(k);\n\t\t\t\t\t\t\tevicted = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (!evicted) break;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (record) {\n\t\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\t\trecord.response = response;\n\t\t\t}\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tmap.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, record] of map) {\n\t\t\t\tif (isExpired(record)) {\n\t\t\t\t\tmap.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n","import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACIO,IAAM,2BAA2B;AACjC,IAAM,0BAA0B;;;ADGvC,IAAM,cAAc,KAAK,KAAK,KAAK;AAa5B,SAAS,YAAY,UAA8B,CAAC,GAAgB;AAC1E,QAAM,MAAM,QAAQ,OAAO;AAC3B,QAAM,UAAU,QAAQ;AACxB,QAAM,MAAM,oBAAI,IAA+B;AAE/C,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,QAAM,QAAQ,MAAY;AACzB,eAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AAAA,MACf;AAAA,IACD;AAAA,EACD;AAEA,SAAO;AAAA,IACN,IAAI,OAAO;AACV,aAAO,IAAI;AAAA,IACZ;AAAA,IAEA,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AACd,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM;AACN,YAAM,WAAW,IAAI,IAAI,GAAG;AAC5B,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,UAAI,IAAI,KAAK,MAAM;AACnB,UAAI,YAAY,QAAW;AAC1B,eAAO,IAAI,OAAO,SAAS;AAC1B,cAAI,UAAU;AACd,qBAAW,CAAC,GAAG,CAAC,KAAK,KAAK;AACzB,gBAAI,EAAE,WAAW,0BAA0B;AAC1C,kBAAI,OAAO,CAAC;AACZ,wBAAU;AACV;AAAA,YACD;AAAA,UACD;AACA,cAAI,CAAC,QAAS;AAAA,QACf;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,QAAQ;AACX,eAAO,SAAS;AAChB,eAAO,WAAW;AAAA,MACnB;AAAA,IACD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,UAAI,OAAO,GAAG;AAAA,IACf;AAAA,IAEA,MAAM,QAAQ;AACb,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,YAAI,UAAU,MAAM,GAAG;AACtB,cAAI,OAAO,GAAG;AACd;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.cjs';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.cjs';
2
2
  import 'hono';
3
3
 
4
4
  interface MemoryStoreOptions {
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.js';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.js';
2
2
  import 'hono';
3
3
 
4
4
  interface MemoryStoreOptions {
@@ -1,3 +1,8 @@
1
+ import {
2
+ RECORD_STATUS_COMPLETED,
3
+ RECORD_STATUS_PROCESSING
4
+ } from "../chunk-C2JZZSOP.js";
5
+
1
6
  // src/stores/memory.ts
2
7
  var DEFAULT_TTL = 24 * 60 * 60 * 1e3;
3
8
  function memoryStore(options = {}) {
@@ -38,7 +43,7 @@ function memoryStore(options = {}) {
38
43
  while (map.size > maxSize) {
39
44
  let evicted = false;
40
45
  for (const [k, r] of map) {
41
- if (r.status !== "processing") {
46
+ if (r.status !== RECORD_STATUS_PROCESSING) {
42
47
  map.delete(k);
43
48
  evicted = true;
44
49
  break;
@@ -52,7 +57,7 @@ function memoryStore(options = {}) {
52
57
  async complete(key, response) {
53
58
  const record = map.get(key);
54
59
  if (record) {
55
- record.status = "completed";
60
+ record.status = RECORD_STATUS_COMPLETED;
56
61
  record.response = response;
57
62
  }
58
63
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/memory.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours\n\nexport interface MemoryStoreOptions {\n\tttl?: number;\n\t/** Maximum number of entries. Oldest entries are evicted when exceeded. */\n\tmaxSize?: number;\n}\n\nexport interface MemoryStore extends IdempotencyStore {\n\t/** Number of entries currently in the store (including expired but not yet swept). */\n\treadonly size: number;\n}\n\nexport function memoryStore(options: MemoryStoreOptions = {}): MemoryStore {\n\tconst ttl = options.ttl ?? DEFAULT_TTL;\n\tconst maxSize = options.maxSize;\n\tconst map = new Map<string, IdempotencyRecord>();\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\tconst sweep = (): void => {\n\t\tfor (const [key, record] of map) {\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t}\n\t\t}\n\t};\n\n\treturn {\n\t\tget size() {\n\t\t\treturn map.size;\n\t\t},\n\n\t\tasync get(key) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tsweep();\n\t\t\tconst existing = map.get(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tmap.set(key, record);\n\t\t\tif (maxSize !== undefined) {\n\t\t\t\twhile (map.size > maxSize) {\n\t\t\t\t\tlet evicted = false;\n\t\t\t\t\tfor (const [k, r] of map) {\n\t\t\t\t\t\tif (r.status !== \"processing\") {\n\t\t\t\t\t\t\tmap.delete(k);\n\t\t\t\t\t\t\tevicted = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (!evicted) break;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (record) {\n\t\t\t\trecord.status = \"completed\";\n\t\t\t\trecord.response = response;\n\t\t\t}\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tmap.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, record] of map) {\n\t\t\t\tif (isExpired(record)) {\n\t\t\t\t\tmap.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n"],"mappings":";AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAa5B,SAAS,YAAY,UAA8B,CAAC,GAAgB;AAC1E,QAAM,MAAM,QAAQ,OAAO;AAC3B,QAAM,UAAU,QAAQ;AACxB,QAAM,MAAM,oBAAI,IAA+B;AAE/C,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,QAAM,QAAQ,MAAY;AACzB,eAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AAAA,MACf;AAAA,IACD;AAAA,EACD;AAEA,SAAO;AAAA,IACN,IAAI,OAAO;AACV,aAAO,IAAI;AAAA,IACZ;AAAA,IAEA,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AACd,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM;AACN,YAAM,WAAW,IAAI,IAAI,GAAG;AAC5B,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,UAAI,IAAI,KAAK,MAAM;AACnB,UAAI,YAAY,QAAW;AAC1B,eAAO,IAAI,OAAO,SAAS;AAC1B,cAAI,UAAU;AACd,qBAAW,CAAC,GAAG,CAAC,KAAK,KAAK;AACzB,gBAAI,EAAE,WAAW,cAAc;AAC9B,kBAAI,OAAO,CAAC;AACZ,wBAAU;AACV;AAAA,YACD;AAAA,UACD;AACA,cAAI,CAAC,QAAS;AAAA,QACf;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,QAAQ;AACX,eAAO,SAAS;AAChB,eAAO,WAAW;AAAA,MACnB;AAAA,IACD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,UAAI,OAAO,GAAG;AAAA,IACf;AAAA,IAEA,MAAM,QAAQ;AACb,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,YAAI,UAAU,MAAM,GAAG;AACtB,cAAI,OAAO,GAAG;AACd;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/memory.ts"],"sourcesContent":["import {\n\ttype IdempotencyRecord,\n\tRECORD_STATUS_COMPLETED,\n\tRECORD_STATUS_PROCESSING,\n\ttype StoredResponse,\n} from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours\n\nexport interface MemoryStoreOptions {\n\tttl?: number;\n\t/** Maximum number of entries. Oldest entries are evicted when exceeded. */\n\tmaxSize?: number;\n}\n\nexport interface MemoryStore extends IdempotencyStore {\n\t/** Number of entries currently in the store (including expired but not yet swept). */\n\treadonly size: number;\n}\n\nexport function memoryStore(options: MemoryStoreOptions = {}): MemoryStore {\n\tconst ttl = options.ttl ?? DEFAULT_TTL;\n\tconst maxSize = options.maxSize;\n\tconst map = new Map<string, IdempotencyRecord>();\n\n\tconst isExpired = (record: IdempotencyRecord): boolean => {\n\t\treturn Date.now() - record.createdAt >= ttl;\n\t};\n\n\tconst sweep = (): void => {\n\t\tfor (const [key, record] of map) {\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t}\n\t\t}\n\t};\n\n\treturn {\n\t\tget size() {\n\t\t\treturn map.size;\n\t\t},\n\n\t\tasync get(key) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (!record) return undefined;\n\t\t\tif (isExpired(record)) {\n\t\t\t\tmap.delete(key);\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\treturn record;\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tsweep();\n\t\t\tconst existing = map.get(key);\n\t\t\tif (existing && !isExpired(existing)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tmap.set(key, record);\n\t\t\tif (maxSize !== undefined) {\n\t\t\t\twhile (map.size > maxSize) {\n\t\t\t\t\tlet evicted = false;\n\t\t\t\t\tfor (const [k, r] of map) {\n\t\t\t\t\t\tif (r.status !== RECORD_STATUS_PROCESSING) {\n\t\t\t\t\t\t\tmap.delete(k);\n\t\t\t\t\t\t\tevicted = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (!evicted) break;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst record = map.get(key);\n\t\t\tif (record) {\n\t\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\t\trecord.response = response;\n\t\t\t}\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tmap.delete(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\tlet count = 0;\n\t\t\tfor (const [key, record] of map) {\n\t\t\t\tif (isExpired(record)) {\n\t\t\t\t\tmap.delete(key);\n\t\t\t\t\tcount++;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;AAQA,IAAM,cAAc,KAAK,KAAK,KAAK;AAa5B,SAAS,YAAY,UAA8B,CAAC,GAAgB;AAC1E,QAAM,MAAM,QAAQ,OAAO;AAC3B,QAAM,UAAU,QAAQ;AACxB,QAAM,MAAM,oBAAI,IAA+B;AAE/C,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,QAAM,QAAQ,MAAY;AACzB,eAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AAAA,MACf;AAAA,IACD;AAAA,EACD;AAEA,SAAO;AAAA,IACN,IAAI,OAAO;AACV,aAAO,IAAI;AAAA,IACZ;AAAA,IAEA,MAAM,IAAI,KAAK;AACd,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,CAAC,OAAQ,QAAO;AACpB,UAAI,UAAU,MAAM,GAAG;AACtB,YAAI,OAAO,GAAG;AACd,eAAO;AAAA,MACR;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,YAAM;AACN,YAAM,WAAW,IAAI,IAAI,GAAG;AAC5B,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,UAAI,IAAI,KAAK,MAAM;AACnB,UAAI,YAAY,QAAW;AAC1B,eAAO,IAAI,OAAO,SAAS;AAC1B,cAAI,UAAU;AACd,qBAAW,CAAC,GAAG,CAAC,KAAK,KAAK;AACzB,gBAAI,EAAE,WAAW,0BAA0B;AAC1C,kBAAI,OAAO,CAAC;AACZ,wBAAU;AACV;AAAA,YACD;AAAA,UACD;AACA,cAAI,CAAC,QAAS;AAAA,QACf;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAI,QAAQ;AACX,eAAO,SAAS;AAChB,eAAO,WAAW;AAAA,MACnB;AAAA,IACD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,UAAI,OAAO,GAAG;AAAA,IACf;AAAA,IAEA,MAAM,QAAQ;AACb,UAAI,QAAQ;AACZ,iBAAW,CAAC,KAAK,MAAM,KAAK,KAAK;AAChC,YAAI,UAAU,MAAM,GAAG;AACtB,cAAI,OAAO,GAAG;AACd;AAAA,QACD;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -23,6 +23,11 @@ __export(redis_exports, {
23
23
  redisStore: () => redisStore
24
24
  });
25
25
  module.exports = __toCommonJS(redis_exports);
26
+
27
+ // src/types.ts
28
+ var RECORD_STATUS_COMPLETED = "completed";
29
+
30
+ // src/stores/redis.ts
26
31
  var DEFAULT_TTL = 86400;
27
32
  function redisStore(options) {
28
33
  const { client, ttl = DEFAULT_TTL } = options;
@@ -49,8 +54,13 @@ function redisStore(options) {
49
54
  async complete(key, response) {
50
55
  const raw = await client.get(key);
51
56
  if (!raw) return;
52
- const record = JSON.parse(raw);
53
- record.status = "completed";
57
+ let record;
58
+ try {
59
+ record = JSON.parse(raw);
60
+ } catch {
61
+ return;
62
+ }
63
+ record.status = RECORD_STATUS_COMPLETED;
54
64
  record.response = response;
55
65
  const elapsed = Math.floor((Date.now() - record.createdAt) / 1e3);
56
66
  const remaining = Math.max(1, ttl - elapsed);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/redis.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal Redis client subset compatible with ioredis, node-redis, and @upstash/redis. */\nexport interface RedisClientLike {\n\tget(key: string): Promise<string | null>;\n\tset(key: string, value: string, options?: { NX?: boolean; EX?: number }): Promise<string | null>;\n\tdel(...keys: string[]): Promise<number>;\n}\n\nexport interface RedisStoreOptions {\n\t/** Redis client instance (ioredis, node-redis, or @upstash/redis). */\n\tclient: RedisClientLike;\n\t/** TTL in seconds (default: 86400 = 24h). Passed as Redis EX option. */\n\tttl?: number;\n}\n\nexport function redisStore(options: RedisStoreOptions): IdempotencyStore {\n\tconst { client, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return undefined;\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(raw) as IdempotencyRecord;\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst result = await client.set(key, serialized, { NX: true, EX: ttl });\n\t\t\treturn result === \"OK\";\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return;\n\t\t\tconst record = JSON.parse(raw) as IdempotencyRecord;\n\t\t\trecord.status = \"completed\";\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait client.set(key, serialized, { EX: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait client.del(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// Redis handles expiration automatically via EX — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,IAAM,cAAc;AAgBb,SAAS,WAAW,SAA8C;AACxE,QAAM,EAAE,QAAQ,MAAM,YAAY,IAAI;AAEtC,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK,QAAO;AACjB,UAAI;AACH,eAAO,KAAK,MAAM,GAAG;AAAA,MACtB,QAAQ;AACP,eAAO;AAAA,MACR;AAAA,IACD;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,SAAS,MAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,MAAM,IAAI,IAAI,CAAC;AACtE,aAAO,WAAW;AAAA,IACnB;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK;AACV,YAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,UAAU,CAAC;AAAA,IACpD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,OAAO,IAAI,GAAG;AAAA,IACrB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/redis.ts","../../src/types.ts"],"sourcesContent":["import { type IdempotencyRecord, RECORD_STATUS_COMPLETED, type StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal Redis client subset compatible with ioredis, node-redis, and @upstash/redis. */\nexport interface RedisClientLike {\n\tget(key: string): Promise<string | null>;\n\tset(key: string, value: string, options?: { NX?: boolean; EX?: number }): Promise<string | null>;\n\tdel(...keys: string[]): Promise<number>;\n}\n\nexport interface RedisStoreOptions {\n\t/** Redis client instance (ioredis, node-redis, or @upstash/redis). */\n\tclient: RedisClientLike;\n\t/** TTL in seconds (default: 86400 = 24h). Passed as Redis EX option. */\n\tttl?: number;\n}\n\nexport function redisStore(options: RedisStoreOptions): IdempotencyStore {\n\tconst { client, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return undefined;\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(raw) as IdempotencyRecord;\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst result = await client.set(key, serialized, { NX: true, EX: ttl });\n\t\t\treturn result === \"OK\";\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return;\n\t\t\tlet record: IdempotencyRecord;\n\t\t\ttry {\n\t\t\t\trecord = JSON.parse(raw) as IdempotencyRecord;\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait client.set(key, serialized, { EX: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait client.del(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// Redis handles expiration automatically via EX — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n","import type { Context, Env } from \"hono\";\nimport type { ProblemDetail } from \"./errors.js\";\nimport type { IdempotencyStore } from \"./stores/types.js\";\n\nexport const RECORD_STATUS_PROCESSING = \"processing\" as const;\nexport const RECORD_STATUS_COMPLETED = \"completed\" as const;\n\nexport interface IdempotencyEnv extends Env {\n\tVariables: {\n\t\tidempotencyKey: string | undefined;\n\t};\n}\n\nexport interface StoredResponse {\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: string;\n}\n\nexport interface IdempotencyRecord {\n\tkey: string;\n\tfingerprint: string;\n\tstatus: \"processing\" | \"completed\";\n\tresponse?: StoredResponse;\n\tcreatedAt: number;\n}\n\nexport interface IdempotencyOptions {\n\tstore: IdempotencyStore;\n\theaderName?: string;\n\tfingerprint?: (c: Context) => string | Promise<string>;\n\trequired?: boolean;\n\tmethods?: string[];\n\tmaxKeyLength?: number;\n\t/** Maximum request body size in bytes. Pre-checked via Content-Length header, then enforced against actual body byte length. */\n\tmaxBodySize?: number;\n\t/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */\n\tskipRequest?: (c: Context) => boolean | Promise<boolean>;\n\t/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */\n\tonError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;\n\tcacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);\n\t/**\n\t * Called when a cached response is about to be replayed.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t * `key` is the raw header value; sanitize before logging to prevent log injection.\n\t */\n\tonCacheHit?: (key: string, c: Context) => void | Promise<void>;\n\t/**\n\t * Called when a new request acquires the lock (before the handler runs).\n\t * Fires on each lock acquisition, including retries after prior failures.\n\t * Errors are swallowed — hooks must not affect request processing.\n\t */\n\tonCacheMiss?: (key: string, c: Context) => void | Promise<void>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,IAAM,0BAA0B;;;ADFvC,IAAM,cAAc;AAgBb,SAAS,WAAW,SAA8C;AACxE,QAAM,EAAE,QAAQ,MAAM,YAAY,IAAI;AAEtC,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK,QAAO;AACjB,UAAI;AACH,eAAO,KAAK,MAAM,GAAG;AAAA,MACtB,QAAQ;AACP,eAAO;AAAA,MACR;AAAA,IACD;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,SAAS,MAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,MAAM,IAAI,IAAI,CAAC;AACtE,aAAO,WAAW;AAAA,IACnB;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK;AACV,UAAI;AACJ,UAAI;AACH,iBAAS,KAAK,MAAM,GAAG;AAAA,MACxB,QAAQ;AACP;AAAA,MACD;AACA,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,UAAU,CAAC;AAAA,IACpD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,OAAO,IAAI,GAAG;AAAA,IACrB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.cjs';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.cjs';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal Redis client subset compatible with ioredis, node-redis, and @upstash/redis. */
@@ -1,4 +1,4 @@
1
- import { e as IdempotencyStore } from '../types-gTE2gccV.js';
1
+ import { e as IdempotencyStore } from '../types-7IwFeI0l.js';
2
2
  import 'hono';
3
3
 
4
4
  /** Minimal Redis client subset compatible with ioredis, node-redis, and @upstash/redis. */
@@ -1,3 +1,7 @@
1
+ import {
2
+ RECORD_STATUS_COMPLETED
3
+ } from "../chunk-C2JZZSOP.js";
4
+
1
5
  // src/stores/redis.ts
2
6
  var DEFAULT_TTL = 86400;
3
7
  function redisStore(options) {
@@ -25,8 +29,13 @@ function redisStore(options) {
25
29
  async complete(key, response) {
26
30
  const raw = await client.get(key);
27
31
  if (!raw) return;
28
- const record = JSON.parse(raw);
29
- record.status = "completed";
32
+ let record;
33
+ try {
34
+ record = JSON.parse(raw);
35
+ } catch {
36
+ return;
37
+ }
38
+ record.status = RECORD_STATUS_COMPLETED;
30
39
  record.response = response;
31
40
  const elapsed = Math.floor((Date.now() - record.createdAt) / 1e3);
32
41
  const remaining = Math.max(1, ttl - elapsed);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/stores/redis.ts"],"sourcesContent":["import type { IdempotencyRecord, StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal Redis client subset compatible with ioredis, node-redis, and @upstash/redis. */\nexport interface RedisClientLike {\n\tget(key: string): Promise<string | null>;\n\tset(key: string, value: string, options?: { NX?: boolean; EX?: number }): Promise<string | null>;\n\tdel(...keys: string[]): Promise<number>;\n}\n\nexport interface RedisStoreOptions {\n\t/** Redis client instance (ioredis, node-redis, or @upstash/redis). */\n\tclient: RedisClientLike;\n\t/** TTL in seconds (default: 86400 = 24h). Passed as Redis EX option. */\n\tttl?: number;\n}\n\nexport function redisStore(options: RedisStoreOptions): IdempotencyStore {\n\tconst { client, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return undefined;\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(raw) as IdempotencyRecord;\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst result = await client.set(key, serialized, { NX: true, EX: ttl });\n\t\t\treturn result === \"OK\";\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return;\n\t\t\tconst record = JSON.parse(raw) as IdempotencyRecord;\n\t\t\trecord.status = \"completed\";\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait client.set(key, serialized, { EX: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait client.del(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// Redis handles expiration automatically via EX — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n"],"mappings":";AAGA,IAAM,cAAc;AAgBb,SAAS,WAAW,SAA8C;AACxE,QAAM,EAAE,QAAQ,MAAM,YAAY,IAAI;AAEtC,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK,QAAO;AACjB,UAAI;AACH,eAAO,KAAK,MAAM,GAAG;AAAA,MACtB,QAAQ;AACP,eAAO;AAAA,MACR;AAAA,IACD;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,SAAS,MAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,MAAM,IAAI,IAAI,CAAC;AACtE,aAAO,WAAW;AAAA,IACnB;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK;AACV,YAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,UAAU,CAAC;AAAA,IACpD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,OAAO,IAAI,GAAG;AAAA,IACrB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/stores/redis.ts"],"sourcesContent":["import { type IdempotencyRecord, RECORD_STATUS_COMPLETED, type StoredResponse } from \"../types.js\";\nimport type { IdempotencyStore } from \"./types.js\";\n\nconst DEFAULT_TTL = 86400; // 24 hours in seconds\n\n/** Minimal Redis client subset compatible with ioredis, node-redis, and @upstash/redis. */\nexport interface RedisClientLike {\n\tget(key: string): Promise<string | null>;\n\tset(key: string, value: string, options?: { NX?: boolean; EX?: number }): Promise<string | null>;\n\tdel(...keys: string[]): Promise<number>;\n}\n\nexport interface RedisStoreOptions {\n\t/** Redis client instance (ioredis, node-redis, or @upstash/redis). */\n\tclient: RedisClientLike;\n\t/** TTL in seconds (default: 86400 = 24h). Passed as Redis EX option. */\n\tttl?: number;\n}\n\nexport function redisStore(options: RedisStoreOptions): IdempotencyStore {\n\tconst { client, ttl = DEFAULT_TTL } = options;\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return undefined;\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(raw) as IdempotencyRecord;\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t},\n\n\t\tasync lock(key, record) {\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst result = await client.set(key, serialized, { NX: true, EX: ttl });\n\t\t\treturn result === \"OK\";\n\t\t},\n\n\t\tasync complete(key, response) {\n\t\t\tconst raw = await client.get(key);\n\t\t\tif (!raw) return;\n\t\t\tlet record: IdempotencyRecord;\n\t\t\ttry {\n\t\t\t\trecord = JSON.parse(raw) as IdempotencyRecord;\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\trecord.status = RECORD_STATUS_COMPLETED;\n\t\t\trecord.response = response;\n\t\t\tconst elapsed = Math.floor((Date.now() - record.createdAt) / 1000);\n\t\t\tconst remaining = Math.max(1, ttl - elapsed);\n\t\t\tlet serialized: string;\n\t\t\ttry {\n\t\t\t\tserialized = JSON.stringify(record);\n\t\t\t} catch {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tawait client.set(key, serialized, { EX: remaining });\n\t\t},\n\n\t\tasync delete(key) {\n\t\t\tawait client.del(key);\n\t\t},\n\n\t\tasync purge() {\n\t\t\t// Redis handles expiration automatically via EX — no manual purge needed\n\t\t\treturn 0;\n\t\t},\n\t};\n}\n"],"mappings":";;;;;AAGA,IAAM,cAAc;AAgBb,SAAS,WAAW,SAA8C;AACxE,QAAM,EAAE,QAAQ,MAAM,YAAY,IAAI;AAEtC,SAAO;AAAA,IACN,MAAM,IAAI,KAAK;AACd,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK,QAAO;AACjB,UAAI;AACH,eAAO,KAAK,MAAM,GAAG;AAAA,MACtB,QAAQ;AACP,eAAO;AAAA,MACR;AAAA,IACD;AAAA,IAEA,MAAM,KAAK,KAAK,QAAQ;AACvB,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP,eAAO;AAAA,MACR;AACA,YAAM,SAAS,MAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,MAAM,IAAI,IAAI,CAAC;AACtE,aAAO,WAAW;AAAA,IACnB;AAAA,IAEA,MAAM,SAAS,KAAK,UAAU;AAC7B,YAAM,MAAM,MAAM,OAAO,IAAI,GAAG;AAChC,UAAI,CAAC,IAAK;AACV,UAAI;AACJ,UAAI;AACH,iBAAS,KAAK,MAAM,GAAG;AAAA,MACxB,QAAQ;AACP;AAAA,MACD;AACA,aAAO,SAAS;AAChB,aAAO,WAAW;AAClB,YAAM,UAAU,KAAK,OAAO,KAAK,IAAI,IAAI,OAAO,aAAa,GAAI;AACjE,YAAM,YAAY,KAAK,IAAI,GAAG,MAAM,OAAO;AAC3C,UAAI;AACJ,UAAI;AACH,qBAAa,KAAK,UAAU,MAAM;AAAA,MACnC,QAAQ;AACP;AAAA,MACD;AACA,YAAM,OAAO,IAAI,KAAK,YAAY,EAAE,IAAI,UAAU,CAAC;AAAA,IACpD;AAAA,IAEA,MAAM,OAAO,KAAK;AACjB,YAAM,OAAO,IAAI,GAAG;AAAA,IACrB;AAAA,IAEA,MAAM,QAAQ;AAEb,aAAO;AAAA,IACR;AAAA,EACD;AACD;","names":[]}
@@ -52,6 +52,8 @@ interface IdempotencyStore {
52
52
  purge(): Promise<number>;
53
53
  }
54
54
 
55
+ declare const RECORD_STATUS_PROCESSING: "processing";
56
+ declare const RECORD_STATUS_COMPLETED: "completed";
55
57
  interface IdempotencyEnv extends Env {
56
58
  Variables: {
57
59
  idempotencyKey: string | undefined;
@@ -97,4 +99,4 @@ interface IdempotencyOptions {
97
99
  onCacheMiss?: (key: string, c: Context) => void | Promise<void>;
98
100
  }
99
101
 
100
- export { type IdempotencyOptions as I, type ProblemDetail as P, type StoredResponse as S, type IdempotencyEnv as a, type IdempotencyErrorCode as b, IdempotencyErrors as c, type IdempotencyRecord as d, type IdempotencyStore as e, clampHttpStatus as f, problemResponse as p };
102
+ export { type IdempotencyOptions as I, type ProblemDetail as P, RECORD_STATUS_COMPLETED as R, type StoredResponse as S, type IdempotencyEnv as a, type IdempotencyErrorCode as b, IdempotencyErrors as c, type IdempotencyRecord as d, type IdempotencyStore as e, RECORD_STATUS_PROCESSING as f, clampHttpStatus as g, problemResponse as p };
@@ -52,6 +52,8 @@ interface IdempotencyStore {
52
52
  purge(): Promise<number>;
53
53
  }
54
54
 
55
+ declare const RECORD_STATUS_PROCESSING: "processing";
56
+ declare const RECORD_STATUS_COMPLETED: "completed";
55
57
  interface IdempotencyEnv extends Env {
56
58
  Variables: {
57
59
  idempotencyKey: string | undefined;
@@ -97,4 +99,4 @@ interface IdempotencyOptions {
97
99
  onCacheMiss?: (key: string, c: Context) => void | Promise<void>;
98
100
  }
99
101
 
100
- export { type IdempotencyOptions as I, type ProblemDetail as P, type StoredResponse as S, type IdempotencyEnv as a, type IdempotencyErrorCode as b, IdempotencyErrors as c, type IdempotencyRecord as d, type IdempotencyStore as e, clampHttpStatus as f, problemResponse as p };
102
+ export { type IdempotencyOptions as I, type ProblemDetail as P, RECORD_STATUS_COMPLETED as R, type StoredResponse as S, type IdempotencyEnv as a, type IdempotencyErrorCode as b, IdempotencyErrors as c, type IdempotencyRecord as d, type IdempotencyStore as e, RECORD_STATUS_PROCESSING as f, clampHttpStatus as g, problemResponse as p };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-idempotency",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Stripe-style Idempotency-Key middleware for Hono. IETF draft-ietf-httpapi-idempotency-key-header compliant.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",