hono-idempotency 0.7.2 → 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.
- package/dist/chunk-C2JZZSOP.js +9 -0
- package/dist/chunk-C2JZZSOP.js.map +1 -0
- package/dist/index.cjs +49 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +47 -12
- package/dist/index.js.map +1 -1
- package/dist/stores/cloudflare-d1.cjs +28 -8
- package/dist/stores/cloudflare-d1.cjs.map +1 -1
- package/dist/stores/cloudflare-d1.d.cts +1 -1
- package/dist/stores/cloudflare-d1.d.ts +1 -1
- package/dist/stores/cloudflare-d1.js +27 -8
- package/dist/stores/cloudflare-d1.js.map +1 -1
- package/dist/stores/cloudflare-kv.cjs +32 -8
- package/dist/stores/cloudflare-kv.cjs.map +1 -1
- package/dist/stores/cloudflare-kv.d.cts +1 -1
- package/dist/stores/cloudflare-kv.d.ts +1 -1
- package/dist/stores/cloudflare-kv.js +31 -8
- package/dist/stores/cloudflare-kv.js.map +1 -1
- package/dist/stores/durable-objects.cjs +10 -2
- package/dist/stores/durable-objects.cjs.map +1 -1
- package/dist/stores/durable-objects.d.cts +1 -1
- package/dist/stores/durable-objects.d.ts +1 -1
- package/dist/stores/durable-objects.js +9 -2
- package/dist/stores/durable-objects.js.map +1 -1
- package/dist/stores/memory.cjs +16 -3
- package/dist/stores/memory.cjs.map +1 -1
- package/dist/stores/memory.d.cts +1 -1
- package/dist/stores/memory.d.ts +1 -1
- package/dist/stores/memory.js +15 -3
- package/dist/stores/memory.js.map +1 -1
- package/dist/stores/redis.cjs +33 -5
- package/dist/stores/redis.cjs.map +1 -1
- package/dist/stores/redis.d.cts +1 -1
- package/dist/stores/redis.d.ts +1 -1
- package/dist/stores/redis.js +32 -5
- package/dist/stores/redis.js.map +1 -1
- package/dist/{types-C_OW_leh.d.cts → types-7IwFeI0l.d.cts} +6 -2
- package/dist/{types-C_OW_leh.d.ts → types-7IwFeI0l.d.ts} +6 -2
- package/package.json +2 -2
|
@@ -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,9 @@ 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,
|
|
36
|
+
clampHttpStatus: () => clampHttpStatus,
|
|
34
37
|
idempotency: () => idempotency,
|
|
35
38
|
problemResponse: () => problemResponse
|
|
36
39
|
});
|
|
@@ -53,15 +56,29 @@ async function getHonoProblemDetails() {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
// src/errors.ts
|
|
59
|
+
function clampHttpStatus(status) {
|
|
60
|
+
if (Number.isNaN(status) || status < 200 || status > 599) return 500;
|
|
61
|
+
return status;
|
|
62
|
+
}
|
|
56
63
|
function problemResponse(problem, extraHeaders) {
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
let body;
|
|
65
|
+
let status;
|
|
66
|
+
try {
|
|
67
|
+
body = JSON.stringify(problem);
|
|
68
|
+
status = clampHttpStatus(problem.status);
|
|
69
|
+
} catch {
|
|
70
|
+
body = '{"title":"Internal Server Error","status":500}';
|
|
71
|
+
status = 500;
|
|
72
|
+
}
|
|
73
|
+
return new Response(body, {
|
|
74
|
+
status,
|
|
59
75
|
headers: {
|
|
60
|
-
"Content-Type":
|
|
76
|
+
"Content-Type": PROBLEM_CONTENT_TYPE,
|
|
61
77
|
...extraHeaders
|
|
62
78
|
}
|
|
63
79
|
});
|
|
64
80
|
}
|
|
81
|
+
var PROBLEM_CONTENT_TYPE = "application/problem+json";
|
|
65
82
|
var BASE_URL = "https://hono-idempotency.dev/errors";
|
|
66
83
|
var IdempotencyErrors = {
|
|
67
84
|
missingKey() {
|
|
@@ -125,10 +142,17 @@ async function generateFingerprint(method, path, body) {
|
|
|
125
142
|
return hex;
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
// src/types.ts
|
|
146
|
+
var RECORD_STATUS_PROCESSING = "processing";
|
|
147
|
+
var RECORD_STATUS_COMPLETED = "completed";
|
|
148
|
+
|
|
128
149
|
// src/middleware.ts
|
|
129
150
|
var DEFAULT_METHODS = ["POST", "PATCH"];
|
|
130
151
|
var DEFAULT_MAX_KEY_LENGTH = 256;
|
|
131
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();
|
|
132
156
|
function idempotency(options) {
|
|
133
157
|
const {
|
|
134
158
|
store,
|
|
@@ -178,16 +202,25 @@ function idempotency(options) {
|
|
|
178
202
|
}
|
|
179
203
|
return next();
|
|
180
204
|
}
|
|
181
|
-
if (key.length > maxKeyLength) {
|
|
205
|
+
if (encoder2.encode(key).length > maxKeyLength) {
|
|
182
206
|
return errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));
|
|
183
207
|
}
|
|
184
208
|
if (maxBodySize != null) {
|
|
185
209
|
const cl = c.req.header("Content-Length");
|
|
186
|
-
if (cl
|
|
187
|
-
|
|
210
|
+
if (cl) {
|
|
211
|
+
const parsed = Number.parseInt(cl, 10);
|
|
212
|
+
if (parsed < 0 || parsed > maxBodySize) {
|
|
213
|
+
return errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));
|
|
214
|
+
}
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
const body = await c.req.text();
|
|
218
|
+
if (maxBodySize != null) {
|
|
219
|
+
const byteLength = encoder2.encode(body).length;
|
|
220
|
+
if (byteLength > maxBodySize) {
|
|
221
|
+
return errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
191
224
|
const fp = customFingerprint ? await customFingerprint(c) : await generateFingerprint(c.req.method, c.req.path, body);
|
|
192
225
|
const rawPrefix = typeof cacheKeyPrefix === "function" ? await cacheKeyPrefix(c) : cacheKeyPrefix;
|
|
193
226
|
const encodedKey = encodeURIComponent(key);
|
|
@@ -195,8 +228,8 @@ function idempotency(options) {
|
|
|
195
228
|
const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;
|
|
196
229
|
const existing = await store.get(storeKey);
|
|
197
230
|
if (existing) {
|
|
198
|
-
if (existing.status ===
|
|
199
|
-
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After":
|
|
231
|
+
if (existing.status === RECORD_STATUS_PROCESSING) {
|
|
232
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
|
|
200
233
|
}
|
|
201
234
|
if (existing.fingerprint !== fp) {
|
|
202
235
|
return errorResponse(IdempotencyErrors.fingerprintMismatch());
|
|
@@ -205,16 +238,17 @@ function idempotency(options) {
|
|
|
205
238
|
await safeHook(onCacheHit, key, c);
|
|
206
239
|
return replayResponse(existing.response);
|
|
207
240
|
}
|
|
241
|
+
await store.delete(storeKey);
|
|
208
242
|
}
|
|
209
243
|
const record = {
|
|
210
244
|
key,
|
|
211
245
|
fingerprint: fp,
|
|
212
|
-
status:
|
|
246
|
+
status: RECORD_STATUS_PROCESSING,
|
|
213
247
|
createdAt: Date.now()
|
|
214
248
|
};
|
|
215
249
|
const locked = await store.lock(storeKey, record);
|
|
216
250
|
if (!locked) {
|
|
217
|
-
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After":
|
|
251
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
|
|
218
252
|
}
|
|
219
253
|
c.set("idempotencyKey", key);
|
|
220
254
|
await safeHook(onCacheMiss, key, c);
|
|
@@ -257,15 +291,18 @@ async function safeHook(fn, key, c) {
|
|
|
257
291
|
}
|
|
258
292
|
function replayResponse(stored) {
|
|
259
293
|
const headers = new Headers(stored.headers);
|
|
260
|
-
headers.set(
|
|
294
|
+
headers.set(REPLAY_HEADER, "true");
|
|
261
295
|
return new Response(stored.body, {
|
|
262
|
-
status: stored.status,
|
|
296
|
+
status: clampHttpStatus(stored.status),
|
|
263
297
|
headers
|
|
264
298
|
});
|
|
265
299
|
}
|
|
266
300
|
// Annotate the CommonJS export names for ESM import in node:
|
|
267
301
|
0 && (module.exports = {
|
|
268
302
|
IdempotencyErrors,
|
|
303
|
+
RECORD_STATUS_COMPLETED,
|
|
304
|
+
RECORD_STATUS_PROCESSING,
|
|
305
|
+
clampHttpStatus,
|
|
269
306
|
idempotency,
|
|
270
307
|
problemResponse
|
|
271
308
|
});
|
package/dist/index.cjs.map
CHANGED
|
@@ -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, 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 { IdempotencyErrors, type ProblemDetail, problemResponse } 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 (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 && Number.parseInt(cl, 10) > maxBodySize) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t}\n\t\t}\n\n\t\tconst body = await c.req.text();\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\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: 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\nexport function problemResponse(\n\tproblem: ProblemDetail,\n\textraHeaders?: Record<string, string>,\n): Response {\n\treturn new Response(JSON.stringify(problem), {\n\t\tstatus: problem.status,\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":"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","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-
|
|
3
|
-
export { b as IdempotencyErrorCode, c as IdempotencyErrors, d as IdempotencyRecord, e as IdempotencyStore, P as ProblemDetail, S as StoredResponse, p as problemResponse } from './types-
|
|
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-
|
|
3
|
-
export { b as IdempotencyErrorCode, c as IdempotencyErrors, d as IdempotencyRecord, e as IdempotencyStore, P as ProblemDetail, S as StoredResponse, p as problemResponse } from './types-
|
|
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
|
|
|
@@ -15,15 +20,29 @@ async function getHonoProblemDetails() {
|
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
// src/errors.ts
|
|
23
|
+
function clampHttpStatus(status) {
|
|
24
|
+
if (Number.isNaN(status) || status < 200 || status > 599) return 500;
|
|
25
|
+
return status;
|
|
26
|
+
}
|
|
18
27
|
function problemResponse(problem, extraHeaders) {
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
let body;
|
|
29
|
+
let status;
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.stringify(problem);
|
|
32
|
+
status = clampHttpStatus(problem.status);
|
|
33
|
+
} catch {
|
|
34
|
+
body = '{"title":"Internal Server Error","status":500}';
|
|
35
|
+
status = 500;
|
|
36
|
+
}
|
|
37
|
+
return new Response(body, {
|
|
38
|
+
status,
|
|
21
39
|
headers: {
|
|
22
|
-
"Content-Type":
|
|
40
|
+
"Content-Type": PROBLEM_CONTENT_TYPE,
|
|
23
41
|
...extraHeaders
|
|
24
42
|
}
|
|
25
43
|
});
|
|
26
44
|
}
|
|
45
|
+
var PROBLEM_CONTENT_TYPE = "application/problem+json";
|
|
27
46
|
var BASE_URL = "https://hono-idempotency.dev/errors";
|
|
28
47
|
var IdempotencyErrors = {
|
|
29
48
|
missingKey() {
|
|
@@ -91,6 +110,9 @@ async function generateFingerprint(method, path, body) {
|
|
|
91
110
|
var DEFAULT_METHODS = ["POST", "PATCH"];
|
|
92
111
|
var DEFAULT_MAX_KEY_LENGTH = 256;
|
|
93
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();
|
|
94
116
|
function idempotency(options) {
|
|
95
117
|
const {
|
|
96
118
|
store,
|
|
@@ -140,16 +162,25 @@ function idempotency(options) {
|
|
|
140
162
|
}
|
|
141
163
|
return next();
|
|
142
164
|
}
|
|
143
|
-
if (key.length > maxKeyLength) {
|
|
165
|
+
if (encoder2.encode(key).length > maxKeyLength) {
|
|
144
166
|
return errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));
|
|
145
167
|
}
|
|
146
168
|
if (maxBodySize != null) {
|
|
147
169
|
const cl = c.req.header("Content-Length");
|
|
148
|
-
if (cl
|
|
149
|
-
|
|
170
|
+
if (cl) {
|
|
171
|
+
const parsed = Number.parseInt(cl, 10);
|
|
172
|
+
if (parsed < 0 || parsed > maxBodySize) {
|
|
173
|
+
return errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));
|
|
174
|
+
}
|
|
150
175
|
}
|
|
151
176
|
}
|
|
152
177
|
const body = await c.req.text();
|
|
178
|
+
if (maxBodySize != null) {
|
|
179
|
+
const byteLength = encoder2.encode(body).length;
|
|
180
|
+
if (byteLength > maxBodySize) {
|
|
181
|
+
return errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
153
184
|
const fp = customFingerprint ? await customFingerprint(c) : await generateFingerprint(c.req.method, c.req.path, body);
|
|
154
185
|
const rawPrefix = typeof cacheKeyPrefix === "function" ? await cacheKeyPrefix(c) : cacheKeyPrefix;
|
|
155
186
|
const encodedKey = encodeURIComponent(key);
|
|
@@ -157,8 +188,8 @@ function idempotency(options) {
|
|
|
157
188
|
const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;
|
|
158
189
|
const existing = await store.get(storeKey);
|
|
159
190
|
if (existing) {
|
|
160
|
-
if (existing.status ===
|
|
161
|
-
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After":
|
|
191
|
+
if (existing.status === RECORD_STATUS_PROCESSING) {
|
|
192
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
|
|
162
193
|
}
|
|
163
194
|
if (existing.fingerprint !== fp) {
|
|
164
195
|
return errorResponse(IdempotencyErrors.fingerprintMismatch());
|
|
@@ -167,16 +198,17 @@ function idempotency(options) {
|
|
|
167
198
|
await safeHook(onCacheHit, key, c);
|
|
168
199
|
return replayResponse(existing.response);
|
|
169
200
|
}
|
|
201
|
+
await store.delete(storeKey);
|
|
170
202
|
}
|
|
171
203
|
const record = {
|
|
172
204
|
key,
|
|
173
205
|
fingerprint: fp,
|
|
174
|
-
status:
|
|
206
|
+
status: RECORD_STATUS_PROCESSING,
|
|
175
207
|
createdAt: Date.now()
|
|
176
208
|
};
|
|
177
209
|
const locked = await store.lock(storeKey, record);
|
|
178
210
|
if (!locked) {
|
|
179
|
-
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After":
|
|
211
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": DEFAULT_RETRY_AFTER });
|
|
180
212
|
}
|
|
181
213
|
c.set("idempotencyKey", key);
|
|
182
214
|
await safeHook(onCacheMiss, key, c);
|
|
@@ -219,14 +251,17 @@ async function safeHook(fn, key, c) {
|
|
|
219
251
|
}
|
|
220
252
|
function replayResponse(stored) {
|
|
221
253
|
const headers = new Headers(stored.headers);
|
|
222
|
-
headers.set(
|
|
254
|
+
headers.set(REPLAY_HEADER, "true");
|
|
223
255
|
return new Response(stored.body, {
|
|
224
|
-
status: stored.status,
|
|
256
|
+
status: clampHttpStatus(stored.status),
|
|
225
257
|
headers
|
|
226
258
|
});
|
|
227
259
|
}
|
|
228
260
|
export {
|
|
229
261
|
IdempotencyErrors,
|
|
262
|
+
RECORD_STATUS_COMPLETED,
|
|
263
|
+
RECORD_STATUS_PROCESSING,
|
|
264
|
+
clampHttpStatus,
|
|
230
265
|
idempotency,
|
|
231
266
|
problemResponse
|
|
232
267
|
};
|
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 { IdempotencyErrors, type ProblemDetail, problemResponse } 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 (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 && Number.parseInt(cl, 10) > maxBodySize) {\n\t\t\t\treturn errorResponse(IdempotencyErrors.bodyTooLarge(maxBodySize));\n\t\t\t}\n\t\t}\n\n\t\tconst body = await c.req.text();\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\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: 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\nexport function problemResponse(\n\tproblem: ProblemDetail,\n\textraHeaders?: Record<string, string>,\n): Response {\n\treturn new Response(JSON.stringify(problem), {\n\t\tstatus: problem.status,\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":";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;;;ACEO,SAAS,gBACf,SACA,cACW;AACX,SAAO,IAAI,SAAS,KAAK,UAAU,OAAO,GAAG;AAAA,IAC5C,QAAQ,QAAQ;AAAA,IAChB,SAAS;AAAA,MACR,gBAAgB;AAAA,MAChB,GAAG;AAAA,IACJ;AAAA,EACD,CAAC;AACF;AAEA,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;;;ACjFA,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;;;AHVA,IAAM,kBAAkB,CAAC,QAAQ,OAAO;AACxC,IAAM,yBAAyB;AAE/B,IAAM,yBAAyB,oBAAI,IAAI,CAAC,YAAY,CAAC;AAE9C,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,CAACA,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,QAAI,IAAI,SAAS,cAAc;AAC9B,aAAO,cAAc,kBAAkB,WAAW,YAAY,CAAC;AAAA,IAChE;AAEA,QAAI,eAAe,MAAM;AACxB,YAAM,KAAK,EAAE,IAAI,OAAO,gBAAgB;AACxC,UAAI,MAAM,OAAO,SAAS,IAAI,EAAE,IAAI,aAAa;AAChD,eAAO,cAAc,kBAAkB,aAAa,WAAW,CAAC;AAAA,MACjE;AAAA,IACD;AAEA,UAAM,OAAO,MAAM,EAAE,IAAI,KAAK;AAC9B,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,cAAc;AACrC,eAAO,cAAc,kBAAkB,SAAS,GAAG,EAAE,eAAe,IAAI,CAAC;AAAA,MAC1E;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;AAAA,IACD;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,IAAI,CAAC;AAAA,IAC1E;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,wBAAwB,MAAM;AAE1C,SAAO,IAAI,SAAS,OAAO,MAAM;AAAA,IAChC,QAAQ,OAAO;AAAA,IACf;AAAA,EACD,CAAC;AACF;","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":"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","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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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();
|
|
@@ -73,7 +87,13 @@ function d1Store(options) {
|
|
|
73
87
|
},
|
|
74
88
|
async complete(key, response) {
|
|
75
89
|
await ensureTable();
|
|
76
|
-
|
|
90
|
+
let serialized;
|
|
91
|
+
try {
|
|
92
|
+
serialized = JSON.stringify(response);
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await db.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`).bind(RECORD_STATUS_COMPLETED, serialized, key).run();
|
|
77
97
|
},
|
|
78
98
|
async delete(key) {
|
|
79
99
|
await ensureTable();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/stores/cloudflare-d1.ts"],"sourcesContent":["import
|
|
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,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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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();
|
|
@@ -49,7 +62,13 @@ function d1Store(options) {
|
|
|
49
62
|
},
|
|
50
63
|
async complete(key, response) {
|
|
51
64
|
await ensureTable();
|
|
52
|
-
|
|
65
|
+
let serialized;
|
|
66
|
+
try {
|
|
67
|
+
serialized = JSON.stringify(response);
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await db.prepare(`UPDATE ${tableName} SET status = ?, response = ? WHERE key = ?`).bind(RECORD_STATUS_COMPLETED, serialized, key).run();
|
|
53
72
|
},
|
|
54
73
|
async delete(key) {
|
|
55
74
|
await ensureTable();
|