hono-idempotency 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/index.cjs +190 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +163 -0
- package/dist/index.js.map +1 -0
- package/dist/stores/cloudflare-d1.cjs +33 -0
- package/dist/stores/cloudflare-d1.cjs.map +1 -0
- package/dist/stores/cloudflare-d1.d.cts +10 -0
- package/dist/stores/cloudflare-d1.d.ts +10 -0
- package/dist/stores/cloudflare-d1.js +8 -0
- package/dist/stores/cloudflare-d1.js.map +1 -0
- package/dist/stores/cloudflare-kv.cjs +33 -0
- package/dist/stores/cloudflare-kv.cjs.map +1 -0
- package/dist/stores/cloudflare-kv.d.cts +10 -0
- package/dist/stores/cloudflare-kv.d.ts +10 -0
- package/dist/stores/cloudflare-kv.js +8 -0
- package/dist/stores/cloudflare-kv.js.map +1 -0
- package/dist/stores/memory.cjs +67 -0
- package/dist/stores/memory.cjs.map +1 -0
- package/dist/stores/memory.d.cts +9 -0
- package/dist/stores/memory.d.ts +9 -0
- package/dist/stores/memory.js +42 -0
- package/dist/stores/memory.js.map +1 -0
- package/dist/types-YeEt4qLu.d.cts +55 -0
- package/dist/types-YeEt4qLu.d.ts +55 -0
- package/package.json +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryota Ikezawa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# hono-idempotency
|
|
2
|
+
|
|
3
|
+
Stripe-style Idempotency-Key middleware for [Hono](https://hono.dev). IETF [draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) compliant.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Idempotency-Key header support for POST/PATCH (configurable)
|
|
8
|
+
- Request fingerprinting (SHA-256) prevents key reuse with different payloads
|
|
9
|
+
- Concurrent request protection with optimistic locking
|
|
10
|
+
- RFC 9457 Problem Details error responses
|
|
11
|
+
- Replayed responses include `Idempotency-Replayed: true` header
|
|
12
|
+
- Non-2xx responses are not cached (Stripe pattern — allows client retry)
|
|
13
|
+
- Pluggable store interface (memory, Cloudflare KV, Cloudflare D1)
|
|
14
|
+
- Works on Cloudflare Workers, Node.js, Deno, Bun, and any Web Standards runtime
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install hono-idempotency
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Hono } from "hono";
|
|
26
|
+
import { idempotency } from "hono-idempotency";
|
|
27
|
+
import { memoryStore } from "hono-idempotency/stores/memory";
|
|
28
|
+
|
|
29
|
+
const app = new Hono();
|
|
30
|
+
|
|
31
|
+
app.use("/api/*", idempotency({ store: memoryStore() }));
|
|
32
|
+
|
|
33
|
+
app.post("/api/payments", (c) => {
|
|
34
|
+
// This handler only runs once per unique Idempotency-Key.
|
|
35
|
+
// Retries with the same key return the cached response.
|
|
36
|
+
return c.json({ id: "pay_123", status: "succeeded" }, 201);
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Client usage:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
curl -X POST http://localhost:3000/api/payments \
|
|
44
|
+
-H "Idempotency-Key: unique-request-id-123" \
|
|
45
|
+
-H "Content-Type: application/json" \
|
|
46
|
+
-d '{"amount": 1000}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Options
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
idempotency({
|
|
53
|
+
// Required: storage backend
|
|
54
|
+
store: memoryStore(),
|
|
55
|
+
|
|
56
|
+
// Header name (default: "Idempotency-Key")
|
|
57
|
+
headerName: "Idempotency-Key",
|
|
58
|
+
|
|
59
|
+
// Return 400 if header is missing (default: false)
|
|
60
|
+
required: false,
|
|
61
|
+
|
|
62
|
+
// HTTP methods to apply idempotency (default: ["POST", "PATCH"])
|
|
63
|
+
methods: ["POST", "PATCH"],
|
|
64
|
+
|
|
65
|
+
// Maximum key length (default: 256)
|
|
66
|
+
maxKeyLength: 256,
|
|
67
|
+
|
|
68
|
+
// Custom fingerprint function (default: SHA-256 of method + path + body)
|
|
69
|
+
fingerprint: (c) => `${c.req.method}:${c.req.path}`,
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Stores
|
|
74
|
+
|
|
75
|
+
### Memory Store
|
|
76
|
+
|
|
77
|
+
Built-in, suitable for single-instance deployments and development.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { memoryStore } from "hono-idempotency/stores/memory";
|
|
81
|
+
|
|
82
|
+
const store = memoryStore({
|
|
83
|
+
ttl: 24 * 60 * 60 * 1000, // 24 hours (default)
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Custom Store
|
|
88
|
+
|
|
89
|
+
Implement the `IdempotencyStore` interface:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import type { IdempotencyStore } from "hono-idempotency";
|
|
93
|
+
|
|
94
|
+
const customStore: IdempotencyStore = {
|
|
95
|
+
async get(key) { /* ... */ },
|
|
96
|
+
async lock(key, record) { /* return false if already locked */ },
|
|
97
|
+
async complete(key, response) { /* ... */ },
|
|
98
|
+
async delete(key) { /* ... */ },
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Error Responses
|
|
103
|
+
|
|
104
|
+
All errors follow [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) with `Content-Type: application/problem+json`.
|
|
105
|
+
|
|
106
|
+
| Status | Type | When |
|
|
107
|
+
|--------|------|------|
|
|
108
|
+
| 400 | `/errors/missing-key` | `required: true` and no header |
|
|
109
|
+
| 400 | `/errors/key-too-long` | Key exceeds `maxKeyLength` |
|
|
110
|
+
| 409 | `/errors/conflict` | Concurrent request with same key |
|
|
111
|
+
| 422 | `/errors/fingerprint-mismatch` | Same key, different request body |
|
|
112
|
+
|
|
113
|
+
## Accessing the Key in Handlers
|
|
114
|
+
|
|
115
|
+
The middleware sets `idempotencyKey` on the Hono context:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import type { IdempotencyEnv } from "hono-idempotency";
|
|
119
|
+
|
|
120
|
+
app.post("/api/payments", (c: Context<IdempotencyEnv>) => {
|
|
121
|
+
const key = c.get("idempotencyKey");
|
|
122
|
+
return c.json({ idempotencyKey: key });
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
idempotency: () => idempotency
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(src_exports);
|
|
26
|
+
|
|
27
|
+
// src/middleware.ts
|
|
28
|
+
var import_factory = require("hono/factory");
|
|
29
|
+
|
|
30
|
+
// src/errors.ts
|
|
31
|
+
function problemResponse(problem, extraHeaders) {
|
|
32
|
+
return new Response(JSON.stringify(problem), {
|
|
33
|
+
status: problem.status,
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/problem+json",
|
|
36
|
+
...extraHeaders
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
var BASE_URL = "https://hono-idempotency.dev/errors";
|
|
41
|
+
var IdempotencyErrors = {
|
|
42
|
+
missingKey() {
|
|
43
|
+
return {
|
|
44
|
+
type: `${BASE_URL}/missing-key`,
|
|
45
|
+
title: "Idempotency-Key header is required",
|
|
46
|
+
status: 400,
|
|
47
|
+
detail: "This endpoint requires an Idempotency-Key header"
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
keyTooLong(maxLength) {
|
|
51
|
+
return {
|
|
52
|
+
type: `${BASE_URL}/key-too-long`,
|
|
53
|
+
title: "Idempotency-Key is too long",
|
|
54
|
+
status: 400,
|
|
55
|
+
detail: `Idempotency-Key must be at most ${maxLength} characters`
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
fingerprintMismatch() {
|
|
59
|
+
return {
|
|
60
|
+
type: `${BASE_URL}/fingerprint-mismatch`,
|
|
61
|
+
title: "Idempotency-Key is already used with a different request",
|
|
62
|
+
status: 422,
|
|
63
|
+
detail: "A request with the same idempotency key but different parameters was already processed"
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
conflict() {
|
|
67
|
+
return {
|
|
68
|
+
type: `${BASE_URL}/conflict`,
|
|
69
|
+
title: "A request is outstanding for this idempotency key",
|
|
70
|
+
status: 409,
|
|
71
|
+
detail: "A request with the same idempotency key is currently being processed"
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/fingerprint.ts
|
|
77
|
+
async function generateFingerprint(method, path, body) {
|
|
78
|
+
const data = `${method}:${path}:${body}`;
|
|
79
|
+
const encoded = new TextEncoder().encode(data);
|
|
80
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoded);
|
|
81
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
82
|
+
return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/middleware.ts
|
|
86
|
+
var DEFAULT_METHODS = ["POST", "PATCH"];
|
|
87
|
+
var DEFAULT_MAX_KEY_LENGTH = 256;
|
|
88
|
+
var EXCLUDED_STORE_HEADERS = /* @__PURE__ */ new Set(["set-cookie"]);
|
|
89
|
+
function idempotency(options) {
|
|
90
|
+
const {
|
|
91
|
+
store,
|
|
92
|
+
headerName = "Idempotency-Key",
|
|
93
|
+
fingerprint: customFingerprint,
|
|
94
|
+
required = false,
|
|
95
|
+
methods = DEFAULT_METHODS,
|
|
96
|
+
maxKeyLength = DEFAULT_MAX_KEY_LENGTH,
|
|
97
|
+
skipRequest,
|
|
98
|
+
onError,
|
|
99
|
+
cacheKeyPrefix
|
|
100
|
+
} = options;
|
|
101
|
+
return (0, import_factory.createMiddleware)(async (c, next) => {
|
|
102
|
+
if (!methods.includes(c.req.method)) {
|
|
103
|
+
return next();
|
|
104
|
+
}
|
|
105
|
+
if (skipRequest && await skipRequest(c)) {
|
|
106
|
+
return next();
|
|
107
|
+
}
|
|
108
|
+
const errorResponse = (problem, extraHeaders) => onError ? onError(problem, c) : problemResponse(problem, extraHeaders);
|
|
109
|
+
const key = c.req.header(headerName);
|
|
110
|
+
if (!key) {
|
|
111
|
+
if (required) {
|
|
112
|
+
return errorResponse(IdempotencyErrors.missingKey());
|
|
113
|
+
}
|
|
114
|
+
return next();
|
|
115
|
+
}
|
|
116
|
+
if (key.length > maxKeyLength) {
|
|
117
|
+
return errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));
|
|
118
|
+
}
|
|
119
|
+
const body = await c.req.text();
|
|
120
|
+
const fp = customFingerprint ? await customFingerprint(c) : await generateFingerprint(c.req.method, c.req.path, body);
|
|
121
|
+
const rawPrefix = typeof cacheKeyPrefix === "function" ? await cacheKeyPrefix(c) : cacheKeyPrefix;
|
|
122
|
+
const encodedKey = encodeURIComponent(key);
|
|
123
|
+
const baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;
|
|
124
|
+
const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;
|
|
125
|
+
const existing = await store.get(storeKey);
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (existing.status === "processing") {
|
|
128
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
|
|
129
|
+
}
|
|
130
|
+
if (existing.fingerprint !== fp) {
|
|
131
|
+
return errorResponse(IdempotencyErrors.fingerprintMismatch());
|
|
132
|
+
}
|
|
133
|
+
if (existing.response) {
|
|
134
|
+
return replayResponse(existing.response);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const record = {
|
|
138
|
+
key,
|
|
139
|
+
fingerprint: fp,
|
|
140
|
+
status: "processing",
|
|
141
|
+
createdAt: Date.now()
|
|
142
|
+
};
|
|
143
|
+
const locked = await store.lock(storeKey, record);
|
|
144
|
+
if (!locked) {
|
|
145
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
|
|
146
|
+
}
|
|
147
|
+
c.set("idempotencyKey", key);
|
|
148
|
+
try {
|
|
149
|
+
await next();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
await store.delete(storeKey);
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
const res = c.res;
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
await store.delete(storeKey);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const resBody = await res.text();
|
|
160
|
+
const resHeaders = {};
|
|
161
|
+
res.headers.forEach((v, k) => {
|
|
162
|
+
if (!EXCLUDED_STORE_HEADERS.has(k.toLowerCase())) {
|
|
163
|
+
resHeaders[k] = v;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const storedResponse = {
|
|
167
|
+
status: res.status,
|
|
168
|
+
headers: resHeaders,
|
|
169
|
+
body: resBody
|
|
170
|
+
};
|
|
171
|
+
await store.complete(storeKey, storedResponse);
|
|
172
|
+
c.res = new Response(resBody, {
|
|
173
|
+
status: res.status,
|
|
174
|
+
headers: res.headers
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function replayResponse(stored) {
|
|
179
|
+
const headers = new Headers(stored.headers);
|
|
180
|
+
headers.set("Idempotency-Replayed", "true");
|
|
181
|
+
return new Response(stored.body, {
|
|
182
|
+
status: stored.status,
|
|
183
|
+
headers
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
187
|
+
0 && (module.exports = {
|
|
188
|
+
idempotency
|
|
189
|
+
});
|
|
190
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/errors.ts","../src/fingerprint.ts"],"sourcesContent":["export { idempotency } from \"./middleware.js\";\nexport type {\n\tIdempotencyEnv,\n\tIdempotencyOptions,\n\tIdempotencyRecord,\n\tStoredResponse,\n} from \"./types.js\";\nexport type { IdempotencyStore } from \"./stores/types.js\";\nexport type { ProblemDetail } from \"./errors.js\";\n","import { createMiddleware } from \"hono/factory\";\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} = 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 = (problem: ProblemDetail, extraHeaders?: Record<string, string>) =>\n\t\t\tonError ? onError(problem, c) : problemResponse(problem, extraHeaders);\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\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\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\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\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","export interface ProblemDetail {\n\ttype: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail: string;\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};\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};\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};\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};\n\t},\n} as const;\n","export async function generateFingerprint(\n\tmethod: string,\n\tpath: string,\n\tbody: string,\n): Promise<string> {\n\tconst data = `${method}:${path}:${body}`;\n\tconst encoded = new TextEncoder().encode(data);\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", encoded);\n\tconst hashArray = new Uint8Array(hashBuffer);\n\treturn Array.from(hashArray)\n\t\t.map((b) => b.toString(16).padStart(2, \"0\"))\n\t\t.join(\"\");\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as hono from 'hono';
|
|
2
|
+
import { I as IdempotencyOptions, a as IdempotencyEnv } from './types-YeEt4qLu.cjs';
|
|
3
|
+
export { b as IdempotencyRecord, c as IdempotencyStore, P as ProblemDetail, S as StoredResponse } from './types-YeEt4qLu.cjs';
|
|
4
|
+
|
|
5
|
+
declare function idempotency(options: IdempotencyOptions): hono.MiddlewareHandler<IdempotencyEnv, string, {}, Response>;
|
|
6
|
+
|
|
7
|
+
export { IdempotencyEnv, IdempotencyOptions, idempotency };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as hono from 'hono';
|
|
2
|
+
import { I as IdempotencyOptions, a as IdempotencyEnv } from './types-YeEt4qLu.js';
|
|
3
|
+
export { b as IdempotencyRecord, c as IdempotencyStore, P as ProblemDetail, S as StoredResponse } from './types-YeEt4qLu.js';
|
|
4
|
+
|
|
5
|
+
declare function idempotency(options: IdempotencyOptions): hono.MiddlewareHandler<IdempotencyEnv, string, {}, Response>;
|
|
6
|
+
|
|
7
|
+
export { IdempotencyEnv, IdempotencyOptions, idempotency };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/middleware.ts
|
|
2
|
+
import { createMiddleware } from "hono/factory";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
function problemResponse(problem, extraHeaders) {
|
|
6
|
+
return new Response(JSON.stringify(problem), {
|
|
7
|
+
status: problem.status,
|
|
8
|
+
headers: {
|
|
9
|
+
"Content-Type": "application/problem+json",
|
|
10
|
+
...extraHeaders
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
var BASE_URL = "https://hono-idempotency.dev/errors";
|
|
15
|
+
var IdempotencyErrors = {
|
|
16
|
+
missingKey() {
|
|
17
|
+
return {
|
|
18
|
+
type: `${BASE_URL}/missing-key`,
|
|
19
|
+
title: "Idempotency-Key header is required",
|
|
20
|
+
status: 400,
|
|
21
|
+
detail: "This endpoint requires an Idempotency-Key header"
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
keyTooLong(maxLength) {
|
|
25
|
+
return {
|
|
26
|
+
type: `${BASE_URL}/key-too-long`,
|
|
27
|
+
title: "Idempotency-Key is too long",
|
|
28
|
+
status: 400,
|
|
29
|
+
detail: `Idempotency-Key must be at most ${maxLength} characters`
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
fingerprintMismatch() {
|
|
33
|
+
return {
|
|
34
|
+
type: `${BASE_URL}/fingerprint-mismatch`,
|
|
35
|
+
title: "Idempotency-Key is already used with a different request",
|
|
36
|
+
status: 422,
|
|
37
|
+
detail: "A request with the same idempotency key but different parameters was already processed"
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
conflict() {
|
|
41
|
+
return {
|
|
42
|
+
type: `${BASE_URL}/conflict`,
|
|
43
|
+
title: "A request is outstanding for this idempotency key",
|
|
44
|
+
status: 409,
|
|
45
|
+
detail: "A request with the same idempotency key is currently being processed"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/fingerprint.ts
|
|
51
|
+
async function generateFingerprint(method, path, body) {
|
|
52
|
+
const data = `${method}:${path}:${body}`;
|
|
53
|
+
const encoded = new TextEncoder().encode(data);
|
|
54
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoded);
|
|
55
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
56
|
+
return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/middleware.ts
|
|
60
|
+
var DEFAULT_METHODS = ["POST", "PATCH"];
|
|
61
|
+
var DEFAULT_MAX_KEY_LENGTH = 256;
|
|
62
|
+
var EXCLUDED_STORE_HEADERS = /* @__PURE__ */ new Set(["set-cookie"]);
|
|
63
|
+
function idempotency(options) {
|
|
64
|
+
const {
|
|
65
|
+
store,
|
|
66
|
+
headerName = "Idempotency-Key",
|
|
67
|
+
fingerprint: customFingerprint,
|
|
68
|
+
required = false,
|
|
69
|
+
methods = DEFAULT_METHODS,
|
|
70
|
+
maxKeyLength = DEFAULT_MAX_KEY_LENGTH,
|
|
71
|
+
skipRequest,
|
|
72
|
+
onError,
|
|
73
|
+
cacheKeyPrefix
|
|
74
|
+
} = options;
|
|
75
|
+
return createMiddleware(async (c, next) => {
|
|
76
|
+
if (!methods.includes(c.req.method)) {
|
|
77
|
+
return next();
|
|
78
|
+
}
|
|
79
|
+
if (skipRequest && await skipRequest(c)) {
|
|
80
|
+
return next();
|
|
81
|
+
}
|
|
82
|
+
const errorResponse = (problem, extraHeaders) => onError ? onError(problem, c) : problemResponse(problem, extraHeaders);
|
|
83
|
+
const key = c.req.header(headerName);
|
|
84
|
+
if (!key) {
|
|
85
|
+
if (required) {
|
|
86
|
+
return errorResponse(IdempotencyErrors.missingKey());
|
|
87
|
+
}
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
if (key.length > maxKeyLength) {
|
|
91
|
+
return errorResponse(IdempotencyErrors.keyTooLong(maxKeyLength));
|
|
92
|
+
}
|
|
93
|
+
const body = await c.req.text();
|
|
94
|
+
const fp = customFingerprint ? await customFingerprint(c) : await generateFingerprint(c.req.method, c.req.path, body);
|
|
95
|
+
const rawPrefix = typeof cacheKeyPrefix === "function" ? await cacheKeyPrefix(c) : cacheKeyPrefix;
|
|
96
|
+
const encodedKey = encodeURIComponent(key);
|
|
97
|
+
const baseKey = `${c.req.method}:${c.req.path}:${encodedKey}`;
|
|
98
|
+
const storeKey = rawPrefix ? `${encodeURIComponent(rawPrefix)}:${baseKey}` : baseKey;
|
|
99
|
+
const existing = await store.get(storeKey);
|
|
100
|
+
if (existing) {
|
|
101
|
+
if (existing.status === "processing") {
|
|
102
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
|
|
103
|
+
}
|
|
104
|
+
if (existing.fingerprint !== fp) {
|
|
105
|
+
return errorResponse(IdempotencyErrors.fingerprintMismatch());
|
|
106
|
+
}
|
|
107
|
+
if (existing.response) {
|
|
108
|
+
return replayResponse(existing.response);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const record = {
|
|
112
|
+
key,
|
|
113
|
+
fingerprint: fp,
|
|
114
|
+
status: "processing",
|
|
115
|
+
createdAt: Date.now()
|
|
116
|
+
};
|
|
117
|
+
const locked = await store.lock(storeKey, record);
|
|
118
|
+
if (!locked) {
|
|
119
|
+
return errorResponse(IdempotencyErrors.conflict(), { "Retry-After": "1" });
|
|
120
|
+
}
|
|
121
|
+
c.set("idempotencyKey", key);
|
|
122
|
+
try {
|
|
123
|
+
await next();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
await store.delete(storeKey);
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
const res = c.res;
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
await store.delete(storeKey);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const resBody = await res.text();
|
|
134
|
+
const resHeaders = {};
|
|
135
|
+
res.headers.forEach((v, k) => {
|
|
136
|
+
if (!EXCLUDED_STORE_HEADERS.has(k.toLowerCase())) {
|
|
137
|
+
resHeaders[k] = v;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
const storedResponse = {
|
|
141
|
+
status: res.status,
|
|
142
|
+
headers: resHeaders,
|
|
143
|
+
body: resBody
|
|
144
|
+
};
|
|
145
|
+
await store.complete(storeKey, storedResponse);
|
|
146
|
+
c.res = new Response(resBody, {
|
|
147
|
+
status: res.status,
|
|
148
|
+
headers: res.headers
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function replayResponse(stored) {
|
|
153
|
+
const headers = new Headers(stored.headers);
|
|
154
|
+
headers.set("Idempotency-Replayed", "true");
|
|
155
|
+
return new Response(stored.body, {
|
|
156
|
+
status: stored.status,
|
|
157
|
+
headers
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export {
|
|
161
|
+
idempotency
|
|
162
|
+
};
|
|
163
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware.ts","../src/errors.ts","../src/fingerprint.ts"],"sourcesContent":["import { createMiddleware } from \"hono/factory\";\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} = 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 = (problem: ProblemDetail, extraHeaders?: Record<string, string>) =>\n\t\t\tonError ? onError(problem, c) : problemResponse(problem, extraHeaders);\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\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\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\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\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","export interface ProblemDetail {\n\ttype: string;\n\ttitle: string;\n\tstatus: number;\n\tdetail: string;\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};\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};\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};\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};\n\t},\n} as const;\n","export async function generateFingerprint(\n\tmethod: string,\n\tpath: string,\n\tbody: string,\n): Promise<string> {\n\tconst data = `${method}:${path}:${body}`;\n\tconst encoded = new TextEncoder().encode(data);\n\tconst hashBuffer = await crypto.subtle.digest(\"SHA-256\", encoded);\n\tconst hashArray = new Uint8Array(hashBuffer);\n\treturn Array.from(hashArray)\n\t\t.map((b) => b.toString(16).padStart(2, \"0\"))\n\t\t.join(\"\");\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/stores/cloudflare-d1.ts
|
|
21
|
+
var cloudflare_d1_exports = {};
|
|
22
|
+
__export(cloudflare_d1_exports, {
|
|
23
|
+
d1Store: () => d1Store
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(cloudflare_d1_exports);
|
|
26
|
+
function d1Store(_options) {
|
|
27
|
+
throw new Error("cloudflare-d1 store is not yet implemented. Coming in Phase 2.");
|
|
28
|
+
}
|
|
29
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
30
|
+
0 && (module.exports = {
|
|
31
|
+
d1Store
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=cloudflare-d1.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/stores/cloudflare-d1.ts"],"sourcesContent":["import type { IdempotencyStore } from \"./types.js\";\n\ninterface D1StoreOptions {\n\tbinding: string;\n\ttableName?: string;\n}\n\n// Phase 2: Cloudflare D1 store implementation\nexport function d1Store(_options: D1StoreOptions): IdempotencyStore {\n\tthrow new Error(\"cloudflare-d1 store is not yet implemented. Coming in Phase 2.\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAQO,SAAS,QAAQ,UAA4C;AACnE,QAAM,IAAI,MAAM,gEAAgE;AACjF;","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/stores/cloudflare-d1.ts"],"sourcesContent":["import type { IdempotencyStore } from \"./types.js\";\n\ninterface D1StoreOptions {\n\tbinding: string;\n\ttableName?: string;\n}\n\n// Phase 2: Cloudflare D1 store implementation\nexport function d1Store(_options: D1StoreOptions): IdempotencyStore {\n\tthrow new Error(\"cloudflare-d1 store is not yet implemented. Coming in Phase 2.\");\n}\n"],"mappings":";AAQO,SAAS,QAAQ,UAA4C;AACnE,QAAM,IAAI,MAAM,gEAAgE;AACjF;","names":[]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/stores/cloudflare-kv.ts
|
|
21
|
+
var cloudflare_kv_exports = {};
|
|
22
|
+
__export(cloudflare_kv_exports, {
|
|
23
|
+
kvStore: () => kvStore
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(cloudflare_kv_exports);
|
|
26
|
+
function kvStore(_options) {
|
|
27
|
+
throw new Error("cloudflare-kv store is not yet implemented. Coming in Phase 2.");
|
|
28
|
+
}
|
|
29
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
30
|
+
0 && (module.exports = {
|
|
31
|
+
kvStore
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=cloudflare-kv.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/stores/cloudflare-kv.ts"],"sourcesContent":["import type { IdempotencyStore } from \"./types.js\";\n\ninterface KVStoreOptions {\n\tbinding: string;\n\tttl?: number;\n}\n\n// Phase 2: Cloudflare KV store implementation\nexport function kvStore(_options: KVStoreOptions): IdempotencyStore {\n\tthrow new Error(\"cloudflare-kv store is not yet implemented. Coming in Phase 2.\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAQO,SAAS,QAAQ,UAA4C;AACnE,QAAM,IAAI,MAAM,gEAAgE;AACjF;","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/stores/cloudflare-kv.ts"],"sourcesContent":["import type { IdempotencyStore } from \"./types.js\";\n\ninterface KVStoreOptions {\n\tbinding: string;\n\tttl?: number;\n}\n\n// Phase 2: Cloudflare KV store implementation\nexport function kvStore(_options: KVStoreOptions): IdempotencyStore {\n\tthrow new Error(\"cloudflare-kv store is not yet implemented. Coming in Phase 2.\");\n}\n"],"mappings":";AAQO,SAAS,QAAQ,UAA4C;AACnE,QAAM,IAAI,MAAM,gEAAgE;AACjF;","names":[]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/stores/memory.ts
|
|
21
|
+
var memory_exports = {};
|
|
22
|
+
__export(memory_exports, {
|
|
23
|
+
memoryStore: () => memoryStore
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(memory_exports);
|
|
26
|
+
var DEFAULT_TTL = 24 * 60 * 60 * 1e3;
|
|
27
|
+
function memoryStore(options = {}) {
|
|
28
|
+
const ttl = options.ttl ?? DEFAULT_TTL;
|
|
29
|
+
const map = /* @__PURE__ */ new Map();
|
|
30
|
+
const isExpired = (record) => {
|
|
31
|
+
return Date.now() - record.createdAt >= ttl;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
async get(key) {
|
|
35
|
+
const record = map.get(key);
|
|
36
|
+
if (!record) return void 0;
|
|
37
|
+
if (isExpired(record)) {
|
|
38
|
+
map.delete(key);
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
return record;
|
|
42
|
+
},
|
|
43
|
+
async lock(key, record) {
|
|
44
|
+
const existing = map.get(key);
|
|
45
|
+
if (existing && !isExpired(existing)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
map.set(key, record);
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
async complete(key, response) {
|
|
52
|
+
const record = map.get(key);
|
|
53
|
+
if (record) {
|
|
54
|
+
record.status = "completed";
|
|
55
|
+
record.response = response;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async delete(key) {
|
|
59
|
+
map.delete(key);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
64
|
+
0 && (module.exports = {
|
|
65
|
+
memoryStore
|
|
66
|
+
});
|
|
67
|
+
//# sourceMappingURL=memory.cjs.map
|
|
@@ -0,0 +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\ninterface MemoryStoreOptions {\n\tttl?: number;\n}\n\nexport function memoryStore(options: MemoryStoreOptions = {}): IdempotencyStore {\n\tconst ttl = options.ttl ?? DEFAULT_TTL;\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\treturn {\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\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\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\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAM5B,SAAS,YAAY,UAA8B,CAAC,GAAqB;AAC/E,QAAM,MAAM,QAAQ,OAAO;AAC3B,QAAM,MAAM,oBAAI,IAA+B;AAE/C,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,SAAO;AAAA,IACN,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,WAAW,IAAI,IAAI,GAAG;AAC5B,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,UAAI,IAAI,KAAK,MAAM;AACnB,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,EACD;AACD;","names":[]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/stores/memory.ts
|
|
2
|
+
var DEFAULT_TTL = 24 * 60 * 60 * 1e3;
|
|
3
|
+
function memoryStore(options = {}) {
|
|
4
|
+
const ttl = options.ttl ?? DEFAULT_TTL;
|
|
5
|
+
const map = /* @__PURE__ */ new Map();
|
|
6
|
+
const isExpired = (record) => {
|
|
7
|
+
return Date.now() - record.createdAt >= ttl;
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
async get(key) {
|
|
11
|
+
const record = map.get(key);
|
|
12
|
+
if (!record) return void 0;
|
|
13
|
+
if (isExpired(record)) {
|
|
14
|
+
map.delete(key);
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
return record;
|
|
18
|
+
},
|
|
19
|
+
async lock(key, record) {
|
|
20
|
+
const existing = map.get(key);
|
|
21
|
+
if (existing && !isExpired(existing)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
map.set(key, record);
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
async complete(key, response) {
|
|
28
|
+
const record = map.get(key);
|
|
29
|
+
if (record) {
|
|
30
|
+
record.status = "completed";
|
|
31
|
+
record.response = response;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async delete(key) {
|
|
35
|
+
map.delete(key);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
memoryStore
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +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\ninterface MemoryStoreOptions {\n\tttl?: number;\n}\n\nexport function memoryStore(options: MemoryStoreOptions = {}): IdempotencyStore {\n\tconst ttl = options.ttl ?? DEFAULT_TTL;\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\treturn {\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\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\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\t};\n}\n"],"mappings":";AAGA,IAAM,cAAc,KAAK,KAAK,KAAK;AAM5B,SAAS,YAAY,UAA8B,CAAC,GAAqB;AAC/E,QAAM,MAAM,QAAQ,OAAO;AAC3B,QAAM,MAAM,oBAAI,IAA+B;AAE/C,QAAM,YAAY,CAAC,WAAuC;AACzD,WAAO,KAAK,IAAI,IAAI,OAAO,aAAa;AAAA,EACzC;AAEA,SAAO;AAAA,IACN,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,WAAW,IAAI,IAAI,GAAG;AAC5B,UAAI,YAAY,CAAC,UAAU,QAAQ,GAAG;AACrC,eAAO;AAAA,MACR;AACA,UAAI,IAAI,KAAK,MAAM;AACnB,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,EACD;AACD;","names":[]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Env, Context } from 'hono';
|
|
2
|
+
|
|
3
|
+
interface ProblemDetail {
|
|
4
|
+
type: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: number;
|
|
7
|
+
detail: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IdempotencyStore {
|
|
11
|
+
/** Get a record by key. Returns undefined if not found. */
|
|
12
|
+
get(key: string): Promise<IdempotencyRecord | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Attempt to lock a key (save in "processing" state).
|
|
15
|
+
* Returns false if the key already exists (optimistic lock).
|
|
16
|
+
*/
|
|
17
|
+
lock(key: string, record: IdempotencyRecord): Promise<boolean>;
|
|
18
|
+
/** Save the response and mark the record as "completed". */
|
|
19
|
+
complete(key: string, response: StoredResponse): Promise<void>;
|
|
20
|
+
/** Delete a key (cleanup on error). */
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface IdempotencyEnv extends Env {
|
|
25
|
+
Variables: {
|
|
26
|
+
idempotencyKey: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
interface StoredResponse {
|
|
30
|
+
status: number;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
interface IdempotencyRecord {
|
|
35
|
+
key: string;
|
|
36
|
+
fingerprint: string;
|
|
37
|
+
status: "processing" | "completed";
|
|
38
|
+
response?: StoredResponse;
|
|
39
|
+
createdAt: number;
|
|
40
|
+
}
|
|
41
|
+
interface IdempotencyOptions {
|
|
42
|
+
store: IdempotencyStore;
|
|
43
|
+
headerName?: string;
|
|
44
|
+
fingerprint?: (c: Context) => string | Promise<string>;
|
|
45
|
+
required?: boolean;
|
|
46
|
+
methods?: string[];
|
|
47
|
+
maxKeyLength?: number;
|
|
48
|
+
/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */
|
|
49
|
+
skipRequest?: (c: Context) => boolean | Promise<boolean>;
|
|
50
|
+
/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */
|
|
51
|
+
onError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;
|
|
52
|
+
cacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { IdempotencyOptions as I, ProblemDetail as P, StoredResponse as S, IdempotencyEnv as a, IdempotencyRecord as b, IdempotencyStore as c };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Env, Context } from 'hono';
|
|
2
|
+
|
|
3
|
+
interface ProblemDetail {
|
|
4
|
+
type: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: number;
|
|
7
|
+
detail: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IdempotencyStore {
|
|
11
|
+
/** Get a record by key. Returns undefined if not found. */
|
|
12
|
+
get(key: string): Promise<IdempotencyRecord | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Attempt to lock a key (save in "processing" state).
|
|
15
|
+
* Returns false if the key already exists (optimistic lock).
|
|
16
|
+
*/
|
|
17
|
+
lock(key: string, record: IdempotencyRecord): Promise<boolean>;
|
|
18
|
+
/** Save the response and mark the record as "completed". */
|
|
19
|
+
complete(key: string, response: StoredResponse): Promise<void>;
|
|
20
|
+
/** Delete a key (cleanup on error). */
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface IdempotencyEnv extends Env {
|
|
25
|
+
Variables: {
|
|
26
|
+
idempotencyKey: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
interface StoredResponse {
|
|
30
|
+
status: number;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
interface IdempotencyRecord {
|
|
35
|
+
key: string;
|
|
36
|
+
fingerprint: string;
|
|
37
|
+
status: "processing" | "completed";
|
|
38
|
+
response?: StoredResponse;
|
|
39
|
+
createdAt: number;
|
|
40
|
+
}
|
|
41
|
+
interface IdempotencyOptions {
|
|
42
|
+
store: IdempotencyStore;
|
|
43
|
+
headerName?: string;
|
|
44
|
+
fingerprint?: (c: Context) => string | Promise<string>;
|
|
45
|
+
required?: boolean;
|
|
46
|
+
methods?: string[];
|
|
47
|
+
maxKeyLength?: number;
|
|
48
|
+
/** Should be a lightweight, side-effect-free predicate. Avoid reading the request body. */
|
|
49
|
+
skipRequest?: (c: Context) => boolean | Promise<boolean>;
|
|
50
|
+
/** Return a Response with an error status (4xx/5xx). Returning 2xx bypasses idempotency guarantees. */
|
|
51
|
+
onError?: (error: ProblemDetail, c: Context) => Response | Promise<Response>;
|
|
52
|
+
cacheKeyPrefix?: string | ((c: Context) => string | Promise<string>);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { IdempotencyOptions as I, ProblemDetail as P, StoredResponse as S, IdempotencyEnv as a, IdempotencyRecord as b, IdempotencyStore as c };
|
package/package.json
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hono-idempotency",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stripe-style Idempotency-Key middleware for Hono. IETF draft-ietf-httpapi-idempotency-key-header compliant.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./stores/memory": {
|
|
21
|
+
"import": {
|
|
22
|
+
"types": "./dist/stores/memory.d.ts",
|
|
23
|
+
"default": "./dist/stores/memory.js"
|
|
24
|
+
},
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/stores/memory.d.cts",
|
|
27
|
+
"default": "./dist/stores/memory.cjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"./stores/cloudflare-kv": {
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/stores/cloudflare-kv.d.ts",
|
|
33
|
+
"default": "./dist/stores/cloudflare-kv.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/stores/cloudflare-kv.d.cts",
|
|
37
|
+
"default": "./dist/stores/cloudflare-kv.cjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"./stores/cloudflare-d1": {
|
|
41
|
+
"import": {
|
|
42
|
+
"types": "./dist/stores/cloudflare-d1.d.ts",
|
|
43
|
+
"default": "./dist/stores/cloudflare-d1.js"
|
|
44
|
+
},
|
|
45
|
+
"require": {
|
|
46
|
+
"types": "./dist/stores/cloudflare-d1.d.cts",
|
|
47
|
+
"default": "./dist/stores/cloudflare-d1.cjs"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"dist"
|
|
53
|
+
],
|
|
54
|
+
"keywords": [
|
|
55
|
+
"hono",
|
|
56
|
+
"idempotency",
|
|
57
|
+
"idempotency-key",
|
|
58
|
+
"middleware",
|
|
59
|
+
"cloudflare-workers",
|
|
60
|
+
"edge"
|
|
61
|
+
],
|
|
62
|
+
"author": "Ryota Ikezawa",
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "https://github.com/paveg/hono-idempotency.git"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/paveg/hono-idempotency#readme",
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/paveg/hono-idempotency/issues"
|
|
70
|
+
},
|
|
71
|
+
"license": "MIT",
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public",
|
|
74
|
+
"provenance": true
|
|
75
|
+
},
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=20"
|
|
78
|
+
},
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"hono": ">=4.0.0"
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"@biomejs/biome": "^1.9.0",
|
|
84
|
+
"@changesets/changelog-github": "0.5.2",
|
|
85
|
+
"@changesets/cli": "2.29.8",
|
|
86
|
+
"@vitest/coverage-v8": "3.2.4",
|
|
87
|
+
"hono": "^4.7.0",
|
|
88
|
+
"lefthook": "2.1.1",
|
|
89
|
+
"tsup": "^8.0.0",
|
|
90
|
+
"typescript": "^5.7.0",
|
|
91
|
+
"vitest": "^3.0.0"
|
|
92
|
+
},
|
|
93
|
+
"scripts": {
|
|
94
|
+
"build": "tsup",
|
|
95
|
+
"test": "vitest run",
|
|
96
|
+
"test:watch": "vitest",
|
|
97
|
+
"lint": "biome check .",
|
|
98
|
+
"lint:fix": "biome check --write .",
|
|
99
|
+
"format": "biome format --write .",
|
|
100
|
+
"typecheck": "tsc --noEmit",
|
|
101
|
+
"release": "pnpm build && changeset publish"
|
|
102
|
+
}
|
|
103
|
+
}
|