@oncely/client 0.2.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/dist/index.js ADDED
@@ -0,0 +1,252 @@
1
+ // src/constants.ts
2
+ var HEADER = "Idempotency-Key";
3
+ var HEADER_REPLAY = "Idempotency-Replay";
4
+
5
+ // src/key.ts
6
+ function generateKey(...components) {
7
+ if (components.length === 0) {
8
+ return generateUUID();
9
+ }
10
+ const input = components.map(String).join(":");
11
+ return simpleHash(input);
12
+ }
13
+ function generatePrefixedKey(prefix) {
14
+ return `${prefix}_${generateUUID()}`;
15
+ }
16
+ function generateUUID() {
17
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
18
+ return crypto.randomUUID();
19
+ }
20
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
21
+ let r;
22
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
23
+ const arr = new Uint8Array(1);
24
+ crypto.getRandomValues(arr);
25
+ r = arr[0] & 15;
26
+ } else {
27
+ r = Math.random() * 16 | 0;
28
+ }
29
+ const v = c === "x" ? r : r & 3 | 8;
30
+ return v.toString(16);
31
+ });
32
+ }
33
+ function simpleHash(str) {
34
+ let hash = 5381;
35
+ for (let i = 0; i < str.length; i++) {
36
+ hash = hash * 33 ^ str.charCodeAt(i);
37
+ }
38
+ return (hash >>> 0).toString(16).padStart(8, "0");
39
+ }
40
+ function createKeyGenerator() {
41
+ const key = ((...components) => {
42
+ return generateKey(...components);
43
+ });
44
+ key.prefixed = generatePrefixedKey;
45
+ return key;
46
+ }
47
+
48
+ // src/store.ts
49
+ var MemoryStorage = class {
50
+ data = /* @__PURE__ */ new Map();
51
+ getItem(key) {
52
+ return this.data.get(key) ?? null;
53
+ }
54
+ setItem(key, value) {
55
+ this.data.set(key, value);
56
+ }
57
+ removeItem(key) {
58
+ this.data.delete(key);
59
+ }
60
+ clear() {
61
+ this.data.clear();
62
+ }
63
+ };
64
+ function parseTtl(ttl) {
65
+ if (typeof ttl === "number") {
66
+ return ttl;
67
+ }
68
+ const match = ttl.match(/^(\d+)(ms|s|m|h|d)$/);
69
+ if (!match) {
70
+ throw new Error(`Invalid TTL format: ${ttl}. Use format like '5m', '1h', '30s'`);
71
+ }
72
+ const value = parseInt(match[1], 10);
73
+ const unit = match[2];
74
+ const multipliers = {
75
+ ms: 1,
76
+ s: 1e3,
77
+ m: 60 * 1e3,
78
+ h: 60 * 60 * 1e3,
79
+ d: 24 * 60 * 60 * 1e3
80
+ };
81
+ return value * multipliers[unit];
82
+ }
83
+ function createStore(options = {}) {
84
+ const prefix = options.prefix ?? "oncely:";
85
+ const ttl = parseTtl(options.ttl ?? "5m");
86
+ let storage;
87
+ if (options.storage) {
88
+ storage = options.storage;
89
+ } else if (typeof globalThis !== "undefined" && "localStorage" in globalThis) {
90
+ storage = globalThis.localStorage;
91
+ } else {
92
+ storage = new MemoryStorage();
93
+ }
94
+ const prefixedKey = (key) => `${prefix}${key}`;
95
+ return {
96
+ pending(key) {
97
+ const expires = Date.now() + ttl;
98
+ storage.setItem(prefixedKey(key), JSON.stringify({ expires }));
99
+ },
100
+ isPending(key) {
101
+ const value = storage.getItem(prefixedKey(key));
102
+ if (!value) return false;
103
+ try {
104
+ const { expires } = JSON.parse(value);
105
+ if (Date.now() > expires) {
106
+ storage.removeItem(prefixedKey(key));
107
+ return false;
108
+ }
109
+ return true;
110
+ } catch {
111
+ storage.removeItem(prefixedKey(key));
112
+ return false;
113
+ }
114
+ },
115
+ clear(key) {
116
+ storage.removeItem(prefixedKey(key));
117
+ },
118
+ clearAll() {
119
+ if (storage instanceof MemoryStorage) {
120
+ storage.clear();
121
+ return;
122
+ }
123
+ const keysToRemove = [];
124
+ try {
125
+ const browserStorage = storage;
126
+ if (typeof browserStorage.length === "number" && typeof browserStorage.key === "function") {
127
+ for (let i = 0; i < browserStorage.length; i++) {
128
+ const key = browserStorage.key(i);
129
+ if (key?.startsWith(prefix)) {
130
+ keysToRemove.push(key);
131
+ }
132
+ }
133
+ }
134
+ } catch {
135
+ return;
136
+ }
137
+ keysToRemove.forEach((key) => storage.removeItem(key));
138
+ }
139
+ };
140
+ }
141
+
142
+ // src/response.ts
143
+ function isReplay(response) {
144
+ return response.headers.get(HEADER_REPLAY)?.toLowerCase() === "true";
145
+ }
146
+ function isConflict(response) {
147
+ return response.status === 409;
148
+ }
149
+ function isMismatch(response) {
150
+ return response.status === 422;
151
+ }
152
+ function isMissingKey(response) {
153
+ return response.status === 400;
154
+ }
155
+ function getRetryAfter(response, defaultValue = 1) {
156
+ const header = response.headers.get("Retry-After");
157
+ if (!header) return defaultValue;
158
+ const seconds = parseInt(header, 10);
159
+ if (isNaN(seconds) || seconds < 0) return defaultValue;
160
+ return seconds;
161
+ }
162
+ async function getProblem(response) {
163
+ try {
164
+ const responseToRead = response.clone ? response.clone() : response;
165
+ if (!responseToRead.json) return null;
166
+ const body = await responseToRead.json();
167
+ if (typeof body === "object" && body !== null && "type" in body && "title" in body && "status" in body) {
168
+ return body;
169
+ }
170
+ return null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ function isIdempotencyError(response) {
176
+ return isMissingKey(response) || isConflict(response) || isMismatch(response);
177
+ }
178
+
179
+ // src/index.ts
180
+ var oncely = {
181
+ /**
182
+ * Generate a unique idempotency key.
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * // Random UUID key
187
+ * const key = oncely.key();
188
+ *
189
+ * // Deterministic key from components
190
+ * const key = oncely.key('user-123', 'action', timestamp);
191
+ *
192
+ * // Prefixed key
193
+ * const key = oncely.key.prefixed('ord');
194
+ * ```
195
+ */
196
+ key: createKeyGenerator(),
197
+ /**
198
+ * Create a key store for managing pending requests.
199
+ *
200
+ * @example
201
+ * ```typescript
202
+ * const store = oncely.store();
203
+ *
204
+ * if (store.isPending(key)) {
205
+ * throw new Error('Request in progress');
206
+ * }
207
+ * store.pending(key);
208
+ * ```
209
+ */
210
+ store: createStore,
211
+ /**
212
+ * Check if response is a replay (served from cache).
213
+ */
214
+ isReplay,
215
+ /**
216
+ * Check if response is a conflict (409).
217
+ */
218
+ isConflict,
219
+ /**
220
+ * Check if response is a mismatch (422).
221
+ */
222
+ isMismatch,
223
+ /**
224
+ * Check if response is a missing key error (400).
225
+ */
226
+ isMissingKey,
227
+ /**
228
+ * Check if response is any idempotency error (400, 409, 422).
229
+ */
230
+ isIdempotencyError,
231
+ /**
232
+ * Get Retry-After value from response.
233
+ */
234
+ getRetryAfter,
235
+ /**
236
+ * Parse RFC 7807 Problem Details from response.
237
+ */
238
+ getProblem,
239
+ /**
240
+ * Standard header name for idempotency keys.
241
+ * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header
242
+ */
243
+ HEADER,
244
+ /**
245
+ * Header indicating a response was replayed from cache.
246
+ */
247
+ HEADER_REPLAY
248
+ };
249
+
250
+ export { HEADER, HEADER_REPLAY, MemoryStorage, createKeyGenerator, createStore, generateKey, generatePrefixedKey, getProblem, getRetryAfter, isConflict, isIdempotencyError, isMismatch, isMissingKey, isReplay, oncely };
251
+ //# sourceMappingURL=index.js.map
252
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/constants.ts","../src/key.ts","../src/store.ts","../src/response.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * HTTP header constants following IETF draft-ietf-httpapi-idempotency-key-header.\n * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header\n */\n\n/** Standard header name for idempotency keys */\nexport const HEADER = 'Idempotency-Key';\n\n/** Header indicating a response was replayed from cache */\nexport const HEADER_REPLAY = 'Idempotency-Replay';\n","/**\n * Key generation utilities for idempotency keys.\n */\n\n/**\n * Generate a unique idempotency key.\n *\n * When called with no arguments, generates a random UUID v4.\n * When called with components, generates a deterministic hash-based key.\n *\n * @example\n * ```typescript\n * // Random key (UUID v4)\n * const key = generateKey();\n * // => \"550e8400-e29b-41d4-a716-446655440000\"\n *\n * // Deterministic key from components\n * const key = generateKey('user-123', 'create-order', 1234567890);\n * // => \"f7c3bc1d...\" (consistent for same inputs)\n * ```\n */\nexport function generateKey(...components: (string | number | boolean)[]): string {\n if (components.length === 0) {\n return generateUUID();\n }\n\n // Create deterministic key from components\n const input = components.map(String).join(':');\n return simpleHash(input);\n}\n\n/**\n * Generate a prefixed idempotency key.\n *\n * @example\n * ```typescript\n * const key = generatePrefixedKey('ord');\n * // => \"ord_550e8400-e29b-41d4-a716-446655440000\"\n * ```\n */\nexport function generatePrefixedKey(prefix: string): string {\n return `${prefix}_${generateUUID()}`;\n}\n\n/**\n * Generate a UUID v4.\n * Uses crypto.randomUUID() in modern environments, falls back to custom implementation.\n */\nfunction generateUUID(): string {\n // Modern browsers and Node.js 19+\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n\n // Fallback for older environments\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n let r: number;\n if (typeof crypto !== 'undefined' && crypto.getRandomValues) {\n const arr = new Uint8Array(1);\n crypto.getRandomValues(arr);\n r = arr[0]! & 15;\n } else {\n r = (Math.random() * 16) | 0;\n }\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n}\n\n/**\n * Simple hash function for deterministic key generation.\n * Uses djb2 algorithm for fast, reasonably distributed hashes.\n */\nfunction simpleHash(str: string): string {\n let hash = 5381;\n for (let i = 0; i < str.length; i++) {\n hash = (hash * 33) ^ str.charCodeAt(i);\n }\n // Convert to hex and pad to 16 chars\n return (hash >>> 0).toString(16).padStart(8, '0');\n}\n\n/**\n * Key generator interface for the namespace.\n */\nexport interface KeyGenerator {\n /**\n * Generate a unique key, optionally from components.\n */\n (...components: (string | number | boolean)[]): string;\n\n /**\n * Generate a prefixed key.\n */\n prefixed(prefix: string): string;\n}\n\n/**\n * Create the key generator function with attached methods.\n */\nexport function createKeyGenerator(): KeyGenerator {\n const key = ((...components: (string | number | boolean)[]) => {\n return generateKey(...components);\n }) as KeyGenerator;\n\n key.prefixed = generatePrefixedKey;\n\n return key;\n}\n","/**\n * Key store for managing pending idempotency keys.\n */\n\n/**\n * Options for creating a key store.\n */\nexport interface StoreOptions {\n /**\n * Storage backend (localStorage, sessionStorage, or custom).\n * @default localStorage in browser, in-memory in Node.js\n */\n storage?: Storage | MemoryStorage;\n\n /**\n * Key prefix for namespacing.\n * @default 'oncely:'\n */\n prefix?: string;\n\n /**\n * TTL for pending keys in milliseconds or string format.\n * After this time, pending keys are considered expired.\n * @default '5m'\n */\n ttl?: number | string;\n}\n\n/**\n * Browser localStorage interface for type checking.\n */\ninterface BrowserStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n removeItem(key: string): void;\n clear(): void;\n length: number;\n key(index: number): string | null;\n}\n\n/**\n * Minimal storage interface compatible with localStorage/sessionStorage.\n */\nexport interface Storage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n removeItem(key: string): void;\n clear(): void;\n}\n\n/**\n * Key store for managing pending idempotency keys.\n */\nexport interface KeyStore {\n /**\n * Mark a key as pending (request in flight).\n */\n pending(key: string): void;\n\n /**\n * Check if a key is currently pending.\n */\n isPending(key: string): boolean;\n\n /**\n * Clear a pending key.\n */\n clear(key: string): void;\n\n /**\n * Clear all pending keys.\n */\n clearAll(): void;\n}\n\n/**\n * In-memory storage implementation.\n */\nexport class MemoryStorage implements Storage {\n private data = new Map<string, string>();\n\n getItem(key: string): string | null {\n return this.data.get(key) ?? null;\n }\n\n setItem(key: string, value: string): void {\n this.data.set(key, value);\n }\n\n removeItem(key: string): void {\n this.data.delete(key);\n }\n\n clear(): void {\n this.data.clear();\n }\n}\n\n/**\n * Parse TTL string to milliseconds.\n */\nfunction parseTtl(ttl: number | string): number {\n if (typeof ttl === 'number') {\n return ttl;\n }\n\n const match = ttl.match(/^(\\d+)(ms|s|m|h|d)$/);\n if (!match) {\n throw new Error(`Invalid TTL format: ${ttl}. Use format like '5m', '1h', '30s'`);\n }\n\n const value = parseInt(match[1]!, 10);\n const unit = match[2]!;\n\n const multipliers: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n };\n\n return value * multipliers[unit]!;\n}\n\n/**\n * Create a key store for managing pending idempotency keys.\n *\n * @example\n * ```typescript\n * const store = createStore();\n *\n * // Before making request\n * if (store.isPending('key-123')) {\n * throw new Error('Request already in progress');\n * }\n * store.pending('key-123');\n *\n * try {\n * await fetch('/api/orders', { ... });\n * } finally {\n * store.clear('key-123');\n * }\n * ```\n */\nexport function createStore(options: StoreOptions = {}): KeyStore {\n const prefix = options.prefix ?? 'oncely:';\n const ttl = parseTtl(options.ttl ?? '5m');\n\n // Determine storage backend\n let storage: Storage;\n if (options.storage) {\n storage = options.storage;\n } else if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {\n storage = (globalThis as unknown as { localStorage: Storage }).localStorage;\n } else {\n storage = new MemoryStorage();\n }\n\n const prefixedKey = (key: string) => `${prefix}${key}`;\n\n return {\n pending(key: string): void {\n const expires = Date.now() + ttl;\n storage.setItem(prefixedKey(key), JSON.stringify({ expires }));\n },\n\n isPending(key: string): boolean {\n const value = storage.getItem(prefixedKey(key));\n if (!value) return false;\n\n try {\n const { expires } = JSON.parse(value);\n if (Date.now() > expires) {\n // Key has expired, clean it up\n storage.removeItem(prefixedKey(key));\n return false;\n }\n return true;\n } catch {\n // Invalid data, remove it\n storage.removeItem(prefixedKey(key));\n return false;\n }\n },\n\n clear(key: string): void {\n storage.removeItem(prefixedKey(key));\n },\n\n clearAll(): void {\n // For custom storage with proper clear, use it\n // For localStorage/sessionStorage, we need to only clear our keys\n if (storage instanceof MemoryStorage) {\n storage.clear();\n return;\n }\n\n // For browser storage, we need to iterate and find our keys\n // This is a workaround since Storage doesn't expose keys()\n const keysToRemove: string[] = [];\n try {\n const browserStorage = storage as BrowserStorage;\n if (typeof browserStorage.length === 'number' && typeof browserStorage.key === 'function') {\n for (let i = 0; i < browserStorage.length; i++) {\n const key = browserStorage.key(i);\n if (key?.startsWith(prefix)) {\n keysToRemove.push(key);\n }\n }\n }\n } catch {\n // If we can't iterate, just return\n return;\n }\n\n keysToRemove.forEach((key) => storage.removeItem(key));\n },\n };\n}\n","/**\n * Response helper utilities for detecting idempotency-related responses.\n */\n\nimport { HEADER_REPLAY } from './constants.js';\n\n/**\n * RFC 7807 Problem Details response format.\n * @see https://www.rfc-editor.org/rfc/rfc7807\n */\nexport interface ProblemDetails {\n /** URI reference identifying the problem type */\n type: string;\n /** Short human-readable summary */\n title: string;\n /** HTTP status code */\n status: number;\n /** Detailed human-readable explanation */\n detail: string;\n /** URI reference to the specific occurrence (optional) */\n instance?: string;\n /** Retry-After value in seconds (for conflict errors) */\n retryAfter?: number;\n /** Additional properties */\n [key: string]: unknown;\n}\n\n/**\n * Response-like object for compatibility with various fetch implementations.\n */\nexport interface ResponseLike {\n status: number;\n headers: {\n get(name: string): string | null;\n };\n json?(): Promise<unknown>;\n clone?(): ResponseLike;\n}\n\n/**\n * Check if a response is a replay (cached response).\n *\n * @example\n * ```typescript\n * const response = await fetch('/api/orders', { ... });\n * if (isReplay(response)) {\n * console.log('Response was served from cache');\n * }\n * ```\n */\nexport function isReplay(response: ResponseLike): boolean {\n return response.headers.get(HEADER_REPLAY)?.toLowerCase() === 'true';\n}\n\n/**\n * Check if a response indicates a conflict (409).\n * This means a request with the same key is already in progress.\n *\n * @example\n * ```typescript\n * if (isConflict(response)) {\n * const retryAfter = getRetryAfter(response);\n * await delay(retryAfter * 1000);\n * // Retry the request\n * }\n * ```\n */\nexport function isConflict(response: ResponseLike): boolean {\n return response.status === 409;\n}\n\n/**\n * Check if a response indicates a mismatch (422).\n * This means the idempotency key was reused with a different request payload.\n *\n * @example\n * ```typescript\n * if (isMismatch(response)) {\n * // Generate a new key and retry\n * const newKey = oncely.key();\n * // Retry with new key\n * }\n * ```\n */\nexport function isMismatch(response: ResponseLike): boolean {\n return response.status === 422;\n}\n\n/**\n * Check if a response indicates a missing key error (400).\n * This means the idempotency key header was required but not provided.\n */\nexport function isMissingKey(response: ResponseLike): boolean {\n return response.status === 400;\n}\n\n/**\n * Get the Retry-After value from a response.\n * Returns the value in seconds, or the default if not present.\n *\n * @param response - The response to check\n * @param defaultValue - Default value if header is not present (default: 1)\n * @returns Retry delay in seconds\n */\nexport function getRetryAfter(response: ResponseLike, defaultValue = 1): number {\n const header = response.headers.get('Retry-After');\n if (!header) return defaultValue;\n\n const seconds = parseInt(header, 10);\n if (isNaN(seconds) || seconds < 0) return defaultValue;\n\n return seconds;\n}\n\n/**\n * Parse RFC 7807 Problem Details from an error response.\n *\n * @example\n * ```typescript\n * if (!response.ok) {\n * const problem = await getProblem(response);\n * console.error(`${problem.title}: ${problem.detail}`);\n * }\n * ```\n */\nexport async function getProblem(response: ResponseLike): Promise<ProblemDetails | null> {\n try {\n // Clone to avoid consuming the body\n const responseToRead = response.clone ? response.clone() : response;\n if (!responseToRead.json) return null;\n\n const body = await responseToRead.json();\n\n // Validate it looks like Problem Details\n if (\n typeof body === 'object' &&\n body !== null &&\n 'type' in body &&\n 'title' in body &&\n 'status' in body\n ) {\n return body as ProblemDetails;\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Check if a response is an idempotency error (400, 409, or 422).\n */\nexport function isIdempotencyError(response: ResponseLike): boolean {\n return isMissingKey(response) || isConflict(response) || isMismatch(response);\n}\n","/**\n * @oncely/client - Client-side idempotency helpers\n *\n * @example\n * ```typescript\n * import { oncely } from '@oncely/client';\n *\n * // Generate a unique key\n * const key = oncely.key();\n *\n * // Make request with idempotency\n * const response = await fetch('/api/orders', {\n * method: 'POST',\n * headers: { [oncely.HEADER]: key },\n * body: JSON.stringify(data),\n * });\n *\n * // Check response type\n * if (oncely.isReplay(response)) {\n * console.log('Cached response');\n * }\n * ```\n */\n\nimport { HEADER, HEADER_REPLAY } from './constants.js';\nimport { createKeyGenerator } from './key.js';\nimport { createStore } from './store.js';\nimport {\n isReplay,\n isConflict,\n isMismatch,\n isMissingKey,\n isIdempotencyError,\n getRetryAfter,\n getProblem,\n} from './response.js';\n\n/**\n * Oncely client namespace.\n * All client-side functionality is accessed through this namespace.\n */\nexport const oncely = {\n /**\n * Generate a unique idempotency key.\n *\n * @example\n * ```typescript\n * // Random UUID key\n * const key = oncely.key();\n *\n * // Deterministic key from components\n * const key = oncely.key('user-123', 'action', timestamp);\n *\n * // Prefixed key\n * const key = oncely.key.prefixed('ord');\n * ```\n */\n key: createKeyGenerator(),\n\n /**\n * Create a key store for managing pending requests.\n *\n * @example\n * ```typescript\n * const store = oncely.store();\n *\n * if (store.isPending(key)) {\n * throw new Error('Request in progress');\n * }\n * store.pending(key);\n * ```\n */\n store: createStore,\n\n /**\n * Check if response is a replay (served from cache).\n */\n isReplay,\n\n /**\n * Check if response is a conflict (409).\n */\n isConflict,\n\n /**\n * Check if response is a mismatch (422).\n */\n isMismatch,\n\n /**\n * Check if response is a missing key error (400).\n */\n isMissingKey,\n\n /**\n * Check if response is any idempotency error (400, 409, 422).\n */\n isIdempotencyError,\n\n /**\n * Get Retry-After value from response.\n */\n getRetryAfter,\n\n /**\n * Parse RFC 7807 Problem Details from response.\n */\n getProblem,\n\n /**\n * Standard header name for idempotency keys.\n * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header\n */\n HEADER,\n\n /**\n * Header indicating a response was replayed from cache.\n */\n HEADER_REPLAY,\n} as const;\n\n// Type for the oncely namespace\nexport type OncelyClient = typeof oncely;\n\n// Re-export individual components for direct imports\nexport { HEADER, HEADER_REPLAY } from './constants.js';\nexport { generateKey, generatePrefixedKey, createKeyGenerator, type KeyGenerator } from './key.js';\nexport {\n createStore,\n MemoryStorage,\n type KeyStore,\n type StoreOptions,\n type Storage,\n} from './store.js';\nexport {\n isReplay,\n isConflict,\n isMismatch,\n isMissingKey,\n isIdempotencyError,\n getRetryAfter,\n getProblem,\n type ProblemDetails,\n type ResponseLike,\n} from './response.js';\n"]}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@oncely/client",
3
+ "version": "0.2.0",
4
+ "description": "Client-side idempotency helpers for browser and frontend applications",
5
+ "author": "stacks0x",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/stacks0x/oncely.git",
10
+ "directory": "packages/client"
11
+ },
12
+ "keywords": [
13
+ "idempotency",
14
+ "idempotent",
15
+ "client",
16
+ "browser",
17
+ "fetch",
18
+ "retry",
19
+ "deduplication"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ }
28
+ },
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "dev": "tsup --watch",
40
+ "typecheck": "tsc --noEmit",
41
+ "clean": "rm -rf dist"
42
+ },
43
+ "devDependencies": {
44
+ "tsup": "^8.0.1",
45
+ "typescript": "^5.3.3"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "sideEffects": false
51
+ }