@oncely/express 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/index.cjs +263 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +142 -0
- package/dist/index.d.ts +142 -0
- package/dist/index.js +231 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @oncely/express
|
|
2
|
+
|
|
3
|
+
Express middleware for oncely idempotency.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
npm install @oncely/core @oncely/express
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
import express from 'express';
|
|
12
|
+
import { express as idempotent, configure } from '@oncely/express';
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
|
|
16
|
+
// Zero-config (uses memory storage)
|
|
17
|
+
app.post('/orders', idempotent(), async (req, res) => {
|
|
18
|
+
res.status(201).json(await createOrder(req.body));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// With options
|
|
22
|
+
app.post('/payments', idempotent({
|
|
23
|
+
required: true,
|
|
24
|
+
ttl: '1h',
|
|
25
|
+
}), paymentHandler);
|
|
26
|
+
|
|
27
|
+
// Pre-configured factory
|
|
28
|
+
import { redis } from '@oncely/redis';
|
|
29
|
+
|
|
30
|
+
const idempotent = configure({
|
|
31
|
+
storage: redis(),
|
|
32
|
+
ttl: '1h',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.post('/orders', idempotent(), orderHandler);
|
|
36
|
+
app.post('/payments', idempotent({ required: true }), paymentHandler);
|
|
37
|
+
|
|
38
|
+
## Headers
|
|
39
|
+
|
|
40
|
+
Request: Idempotency-Key
|
|
41
|
+
Response: Idempotency-Key, Idempotency-Replay (on cache hit)
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@oncely/core');
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
var PROBLEM_BASE = "https://oncely.dev/errors";
|
|
7
|
+
var IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
8
|
+
var IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay";
|
|
9
|
+
var defaultStorage = null;
|
|
10
|
+
function getStorage(provided) {
|
|
11
|
+
if (provided) return provided;
|
|
12
|
+
if (!defaultStorage) {
|
|
13
|
+
defaultStorage = new core.MemoryStorage();
|
|
14
|
+
}
|
|
15
|
+
return defaultStorage;
|
|
16
|
+
}
|
|
17
|
+
function express(options = {}) {
|
|
18
|
+
const {
|
|
19
|
+
storage: providedStorage,
|
|
20
|
+
ttl = "24h",
|
|
21
|
+
required = false,
|
|
22
|
+
methods = ["POST", "PUT", "PATCH", "DELETE"],
|
|
23
|
+
getKey = (req) => req.get(IDEMPOTENCY_KEY_HEADER),
|
|
24
|
+
getHash = (req) => req.body ? core.hashObject(req.body) : null,
|
|
25
|
+
includeHeaders = true,
|
|
26
|
+
debug = false,
|
|
27
|
+
failOpen = false,
|
|
28
|
+
onHit,
|
|
29
|
+
onMiss,
|
|
30
|
+
onError
|
|
31
|
+
} = options;
|
|
32
|
+
const storage = getStorage(providedStorage);
|
|
33
|
+
const ttlMs = core.parseTtl(ttl);
|
|
34
|
+
const log = (message) => {
|
|
35
|
+
if (debug) {
|
|
36
|
+
console.log(`[oncely/express] ${message}`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return async (req, res, next) => {
|
|
40
|
+
if (!methods.includes(req.method)) {
|
|
41
|
+
return next();
|
|
42
|
+
}
|
|
43
|
+
const key = getKey(req);
|
|
44
|
+
if (!key && !required) {
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
if (!key && required) {
|
|
48
|
+
if (includeHeaders) {
|
|
49
|
+
res.set("Content-Type", "application/problem+json");
|
|
50
|
+
}
|
|
51
|
+
res.status(400).json({
|
|
52
|
+
type: `${PROBLEM_BASE}/key-required`,
|
|
53
|
+
title: "Idempotency Key Required",
|
|
54
|
+
status: 400,
|
|
55
|
+
detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const hash = getHash(req);
|
|
60
|
+
try {
|
|
61
|
+
log(`Acquiring lock for key: ${key}`);
|
|
62
|
+
let result;
|
|
63
|
+
try {
|
|
64
|
+
result = await storage.acquire(key, hash, ttlMs);
|
|
65
|
+
} catch (storageErr) {
|
|
66
|
+
if (failOpen) {
|
|
67
|
+
log(`Storage error for key: ${key}, failing open`);
|
|
68
|
+
onError?.(key, storageErr);
|
|
69
|
+
return next();
|
|
70
|
+
}
|
|
71
|
+
throw storageErr;
|
|
72
|
+
}
|
|
73
|
+
if (result.status === "hit") {
|
|
74
|
+
log(`Cache hit for key: ${key}`);
|
|
75
|
+
onHit?.(key, result.response);
|
|
76
|
+
const cached = result.response.data;
|
|
77
|
+
if (includeHeaders) {
|
|
78
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
79
|
+
res.set(IDEMPOTENCY_REPLAY_HEADER, "true");
|
|
80
|
+
if (cached.headers) {
|
|
81
|
+
for (const [name, value] of Object.entries(cached.headers)) {
|
|
82
|
+
if (value !== void 0 && !name.toLowerCase().startsWith("x-powered-by")) {
|
|
83
|
+
res.set(name, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
res.status(cached.status);
|
|
89
|
+
if (cached.body !== void 0) {
|
|
90
|
+
if (typeof cached.body === "object") {
|
|
91
|
+
res.json(cached.body);
|
|
92
|
+
} else {
|
|
93
|
+
res.send(cached.body);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
res.end();
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (result.status === "conflict") {
|
|
101
|
+
log(`Conflict for key: ${key}`);
|
|
102
|
+
if (includeHeaders) {
|
|
103
|
+
res.set("Content-Type", "application/problem+json");
|
|
104
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
105
|
+
res.set("Retry-After", "1");
|
|
106
|
+
}
|
|
107
|
+
res.status(409).json({
|
|
108
|
+
type: `${PROBLEM_BASE}/conflict`,
|
|
109
|
+
title: "Conflict",
|
|
110
|
+
status: 409,
|
|
111
|
+
detail: "A request with this idempotency key is already being processed."
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (result.status === "mismatch") {
|
|
116
|
+
log(`Hash mismatch for key: ${key}`);
|
|
117
|
+
if (includeHeaders) {
|
|
118
|
+
res.set("Content-Type", "application/problem+json");
|
|
119
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
120
|
+
}
|
|
121
|
+
res.status(422).json({
|
|
122
|
+
type: `${PROBLEM_BASE}/mismatch`,
|
|
123
|
+
title: "Idempotency Key Mismatch",
|
|
124
|
+
status: 422,
|
|
125
|
+
detail: "The request body does not match the original request for this idempotency key.",
|
|
126
|
+
existingHash: result.existingHash,
|
|
127
|
+
providedHash: result.providedHash
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
log(`Lock acquired for key: ${key}`);
|
|
132
|
+
onMiss?.(key);
|
|
133
|
+
const captured = {
|
|
134
|
+
status: 200,
|
|
135
|
+
headers: {},
|
|
136
|
+
body: void 0
|
|
137
|
+
};
|
|
138
|
+
const originalJson = res.json.bind(res);
|
|
139
|
+
const originalSend = res.send.bind(res);
|
|
140
|
+
const originalEnd = res.end.bind(res);
|
|
141
|
+
const originalSetHeader = res.setHeader.bind(res);
|
|
142
|
+
let finished = false;
|
|
143
|
+
res.setHeader = function(name, value) {
|
|
144
|
+
captured.headers[name] = value;
|
|
145
|
+
return originalSetHeader(name, value);
|
|
146
|
+
};
|
|
147
|
+
res.json = function(data) {
|
|
148
|
+
if (!finished) {
|
|
149
|
+
captured.body = data;
|
|
150
|
+
captured.status = res.statusCode;
|
|
151
|
+
}
|
|
152
|
+
return originalJson(data);
|
|
153
|
+
};
|
|
154
|
+
res.send = function(data) {
|
|
155
|
+
if (!finished) {
|
|
156
|
+
captured.body = data;
|
|
157
|
+
captured.status = res.statusCode;
|
|
158
|
+
}
|
|
159
|
+
return originalSend(data);
|
|
160
|
+
};
|
|
161
|
+
res.end = function(chunk, encoding, _cb) {
|
|
162
|
+
if (!finished) {
|
|
163
|
+
finished = true;
|
|
164
|
+
captured.status = res.statusCode;
|
|
165
|
+
if (chunk !== void 0 && captured.body === void 0) {
|
|
166
|
+
captured.body = chunk;
|
|
167
|
+
}
|
|
168
|
+
if (includeHeaders) {
|
|
169
|
+
originalSetHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
170
|
+
}
|
|
171
|
+
const storedResponse = {
|
|
172
|
+
data: captured,
|
|
173
|
+
createdAt: Date.now(),
|
|
174
|
+
hash
|
|
175
|
+
};
|
|
176
|
+
storage.save(key, storedResponse).catch((err) => {
|
|
177
|
+
log(`Failed to save response for key: ${key}`);
|
|
178
|
+
onError?.(key, err);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return originalEnd.call(res, chunk, encoding);
|
|
182
|
+
};
|
|
183
|
+
res.on("error", async () => {
|
|
184
|
+
if (!finished) {
|
|
185
|
+
finished = true;
|
|
186
|
+
log(`Response error for key: ${key}, releasing lock`);
|
|
187
|
+
await storage.release(key).catch(() => {
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
req.on("close", async () => {
|
|
192
|
+
if (!finished && !res.writableFinished) {
|
|
193
|
+
finished = true;
|
|
194
|
+
log(`Client disconnected for key: ${key}, releasing lock`);
|
|
195
|
+
await storage.release(key).catch(() => {
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
next();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err instanceof core.IdempotencyError) {
|
|
202
|
+
if (includeHeaders) {
|
|
203
|
+
res.set("Content-Type", "application/problem+json");
|
|
204
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
205
|
+
if (err instanceof core.ConflictError) {
|
|
206
|
+
res.set("Retry-After", String(err.retryAfter ?? 1));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
let type = `${PROBLEM_BASE}/error`;
|
|
210
|
+
if (err instanceof core.MissingKeyError) type = `${PROBLEM_BASE}/key-required`;
|
|
211
|
+
else if (err instanceof core.ConflictError) type = `${PROBLEM_BASE}/conflict`;
|
|
212
|
+
else if (err instanceof core.MismatchError) type = `${PROBLEM_BASE}/mismatch`;
|
|
213
|
+
res.status(err.statusCode).json({
|
|
214
|
+
type,
|
|
215
|
+
title: err.name,
|
|
216
|
+
status: err.statusCode,
|
|
217
|
+
detail: err.message
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
next(err);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function configure(defaults) {
|
|
226
|
+
return (overrides = {}) => express({ ...defaults, ...overrides });
|
|
227
|
+
}
|
|
228
|
+
core.oncely.express = express;
|
|
229
|
+
|
|
230
|
+
Object.defineProperty(exports, "ConflictError", {
|
|
231
|
+
enumerable: true,
|
|
232
|
+
get: function () { return core.ConflictError; }
|
|
233
|
+
});
|
|
234
|
+
Object.defineProperty(exports, "IdempotencyError", {
|
|
235
|
+
enumerable: true,
|
|
236
|
+
get: function () { return core.IdempotencyError; }
|
|
237
|
+
});
|
|
238
|
+
Object.defineProperty(exports, "MemoryStorage", {
|
|
239
|
+
enumerable: true,
|
|
240
|
+
get: function () { return core.MemoryStorage; }
|
|
241
|
+
});
|
|
242
|
+
Object.defineProperty(exports, "MismatchError", {
|
|
243
|
+
enumerable: true,
|
|
244
|
+
get: function () { return core.MismatchError; }
|
|
245
|
+
});
|
|
246
|
+
Object.defineProperty(exports, "MissingKeyError", {
|
|
247
|
+
enumerable: true,
|
|
248
|
+
get: function () { return core.MissingKeyError; }
|
|
249
|
+
});
|
|
250
|
+
Object.defineProperty(exports, "hashObject", {
|
|
251
|
+
enumerable: true,
|
|
252
|
+
get: function () { return core.hashObject; }
|
|
253
|
+
});
|
|
254
|
+
Object.defineProperty(exports, "oncely", {
|
|
255
|
+
enumerable: true,
|
|
256
|
+
get: function () { return core.oncely; }
|
|
257
|
+
});
|
|
258
|
+
exports.IDEMPOTENCY_KEY_HEADER = IDEMPOTENCY_KEY_HEADER;
|
|
259
|
+
exports.IDEMPOTENCY_REPLAY_HEADER = IDEMPOTENCY_REPLAY_HEADER;
|
|
260
|
+
exports.configure = configure;
|
|
261
|
+
exports.express = express;
|
|
262
|
+
//# sourceMappingURL=index.cjs.map
|
|
263
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["MemoryStorage","hashObject","parseTtl","IdempotencyError","ConflictError","MissingKeyError","MismatchError","oncely"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @oncely/express - Express middleware for oncely idempotency\n *\n * Provides Express middleware that wraps route handlers with idempotency protection.\n *\n * @example\n * ```typescript\n * import { oncely } from '@oncely/core';\n * import '@oncely/express';\n *\n * // Now oncely.express() is available\n * app.post('/orders', oncely.express(), async (req, res) => {\n * const order = await createOrder(req.body);\n * res.status(201).json(order);\n * });\n * ```\n */\n\nimport type { Request, Response, NextFunction, RequestHandler } from 'express';\nimport {\n oncely,\n MemoryStorage,\n hashObject,\n parseTtl,\n IdempotencyError,\n ConflictError,\n MissingKeyError,\n MismatchError,\n type StorageAdapter,\n type OncelyOptions,\n type StoredResponse,\n} from '@oncely/core';\n\n// RFC 7807 Problem Details type URL base\nconst PROBLEM_BASE = 'https://oncely.dev/errors';\n\n/**\n * IETF standard header names\n */\nexport const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';\nexport const IDEMPOTENCY_REPLAY_HEADER = 'Idempotency-Replay';\n\n/**\n * Options for Express idempotency middleware\n */\nexport interface ExpressMiddlewareOptions {\n /**\n * Storage adapter for persisting idempotency records.\n * @default oncely.config.storage or memory\n */\n storage?: StorageAdapter;\n\n /**\n * TTL for idempotency records in milliseconds or duration string.\n * @default oncely.config.ttl or '24h'\n */\n ttl?: number | string;\n\n /**\n * Whether to require an idempotency key.\n * If true, returns 400 when key is missing.\n * @default false\n */\n required?: boolean;\n\n /**\n * HTTP methods to apply idempotency to.\n * @default ['POST', 'PUT', 'PATCH', 'DELETE']\n */\n methods?: string[];\n\n /**\n * Custom function to extract idempotency key from request.\n * @default Reads Idempotency-Key header\n */\n getKey?: (req: Request) => string | undefined;\n\n /**\n * Custom function to generate hash from request for mismatch detection.\n * @default Uses hashObject(req.body)\n */\n getHash?: (req: Request) => string | null;\n\n /**\n * Whether to add idempotency headers to responses.\n * @default true\n */\n includeHeaders?: boolean;\n\n /**\n * Debug mode for logging.\n * @default false\n */\n debug?: boolean;\n\n /**\n * Callback when a cached response is returned.\n */\n onHit?: OncelyOptions['onHit'];\n\n /**\n * Callback when a new response is generated.\n */\n onMiss?: OncelyOptions['onMiss'];\n\n /**\n * Callback when an error occurs.\n */\n onError?: OncelyOptions['onError'];\n\n /**\n * If true, proceed with the request when storage fails (e.g., Redis down).\n * When false (default), storage errors return 500.\n * Use with non-critical idempotency where availability is more important.\n * @default false\n */\n failOpen?: boolean;\n}\n\n/**\n * Captured response data\n */\ninterface CapturedResponse {\n status: number;\n headers: Record<string, string | string[] | number | undefined>;\n body: unknown;\n}\n\n// Shared default storage instance\nlet defaultStorage: StorageAdapter | null = null;\n\nfunction getStorage(provided?: StorageAdapter): StorageAdapter {\n if (provided) return provided;\n if (!defaultStorage) {\n defaultStorage = new MemoryStorage();\n }\n return defaultStorage;\n}\n\n/**\n * Create Express middleware for idempotency.\n *\n * @example\n * ```typescript\n * // Basic usage with defaults\n * app.post('/orders', express(), orderHandler);\n *\n * // With options\n * app.post('/payments', express({\n * storage: redis(),\n * required: true,\n * ttl: '1h',\n * }), paymentHandler);\n *\n * // Custom key extraction\n * app.post('/api/:version/orders', express({\n * getKey: (req) => req.headers['x-request-id'] as string,\n * }), orderHandler);\n * ```\n */\nexport function express(options: ExpressMiddlewareOptions = {}): RequestHandler {\n const {\n storage: providedStorage,\n ttl = '24h',\n required = false,\n methods = ['POST', 'PUT', 'PATCH', 'DELETE'],\n getKey = (req) => req.get(IDEMPOTENCY_KEY_HEADER),\n getHash = (req) => (req.body ? hashObject(req.body) : null),\n includeHeaders = true,\n debug = false,\n failOpen = false,\n onHit,\n onMiss,\n onError,\n } = options;\n\n const storage = getStorage(providedStorage);\n const ttlMs = parseTtl(ttl);\n\n const log = (message: string) => {\n if (debug) {\n console.log(`[oncely/express] ${message}`);\n }\n };\n\n return async (req: Request, res: Response, next: NextFunction): Promise<void> => {\n // Skip if method not in list\n if (!methods.includes(req.method)) {\n return next();\n }\n\n // Get idempotency key\n const key = getKey(req);\n\n // Skip if no key and not required\n if (!key && !required) {\n return next();\n }\n\n // Return 400 if key required but not provided\n if (!key && required) {\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n }\n res.status(400).json({\n type: `${PROBLEM_BASE}/key-required`,\n title: 'Idempotency Key Required',\n status: 400,\n detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`,\n });\n return;\n }\n\n // Compute request hash for mismatch detection\n const hash = getHash(req);\n\n try {\n log(`Acquiring lock for key: ${key}`);\n let result;\n try {\n result = await storage.acquire(key!, hash, ttlMs);\n } catch (storageErr) {\n // Storage failed (e.g., Redis down)\n if (failOpen) {\n log(`Storage error for key: ${key}, failing open`);\n onError?.(key!, storageErr as Error);\n return next(); // Proceed without idempotency protection\n }\n throw storageErr;\n }\n\n if (result.status === 'hit') {\n // Return cached response\n log(`Cache hit for key: ${key}`);\n onHit?.(key!, result.response);\n const cached = result.response.data as CapturedResponse;\n\n if (includeHeaders) {\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n res.set(IDEMPOTENCY_REPLAY_HEADER, 'true');\n\n // Restore original headers\n if (cached.headers) {\n for (const [name, value] of Object.entries(cached.headers)) {\n if (value !== undefined && !name.toLowerCase().startsWith('x-powered-by')) {\n res.set(name, value as string);\n }\n }\n }\n }\n\n res.status(cached.status);\n if (cached.body !== undefined) {\n if (typeof cached.body === 'object') {\n res.json(cached.body);\n } else {\n res.send(cached.body);\n }\n } else {\n res.end();\n }\n return;\n }\n\n if (result.status === 'conflict') {\n log(`Conflict for key: ${key}`);\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n res.set('Retry-After', '1');\n }\n res.status(409).json({\n type: `${PROBLEM_BASE}/conflict`,\n title: 'Conflict',\n status: 409,\n detail: 'A request with this idempotency key is already being processed.',\n });\n return;\n }\n\n if (result.status === 'mismatch') {\n log(`Hash mismatch for key: ${key}`);\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n }\n res.status(422).json({\n type: `${PROBLEM_BASE}/mismatch`,\n title: 'Idempotency Key Mismatch',\n status: 422,\n detail: 'The request body does not match the original request for this idempotency key.',\n existingHash: result.existingHash,\n providedHash: result.providedHash,\n });\n return;\n }\n\n // status === 'acquired' - proceed with handler\n log(`Lock acquired for key: ${key}`);\n onMiss?.(key!);\n\n // Capture response\n const captured: CapturedResponse = {\n status: 200,\n headers: {},\n body: undefined,\n };\n\n // Store original methods\n const originalJson = res.json.bind(res);\n const originalSend = res.send.bind(res);\n const originalEnd = res.end.bind(res);\n const originalSetHeader = res.setHeader.bind(res);\n let finished = false;\n\n // Capture headers\n res.setHeader = function (name: string, value: string | string[] | number) {\n captured.headers[name] = value;\n return originalSetHeader(name, value);\n };\n\n // Capture JSON responses\n res.json = function (data: unknown) {\n if (!finished) {\n captured.body = data;\n captured.status = res.statusCode;\n }\n return originalJson(data);\n };\n\n // Capture send responses\n res.send = function (data: unknown) {\n if (!finished) {\n captured.body = data;\n captured.status = res.statusCode;\n }\n return originalSend(data);\n };\n\n // Capture end and save\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n res.end = function (chunk?: any, encoding?: any, _cb?: any) {\n if (!finished) {\n finished = true;\n captured.status = res.statusCode;\n if (chunk !== undefined && captured.body === undefined) {\n captured.body = chunk;\n }\n\n // Add idempotency headers\n if (includeHeaders) {\n originalSetHeader(IDEMPOTENCY_KEY_HEADER, key!);\n }\n\n // Save response asynchronously (don't block)\n const storedResponse: StoredResponse = {\n data: captured,\n createdAt: Date.now(),\n hash,\n };\n\n storage.save(key!, storedResponse).catch((err) => {\n log(`Failed to save response for key: ${key}`);\n onError?.(key!, err as Error);\n });\n }\n\n return originalEnd.call(res, chunk, encoding);\n } as typeof res.end;\n\n // Handle errors - release lock\n res.on('error', async () => {\n if (!finished) {\n finished = true;\n log(`Response error for key: ${key}, releasing lock`);\n await storage.release(key!).catch(() => {});\n }\n });\n\n // Handle client disconnect before response completes - release lock to allow retry\n req.on('close', async () => {\n if (!finished && !res.writableFinished) {\n finished = true;\n log(`Client disconnected for key: ${key}, releasing lock`);\n await storage.release(key!).catch(() => {});\n }\n });\n\n // Call next handler\n next();\n } catch (err) {\n // Handle oncely errors\n if (err instanceof IdempotencyError) {\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n\n if (err instanceof ConflictError) {\n res.set('Retry-After', String(err.retryAfter ?? 1));\n }\n }\n\n let type = `${PROBLEM_BASE}/error`;\n if (err instanceof MissingKeyError) type = `${PROBLEM_BASE}/key-required`;\n else if (err instanceof ConflictError) type = `${PROBLEM_BASE}/conflict`;\n else if (err instanceof MismatchError) type = `${PROBLEM_BASE}/mismatch`;\n\n res.status(err.statusCode).json({\n type,\n title: err.name,\n status: err.statusCode,\n detail: err.message,\n });\n return;\n }\n\n // Pass other errors to Express error handler\n next(err);\n }\n };\n}\n\n/**\n * Create a pre-configured middleware factory with default options.\n *\n * @example\n * ```typescript\n * import { configure } from '@oncely/express';\n * import { redis } from '@oncely/redis';\n *\n * const idempotent = configure({\n * storage: redis(),\n * ttl: '1h',\n * });\n *\n * app.post('/orders', idempotent(), orderHandler);\n * app.post('/payments', idempotent({ required: true }), paymentHandler);\n * ```\n */\nexport function configure(\n defaults: ExpressMiddlewareOptions\n): (overrides?: Partial<ExpressMiddlewareOptions>) => RequestHandler {\n return (overrides = {}) => express({ ...defaults, ...overrides });\n}\n\n// Augment oncely namespace\ndeclare module '@oncely/core' {\n interface OncelyNamespace {\n /**\n * Create Express middleware for idempotency.\n */\n express: typeof express;\n }\n}\n\n// Register on oncely namespace\n(oncely as Record<string, unknown>).express = express;\n\n// Re-export types and utilities\nexport {\n oncely,\n MemoryStorage,\n hashObject,\n IdempotencyError,\n MissingKeyError,\n ConflictError,\n MismatchError,\n type StorageAdapter,\n type OncelyOptions,\n} from '@oncely/core';\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Request, RequestHandler } from 'express';
|
|
2
|
+
import { StorageAdapter, OncelyOptions } from '@oncely/core';
|
|
3
|
+
export { ConflictError, IdempotencyError, MemoryStorage, MismatchError, MissingKeyError, OncelyOptions, StorageAdapter, hashObject, oncely } from '@oncely/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @oncely/express - Express middleware for oncely idempotency
|
|
7
|
+
*
|
|
8
|
+
* Provides Express middleware that wraps route handlers with idempotency protection.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { oncely } from '@oncely/core';
|
|
13
|
+
* import '@oncely/express';
|
|
14
|
+
*
|
|
15
|
+
* // Now oncely.express() is available
|
|
16
|
+
* app.post('/orders', oncely.express(), async (req, res) => {
|
|
17
|
+
* const order = await createOrder(req.body);
|
|
18
|
+
* res.status(201).json(order);
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* IETF standard header names
|
|
25
|
+
*/
|
|
26
|
+
declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
27
|
+
declare const IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay";
|
|
28
|
+
/**
|
|
29
|
+
* Options for Express idempotency middleware
|
|
30
|
+
*/
|
|
31
|
+
interface ExpressMiddlewareOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Storage adapter for persisting idempotency records.
|
|
34
|
+
* @default oncely.config.storage or memory
|
|
35
|
+
*/
|
|
36
|
+
storage?: StorageAdapter;
|
|
37
|
+
/**
|
|
38
|
+
* TTL for idempotency records in milliseconds or duration string.
|
|
39
|
+
* @default oncely.config.ttl or '24h'
|
|
40
|
+
*/
|
|
41
|
+
ttl?: number | string;
|
|
42
|
+
/**
|
|
43
|
+
* Whether to require an idempotency key.
|
|
44
|
+
* If true, returns 400 when key is missing.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
required?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* HTTP methods to apply idempotency to.
|
|
50
|
+
* @default ['POST', 'PUT', 'PATCH', 'DELETE']
|
|
51
|
+
*/
|
|
52
|
+
methods?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Custom function to extract idempotency key from request.
|
|
55
|
+
* @default Reads Idempotency-Key header
|
|
56
|
+
*/
|
|
57
|
+
getKey?: (req: Request) => string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Custom function to generate hash from request for mismatch detection.
|
|
60
|
+
* @default Uses hashObject(req.body)
|
|
61
|
+
*/
|
|
62
|
+
getHash?: (req: Request) => string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Whether to add idempotency headers to responses.
|
|
65
|
+
* @default true
|
|
66
|
+
*/
|
|
67
|
+
includeHeaders?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Debug mode for logging.
|
|
70
|
+
* @default false
|
|
71
|
+
*/
|
|
72
|
+
debug?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Callback when a cached response is returned.
|
|
75
|
+
*/
|
|
76
|
+
onHit?: OncelyOptions['onHit'];
|
|
77
|
+
/**
|
|
78
|
+
* Callback when a new response is generated.
|
|
79
|
+
*/
|
|
80
|
+
onMiss?: OncelyOptions['onMiss'];
|
|
81
|
+
/**
|
|
82
|
+
* Callback when an error occurs.
|
|
83
|
+
*/
|
|
84
|
+
onError?: OncelyOptions['onError'];
|
|
85
|
+
/**
|
|
86
|
+
* If true, proceed with the request when storage fails (e.g., Redis down).
|
|
87
|
+
* When false (default), storage errors return 500.
|
|
88
|
+
* Use with non-critical idempotency where availability is more important.
|
|
89
|
+
* @default false
|
|
90
|
+
*/
|
|
91
|
+
failOpen?: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create Express middleware for idempotency.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* // Basic usage with defaults
|
|
99
|
+
* app.post('/orders', express(), orderHandler);
|
|
100
|
+
*
|
|
101
|
+
* // With options
|
|
102
|
+
* app.post('/payments', express({
|
|
103
|
+
* storage: redis(),
|
|
104
|
+
* required: true,
|
|
105
|
+
* ttl: '1h',
|
|
106
|
+
* }), paymentHandler);
|
|
107
|
+
*
|
|
108
|
+
* // Custom key extraction
|
|
109
|
+
* app.post('/api/:version/orders', express({
|
|
110
|
+
* getKey: (req) => req.headers['x-request-id'] as string,
|
|
111
|
+
* }), orderHandler);
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
declare function express(options?: ExpressMiddlewareOptions): RequestHandler;
|
|
115
|
+
/**
|
|
116
|
+
* Create a pre-configured middleware factory with default options.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* import { configure } from '@oncely/express';
|
|
121
|
+
* import { redis } from '@oncely/redis';
|
|
122
|
+
*
|
|
123
|
+
* const idempotent = configure({
|
|
124
|
+
* storage: redis(),
|
|
125
|
+
* ttl: '1h',
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* app.post('/orders', idempotent(), orderHandler);
|
|
129
|
+
* app.post('/payments', idempotent({ required: true }), paymentHandler);
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare function configure(defaults: ExpressMiddlewareOptions): (overrides?: Partial<ExpressMiddlewareOptions>) => RequestHandler;
|
|
133
|
+
declare module '@oncely/core' {
|
|
134
|
+
interface OncelyNamespace {
|
|
135
|
+
/**
|
|
136
|
+
* Create Express middleware for idempotency.
|
|
137
|
+
*/
|
|
138
|
+
express: typeof express;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { type ExpressMiddlewareOptions, IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_REPLAY_HEADER, configure, express };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Request, RequestHandler } from 'express';
|
|
2
|
+
import { StorageAdapter, OncelyOptions } from '@oncely/core';
|
|
3
|
+
export { ConflictError, IdempotencyError, MemoryStorage, MismatchError, MissingKeyError, OncelyOptions, StorageAdapter, hashObject, oncely } from '@oncely/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @oncely/express - Express middleware for oncely idempotency
|
|
7
|
+
*
|
|
8
|
+
* Provides Express middleware that wraps route handlers with idempotency protection.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { oncely } from '@oncely/core';
|
|
13
|
+
* import '@oncely/express';
|
|
14
|
+
*
|
|
15
|
+
* // Now oncely.express() is available
|
|
16
|
+
* app.post('/orders', oncely.express(), async (req, res) => {
|
|
17
|
+
* const order = await createOrder(req.body);
|
|
18
|
+
* res.status(201).json(order);
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* IETF standard header names
|
|
25
|
+
*/
|
|
26
|
+
declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
27
|
+
declare const IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay";
|
|
28
|
+
/**
|
|
29
|
+
* Options for Express idempotency middleware
|
|
30
|
+
*/
|
|
31
|
+
interface ExpressMiddlewareOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Storage adapter for persisting idempotency records.
|
|
34
|
+
* @default oncely.config.storage or memory
|
|
35
|
+
*/
|
|
36
|
+
storage?: StorageAdapter;
|
|
37
|
+
/**
|
|
38
|
+
* TTL for idempotency records in milliseconds or duration string.
|
|
39
|
+
* @default oncely.config.ttl or '24h'
|
|
40
|
+
*/
|
|
41
|
+
ttl?: number | string;
|
|
42
|
+
/**
|
|
43
|
+
* Whether to require an idempotency key.
|
|
44
|
+
* If true, returns 400 when key is missing.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
required?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* HTTP methods to apply idempotency to.
|
|
50
|
+
* @default ['POST', 'PUT', 'PATCH', 'DELETE']
|
|
51
|
+
*/
|
|
52
|
+
methods?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Custom function to extract idempotency key from request.
|
|
55
|
+
* @default Reads Idempotency-Key header
|
|
56
|
+
*/
|
|
57
|
+
getKey?: (req: Request) => string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Custom function to generate hash from request for mismatch detection.
|
|
60
|
+
* @default Uses hashObject(req.body)
|
|
61
|
+
*/
|
|
62
|
+
getHash?: (req: Request) => string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Whether to add idempotency headers to responses.
|
|
65
|
+
* @default true
|
|
66
|
+
*/
|
|
67
|
+
includeHeaders?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Debug mode for logging.
|
|
70
|
+
* @default false
|
|
71
|
+
*/
|
|
72
|
+
debug?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Callback when a cached response is returned.
|
|
75
|
+
*/
|
|
76
|
+
onHit?: OncelyOptions['onHit'];
|
|
77
|
+
/**
|
|
78
|
+
* Callback when a new response is generated.
|
|
79
|
+
*/
|
|
80
|
+
onMiss?: OncelyOptions['onMiss'];
|
|
81
|
+
/**
|
|
82
|
+
* Callback when an error occurs.
|
|
83
|
+
*/
|
|
84
|
+
onError?: OncelyOptions['onError'];
|
|
85
|
+
/**
|
|
86
|
+
* If true, proceed with the request when storage fails (e.g., Redis down).
|
|
87
|
+
* When false (default), storage errors return 500.
|
|
88
|
+
* Use with non-critical idempotency where availability is more important.
|
|
89
|
+
* @default false
|
|
90
|
+
*/
|
|
91
|
+
failOpen?: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Create Express middleware for idempotency.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* // Basic usage with defaults
|
|
99
|
+
* app.post('/orders', express(), orderHandler);
|
|
100
|
+
*
|
|
101
|
+
* // With options
|
|
102
|
+
* app.post('/payments', express({
|
|
103
|
+
* storage: redis(),
|
|
104
|
+
* required: true,
|
|
105
|
+
* ttl: '1h',
|
|
106
|
+
* }), paymentHandler);
|
|
107
|
+
*
|
|
108
|
+
* // Custom key extraction
|
|
109
|
+
* app.post('/api/:version/orders', express({
|
|
110
|
+
* getKey: (req) => req.headers['x-request-id'] as string,
|
|
111
|
+
* }), orderHandler);
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
declare function express(options?: ExpressMiddlewareOptions): RequestHandler;
|
|
115
|
+
/**
|
|
116
|
+
* Create a pre-configured middleware factory with default options.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* import { configure } from '@oncely/express';
|
|
121
|
+
* import { redis } from '@oncely/redis';
|
|
122
|
+
*
|
|
123
|
+
* const idempotent = configure({
|
|
124
|
+
* storage: redis(),
|
|
125
|
+
* ttl: '1h',
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* app.post('/orders', idempotent(), orderHandler);
|
|
129
|
+
* app.post('/payments', idempotent({ required: true }), paymentHandler);
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare function configure(defaults: ExpressMiddlewareOptions): (overrides?: Partial<ExpressMiddlewareOptions>) => RequestHandler;
|
|
133
|
+
declare module '@oncely/core' {
|
|
134
|
+
interface OncelyNamespace {
|
|
135
|
+
/**
|
|
136
|
+
* Create Express middleware for idempotency.
|
|
137
|
+
*/
|
|
138
|
+
express: typeof express;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { type ExpressMiddlewareOptions, IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_REPLAY_HEADER, configure, express };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { oncely, hashObject, parseTtl, IdempotencyError, ConflictError, MemoryStorage, MissingKeyError, MismatchError } from '@oncely/core';
|
|
2
|
+
export { ConflictError, IdempotencyError, MemoryStorage, MismatchError, MissingKeyError, hashObject, oncely } from '@oncely/core';
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
var PROBLEM_BASE = "https://oncely.dev/errors";
|
|
6
|
+
var IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
7
|
+
var IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay";
|
|
8
|
+
var defaultStorage = null;
|
|
9
|
+
function getStorage(provided) {
|
|
10
|
+
if (provided) return provided;
|
|
11
|
+
if (!defaultStorage) {
|
|
12
|
+
defaultStorage = new MemoryStorage();
|
|
13
|
+
}
|
|
14
|
+
return defaultStorage;
|
|
15
|
+
}
|
|
16
|
+
function express(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
storage: providedStorage,
|
|
19
|
+
ttl = "24h",
|
|
20
|
+
required = false,
|
|
21
|
+
methods = ["POST", "PUT", "PATCH", "DELETE"],
|
|
22
|
+
getKey = (req) => req.get(IDEMPOTENCY_KEY_HEADER),
|
|
23
|
+
getHash = (req) => req.body ? hashObject(req.body) : null,
|
|
24
|
+
includeHeaders = true,
|
|
25
|
+
debug = false,
|
|
26
|
+
failOpen = false,
|
|
27
|
+
onHit,
|
|
28
|
+
onMiss,
|
|
29
|
+
onError
|
|
30
|
+
} = options;
|
|
31
|
+
const storage = getStorage(providedStorage);
|
|
32
|
+
const ttlMs = parseTtl(ttl);
|
|
33
|
+
const log = (message) => {
|
|
34
|
+
if (debug) {
|
|
35
|
+
console.log(`[oncely/express] ${message}`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
return async (req, res, next) => {
|
|
39
|
+
if (!methods.includes(req.method)) {
|
|
40
|
+
return next();
|
|
41
|
+
}
|
|
42
|
+
const key = getKey(req);
|
|
43
|
+
if (!key && !required) {
|
|
44
|
+
return next();
|
|
45
|
+
}
|
|
46
|
+
if (!key && required) {
|
|
47
|
+
if (includeHeaders) {
|
|
48
|
+
res.set("Content-Type", "application/problem+json");
|
|
49
|
+
}
|
|
50
|
+
res.status(400).json({
|
|
51
|
+
type: `${PROBLEM_BASE}/key-required`,
|
|
52
|
+
title: "Idempotency Key Required",
|
|
53
|
+
status: 400,
|
|
54
|
+
detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const hash = getHash(req);
|
|
59
|
+
try {
|
|
60
|
+
log(`Acquiring lock for key: ${key}`);
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await storage.acquire(key, hash, ttlMs);
|
|
64
|
+
} catch (storageErr) {
|
|
65
|
+
if (failOpen) {
|
|
66
|
+
log(`Storage error for key: ${key}, failing open`);
|
|
67
|
+
onError?.(key, storageErr);
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
throw storageErr;
|
|
71
|
+
}
|
|
72
|
+
if (result.status === "hit") {
|
|
73
|
+
log(`Cache hit for key: ${key}`);
|
|
74
|
+
onHit?.(key, result.response);
|
|
75
|
+
const cached = result.response.data;
|
|
76
|
+
if (includeHeaders) {
|
|
77
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
78
|
+
res.set(IDEMPOTENCY_REPLAY_HEADER, "true");
|
|
79
|
+
if (cached.headers) {
|
|
80
|
+
for (const [name, value] of Object.entries(cached.headers)) {
|
|
81
|
+
if (value !== void 0 && !name.toLowerCase().startsWith("x-powered-by")) {
|
|
82
|
+
res.set(name, value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
res.status(cached.status);
|
|
88
|
+
if (cached.body !== void 0) {
|
|
89
|
+
if (typeof cached.body === "object") {
|
|
90
|
+
res.json(cached.body);
|
|
91
|
+
} else {
|
|
92
|
+
res.send(cached.body);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
res.end();
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (result.status === "conflict") {
|
|
100
|
+
log(`Conflict for key: ${key}`);
|
|
101
|
+
if (includeHeaders) {
|
|
102
|
+
res.set("Content-Type", "application/problem+json");
|
|
103
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
104
|
+
res.set("Retry-After", "1");
|
|
105
|
+
}
|
|
106
|
+
res.status(409).json({
|
|
107
|
+
type: `${PROBLEM_BASE}/conflict`,
|
|
108
|
+
title: "Conflict",
|
|
109
|
+
status: 409,
|
|
110
|
+
detail: "A request with this idempotency key is already being processed."
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (result.status === "mismatch") {
|
|
115
|
+
log(`Hash mismatch for key: ${key}`);
|
|
116
|
+
if (includeHeaders) {
|
|
117
|
+
res.set("Content-Type", "application/problem+json");
|
|
118
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
119
|
+
}
|
|
120
|
+
res.status(422).json({
|
|
121
|
+
type: `${PROBLEM_BASE}/mismatch`,
|
|
122
|
+
title: "Idempotency Key Mismatch",
|
|
123
|
+
status: 422,
|
|
124
|
+
detail: "The request body does not match the original request for this idempotency key.",
|
|
125
|
+
existingHash: result.existingHash,
|
|
126
|
+
providedHash: result.providedHash
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
log(`Lock acquired for key: ${key}`);
|
|
131
|
+
onMiss?.(key);
|
|
132
|
+
const captured = {
|
|
133
|
+
status: 200,
|
|
134
|
+
headers: {},
|
|
135
|
+
body: void 0
|
|
136
|
+
};
|
|
137
|
+
const originalJson = res.json.bind(res);
|
|
138
|
+
const originalSend = res.send.bind(res);
|
|
139
|
+
const originalEnd = res.end.bind(res);
|
|
140
|
+
const originalSetHeader = res.setHeader.bind(res);
|
|
141
|
+
let finished = false;
|
|
142
|
+
res.setHeader = function(name, value) {
|
|
143
|
+
captured.headers[name] = value;
|
|
144
|
+
return originalSetHeader(name, value);
|
|
145
|
+
};
|
|
146
|
+
res.json = function(data) {
|
|
147
|
+
if (!finished) {
|
|
148
|
+
captured.body = data;
|
|
149
|
+
captured.status = res.statusCode;
|
|
150
|
+
}
|
|
151
|
+
return originalJson(data);
|
|
152
|
+
};
|
|
153
|
+
res.send = function(data) {
|
|
154
|
+
if (!finished) {
|
|
155
|
+
captured.body = data;
|
|
156
|
+
captured.status = res.statusCode;
|
|
157
|
+
}
|
|
158
|
+
return originalSend(data);
|
|
159
|
+
};
|
|
160
|
+
res.end = function(chunk, encoding, _cb) {
|
|
161
|
+
if (!finished) {
|
|
162
|
+
finished = true;
|
|
163
|
+
captured.status = res.statusCode;
|
|
164
|
+
if (chunk !== void 0 && captured.body === void 0) {
|
|
165
|
+
captured.body = chunk;
|
|
166
|
+
}
|
|
167
|
+
if (includeHeaders) {
|
|
168
|
+
originalSetHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
169
|
+
}
|
|
170
|
+
const storedResponse = {
|
|
171
|
+
data: captured,
|
|
172
|
+
createdAt: Date.now(),
|
|
173
|
+
hash
|
|
174
|
+
};
|
|
175
|
+
storage.save(key, storedResponse).catch((err) => {
|
|
176
|
+
log(`Failed to save response for key: ${key}`);
|
|
177
|
+
onError?.(key, err);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return originalEnd.call(res, chunk, encoding);
|
|
181
|
+
};
|
|
182
|
+
res.on("error", async () => {
|
|
183
|
+
if (!finished) {
|
|
184
|
+
finished = true;
|
|
185
|
+
log(`Response error for key: ${key}, releasing lock`);
|
|
186
|
+
await storage.release(key).catch(() => {
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
req.on("close", async () => {
|
|
191
|
+
if (!finished && !res.writableFinished) {
|
|
192
|
+
finished = true;
|
|
193
|
+
log(`Client disconnected for key: ${key}, releasing lock`);
|
|
194
|
+
await storage.release(key).catch(() => {
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
next();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (err instanceof IdempotencyError) {
|
|
201
|
+
if (includeHeaders) {
|
|
202
|
+
res.set("Content-Type", "application/problem+json");
|
|
203
|
+
res.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
204
|
+
if (err instanceof ConflictError) {
|
|
205
|
+
res.set("Retry-After", String(err.retryAfter ?? 1));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let type = `${PROBLEM_BASE}/error`;
|
|
209
|
+
if (err instanceof MissingKeyError) type = `${PROBLEM_BASE}/key-required`;
|
|
210
|
+
else if (err instanceof ConflictError) type = `${PROBLEM_BASE}/conflict`;
|
|
211
|
+
else if (err instanceof MismatchError) type = `${PROBLEM_BASE}/mismatch`;
|
|
212
|
+
res.status(err.statusCode).json({
|
|
213
|
+
type,
|
|
214
|
+
title: err.name,
|
|
215
|
+
status: err.statusCode,
|
|
216
|
+
detail: err.message
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
next(err);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function configure(defaults) {
|
|
225
|
+
return (overrides = {}) => express({ ...defaults, ...overrides });
|
|
226
|
+
}
|
|
227
|
+
oncely.express = express;
|
|
228
|
+
|
|
229
|
+
export { IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_REPLAY_HEADER, configure, express };
|
|
230
|
+
//# sourceMappingURL=index.js.map
|
|
231
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @oncely/express - Express middleware for oncely idempotency\n *\n * Provides Express middleware that wraps route handlers with idempotency protection.\n *\n * @example\n * ```typescript\n * import { oncely } from '@oncely/core';\n * import '@oncely/express';\n *\n * // Now oncely.express() is available\n * app.post('/orders', oncely.express(), async (req, res) => {\n * const order = await createOrder(req.body);\n * res.status(201).json(order);\n * });\n * ```\n */\n\nimport type { Request, Response, NextFunction, RequestHandler } from 'express';\nimport {\n oncely,\n MemoryStorage,\n hashObject,\n parseTtl,\n IdempotencyError,\n ConflictError,\n MissingKeyError,\n MismatchError,\n type StorageAdapter,\n type OncelyOptions,\n type StoredResponse,\n} from '@oncely/core';\n\n// RFC 7807 Problem Details type URL base\nconst PROBLEM_BASE = 'https://oncely.dev/errors';\n\n/**\n * IETF standard header names\n */\nexport const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';\nexport const IDEMPOTENCY_REPLAY_HEADER = 'Idempotency-Replay';\n\n/**\n * Options for Express idempotency middleware\n */\nexport interface ExpressMiddlewareOptions {\n /**\n * Storage adapter for persisting idempotency records.\n * @default oncely.config.storage or memory\n */\n storage?: StorageAdapter;\n\n /**\n * TTL for idempotency records in milliseconds or duration string.\n * @default oncely.config.ttl or '24h'\n */\n ttl?: number | string;\n\n /**\n * Whether to require an idempotency key.\n * If true, returns 400 when key is missing.\n * @default false\n */\n required?: boolean;\n\n /**\n * HTTP methods to apply idempotency to.\n * @default ['POST', 'PUT', 'PATCH', 'DELETE']\n */\n methods?: string[];\n\n /**\n * Custom function to extract idempotency key from request.\n * @default Reads Idempotency-Key header\n */\n getKey?: (req: Request) => string | undefined;\n\n /**\n * Custom function to generate hash from request for mismatch detection.\n * @default Uses hashObject(req.body)\n */\n getHash?: (req: Request) => string | null;\n\n /**\n * Whether to add idempotency headers to responses.\n * @default true\n */\n includeHeaders?: boolean;\n\n /**\n * Debug mode for logging.\n * @default false\n */\n debug?: boolean;\n\n /**\n * Callback when a cached response is returned.\n */\n onHit?: OncelyOptions['onHit'];\n\n /**\n * Callback when a new response is generated.\n */\n onMiss?: OncelyOptions['onMiss'];\n\n /**\n * Callback when an error occurs.\n */\n onError?: OncelyOptions['onError'];\n\n /**\n * If true, proceed with the request when storage fails (e.g., Redis down).\n * When false (default), storage errors return 500.\n * Use with non-critical idempotency where availability is more important.\n * @default false\n */\n failOpen?: boolean;\n}\n\n/**\n * Captured response data\n */\ninterface CapturedResponse {\n status: number;\n headers: Record<string, string | string[] | number | undefined>;\n body: unknown;\n}\n\n// Shared default storage instance\nlet defaultStorage: StorageAdapter | null = null;\n\nfunction getStorage(provided?: StorageAdapter): StorageAdapter {\n if (provided) return provided;\n if (!defaultStorage) {\n defaultStorage = new MemoryStorage();\n }\n return defaultStorage;\n}\n\n/**\n * Create Express middleware for idempotency.\n *\n * @example\n * ```typescript\n * // Basic usage with defaults\n * app.post('/orders', express(), orderHandler);\n *\n * // With options\n * app.post('/payments', express({\n * storage: redis(),\n * required: true,\n * ttl: '1h',\n * }), paymentHandler);\n *\n * // Custom key extraction\n * app.post('/api/:version/orders', express({\n * getKey: (req) => req.headers['x-request-id'] as string,\n * }), orderHandler);\n * ```\n */\nexport function express(options: ExpressMiddlewareOptions = {}): RequestHandler {\n const {\n storage: providedStorage,\n ttl = '24h',\n required = false,\n methods = ['POST', 'PUT', 'PATCH', 'DELETE'],\n getKey = (req) => req.get(IDEMPOTENCY_KEY_HEADER),\n getHash = (req) => (req.body ? hashObject(req.body) : null),\n includeHeaders = true,\n debug = false,\n failOpen = false,\n onHit,\n onMiss,\n onError,\n } = options;\n\n const storage = getStorage(providedStorage);\n const ttlMs = parseTtl(ttl);\n\n const log = (message: string) => {\n if (debug) {\n console.log(`[oncely/express] ${message}`);\n }\n };\n\n return async (req: Request, res: Response, next: NextFunction): Promise<void> => {\n // Skip if method not in list\n if (!methods.includes(req.method)) {\n return next();\n }\n\n // Get idempotency key\n const key = getKey(req);\n\n // Skip if no key and not required\n if (!key && !required) {\n return next();\n }\n\n // Return 400 if key required but not provided\n if (!key && required) {\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n }\n res.status(400).json({\n type: `${PROBLEM_BASE}/key-required`,\n title: 'Idempotency Key Required',\n status: 400,\n detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`,\n });\n return;\n }\n\n // Compute request hash for mismatch detection\n const hash = getHash(req);\n\n try {\n log(`Acquiring lock for key: ${key}`);\n let result;\n try {\n result = await storage.acquire(key!, hash, ttlMs);\n } catch (storageErr) {\n // Storage failed (e.g., Redis down)\n if (failOpen) {\n log(`Storage error for key: ${key}, failing open`);\n onError?.(key!, storageErr as Error);\n return next(); // Proceed without idempotency protection\n }\n throw storageErr;\n }\n\n if (result.status === 'hit') {\n // Return cached response\n log(`Cache hit for key: ${key}`);\n onHit?.(key!, result.response);\n const cached = result.response.data as CapturedResponse;\n\n if (includeHeaders) {\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n res.set(IDEMPOTENCY_REPLAY_HEADER, 'true');\n\n // Restore original headers\n if (cached.headers) {\n for (const [name, value] of Object.entries(cached.headers)) {\n if (value !== undefined && !name.toLowerCase().startsWith('x-powered-by')) {\n res.set(name, value as string);\n }\n }\n }\n }\n\n res.status(cached.status);\n if (cached.body !== undefined) {\n if (typeof cached.body === 'object') {\n res.json(cached.body);\n } else {\n res.send(cached.body);\n }\n } else {\n res.end();\n }\n return;\n }\n\n if (result.status === 'conflict') {\n log(`Conflict for key: ${key}`);\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n res.set('Retry-After', '1');\n }\n res.status(409).json({\n type: `${PROBLEM_BASE}/conflict`,\n title: 'Conflict',\n status: 409,\n detail: 'A request with this idempotency key is already being processed.',\n });\n return;\n }\n\n if (result.status === 'mismatch') {\n log(`Hash mismatch for key: ${key}`);\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n }\n res.status(422).json({\n type: `${PROBLEM_BASE}/mismatch`,\n title: 'Idempotency Key Mismatch',\n status: 422,\n detail: 'The request body does not match the original request for this idempotency key.',\n existingHash: result.existingHash,\n providedHash: result.providedHash,\n });\n return;\n }\n\n // status === 'acquired' - proceed with handler\n log(`Lock acquired for key: ${key}`);\n onMiss?.(key!);\n\n // Capture response\n const captured: CapturedResponse = {\n status: 200,\n headers: {},\n body: undefined,\n };\n\n // Store original methods\n const originalJson = res.json.bind(res);\n const originalSend = res.send.bind(res);\n const originalEnd = res.end.bind(res);\n const originalSetHeader = res.setHeader.bind(res);\n let finished = false;\n\n // Capture headers\n res.setHeader = function (name: string, value: string | string[] | number) {\n captured.headers[name] = value;\n return originalSetHeader(name, value);\n };\n\n // Capture JSON responses\n res.json = function (data: unknown) {\n if (!finished) {\n captured.body = data;\n captured.status = res.statusCode;\n }\n return originalJson(data);\n };\n\n // Capture send responses\n res.send = function (data: unknown) {\n if (!finished) {\n captured.body = data;\n captured.status = res.statusCode;\n }\n return originalSend(data);\n };\n\n // Capture end and save\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n res.end = function (chunk?: any, encoding?: any, _cb?: any) {\n if (!finished) {\n finished = true;\n captured.status = res.statusCode;\n if (chunk !== undefined && captured.body === undefined) {\n captured.body = chunk;\n }\n\n // Add idempotency headers\n if (includeHeaders) {\n originalSetHeader(IDEMPOTENCY_KEY_HEADER, key!);\n }\n\n // Save response asynchronously (don't block)\n const storedResponse: StoredResponse = {\n data: captured,\n createdAt: Date.now(),\n hash,\n };\n\n storage.save(key!, storedResponse).catch((err) => {\n log(`Failed to save response for key: ${key}`);\n onError?.(key!, err as Error);\n });\n }\n\n return originalEnd.call(res, chunk, encoding);\n } as typeof res.end;\n\n // Handle errors - release lock\n res.on('error', async () => {\n if (!finished) {\n finished = true;\n log(`Response error for key: ${key}, releasing lock`);\n await storage.release(key!).catch(() => {});\n }\n });\n\n // Handle client disconnect before response completes - release lock to allow retry\n req.on('close', async () => {\n if (!finished && !res.writableFinished) {\n finished = true;\n log(`Client disconnected for key: ${key}, releasing lock`);\n await storage.release(key!).catch(() => {});\n }\n });\n\n // Call next handler\n next();\n } catch (err) {\n // Handle oncely errors\n if (err instanceof IdempotencyError) {\n if (includeHeaders) {\n res.set('Content-Type', 'application/problem+json');\n res.set(IDEMPOTENCY_KEY_HEADER, key!);\n\n if (err instanceof ConflictError) {\n res.set('Retry-After', String(err.retryAfter ?? 1));\n }\n }\n\n let type = `${PROBLEM_BASE}/error`;\n if (err instanceof MissingKeyError) type = `${PROBLEM_BASE}/key-required`;\n else if (err instanceof ConflictError) type = `${PROBLEM_BASE}/conflict`;\n else if (err instanceof MismatchError) type = `${PROBLEM_BASE}/mismatch`;\n\n res.status(err.statusCode).json({\n type,\n title: err.name,\n status: err.statusCode,\n detail: err.message,\n });\n return;\n }\n\n // Pass other errors to Express error handler\n next(err);\n }\n };\n}\n\n/**\n * Create a pre-configured middleware factory with default options.\n *\n * @example\n * ```typescript\n * import { configure } from '@oncely/express';\n * import { redis } from '@oncely/redis';\n *\n * const idempotent = configure({\n * storage: redis(),\n * ttl: '1h',\n * });\n *\n * app.post('/orders', idempotent(), orderHandler);\n * app.post('/payments', idempotent({ required: true }), paymentHandler);\n * ```\n */\nexport function configure(\n defaults: ExpressMiddlewareOptions\n): (overrides?: Partial<ExpressMiddlewareOptions>) => RequestHandler {\n return (overrides = {}) => express({ ...defaults, ...overrides });\n}\n\n// Augment oncely namespace\ndeclare module '@oncely/core' {\n interface OncelyNamespace {\n /**\n * Create Express middleware for idempotency.\n */\n express: typeof express;\n }\n}\n\n// Register on oncely namespace\n(oncely as Record<string, unknown>).express = express;\n\n// Re-export types and utilities\nexport {\n oncely,\n MemoryStorage,\n hashObject,\n IdempotencyError,\n MissingKeyError,\n ConflictError,\n MismatchError,\n type StorageAdapter,\n type OncelyOptions,\n} from '@oncely/core';\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oncely/express",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Express middleware for oncely idempotency",
|
|
5
|
+
"author": "stacks0x",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/stacks0x/oncely.git",
|
|
10
|
+
"directory": "packages/express"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"oncely",
|
|
14
|
+
"idempotency",
|
|
15
|
+
"express",
|
|
16
|
+
"middleware"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"require": "./dist/index.cjs"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/index.cjs",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"dev": "tsup --watch",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"clean": "rm -rf dist"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@oncely/core": "workspace:*",
|
|
42
|
+
"express": "^4.18.0 || ^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@oncely/core": "workspace:*",
|
|
46
|
+
"@types/express": "^5.0.6",
|
|
47
|
+
"express": "^5.2.1",
|
|
48
|
+
"tsup": "^8.0.1",
|
|
49
|
+
"typescript": "^5.3.3"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=18.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|