request-ledger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +282 -0
- package/dist/index.cjs +1059 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +575 -0
- package/dist/index.js +1044 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# request-ledger
|
|
2
|
+
|
|
3
|
+
A durable, client-side HTTP request ledger for web applications operating on unreliable networks.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/request-ledger)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
## Mental Model
|
|
10
|
+
|
|
11
|
+
Think of `request-ledger` as a **transactional outbox** for your client-side HTTP requests:
|
|
12
|
+
|
|
13
|
+
1. When your app needs to make an API request, it goes through the ledger
|
|
14
|
+
2. If online, the request executes immediately
|
|
15
|
+
3. If offline (or network fails), the request is **durably persisted** to IndexedDB
|
|
16
|
+
4. When connectivity returns, queued requests are **replayed in order**
|
|
17
|
+
5. Failed requests are never silently dropped
|
|
18
|
+
|
|
19
|
+
This is **not** a retry library (it doesn't retry on every failure), and **not** a sync engine (it doesn't resolve conflicts).
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install request-ledger
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createLedger } from "request-ledger";
|
|
31
|
+
|
|
32
|
+
const ledger = createLedger({
|
|
33
|
+
onlineCheck: {
|
|
34
|
+
pingUrl: "/api/health", // Optional: ping endpoint for reliable online detection
|
|
35
|
+
},
|
|
36
|
+
hooks: {
|
|
37
|
+
onPersist: (entry) => console.log("Queued:", entry.id),
|
|
38
|
+
onReplaySuccess: (entry) => console.log("Completed:", entry.id),
|
|
39
|
+
onReplayFailure: (entry, error) =>
|
|
40
|
+
console.error("Failed:", entry.id, error),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Make a request (queued if offline)
|
|
45
|
+
await ledger.request({
|
|
46
|
+
id: "order-123", // Required: unique ID
|
|
47
|
+
url: "/api/orders",
|
|
48
|
+
method: "POST",
|
|
49
|
+
body: { items: ["item-1", "item-2"] },
|
|
50
|
+
idempotencyKey: "order-123-v1", // Recommended for safe replay
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Process queued requests when online
|
|
54
|
+
await ledger.process({ concurrency: 1, stopOnError: true });
|
|
55
|
+
|
|
56
|
+
// Check state
|
|
57
|
+
const state = await ledger.getState(); // 'idle' | 'pending' | 'processing' | 'paused' | 'error'
|
|
58
|
+
const entries = await ledger.list(); // All entries with status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API Reference
|
|
62
|
+
|
|
63
|
+
### `createLedger(config?)`
|
|
64
|
+
|
|
65
|
+
Creates a new ledger instance.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const ledger = createLedger({
|
|
69
|
+
// Optional: custom storage adapter (default: IndexedDB)
|
|
70
|
+
storage: new IndexedDBStorage({ maxEntries: 1000 }),
|
|
71
|
+
|
|
72
|
+
// Optional: retry strategy (default: exponential backoff)
|
|
73
|
+
retry: { type: "exponential", baseMs: 1000, maxMs: 30000, maxAttempts: 3 },
|
|
74
|
+
|
|
75
|
+
// Optional: online detection
|
|
76
|
+
onlineCheck: {
|
|
77
|
+
pingUrl: "/api/health",
|
|
78
|
+
pingTimeout: 5000,
|
|
79
|
+
customCheck: async () => {
|
|
80
|
+
/* your logic */ return true;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Optional: lifecycle hooks
|
|
85
|
+
hooks: {
|
|
86
|
+
onPersist: (entry) => {},
|
|
87
|
+
onReplayStart: (entry) => {},
|
|
88
|
+
onReplaySuccess: (entry, response) => {},
|
|
89
|
+
onReplayFailure: (entry, error) => {},
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Optional: idempotency header name (default: 'X-Idempotency-Key')
|
|
93
|
+
idempotencyHeader: "X-Idempotency-Key",
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `ledger.request(options)`
|
|
98
|
+
|
|
99
|
+
Make a request through the ledger.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await ledger.request({
|
|
103
|
+
id: string; // Required: unique identifier
|
|
104
|
+
url: string; // Required: target URL
|
|
105
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
106
|
+
headers?: Record<string, string>;
|
|
107
|
+
body?: unknown; // JSON serializable
|
|
108
|
+
idempotencyKey?: string; // For safe replay
|
|
109
|
+
metadata?: Record<string, unknown>; // Your custom data
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Behavior:**
|
|
114
|
+
|
|
115
|
+
- If online → attempts immediately, returns `Response`
|
|
116
|
+
- If offline or network fails → persists to ledger, returns `void`
|
|
117
|
+
- If persistence fails → throws `PersistenceError`
|
|
118
|
+
|
|
119
|
+
### `ledger.process(options?)`
|
|
120
|
+
|
|
121
|
+
Process queued entries.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
await ledger.process({
|
|
125
|
+
concurrency: 1, // Max concurrent requests (default: 1)
|
|
126
|
+
stopOnError: true, // Stop on first failure (default: true)
|
|
127
|
+
onSuccess: (entry) => {},
|
|
128
|
+
onFailure: (entry, error) => {},
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Control Methods
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
ledger.pause(); // Pause processing
|
|
136
|
+
ledger.resume(); // Resume processing
|
|
137
|
+
await ledger.getState(); // 'idle' | 'pending' | 'processing' | 'paused' | 'error'
|
|
138
|
+
await ledger.list(); // All entries
|
|
139
|
+
await ledger.get(id); // Single entry
|
|
140
|
+
await ledger.retry(id); // Retry a failed entry
|
|
141
|
+
await ledger.remove(id); // Remove an entry
|
|
142
|
+
await ledger.clear(); // Remove all entries
|
|
143
|
+
await ledger.destroy(); // Close and cleanup
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Ledger Entry Schema
|
|
147
|
+
|
|
148
|
+
Each entry contains:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
{
|
|
152
|
+
id: string;
|
|
153
|
+
request: {
|
|
154
|
+
url: string;
|
|
155
|
+
method: string;
|
|
156
|
+
headers: Record<string, string>;
|
|
157
|
+
body: unknown;
|
|
158
|
+
};
|
|
159
|
+
status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
160
|
+
attemptCount: number;
|
|
161
|
+
createdAt: number; // ms since epoch
|
|
162
|
+
lastAttemptAt?: number;
|
|
163
|
+
error?: { message: string; code?: string };
|
|
164
|
+
idempotencyKey?: string;
|
|
165
|
+
metadata?: Record<string, unknown>;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Retry Strategies
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// Fixed delay
|
|
173
|
+
{ type: 'fixed', delayMs: 1000, maxAttempts: 3 }
|
|
174
|
+
|
|
175
|
+
// Exponential backoff (default)
|
|
176
|
+
{ type: 'exponential', baseMs: 1000, maxMs: 30000, maxAttempts: 3 }
|
|
177
|
+
|
|
178
|
+
// Manual (user-triggered retries only)
|
|
179
|
+
{ type: 'manual' }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Retry rules:**
|
|
183
|
+
|
|
184
|
+
- ✅ Retry on network errors
|
|
185
|
+
- ✅ Retry on 5xx server errors
|
|
186
|
+
- ❌ Never retry on 4xx client errors
|
|
187
|
+
|
|
188
|
+
## Custom Storage
|
|
189
|
+
|
|
190
|
+
Implement the `LedgerStorage` interface:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
interface LedgerStorage {
|
|
194
|
+
put(entry: LedgerEntry): Promise<void>;
|
|
195
|
+
getAll(): Promise<LedgerEntry[]>;
|
|
196
|
+
get(id: string): Promise<LedgerEntry | undefined>;
|
|
197
|
+
update(id: string, patch: Partial<LedgerEntry>): Promise<void>;
|
|
198
|
+
remove(id: string): Promise<void>;
|
|
199
|
+
clear(): Promise<void>;
|
|
200
|
+
count(): Promise<number>;
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Failure Scenarios
|
|
205
|
+
|
|
206
|
+
| Scenario | Behavior |
|
|
207
|
+
| ----------------------------- | ------------------------------------------------ |
|
|
208
|
+
| Offline when request made | Persisted to IndexedDB, replayed when online |
|
|
209
|
+
| Network fails mid-request | Persisted, retried with backoff |
|
|
210
|
+
| Page closed during processing | Entry stays in `processing`, recovered on reload |
|
|
211
|
+
| 4xx response | Marked as `failed`, no retry |
|
|
212
|
+
| 5xx response | Retried up to `maxAttempts` |
|
|
213
|
+
| IndexedDB quota exceeded | `PersistenceError` thrown |
|
|
214
|
+
|
|
215
|
+
## ⚠️ Backend Idempotency Required
|
|
216
|
+
|
|
217
|
+
**Your backend MUST support idempotency keys for safe replay.**
|
|
218
|
+
|
|
219
|
+
When a request is replayed, there's no guarantee the first attempt didn't succeed. Your backend must:
|
|
220
|
+
|
|
221
|
+
1. Accept an `X-Idempotency-Key` header
|
|
222
|
+
2. If the key was already processed, return the cached response
|
|
223
|
+
3. If new, process and cache the result
|
|
224
|
+
|
|
225
|
+
Without this, replayed requests may cause **duplicate side effects** (double charges, duplicate orders, etc.).
|
|
226
|
+
|
|
227
|
+
## Non-Goals
|
|
228
|
+
|
|
229
|
+
This library explicitly does **NOT**:
|
|
230
|
+
|
|
231
|
+
- ❌ Resolve application-level conflicts
|
|
232
|
+
- ❌ Sync application state
|
|
233
|
+
- ❌ Guess backend behavior
|
|
234
|
+
- ❌ Mutate request payloads
|
|
235
|
+
- ❌ Hide failures
|
|
236
|
+
- ❌ Depend on Service Workers
|
|
237
|
+
|
|
238
|
+
## Technical Details
|
|
239
|
+
|
|
240
|
+
- **Zero runtime dependencies**
|
|
241
|
+
- **TypeScript-first** with full type definitions
|
|
242
|
+
- **Tree-shakeable** ES modules
|
|
243
|
+
- **~8KB** gzipped
|
|
244
|
+
- Works in modern browsers (Chrome 80+, Firefox 75+, Safari 14+, Edge 80+)
|
|
245
|
+
|
|
246
|
+
## Example: Offline → Reload → Replay
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// User creates an order while offline
|
|
250
|
+
await ledger.request({
|
|
251
|
+
id: "order-456",
|
|
252
|
+
url: "/api/orders",
|
|
253
|
+
method: "POST",
|
|
254
|
+
body: { product: "Widget", quantity: 5 },
|
|
255
|
+
idempotencyKey: "order-456-v1",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Entry is now persisted in IndexedDB
|
|
259
|
+
console.log(await ledger.list());
|
|
260
|
+
// [{ id: 'order-456', status: 'pending', ... }]
|
|
261
|
+
|
|
262
|
+
// --- User closes browser, reopens later ---
|
|
263
|
+
|
|
264
|
+
// On app startup, check for pending entries
|
|
265
|
+
const ledger = createLedger({
|
|
266
|
+
/* config */
|
|
267
|
+
});
|
|
268
|
+
const state = await ledger.getState();
|
|
269
|
+
|
|
270
|
+
if (state === "pending") {
|
|
271
|
+
// Process queued requests
|
|
272
|
+
await ledger.process({
|
|
273
|
+
onSuccess: (entry) => showNotification(`Order ${entry.id} completed!`),
|
|
274
|
+
onFailure: (entry, error) =>
|
|
275
|
+
showError(`Order ${entry.id} failed: ${error.message}`),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## License
|
|
281
|
+
|
|
282
|
+
MIT
|