@oncely/next 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 +147 -0
- package/dist/index.cjs +264 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +147 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +232 -0
- package/dist/index.js.map +1 -0
- package/dist/pages.cjs +253 -0
- package/dist/pages.cjs.map +1 -0
- package/dist/pages.d.cts +145 -0
- package/dist/pages.d.ts +145 -0
- package/dist/pages.js +221 -0
- package/dist/pages.js.map +1 -0
- package/package.json +63 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { StorageAdapter, OncelyOptions } from '@oncely/core';
|
|
2
|
+
export { ConflictError, IdempotencyError, MemoryStorage, MismatchError, MissingKeyError, OncelyOptions, StorageAdapter, hashObject, oncely } from '@oncely/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @oncely/next - Next.js App Router integration for oncely idempotency
|
|
6
|
+
*
|
|
7
|
+
* Provides a wrapper for Next.js App Router route handlers with idempotency protection.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* // app/api/orders/route.ts
|
|
12
|
+
* import { next } from '@oncely/next';
|
|
13
|
+
*
|
|
14
|
+
* export const POST = next()(async (req) => {
|
|
15
|
+
* const body = await req.json();
|
|
16
|
+
* const order = await createOrder(body);
|
|
17
|
+
* return Response.json(order, { status: 201 });
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* IETF standard header names
|
|
24
|
+
*/
|
|
25
|
+
declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
|
|
26
|
+
declare const IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay";
|
|
27
|
+
/**
|
|
28
|
+
* Options for the Next.js App Router wrapper
|
|
29
|
+
*/
|
|
30
|
+
interface NextMiddlewareOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Storage adapter for persisting idempotency records.
|
|
33
|
+
* @default MemoryStorage
|
|
34
|
+
*/
|
|
35
|
+
storage?: StorageAdapter;
|
|
36
|
+
/**
|
|
37
|
+
* TTL for idempotency records in milliseconds or duration string.
|
|
38
|
+
* @default '24h'
|
|
39
|
+
*/
|
|
40
|
+
ttl?: number | string;
|
|
41
|
+
/**
|
|
42
|
+
* Whether to require an idempotency key.
|
|
43
|
+
* If true, returns 400 when key is missing.
|
|
44
|
+
* @default false
|
|
45
|
+
*/
|
|
46
|
+
required?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Custom function to extract idempotency key from request.
|
|
49
|
+
* @default Reads Idempotency-Key header
|
|
50
|
+
*/
|
|
51
|
+
getKey?: (req: Request) => string | undefined | null;
|
|
52
|
+
/**
|
|
53
|
+
* Custom function to generate hash from request for mismatch detection.
|
|
54
|
+
* @default Hashes request body as JSON
|
|
55
|
+
*/
|
|
56
|
+
getHash?: (req: Request) => Promise<string | null> | string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Whether to add idempotency headers to responses.
|
|
59
|
+
* @default true
|
|
60
|
+
*/
|
|
61
|
+
includeHeaders?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Debug mode for logging.
|
|
64
|
+
* @default false
|
|
65
|
+
*/
|
|
66
|
+
debug?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Callback when a cached response is returned.
|
|
69
|
+
*/
|
|
70
|
+
onHit?: OncelyOptions['onHit'];
|
|
71
|
+
/**
|
|
72
|
+
* Callback when a new response is generated.
|
|
73
|
+
*/
|
|
74
|
+
onMiss?: OncelyOptions['onMiss'];
|
|
75
|
+
/**
|
|
76
|
+
* Callback when an error occurs.
|
|
77
|
+
*/
|
|
78
|
+
onError?: OncelyOptions['onError'];
|
|
79
|
+
/**
|
|
80
|
+
* If true, proceed with the request when storage fails (e.g., Redis down).
|
|
81
|
+
* When false (default), storage errors return 500.
|
|
82
|
+
* Use with non-critical idempotency where availability is more important.
|
|
83
|
+
* @default false
|
|
84
|
+
*/
|
|
85
|
+
failOpen?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Route handler function signature
|
|
89
|
+
*/
|
|
90
|
+
type RouteHandler = (req: Request, context?: unknown) => Promise<Response> | Response;
|
|
91
|
+
/**
|
|
92
|
+
* Create a Next.js App Router route handler wrapper with idempotency protection.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* // app/api/orders/route.ts
|
|
97
|
+
* import { next } from '@oncely/next';
|
|
98
|
+
*
|
|
99
|
+
* // Basic usage
|
|
100
|
+
* export const POST = next()(async (req) => {
|
|
101
|
+
* const body = await req.json();
|
|
102
|
+
* const order = await createOrder(body);
|
|
103
|
+
* return Response.json(order, { status: 201 });
|
|
104
|
+
* });
|
|
105
|
+
*
|
|
106
|
+
* // With options
|
|
107
|
+
* export const POST = next({ required: true, ttl: '1h' })(async (req) => {
|
|
108
|
+
* const body = await req.json();
|
|
109
|
+
* return Response.json(await processPayment(body));
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function next(options?: NextMiddlewareOptions): (handler: RouteHandler) => RouteHandler;
|
|
114
|
+
/**
|
|
115
|
+
* Create a pre-configured wrapper factory with default options.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* // lib/idempotency.ts
|
|
120
|
+
* import { configure } from '@oncely/next';
|
|
121
|
+
* import { redis } from '@oncely/redis';
|
|
122
|
+
*
|
|
123
|
+
* export const idempotent = configure({
|
|
124
|
+
* storage: redis(),
|
|
125
|
+
* ttl: '1h',
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* // app/api/orders/route.ts
|
|
129
|
+
* import { idempotent } from '@/lib/idempotency';
|
|
130
|
+
*
|
|
131
|
+
* export const POST = idempotent()(async (req) => {
|
|
132
|
+
* const body = await req.json();
|
|
133
|
+
* return Response.json(await createOrder(body), { status: 201 });
|
|
134
|
+
* });
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
declare function configure(defaults: NextMiddlewareOptions): (overrides?: Partial<NextMiddlewareOptions>) => (handler: RouteHandler) => RouteHandler;
|
|
138
|
+
declare module '@oncely/core' {
|
|
139
|
+
interface OncelyNamespace {
|
|
140
|
+
/**
|
|
141
|
+
* Create Next.js App Router wrapper with idempotency.
|
|
142
|
+
*/
|
|
143
|
+
next: typeof next;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_REPLAY_HEADER, type NextMiddlewareOptions, configure, next };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { oncely, parseTtl, IdempotencyError, ConflictError, hashObject, 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
|
+
async function defaultGetHash(req) {
|
|
17
|
+
const clone = req.clone();
|
|
18
|
+
try {
|
|
19
|
+
const body = await clone.json();
|
|
20
|
+
return hashObject(body);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function next(options = {}) {
|
|
26
|
+
const {
|
|
27
|
+
storage: providedStorage,
|
|
28
|
+
ttl = "24h",
|
|
29
|
+
required = false,
|
|
30
|
+
getKey = (req) => req.headers.get(IDEMPOTENCY_KEY_HEADER),
|
|
31
|
+
getHash = defaultGetHash,
|
|
32
|
+
includeHeaders = true,
|
|
33
|
+
debug = false,
|
|
34
|
+
failOpen = false,
|
|
35
|
+
onHit,
|
|
36
|
+
onMiss,
|
|
37
|
+
onError
|
|
38
|
+
} = options;
|
|
39
|
+
const storage = getStorage(providedStorage);
|
|
40
|
+
const ttlMs = parseTtl(ttl);
|
|
41
|
+
const log = (message) => {
|
|
42
|
+
if (debug) {
|
|
43
|
+
console.log(`[oncely/next] ${message}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return (handler) => {
|
|
47
|
+
return async (req, context) => {
|
|
48
|
+
const key = getKey(req);
|
|
49
|
+
if (!key && !required) {
|
|
50
|
+
return handler(req, context);
|
|
51
|
+
}
|
|
52
|
+
if (!key && required) {
|
|
53
|
+
const headers = {};
|
|
54
|
+
if (includeHeaders) {
|
|
55
|
+
headers["Content-Type"] = "application/problem+json";
|
|
56
|
+
}
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
type: `${PROBLEM_BASE}/key-required`,
|
|
60
|
+
title: "Idempotency Key Required",
|
|
61
|
+
status: 400,
|
|
62
|
+
detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`
|
|
63
|
+
}),
|
|
64
|
+
{ status: 400, headers }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const hash = await getHash(req);
|
|
68
|
+
try {
|
|
69
|
+
log(`Acquiring lock for key: ${key}`);
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await storage.acquire(key, hash, ttlMs);
|
|
73
|
+
} catch (storageErr) {
|
|
74
|
+
if (failOpen) {
|
|
75
|
+
log(`Storage error for key: ${key}, failing open`);
|
|
76
|
+
onError?.(key, storageErr);
|
|
77
|
+
return handler(req, context);
|
|
78
|
+
}
|
|
79
|
+
throw storageErr;
|
|
80
|
+
}
|
|
81
|
+
if (result.status === "hit") {
|
|
82
|
+
log(`Cache hit for key: ${key}`);
|
|
83
|
+
onHit?.(key, result.response);
|
|
84
|
+
const cached = result.response.data;
|
|
85
|
+
const responseHeaders = {
|
|
86
|
+
...cached.headers
|
|
87
|
+
};
|
|
88
|
+
if (includeHeaders) {
|
|
89
|
+
responseHeaders[IDEMPOTENCY_KEY_HEADER] = key;
|
|
90
|
+
responseHeaders[IDEMPOTENCY_REPLAY_HEADER] = "true";
|
|
91
|
+
}
|
|
92
|
+
if (cached.isJson) {
|
|
93
|
+
return new Response(JSON.stringify(cached.body), {
|
|
94
|
+
status: cached.status,
|
|
95
|
+
headers: { ...responseHeaders, "Content-Type": "application/json" }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return new Response(String(cached.body ?? ""), {
|
|
99
|
+
status: cached.status,
|
|
100
|
+
headers: responseHeaders
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (result.status === "conflict") {
|
|
104
|
+
log(`Conflict for key: ${key}`);
|
|
105
|
+
const headers = {
|
|
106
|
+
"Content-Type": "application/problem+json"
|
|
107
|
+
};
|
|
108
|
+
if (includeHeaders) {
|
|
109
|
+
headers[IDEMPOTENCY_KEY_HEADER] = key;
|
|
110
|
+
headers["Retry-After"] = "1";
|
|
111
|
+
}
|
|
112
|
+
return new Response(
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
type: `${PROBLEM_BASE}/conflict`,
|
|
115
|
+
title: "Conflict",
|
|
116
|
+
status: 409,
|
|
117
|
+
detail: "A request with this idempotency key is already being processed."
|
|
118
|
+
}),
|
|
119
|
+
{ status: 409, headers }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (result.status === "mismatch") {
|
|
123
|
+
log(`Hash mismatch for key: ${key}`);
|
|
124
|
+
const headers = {
|
|
125
|
+
"Content-Type": "application/problem+json"
|
|
126
|
+
};
|
|
127
|
+
if (includeHeaders) {
|
|
128
|
+
headers[IDEMPOTENCY_KEY_HEADER] = key;
|
|
129
|
+
}
|
|
130
|
+
return new Response(
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
type: `${PROBLEM_BASE}/mismatch`,
|
|
133
|
+
title: "Idempotency Key Mismatch",
|
|
134
|
+
status: 422,
|
|
135
|
+
detail: "The request body does not match the original request for this idempotency key.",
|
|
136
|
+
existingHash: result.existingHash,
|
|
137
|
+
providedHash: result.providedHash
|
|
138
|
+
}),
|
|
139
|
+
{ status: 422, headers }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
log(`Lock acquired for key: ${key}`);
|
|
143
|
+
onMiss?.(key);
|
|
144
|
+
try {
|
|
145
|
+
const response = await handler(req, context);
|
|
146
|
+
const clone = response.clone();
|
|
147
|
+
let body;
|
|
148
|
+
let isJson = false;
|
|
149
|
+
const contentType = response.headers.get("Content-Type") || "";
|
|
150
|
+
if (contentType.includes("application/json")) {
|
|
151
|
+
try {
|
|
152
|
+
body = await clone.json();
|
|
153
|
+
isJson = true;
|
|
154
|
+
} catch {
|
|
155
|
+
body = await clone.text();
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
body = await clone.text();
|
|
159
|
+
}
|
|
160
|
+
const responseHeaders = {};
|
|
161
|
+
response.headers.forEach((value, key2) => {
|
|
162
|
+
responseHeaders[key2] = value;
|
|
163
|
+
});
|
|
164
|
+
const cached = {
|
|
165
|
+
status: response.status,
|
|
166
|
+
headers: responseHeaders,
|
|
167
|
+
body,
|
|
168
|
+
isJson
|
|
169
|
+
};
|
|
170
|
+
const storedResponse = {
|
|
171
|
+
data: cached,
|
|
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
|
+
if (includeHeaders) {
|
|
180
|
+
const newHeaders = new Headers(response.headers);
|
|
181
|
+
newHeaders.set(IDEMPOTENCY_KEY_HEADER, key);
|
|
182
|
+
return new Response(response.body, {
|
|
183
|
+
status: response.status,
|
|
184
|
+
statusText: response.statusText,
|
|
185
|
+
headers: newHeaders
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return response;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
log(`Handler error for key: ${key}, releasing lock`);
|
|
191
|
+
await storage.release(key).catch(() => {
|
|
192
|
+
});
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err instanceof IdempotencyError) {
|
|
197
|
+
const headers = {
|
|
198
|
+
"Content-Type": "application/problem+json"
|
|
199
|
+
};
|
|
200
|
+
if (includeHeaders) {
|
|
201
|
+
headers[IDEMPOTENCY_KEY_HEADER] = key;
|
|
202
|
+
if (err instanceof ConflictError) {
|
|
203
|
+
headers["Retry-After"] = String(err.retryAfter ?? 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let type = `${PROBLEM_BASE}/error`;
|
|
207
|
+
if (err instanceof MissingKeyError) type = `${PROBLEM_BASE}/key-required`;
|
|
208
|
+
else if (err instanceof ConflictError) type = `${PROBLEM_BASE}/conflict`;
|
|
209
|
+
else if (err instanceof MismatchError) type = `${PROBLEM_BASE}/mismatch`;
|
|
210
|
+
return new Response(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
type,
|
|
213
|
+
title: err.name,
|
|
214
|
+
status: err.statusCode,
|
|
215
|
+
detail: err.message
|
|
216
|
+
}),
|
|
217
|
+
{ status: err.statusCode, headers }
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function configure(defaults) {
|
|
226
|
+
return (overrides = {}) => next({ ...defaults, ...overrides });
|
|
227
|
+
}
|
|
228
|
+
oncely.next = next;
|
|
229
|
+
|
|
230
|
+
export { IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_REPLAY_HEADER, configure, next };
|
|
231
|
+
//# sourceMappingURL=index.js.map
|
|
232
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["key"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @oncely/next - Next.js App Router integration for oncely idempotency\n *\n * Provides a wrapper for Next.js App Router route handlers with idempotency protection.\n *\n * @example\n * ```typescript\n * // app/api/orders/route.ts\n * import { next } from '@oncely/next';\n *\n * export const POST = next()(async (req) => {\n * const body = await req.json();\n * const order = await createOrder(body);\n * return Response.json(order, { status: 201 });\n * });\n * ```\n */\n\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 the Next.js App Router wrapper\n */\nexport interface NextMiddlewareOptions {\n /**\n * Storage adapter for persisting idempotency records.\n * @default MemoryStorage\n */\n storage?: StorageAdapter;\n\n /**\n * TTL for idempotency records in milliseconds or duration string.\n * @default '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 * Custom function to extract idempotency key from request.\n * @default Reads Idempotency-Key header\n */\n getKey?: (req: Request) => string | undefined | null;\n\n /**\n * Custom function to generate hash from request for mismatch detection.\n * @default Hashes request body as JSON\n */\n getHash?: (req: Request) => Promise<string | null> | 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 * Stored response data for replay\n */\ninterface CachedResponse {\n status: number;\n headers: Record<string, string>;\n body: unknown;\n isJson: boolean;\n}\n\n/**\n * Route handler function signature\n */\ntype RouteHandler = (req: Request, context?: unknown) => Promise<Response> | Response;\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 * Default hash function that reads and hashes request body\n */\nasync function defaultGetHash(req: Request): Promise<string | null> {\n const clone = req.clone();\n try {\n const body = await clone.json();\n return hashObject(body);\n } catch {\n return null;\n }\n}\n\n/**\n * Create a Next.js App Router route handler wrapper with idempotency protection.\n *\n * @example\n * ```typescript\n * // app/api/orders/route.ts\n * import { next } from '@oncely/next';\n *\n * // Basic usage\n * export const POST = next()(async (req) => {\n * const body = await req.json();\n * const order = await createOrder(body);\n * return Response.json(order, { status: 201 });\n * });\n *\n * // With options\n * export const POST = next({ required: true, ttl: '1h' })(async (req) => {\n * const body = await req.json();\n * return Response.json(await processPayment(body));\n * });\n * ```\n */\nexport function next(options: NextMiddlewareOptions = {}): (handler: RouteHandler) => RouteHandler {\n const {\n storage: providedStorage,\n ttl = '24h',\n required = false,\n getKey = (req) => req.headers.get(IDEMPOTENCY_KEY_HEADER),\n getHash = defaultGetHash,\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/next] ${message}`);\n }\n };\n\n return (handler: RouteHandler): RouteHandler => {\n return async (req: Request, context?: unknown): Promise<Response> => {\n // Get idempotency key\n const key = getKey(req);\n\n // Skip if no key and not required\n if (!key && !required) {\n return handler(req, context);\n }\n\n // Return 400 if key required but not provided\n if (!key && required) {\n const headers: Record<string, string> = {};\n if (includeHeaders) {\n headers['Content-Type'] = 'application/problem+json';\n }\n return new Response(\n JSON.stringify({\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 { status: 400, headers }\n );\n }\n\n // Compute request hash\n const hash = await 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 handler(req, context); // 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\n const cached = result.response.data as CachedResponse;\n const responseHeaders: Record<string, string> = {\n ...cached.headers,\n };\n\n if (includeHeaders) {\n responseHeaders[IDEMPOTENCY_KEY_HEADER] = key!;\n responseHeaders[IDEMPOTENCY_REPLAY_HEADER] = 'true';\n }\n\n if (cached.isJson) {\n return new Response(JSON.stringify(cached.body), {\n status: cached.status,\n headers: { ...responseHeaders, 'Content-Type': 'application/json' },\n });\n }\n\n return new Response(String(cached.body ?? ''), {\n status: cached.status,\n headers: responseHeaders,\n });\n }\n\n if (result.status === 'conflict') {\n log(`Conflict for key: ${key}`);\n const headers: Record<string, string> = {\n 'Content-Type': 'application/problem+json',\n };\n if (includeHeaders) {\n headers[IDEMPOTENCY_KEY_HEADER] = key!;\n headers['Retry-After'] = '1';\n }\n return new Response(\n JSON.stringify({\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 { status: 409, headers }\n );\n }\n\n if (result.status === 'mismatch') {\n log(`Hash mismatch for key: ${key}`);\n const headers: Record<string, string> = {\n 'Content-Type': 'application/problem+json',\n };\n if (includeHeaders) {\n headers[IDEMPOTENCY_KEY_HEADER] = key!;\n }\n return new Response(\n JSON.stringify({\n type: `${PROBLEM_BASE}/mismatch`,\n title: 'Idempotency Key Mismatch',\n status: 422,\n detail:\n 'The request body does not match the original request for this idempotency key.',\n existingHash: result.existingHash,\n providedHash: result.providedHash,\n }),\n { status: 422, headers }\n );\n }\n\n // status === 'acquired' - execute handler\n log(`Lock acquired for key: ${key}`);\n onMiss?.(key!);\n\n try {\n const response = await handler(req, context);\n\n // Capture response for storage\n const clone = response.clone();\n let body: unknown;\n let isJson = false;\n\n const contentType = response.headers.get('Content-Type') || '';\n if (contentType.includes('application/json')) {\n try {\n body = await clone.json();\n isJson = true;\n } catch {\n body = await clone.text();\n }\n } else {\n body = await clone.text();\n }\n\n // Capture headers\n const responseHeaders: Record<string, string> = {};\n response.headers.forEach((value, key) => {\n responseHeaders[key] = value;\n });\n\n // Save to storage\n const cached: CachedResponse = {\n status: response.status,\n headers: responseHeaders,\n body,\n isJson,\n };\n\n const storedResponse: StoredResponse = {\n data: cached,\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 // Add idempotency headers to original response\n if (includeHeaders) {\n const newHeaders = new Headers(response.headers);\n newHeaders.set(IDEMPOTENCY_KEY_HEADER, key!);\n\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: newHeaders,\n });\n }\n\n return response;\n } catch (err) {\n // Release lock on error\n log(`Handler error for key: ${key}, releasing lock`);\n await storage.release(key!).catch(() => {});\n throw err;\n }\n } catch (err) {\n // Handle idempotency errors\n if (err instanceof IdempotencyError) {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/problem+json',\n };\n if (includeHeaders) {\n headers[IDEMPOTENCY_KEY_HEADER] = key!;\n if (err instanceof ConflictError) {\n headers['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 return new Response(\n JSON.stringify({\n type,\n title: err.name,\n status: err.statusCode,\n detail: err.message,\n }),\n { status: err.statusCode, headers }\n );\n }\n\n throw err;\n }\n };\n };\n}\n\n/**\n * Create a pre-configured wrapper factory with default options.\n *\n * @example\n * ```typescript\n * // lib/idempotency.ts\n * import { configure } from '@oncely/next';\n * import { redis } from '@oncely/redis';\n *\n * export const idempotent = configure({\n * storage: redis(),\n * ttl: '1h',\n * });\n *\n * // app/api/orders/route.ts\n * import { idempotent } from '@/lib/idempotency';\n *\n * export const POST = idempotent()(async (req) => {\n * const body = await req.json();\n * return Response.json(await createOrder(body), { status: 201 });\n * });\n * ```\n */\nexport function configure(\n defaults: NextMiddlewareOptions\n): (overrides?: Partial<NextMiddlewareOptions>) => (handler: RouteHandler) => RouteHandler {\n return (overrides = {}) => next({ ...defaults, ...overrides });\n}\n\n// Augment oncely namespace\ndeclare module '@oncely/core' {\n interface OncelyNamespace {\n /**\n * Create Next.js App Router wrapper with idempotency.\n */\n next: typeof next;\n }\n}\n\n// Register on oncely namespace\n(oncely as Record<string, unknown>).next = next;\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/pages.cjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@oncely/core');
|
|
4
|
+
|
|
5
|
+
// src/pages.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 defaultGetHash(req) {
|
|
18
|
+
if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) {
|
|
19
|
+
return core.hashObject(req.body);
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function pages(options = {}) {
|
|
24
|
+
const {
|
|
25
|
+
storage: providedStorage,
|
|
26
|
+
ttl = "24h",
|
|
27
|
+
required = false,
|
|
28
|
+
getKey = (req) => {
|
|
29
|
+
const header = req.headers[IDEMPOTENCY_KEY_HEADER.toLowerCase()];
|
|
30
|
+
return Array.isArray(header) ? header[0] : header;
|
|
31
|
+
},
|
|
32
|
+
getHash = defaultGetHash,
|
|
33
|
+
includeHeaders = true,
|
|
34
|
+
debug = false,
|
|
35
|
+
failOpen = false,
|
|
36
|
+
onHit,
|
|
37
|
+
onMiss,
|
|
38
|
+
onError
|
|
39
|
+
} = options;
|
|
40
|
+
const storage = getStorage(providedStorage);
|
|
41
|
+
const ttlMs = core.parseTtl(ttl);
|
|
42
|
+
const log = (message) => {
|
|
43
|
+
if (debug) {
|
|
44
|
+
console.log(`[oncely/next/pages] ${message}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return (handler) => {
|
|
48
|
+
return async (req, res) => {
|
|
49
|
+
const key = getKey(req);
|
|
50
|
+
if (!key && !required) {
|
|
51
|
+
return handler(req, res);
|
|
52
|
+
}
|
|
53
|
+
if (!key && required) {
|
|
54
|
+
if (includeHeaders) {
|
|
55
|
+
res.setHeader("Content-Type", "application/problem+json");
|
|
56
|
+
}
|
|
57
|
+
return res.status(400).json({
|
|
58
|
+
type: `${PROBLEM_BASE}/key-required`,
|
|
59
|
+
title: "Idempotency Key Required",
|
|
60
|
+
status: 400,
|
|
61
|
+
detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const hash = await getHash(req);
|
|
65
|
+
try {
|
|
66
|
+
log(`Acquiring lock for key: ${key}`);
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await storage.acquire(key, hash, ttlMs);
|
|
70
|
+
} catch (storageErr) {
|
|
71
|
+
if (failOpen) {
|
|
72
|
+
log(`Storage error for key: ${key}, failing open`);
|
|
73
|
+
onError?.(key, storageErr);
|
|
74
|
+
return handler(req, res);
|
|
75
|
+
}
|
|
76
|
+
throw storageErr;
|
|
77
|
+
}
|
|
78
|
+
if (result.status === "hit") {
|
|
79
|
+
log(`Cache hit for key: ${key}`);
|
|
80
|
+
onHit?.(key, result.response);
|
|
81
|
+
const cached = result.response.data;
|
|
82
|
+
Object.entries(cached.headers).forEach(([headerKey, value]) => {
|
|
83
|
+
res.setHeader(headerKey, value);
|
|
84
|
+
});
|
|
85
|
+
if (includeHeaders) {
|
|
86
|
+
res.setHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
87
|
+
res.setHeader(IDEMPOTENCY_REPLAY_HEADER, "true");
|
|
88
|
+
}
|
|
89
|
+
return res.status(cached.status).json(cached.body);
|
|
90
|
+
}
|
|
91
|
+
if (result.status === "conflict") {
|
|
92
|
+
log(`Conflict for key: ${key}`);
|
|
93
|
+
if (includeHeaders) {
|
|
94
|
+
res.setHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
95
|
+
res.setHeader("Retry-After", "1");
|
|
96
|
+
}
|
|
97
|
+
return res.status(409).json({
|
|
98
|
+
type: `${PROBLEM_BASE}/conflict`,
|
|
99
|
+
title: "Conflict",
|
|
100
|
+
status: 409,
|
|
101
|
+
detail: "A request with this idempotency key is already being processed."
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (result.status === "mismatch") {
|
|
105
|
+
log(`Hash mismatch for key: ${key}`);
|
|
106
|
+
if (includeHeaders) {
|
|
107
|
+
res.setHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
108
|
+
}
|
|
109
|
+
return res.status(422).json({
|
|
110
|
+
type: `${PROBLEM_BASE}/mismatch`,
|
|
111
|
+
title: "Idempotency Key Mismatch",
|
|
112
|
+
status: 422,
|
|
113
|
+
detail: "The request body does not match the original request for this idempotency key.",
|
|
114
|
+
existingHash: result.existingHash,
|
|
115
|
+
providedHash: result.providedHash
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
log(`Lock acquired for key: ${key}`);
|
|
119
|
+
onMiss?.(key);
|
|
120
|
+
let capturedStatus = 200;
|
|
121
|
+
const capturedHeaders = {};
|
|
122
|
+
let capturedBody;
|
|
123
|
+
const originalStatus = res.status.bind(res);
|
|
124
|
+
const originalSetHeader = res.setHeader.bind(res);
|
|
125
|
+
const originalJson = res.json.bind(res);
|
|
126
|
+
const originalSend = res.send.bind(res);
|
|
127
|
+
res.status = (statusCode) => {
|
|
128
|
+
capturedStatus = statusCode;
|
|
129
|
+
return originalStatus(statusCode);
|
|
130
|
+
};
|
|
131
|
+
res.setHeader = (name, value) => {
|
|
132
|
+
if (typeof value === "string") {
|
|
133
|
+
capturedHeaders[name] = value;
|
|
134
|
+
} else if (Array.isArray(value)) {
|
|
135
|
+
capturedHeaders[name] = value.join(", ");
|
|
136
|
+
} else {
|
|
137
|
+
capturedHeaders[name] = String(value);
|
|
138
|
+
}
|
|
139
|
+
return originalSetHeader(name, value);
|
|
140
|
+
};
|
|
141
|
+
res.json = (body) => {
|
|
142
|
+
capturedBody = body;
|
|
143
|
+
const cached = {
|
|
144
|
+
status: capturedStatus,
|
|
145
|
+
headers: capturedHeaders,
|
|
146
|
+
body: capturedBody
|
|
147
|
+
};
|
|
148
|
+
const storedResponse = {
|
|
149
|
+
data: cached,
|
|
150
|
+
createdAt: Date.now(),
|
|
151
|
+
hash
|
|
152
|
+
};
|
|
153
|
+
storage.save(key, storedResponse).catch((err) => {
|
|
154
|
+
log(`Failed to save response for key: ${key}`);
|
|
155
|
+
onError?.(key, err);
|
|
156
|
+
});
|
|
157
|
+
if (includeHeaders) {
|
|
158
|
+
res.setHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
159
|
+
}
|
|
160
|
+
return originalJson(body);
|
|
161
|
+
};
|
|
162
|
+
res.send = (body) => {
|
|
163
|
+
capturedBody = body;
|
|
164
|
+
const cached = {
|
|
165
|
+
status: capturedStatus,
|
|
166
|
+
headers: capturedHeaders,
|
|
167
|
+
body: capturedBody
|
|
168
|
+
};
|
|
169
|
+
const storedResponse = {
|
|
170
|
+
data: cached,
|
|
171
|
+
createdAt: Date.now(),
|
|
172
|
+
hash
|
|
173
|
+
};
|
|
174
|
+
storage.save(key, storedResponse).catch((err) => {
|
|
175
|
+
log(`Failed to save response for key: ${key}`);
|
|
176
|
+
onError?.(key, err);
|
|
177
|
+
});
|
|
178
|
+
if (includeHeaders) {
|
|
179
|
+
res.setHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
180
|
+
}
|
|
181
|
+
return originalSend(body);
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
return await handler(req, res);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
log(`Handler error for key: ${key}, releasing lock`);
|
|
187
|
+
await storage.release(key).catch(() => {
|
|
188
|
+
});
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof core.IdempotencyError) {
|
|
193
|
+
if (includeHeaders) {
|
|
194
|
+
res.setHeader(IDEMPOTENCY_KEY_HEADER, key);
|
|
195
|
+
if (err instanceof core.ConflictError) {
|
|
196
|
+
res.setHeader("Retry-After", String(err.retryAfter ?? 1));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let type = `${PROBLEM_BASE}/error`;
|
|
200
|
+
if (err instanceof core.MissingKeyError) type = `${PROBLEM_BASE}/key-required`;
|
|
201
|
+
else if (err instanceof core.ConflictError) type = `${PROBLEM_BASE}/conflict`;
|
|
202
|
+
else if (err instanceof core.MismatchError) type = `${PROBLEM_BASE}/mismatch`;
|
|
203
|
+
return res.status(err.statusCode).json({
|
|
204
|
+
type,
|
|
205
|
+
title: err.name,
|
|
206
|
+
status: err.statusCode,
|
|
207
|
+
detail: err.message
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function configure(defaults) {
|
|
216
|
+
return (overrides = {}) => pages({ ...defaults, ...overrides });
|
|
217
|
+
}
|
|
218
|
+
core.oncely.pages = pages;
|
|
219
|
+
|
|
220
|
+
Object.defineProperty(exports, "ConflictError", {
|
|
221
|
+
enumerable: true,
|
|
222
|
+
get: function () { return core.ConflictError; }
|
|
223
|
+
});
|
|
224
|
+
Object.defineProperty(exports, "IdempotencyError", {
|
|
225
|
+
enumerable: true,
|
|
226
|
+
get: function () { return core.IdempotencyError; }
|
|
227
|
+
});
|
|
228
|
+
Object.defineProperty(exports, "MemoryStorage", {
|
|
229
|
+
enumerable: true,
|
|
230
|
+
get: function () { return core.MemoryStorage; }
|
|
231
|
+
});
|
|
232
|
+
Object.defineProperty(exports, "MismatchError", {
|
|
233
|
+
enumerable: true,
|
|
234
|
+
get: function () { return core.MismatchError; }
|
|
235
|
+
});
|
|
236
|
+
Object.defineProperty(exports, "MissingKeyError", {
|
|
237
|
+
enumerable: true,
|
|
238
|
+
get: function () { return core.MissingKeyError; }
|
|
239
|
+
});
|
|
240
|
+
Object.defineProperty(exports, "hashObject", {
|
|
241
|
+
enumerable: true,
|
|
242
|
+
get: function () { return core.hashObject; }
|
|
243
|
+
});
|
|
244
|
+
Object.defineProperty(exports, "oncely", {
|
|
245
|
+
enumerable: true,
|
|
246
|
+
get: function () { return core.oncely; }
|
|
247
|
+
});
|
|
248
|
+
exports.IDEMPOTENCY_KEY_HEADER = IDEMPOTENCY_KEY_HEADER;
|
|
249
|
+
exports.IDEMPOTENCY_REPLAY_HEADER = IDEMPOTENCY_REPLAY_HEADER;
|
|
250
|
+
exports.configure = configure;
|
|
251
|
+
exports.pages = pages;
|
|
252
|
+
//# sourceMappingURL=pages.cjs.map
|
|
253
|
+
//# sourceMappingURL=pages.cjs.map
|