@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 ADDED
@@ -0,0 +1,147 @@
1
+ # @oncely/next
2
+
3
+ Next.js integration for oncely idempotency.
4
+
5
+ ## Installation
6
+
7
+ npm install @oncely/core @oncely/next
8
+
9
+ ## App Router
10
+
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
+ return Response.json(await createOrder(body), { status: 201 });
17
+ });
18
+
19
+ // With options
20
+ export const POST = next({
21
+ required: true,
22
+ ttl: '1h',
23
+ })(handler);
24
+
25
+ ## Pages Router
26
+
27
+ // pages/api/orders.ts
28
+ import { pages } from '@oncely/next/pages';
29
+
30
+ export default pages()(async (req, res) => {
31
+ res.status(201).json(await createOrder(req.body));
32
+ });
33
+
34
+ ## Pre-configured Factory
35
+
36
+ import { configure } from '@oncely/next';
37
+ import { upstash } from '@oncely/upstash';
38
+
39
+ export const idempotent = configure({
40
+ storage: upstash(),
41
+ ttl: '1h',
42
+ });
43
+
44
+ // Usage
45
+ export const POST = idempotent()(handler);
46
+
47
+ ## Headers
48
+
49
+ Request: Idempotency-Key
50
+ Response: Idempotency-Key, Idempotency-Replay (on cache hit)
51
+
52
+ ## License
53
+
54
+ MIT
55
+ }
56
+
57
+ const result = await idempotency.run({
58
+ key,
59
+ hash: hashObject(body),
60
+ handler: () => handler(req, ctx, body),
61
+ });
62
+
63
+ return result.data;
64
+ }
65
+
66
+ return handler(req, ctx, body);
67
+
68
+ };
69
+ }
70
+
71
+ ````
72
+
73
+ ## Pages Router
74
+
75
+ ```typescript
76
+ // pages/api/orders.ts
77
+ import { oncely } from "oncely-next/pages";
78
+ import { memory } from "oncely";
79
+
80
+ export default oncely(
81
+ async (req, res) => {
82
+ const order = await createOrder(req.body);
83
+ res.status(201).json(order);
84
+ },
85
+ { storage: memory },
86
+ );
87
+ ````
88
+
89
+ ### Shared Configuration (Pages)
90
+
91
+ ```typescript
92
+ // lib/idempotency.ts
93
+ import { configure } from 'oncely-next/pages';
94
+ import { ioredis } from 'oncely-redis';
95
+
96
+ export const oncely = configure({
97
+ storage: ioredis({ client: redis }),
98
+ ttl: '24h',
99
+ });
100
+
101
+ // pages/api/orders.ts
102
+ import { oncely } from '@/lib/idempotency';
103
+
104
+ export default oncely(async (req, res) => {
105
+ const order = await createOrder(req.body);
106
+ res.status(201).json(order);
107
+ });
108
+ ```
109
+
110
+ ## Options
111
+
112
+ ```typescript
113
+ oncely(handler, {
114
+ // Required
115
+ storage: StorageAdapter,
116
+
117
+ // Optional
118
+ keyHeader: 'Idempotency-Key', // Header name (default: 'Idempotency-Key')
119
+ required: false, // Require key? (default: false)
120
+ responseHeaders: true, // Add idempotency headers to response
121
+ ttl: '24h', // Key expiration
122
+ debug: false, // Enable debug logging
123
+
124
+ // Custom hash function
125
+ hashRequest: async (req) => hashObject(await req.json()),
126
+
127
+ // Callbacks
128
+ onHit: (key) => {},
129
+ onMiss: (key) => {},
130
+ onError: (key, err) => {},
131
+ });
132
+ ```
133
+
134
+ ## Response Headers
135
+
136
+ When `responseHeaders` is enabled (default), responses include:
137
+
138
+ ```
139
+ Idempotency-Key: <key>
140
+ Idempotency-Status: hit | created
141
+ Idempotency-Created: <ISO timestamp> (on cache hit)
142
+ Retry-After: <seconds> (on 409 conflict)
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,264 @@
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
+ async function defaultGetHash(req) {
18
+ const clone = req.clone();
19
+ try {
20
+ const body = await clone.json();
21
+ return core.hashObject(body);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function next(options = {}) {
27
+ const {
28
+ storage: providedStorage,
29
+ ttl = "24h",
30
+ required = false,
31
+ getKey = (req) => req.headers.get(IDEMPOTENCY_KEY_HEADER),
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] ${message}`);
45
+ }
46
+ };
47
+ return (handler) => {
48
+ return async (req, context) => {
49
+ const key = getKey(req);
50
+ if (!key && !required) {
51
+ return handler(req, context);
52
+ }
53
+ if (!key && required) {
54
+ const headers = {};
55
+ if (includeHeaders) {
56
+ headers["Content-Type"] = "application/problem+json";
57
+ }
58
+ return new Response(
59
+ JSON.stringify({
60
+ type: `${PROBLEM_BASE}/key-required`,
61
+ title: "Idempotency Key Required",
62
+ status: 400,
63
+ detail: `The ${IDEMPOTENCY_KEY_HEADER} header is required for this request.`
64
+ }),
65
+ { status: 400, headers }
66
+ );
67
+ }
68
+ const hash = await getHash(req);
69
+ try {
70
+ log(`Acquiring lock for key: ${key}`);
71
+ let result;
72
+ try {
73
+ result = await storage.acquire(key, hash, ttlMs);
74
+ } catch (storageErr) {
75
+ if (failOpen) {
76
+ log(`Storage error for key: ${key}, failing open`);
77
+ onError?.(key, storageErr);
78
+ return handler(req, context);
79
+ }
80
+ throw storageErr;
81
+ }
82
+ if (result.status === "hit") {
83
+ log(`Cache hit for key: ${key}`);
84
+ onHit?.(key, result.response);
85
+ const cached = result.response.data;
86
+ const responseHeaders = {
87
+ ...cached.headers
88
+ };
89
+ if (includeHeaders) {
90
+ responseHeaders[IDEMPOTENCY_KEY_HEADER] = key;
91
+ responseHeaders[IDEMPOTENCY_REPLAY_HEADER] = "true";
92
+ }
93
+ if (cached.isJson) {
94
+ return new Response(JSON.stringify(cached.body), {
95
+ status: cached.status,
96
+ headers: { ...responseHeaders, "Content-Type": "application/json" }
97
+ });
98
+ }
99
+ return new Response(String(cached.body ?? ""), {
100
+ status: cached.status,
101
+ headers: responseHeaders
102
+ });
103
+ }
104
+ if (result.status === "conflict") {
105
+ log(`Conflict for key: ${key}`);
106
+ const headers = {
107
+ "Content-Type": "application/problem+json"
108
+ };
109
+ if (includeHeaders) {
110
+ headers[IDEMPOTENCY_KEY_HEADER] = key;
111
+ headers["Retry-After"] = "1";
112
+ }
113
+ return new Response(
114
+ JSON.stringify({
115
+ type: `${PROBLEM_BASE}/conflict`,
116
+ title: "Conflict",
117
+ status: 409,
118
+ detail: "A request with this idempotency key is already being processed."
119
+ }),
120
+ { status: 409, headers }
121
+ );
122
+ }
123
+ if (result.status === "mismatch") {
124
+ log(`Hash mismatch for key: ${key}`);
125
+ const headers = {
126
+ "Content-Type": "application/problem+json"
127
+ };
128
+ if (includeHeaders) {
129
+ headers[IDEMPOTENCY_KEY_HEADER] = key;
130
+ }
131
+ return new Response(
132
+ JSON.stringify({
133
+ type: `${PROBLEM_BASE}/mismatch`,
134
+ title: "Idempotency Key Mismatch",
135
+ status: 422,
136
+ detail: "The request body does not match the original request for this idempotency key.",
137
+ existingHash: result.existingHash,
138
+ providedHash: result.providedHash
139
+ }),
140
+ { status: 422, headers }
141
+ );
142
+ }
143
+ log(`Lock acquired for key: ${key}`);
144
+ onMiss?.(key);
145
+ try {
146
+ const response = await handler(req, context);
147
+ const clone = response.clone();
148
+ let body;
149
+ let isJson = false;
150
+ const contentType = response.headers.get("Content-Type") || "";
151
+ if (contentType.includes("application/json")) {
152
+ try {
153
+ body = await clone.json();
154
+ isJson = true;
155
+ } catch {
156
+ body = await clone.text();
157
+ }
158
+ } else {
159
+ body = await clone.text();
160
+ }
161
+ const responseHeaders = {};
162
+ response.headers.forEach((value, key2) => {
163
+ responseHeaders[key2] = value;
164
+ });
165
+ const cached = {
166
+ status: response.status,
167
+ headers: responseHeaders,
168
+ body,
169
+ isJson
170
+ };
171
+ const storedResponse = {
172
+ data: cached,
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
+ if (includeHeaders) {
181
+ const newHeaders = new Headers(response.headers);
182
+ newHeaders.set(IDEMPOTENCY_KEY_HEADER, key);
183
+ return new Response(response.body, {
184
+ status: response.status,
185
+ statusText: response.statusText,
186
+ headers: newHeaders
187
+ });
188
+ }
189
+ return response;
190
+ } catch (err) {
191
+ log(`Handler error for key: ${key}, releasing lock`);
192
+ await storage.release(key).catch(() => {
193
+ });
194
+ throw err;
195
+ }
196
+ } catch (err) {
197
+ if (err instanceof core.IdempotencyError) {
198
+ const headers = {
199
+ "Content-Type": "application/problem+json"
200
+ };
201
+ if (includeHeaders) {
202
+ headers[IDEMPOTENCY_KEY_HEADER] = key;
203
+ if (err instanceof core.ConflictError) {
204
+ headers["Retry-After"] = String(err.retryAfter ?? 1);
205
+ }
206
+ }
207
+ let type = `${PROBLEM_BASE}/error`;
208
+ if (err instanceof core.MissingKeyError) type = `${PROBLEM_BASE}/key-required`;
209
+ else if (err instanceof core.ConflictError) type = `${PROBLEM_BASE}/conflict`;
210
+ else if (err instanceof core.MismatchError) type = `${PROBLEM_BASE}/mismatch`;
211
+ return new Response(
212
+ JSON.stringify({
213
+ type,
214
+ title: err.name,
215
+ status: err.statusCode,
216
+ detail: err.message
217
+ }),
218
+ { status: err.statusCode, headers }
219
+ );
220
+ }
221
+ throw err;
222
+ }
223
+ };
224
+ };
225
+ }
226
+ function configure(defaults) {
227
+ return (overrides = {}) => next({ ...defaults, ...overrides });
228
+ }
229
+ core.oncely.next = next;
230
+
231
+ Object.defineProperty(exports, "ConflictError", {
232
+ enumerable: true,
233
+ get: function () { return core.ConflictError; }
234
+ });
235
+ Object.defineProperty(exports, "IdempotencyError", {
236
+ enumerable: true,
237
+ get: function () { return core.IdempotencyError; }
238
+ });
239
+ Object.defineProperty(exports, "MemoryStorage", {
240
+ enumerable: true,
241
+ get: function () { return core.MemoryStorage; }
242
+ });
243
+ Object.defineProperty(exports, "MismatchError", {
244
+ enumerable: true,
245
+ get: function () { return core.MismatchError; }
246
+ });
247
+ Object.defineProperty(exports, "MissingKeyError", {
248
+ enumerable: true,
249
+ get: function () { return core.MissingKeyError; }
250
+ });
251
+ Object.defineProperty(exports, "hashObject", {
252
+ enumerable: true,
253
+ get: function () { return core.hashObject; }
254
+ });
255
+ Object.defineProperty(exports, "oncely", {
256
+ enumerable: true,
257
+ get: function () { return core.oncely; }
258
+ });
259
+ exports.IDEMPOTENCY_KEY_HEADER = IDEMPOTENCY_KEY_HEADER;
260
+ exports.IDEMPOTENCY_REPLAY_HEADER = IDEMPOTENCY_REPLAY_HEADER;
261
+ exports.configure = configure;
262
+ exports.next = next;
263
+ //# sourceMappingURL=index.cjs.map
264
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":["MemoryStorage","hashObject","parseTtl","key","IdempotencyError","ConflictError","MissingKeyError","MismatchError","oncely"],"mappings":"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","file":"index.cjs","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"]}
@@ -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 };