lowdata 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/LICENSE +21 -0
- package/README.md +267 -0
- package/dist/client-BvdWSYIM.d.ts +187 -0
- package/dist/client-CqO3Y1J5.d.cts +187 -0
- package/dist/forms.cjs +1090 -0
- package/dist/forms.cjs.map +1 -0
- package/dist/forms.d.cts +29 -0
- package/dist/forms.d.ts +29 -0
- package/dist/forms.js +1088 -0
- package/dist/forms.js.map +1 -0
- package/dist/index.cjs +1109 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1099 -0
- package/dist/index.js.map +1 -0
- package/dist/media.cjs +278 -0
- package/dist/media.cjs.map +1 -0
- package/dist/media.d.cts +35 -0
- package/dist/media.d.ts +35 -0
- package/dist/media.js +274 -0
- package/dist/media.js.map +1 -0
- package/dist/network.cjs +923 -0
- package/dist/network.cjs.map +1 -0
- package/dist/network.d.cts +46 -0
- package/dist/network.d.ts +46 -0
- package/dist/network.js +912 -0
- package/dist/network.js.map +1 -0
- package/dist/progressiveImage-BWNdewbi.d.cts +23 -0
- package/dist/progressiveImage-BhY_K8Dj.d.ts +23 -0
- package/dist/react.cjs +1195 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +33 -0
- package/dist/react.d.ts +33 -0
- package/dist/react.js +1190 -0
- package/dist/react.js.map +1 -0
- package/dist/retry-C3zL9T5Z.d.cts +22 -0
- package/dist/retry-D6DfKGOi.d.ts +22 -0
- package/dist/types--FRrBa-i.d.cts +39 -0
- package/dist/types--FRrBa-i.d.ts +39 -0
- package/dist/types-Bn1BAcch.d.ts +33 -0
- package/dist/types-CZDYS-fB.d.cts +33 -0
- package/package.json +97 -0
package/dist/forms.cjs
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/events.ts
|
|
4
|
+
var Emitter = class {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
7
|
+
}
|
|
8
|
+
subscribe(listener) {
|
|
9
|
+
this.listeners.add(listener);
|
|
10
|
+
return () => {
|
|
11
|
+
this.listeners.delete(listener);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
emit(value) {
|
|
15
|
+
for (const listener of this.listeners) {
|
|
16
|
+
listener(value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
clear() {
|
|
20
|
+
this.listeners.clear();
|
|
21
|
+
}
|
|
22
|
+
get size() {
|
|
23
|
+
return this.listeners.size;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/core/id.ts
|
|
28
|
+
function createId() {
|
|
29
|
+
const g = globalThis;
|
|
30
|
+
if (g.crypto && typeof g.crypto.randomUUID === "function") {
|
|
31
|
+
return g.crypto.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
return `ld_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/core/connection.ts
|
|
37
|
+
var DEFAULT_SLOW_RTT_MS = 600;
|
|
38
|
+
var DEFAULT_SLOW_DOWNLINK_MBPS = 0.5;
|
|
39
|
+
var DEFAULT_PING_TIMEOUT_MS = 5e3;
|
|
40
|
+
function hasNavigator() {
|
|
41
|
+
return typeof navigator !== "undefined";
|
|
42
|
+
}
|
|
43
|
+
var ConnectionMonitor = class {
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.emitter = new Emitter();
|
|
46
|
+
this.disposed = false;
|
|
47
|
+
var _a, _b, _c;
|
|
48
|
+
this.options = {
|
|
49
|
+
pingUrl: options.pingUrl,
|
|
50
|
+
slowRttThresholdMs: (_a = options.slowRttThresholdMs) != null ? _a : DEFAULT_SLOW_RTT_MS,
|
|
51
|
+
slowDownlinkMbps: (_b = options.slowDownlinkMbps) != null ? _b : DEFAULT_SLOW_DOWNLINK_MBPS,
|
|
52
|
+
pingTimeoutMs: (_c = options.pingTimeoutMs) != null ? _c : DEFAULT_PING_TIMEOUT_MS
|
|
53
|
+
};
|
|
54
|
+
this.current = this.computeInfo();
|
|
55
|
+
if (hasNavigator() && typeof window !== "undefined") {
|
|
56
|
+
this.onlineHandler = () => {
|
|
57
|
+
this.refresh();
|
|
58
|
+
void this.probeNow();
|
|
59
|
+
};
|
|
60
|
+
this.offlineHandler = () => this.refresh();
|
|
61
|
+
window.addEventListener("online", this.onlineHandler);
|
|
62
|
+
window.addEventListener("offline", this.offlineHandler);
|
|
63
|
+
const conn = navigator.connection;
|
|
64
|
+
if (conn == null ? void 0 : conn.addEventListener) {
|
|
65
|
+
this.connectionChangeHandler = () => this.refresh();
|
|
66
|
+
conn.addEventListener("change", this.connectionChangeHandler);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (this.options.pingUrl) {
|
|
70
|
+
void this.probeNow();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
getStatus() {
|
|
74
|
+
return this.current;
|
|
75
|
+
}
|
|
76
|
+
subscribe(listener) {
|
|
77
|
+
return this.emitter.subscribe(listener);
|
|
78
|
+
}
|
|
79
|
+
/** Manually re-run the opt-in ping probe (no-op if no `pingUrl` was configured). */
|
|
80
|
+
async probeNow() {
|
|
81
|
+
if (!this.options.pingUrl || !hasNavigator() || navigator.onLine === false) {
|
|
82
|
+
return this.current;
|
|
83
|
+
}
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => controller.abort(), this.options.pingTimeoutMs);
|
|
87
|
+
try {
|
|
88
|
+
await fetch(this.options.pingUrl, {
|
|
89
|
+
method: "HEAD",
|
|
90
|
+
cache: "no-store",
|
|
91
|
+
signal: controller.signal
|
|
92
|
+
});
|
|
93
|
+
this.probedRttMs = Date.now() - start;
|
|
94
|
+
} catch {
|
|
95
|
+
} finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
return this.refresh();
|
|
99
|
+
}
|
|
100
|
+
destroy() {
|
|
101
|
+
var _a;
|
|
102
|
+
if (this.disposed) return;
|
|
103
|
+
this.disposed = true;
|
|
104
|
+
if (typeof window !== "undefined") {
|
|
105
|
+
if (this.onlineHandler) window.removeEventListener("online", this.onlineHandler);
|
|
106
|
+
if (this.offlineHandler) window.removeEventListener("offline", this.offlineHandler);
|
|
107
|
+
}
|
|
108
|
+
if (hasNavigator() && this.connectionChangeHandler) {
|
|
109
|
+
const conn = navigator.connection;
|
|
110
|
+
(_a = conn == null ? void 0 : conn.removeEventListener) == null ? void 0 : _a.call(conn, "change", this.connectionChangeHandler);
|
|
111
|
+
}
|
|
112
|
+
this.emitter.clear();
|
|
113
|
+
}
|
|
114
|
+
refresh() {
|
|
115
|
+
this.current = this.computeInfo();
|
|
116
|
+
this.emitter.emit(this.current);
|
|
117
|
+
return this.current;
|
|
118
|
+
}
|
|
119
|
+
computeInfo() {
|
|
120
|
+
if (!hasNavigator()) {
|
|
121
|
+
return { quality: "online", online: true };
|
|
122
|
+
}
|
|
123
|
+
const online = navigator.onLine !== false;
|
|
124
|
+
if (!online) {
|
|
125
|
+
return { quality: "offline", online: false };
|
|
126
|
+
}
|
|
127
|
+
const conn = navigator.connection;
|
|
128
|
+
if (conn) {
|
|
129
|
+
const info = {
|
|
130
|
+
quality: "online",
|
|
131
|
+
online: true,
|
|
132
|
+
effectiveType: conn.effectiveType,
|
|
133
|
+
downlinkMbps: conn.downlink,
|
|
134
|
+
rttMs: conn.rtt,
|
|
135
|
+
saveData: conn.saveData
|
|
136
|
+
};
|
|
137
|
+
const isSlow = conn.saveData === true || conn.effectiveType === "2g" || conn.effectiveType === "slow-2g" || typeof conn.downlink === "number" && conn.downlink < this.options.slowDownlinkMbps || typeof conn.rtt === "number" && conn.rtt > this.options.slowRttThresholdMs;
|
|
138
|
+
return { ...info, quality: isSlow ? "slow" : "online" };
|
|
139
|
+
}
|
|
140
|
+
const quality = typeof this.probedRttMs === "number" && this.probedRttMs > this.options.slowRttThresholdMs ? "slow" : "online";
|
|
141
|
+
return {
|
|
142
|
+
quality,
|
|
143
|
+
online: true,
|
|
144
|
+
rttMs: this.probedRttMs
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// src/core/idb.ts
|
|
150
|
+
function isIndexedDbAvailable() {
|
|
151
|
+
try {
|
|
152
|
+
return typeof indexedDB !== "undefined" && indexedDB !== null;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function openDatabase(name, version, stores) {
|
|
158
|
+
if (!isIndexedDbAvailable()) {
|
|
159
|
+
return Promise.reject(new Error("lowdata: IndexedDB is not available in this environment"));
|
|
160
|
+
}
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const request = indexedDB.open(name, version);
|
|
163
|
+
request.onupgradeneeded = () => {
|
|
164
|
+
var _a, _b;
|
|
165
|
+
const db = request.result;
|
|
166
|
+
for (const store of stores) {
|
|
167
|
+
if (db.objectStoreNames.contains(store.name)) continue;
|
|
168
|
+
const objectStore = db.createObjectStore(store.name, { keyPath: store.keyPath });
|
|
169
|
+
for (const index of (_a = store.indexes) != null ? _a : []) {
|
|
170
|
+
objectStore.createIndex(index.name, index.keyPath, { unique: (_b = index.unique) != null ? _b : false });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
request.onsuccess = () => resolve(request.result);
|
|
175
|
+
request.onerror = () => {
|
|
176
|
+
var _a;
|
|
177
|
+
return reject((_a = request.error) != null ? _a : new Error("lowdata: failed to open IndexedDB database"));
|
|
178
|
+
};
|
|
179
|
+
request.onblocked = () => reject(new Error("lowdata: IndexedDB open blocked by another connection"));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function wrapRequest(request) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
request.onsuccess = () => resolve(request.result);
|
|
185
|
+
request.onerror = () => {
|
|
186
|
+
var _a;
|
|
187
|
+
return reject((_a = request.error) != null ? _a : new Error("lowdata: IndexedDB request failed"));
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function wrapTransaction(tx) {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
tx.oncomplete = () => resolve();
|
|
194
|
+
tx.onerror = () => {
|
|
195
|
+
var _a;
|
|
196
|
+
return reject((_a = tx.error) != null ? _a : new Error("lowdata: IndexedDB transaction failed"));
|
|
197
|
+
};
|
|
198
|
+
tx.onabort = () => {
|
|
199
|
+
var _a;
|
|
200
|
+
return reject((_a = tx.error) != null ? _a : new Error("lowdata: IndexedDB transaction aborted"));
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async function idbPut(db, storeName, value) {
|
|
205
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
206
|
+
tx.objectStore(storeName).put(value);
|
|
207
|
+
await wrapTransaction(tx);
|
|
208
|
+
}
|
|
209
|
+
async function idbGet(db, storeName, key) {
|
|
210
|
+
const tx = db.transaction(storeName, "readonly");
|
|
211
|
+
return wrapRequest(tx.objectStore(storeName).get(key));
|
|
212
|
+
}
|
|
213
|
+
async function idbDelete(db, storeName, key) {
|
|
214
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
215
|
+
tx.objectStore(storeName).delete(key);
|
|
216
|
+
await wrapTransaction(tx);
|
|
217
|
+
}
|
|
218
|
+
async function idbGetAll(db, storeName, options) {
|
|
219
|
+
const tx = db.transaction(storeName, "readonly");
|
|
220
|
+
const source = (options == null ? void 0 : options.indexName) ? tx.objectStore(storeName).index(options.indexName) : tx.objectStore(storeName);
|
|
221
|
+
return wrapRequest(source.getAll(options == null ? void 0 : options.query));
|
|
222
|
+
}
|
|
223
|
+
async function idbClear(db, storeName) {
|
|
224
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
225
|
+
tx.objectStore(storeName).clear();
|
|
226
|
+
await wrapTransaction(tx);
|
|
227
|
+
}
|
|
228
|
+
var LOWDATA_DB_NAME = "lowdata";
|
|
229
|
+
var LOWDATA_DB_VERSION = 1;
|
|
230
|
+
var LOWDATA_STORES = [
|
|
231
|
+
{
|
|
232
|
+
name: "queue",
|
|
233
|
+
keyPath: "id",
|
|
234
|
+
indexes: [
|
|
235
|
+
{ name: "status", keyPath: "status" },
|
|
236
|
+
{ name: "priority", keyPath: "priority" },
|
|
237
|
+
{ name: "nextAttemptAt", keyPath: "nextAttemptAt" }
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
{ name: "meta", keyPath: "key" },
|
|
241
|
+
{
|
|
242
|
+
name: "formDrafts",
|
|
243
|
+
keyPath: "submissionId",
|
|
244
|
+
indexes: [{ name: "formId", keyPath: "formId" }]
|
|
245
|
+
}
|
|
246
|
+
];
|
|
247
|
+
var sharedDbPromise;
|
|
248
|
+
function getSharedDb() {
|
|
249
|
+
if (!sharedDbPromise) {
|
|
250
|
+
sharedDbPromise = openDatabase(LOWDATA_DB_NAME, LOWDATA_DB_VERSION, LOWDATA_STORES).catch(
|
|
251
|
+
(err) => {
|
|
252
|
+
sharedDbPromise = void 0;
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return sharedDbPromise;
|
|
258
|
+
}
|
|
259
|
+
function createDbFallbackAccessor(getDb) {
|
|
260
|
+
let dbAvailable = true;
|
|
261
|
+
return {
|
|
262
|
+
async run(fn, fallback) {
|
|
263
|
+
if (!dbAvailable) return fallback();
|
|
264
|
+
try {
|
|
265
|
+
const db = await getDb();
|
|
266
|
+
return await fn(db);
|
|
267
|
+
} catch {
|
|
268
|
+
dbAvailable = false;
|
|
269
|
+
return fallback();
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
isPersistent: () => dbAvailable
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/network/errors.ts
|
|
277
|
+
var LowdataRequestError = class extends Error {
|
|
278
|
+
constructor(message, init) {
|
|
279
|
+
var _a, _b;
|
|
280
|
+
super(message, init.cause !== void 0 ? { cause: init.cause } : void 0);
|
|
281
|
+
this.name = "LowdataRequestError";
|
|
282
|
+
this.status = init.status;
|
|
283
|
+
this.isNetworkError = (_a = init.isNetworkError) != null ? _a : false;
|
|
284
|
+
this.isTimeout = (_b = init.isTimeout) != null ? _b : false;
|
|
285
|
+
this.attempt = init.attempt;
|
|
286
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// src/network/queue.ts
|
|
291
|
+
var STORE = "queue";
|
|
292
|
+
var PRIORITY_ORDER = { high: 0, normal: 1, low: 2 };
|
|
293
|
+
var RequestQueue = class {
|
|
294
|
+
constructor(getDb) {
|
|
295
|
+
this.memory = /* @__PURE__ */ new Map();
|
|
296
|
+
this.accessor = createDbFallbackAccessor(getDb);
|
|
297
|
+
}
|
|
298
|
+
isPersistent() {
|
|
299
|
+
return this.accessor.isPersistent();
|
|
300
|
+
}
|
|
301
|
+
async add(item) {
|
|
302
|
+
return this.accessor.run(
|
|
303
|
+
async (db) => {
|
|
304
|
+
await idbPut(db, STORE, item);
|
|
305
|
+
return item;
|
|
306
|
+
},
|
|
307
|
+
() => {
|
|
308
|
+
this.memory.set(item.id, item);
|
|
309
|
+
return item;
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
async update(item) {
|
|
314
|
+
await this.accessor.run(
|
|
315
|
+
async (db) => {
|
|
316
|
+
await idbPut(db, STORE, item);
|
|
317
|
+
},
|
|
318
|
+
() => {
|
|
319
|
+
this.memory.set(item.id, item);
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
async get(id) {
|
|
324
|
+
return this.accessor.run(
|
|
325
|
+
(db) => idbGet(db, STORE, id),
|
|
326
|
+
() => this.memory.get(id)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
async remove(id) {
|
|
330
|
+
await this.accessor.run(
|
|
331
|
+
(db) => idbDelete(db, STORE, id),
|
|
332
|
+
() => {
|
|
333
|
+
this.memory.delete(id);
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
/** Filtering by status queries the `status` index rather than scanning the whole store. */
|
|
338
|
+
async list(filter) {
|
|
339
|
+
if (filter == null ? void 0 : filter.status) {
|
|
340
|
+
const status = filter.status;
|
|
341
|
+
return this.accessor.run(
|
|
342
|
+
(db) => idbGetAll(db, STORE, { indexName: "status", query: status }),
|
|
343
|
+
() => Array.from(this.memory.values()).filter((item) => item.status === status)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return this.accessor.run(
|
|
347
|
+
(db) => idbGetAll(db, STORE),
|
|
348
|
+
() => Array.from(this.memory.values())
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
async clear() {
|
|
352
|
+
await this.accessor.run(
|
|
353
|
+
(db) => idbClear(db, STORE),
|
|
354
|
+
() => {
|
|
355
|
+
this.memory.clear();
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
/** Items ready to send now: `pending` and due, sorted by priority then insertion order. */
|
|
360
|
+
async selectEligible(now) {
|
|
361
|
+
const pending = await this.list({ status: "pending" });
|
|
362
|
+
return pending.filter((item) => item.nextAttemptAt <= now).sort((a, b) => {
|
|
363
|
+
const byPriority = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
|
364
|
+
return byPriority !== 0 ? byPriority : a.createdAt - b.createdAt;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** Revive items stuck in `sending` longer than `staleAfterMs` (recovers from a crash mid-send). */
|
|
368
|
+
async sweepStale(staleAfterMs, now) {
|
|
369
|
+
const sending = await this.list({ status: "sending" });
|
|
370
|
+
const revived = [];
|
|
371
|
+
for (const item of sending) {
|
|
372
|
+
if (now - item.updatedAt > staleAfterMs) {
|
|
373
|
+
const next = { ...item, status: "pending", updatedAt: now };
|
|
374
|
+
await this.update(next);
|
|
375
|
+
revived.push(next);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return revived;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// src/core/backoff.ts
|
|
383
|
+
function computeBackoffDelay(attempt, config) {
|
|
384
|
+
const safeAttempt = Math.max(0, attempt);
|
|
385
|
+
const exp = Math.min(config.maxDelayMs, config.baseDelayMs * 2 ** safeAttempt);
|
|
386
|
+
switch (config.jitter) {
|
|
387
|
+
case "none":
|
|
388
|
+
return exp;
|
|
389
|
+
case "equal":
|
|
390
|
+
return exp / 2 + Math.random() * (exp / 2);
|
|
391
|
+
case "full":
|
|
392
|
+
default:
|
|
393
|
+
return Math.random() * exp;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/core/abortAny.ts
|
|
398
|
+
function combineSignals(signals) {
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
const active = signals.filter((s) => Boolean(s));
|
|
401
|
+
const onAbort = (source) => () => {
|
|
402
|
+
if (!controller.signal.aborted) {
|
|
403
|
+
controller.abort(source.reason);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const cleanups = [];
|
|
407
|
+
for (const s of active) {
|
|
408
|
+
if (s.aborted) {
|
|
409
|
+
controller.abort(s.reason);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
const handler = onAbort(s);
|
|
413
|
+
s.addEventListener("abort", handler, { once: true });
|
|
414
|
+
cleanups.push(() => s.removeEventListener("abort", handler));
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
signal: controller.signal,
|
|
418
|
+
dispose: () => {
|
|
419
|
+
for (const cleanup of cleanups) cleanup();
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/core/types.ts
|
|
425
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
426
|
+
maxRetries: 8,
|
|
427
|
+
baseDelayMs: 500,
|
|
428
|
+
maxDelayMs: 3e4,
|
|
429
|
+
jitter: "full"
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// src/network/retry.ts
|
|
433
|
+
var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
434
|
+
function isRetryableStatus(status) {
|
|
435
|
+
return RETRYABLE_STATUS.has(status);
|
|
436
|
+
}
|
|
437
|
+
function defaultRetryOn(error) {
|
|
438
|
+
if (error.isNetworkError || error.isTimeout) return true;
|
|
439
|
+
return typeof error.status === "number" && RETRYABLE_STATUS.has(error.status);
|
|
440
|
+
}
|
|
441
|
+
function parseRetryAfterMs(response) {
|
|
442
|
+
const header = response.headers.get("Retry-After");
|
|
443
|
+
if (!header) return void 0;
|
|
444
|
+
const seconds = Number(header);
|
|
445
|
+
if (!Number.isNaN(seconds)) return seconds * 1e3;
|
|
446
|
+
const dateMs = Date.parse(header);
|
|
447
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
448
|
+
return void 0;
|
|
449
|
+
}
|
|
450
|
+
function sleep(ms, signal) {
|
|
451
|
+
return new Promise((resolve, reject) => {
|
|
452
|
+
var _a;
|
|
453
|
+
if (signal == null ? void 0 : signal.aborted) {
|
|
454
|
+
reject((_a = signal.reason) != null ? _a : new Error("lowdata: aborted"));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const timer = setTimeout(resolve, ms);
|
|
458
|
+
signal == null ? void 0 : signal.addEventListener(
|
|
459
|
+
"abort",
|
|
460
|
+
() => {
|
|
461
|
+
var _a2;
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
reject((_a2 = signal.reason) != null ? _a2 : new Error("lowdata: aborted"));
|
|
464
|
+
},
|
|
465
|
+
{ once: true }
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
470
|
+
async function attemptWithRetry(options) {
|
|
471
|
+
var _a, _b, _c, _d;
|
|
472
|
+
const config = { ...DEFAULT_RETRY_CONFIG, ...options.retryConfig };
|
|
473
|
+
const retryOn = (_a = config.retryOn) != null ? _a : defaultRetryOn;
|
|
474
|
+
const timeoutMs = (_b = options.timeoutMs) != null ? _b : DEFAULT_TIMEOUT_MS;
|
|
475
|
+
let lastError;
|
|
476
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
477
|
+
const timeoutController = new AbortController();
|
|
478
|
+
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
479
|
+
const combined = combineSignals([options.signal, timeoutController.signal]);
|
|
480
|
+
try {
|
|
481
|
+
const response = await fetch(options.url, { ...options.init, signal: combined.signal });
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
combined.dispose();
|
|
484
|
+
if (response.ok || !RETRYABLE_STATUS.has(response.status)) {
|
|
485
|
+
return response;
|
|
486
|
+
}
|
|
487
|
+
lastError = new LowdataRequestError(`Request failed with status ${response.status}`, {
|
|
488
|
+
status: response.status,
|
|
489
|
+
attempt,
|
|
490
|
+
retryAfterMs: parseRetryAfterMs(response)
|
|
491
|
+
});
|
|
492
|
+
} catch (cause) {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
combined.dispose();
|
|
495
|
+
if ((_c = options.signal) == null ? void 0 : _c.aborted) {
|
|
496
|
+
throw cause;
|
|
497
|
+
}
|
|
498
|
+
const isTimeout = timeoutController.signal.aborted;
|
|
499
|
+
lastError = new LowdataRequestError(
|
|
500
|
+
isTimeout ? "Request timed out" : "Network request failed",
|
|
501
|
+
{ isNetworkError: !isTimeout, isTimeout, attempt, cause }
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const isLastAttempt = attempt === config.maxRetries;
|
|
505
|
+
const continueRetrying = options.shouldContinue ? options.shouldContinue() : true;
|
|
506
|
+
if (isLastAttempt || !continueRetrying || !retryOn(lastError, attempt)) {
|
|
507
|
+
throw lastError;
|
|
508
|
+
}
|
|
509
|
+
const delay = (_d = lastError.retryAfterMs) != null ? _d : computeBackoffDelay(attempt, config);
|
|
510
|
+
await sleep(Math.min(delay, config.maxDelayMs), options.signal);
|
|
511
|
+
}
|
|
512
|
+
throw lastError != null ? lastError : new LowdataRequestError("Request failed", { attempt: config.maxRetries });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/core/lock.ts
|
|
516
|
+
var LOCK_STALE_AFTER_MS = 15e3;
|
|
517
|
+
function getLockManager() {
|
|
518
|
+
if (typeof navigator === "undefined") return void 0;
|
|
519
|
+
return navigator.locks;
|
|
520
|
+
}
|
|
521
|
+
function acquireWebLock(locks, name) {
|
|
522
|
+
return new Promise((resolveOuter) => {
|
|
523
|
+
locks.request(name, { ifAvailable: true }, (lock) => {
|
|
524
|
+
if (!lock) {
|
|
525
|
+
resolveOuter(void 0);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
return new Promise((resolveInner) => {
|
|
529
|
+
resolveOuter({
|
|
530
|
+
release: async () => resolveInner(),
|
|
531
|
+
renew: async () => {
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}).catch(() => resolveOuter(void 0));
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async function acquireIdbLock(db, name, ownerId) {
|
|
539
|
+
const key = `syncLock:${name}`;
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
const existing = await idbGet(db, "meta", key);
|
|
542
|
+
if (existing && existing.expiresAt > now && existing.ownerId !== ownerId) {
|
|
543
|
+
return void 0;
|
|
544
|
+
}
|
|
545
|
+
await idbPut(db, "meta", { key, ownerId, expiresAt: now + LOCK_STALE_AFTER_MS });
|
|
546
|
+
return {
|
|
547
|
+
release: async () => {
|
|
548
|
+
const current = await idbGet(db, "meta", key);
|
|
549
|
+
if ((current == null ? void 0 : current.ownerId) === ownerId) {
|
|
550
|
+
await idbDelete(db, "meta", key);
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
renew: async () => {
|
|
554
|
+
await idbPut(db, "meta", {
|
|
555
|
+
key,
|
|
556
|
+
ownerId,
|
|
557
|
+
expiresAt: Date.now() + LOCK_STALE_AFTER_MS
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async function acquireSyncLock(db, name, ownerId) {
|
|
563
|
+
const locks = getLockManager();
|
|
564
|
+
if (locks) {
|
|
565
|
+
return acquireWebLock(locks, name);
|
|
566
|
+
}
|
|
567
|
+
if (!db) return void 0;
|
|
568
|
+
return acquireIdbLock(db, name, ownerId);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/network/sync.ts
|
|
572
|
+
var STALE_SENDING_MS = 6e4;
|
|
573
|
+
var SAFETY_POLL_MS = 3e4;
|
|
574
|
+
var SYNC_LOCK_NAME = "lowdata-sync";
|
|
575
|
+
var CANCELLED = /* @__PURE__ */ Symbol("lowdata-cancelled");
|
|
576
|
+
var SyncManager = class {
|
|
577
|
+
constructor(opts) {
|
|
578
|
+
this.opts = opts;
|
|
579
|
+
this.ownerId = createId();
|
|
580
|
+
this.inFlight = /* @__PURE__ */ new Map();
|
|
581
|
+
this.draining = false;
|
|
582
|
+
this.disposed = false;
|
|
583
|
+
var _a;
|
|
584
|
+
this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...opts.retryConfig };
|
|
585
|
+
this.syncConcurrency = Math.max(1, (_a = opts.syncConcurrency) != null ? _a : 1);
|
|
586
|
+
this.unsubscribeConnection = this.opts.connection.subscribe((info) => {
|
|
587
|
+
if (info.quality !== "offline") void this.drain();
|
|
588
|
+
});
|
|
589
|
+
if (typeof document !== "undefined" && typeof setInterval !== "undefined") {
|
|
590
|
+
this.safetyTimer = setInterval(() => {
|
|
591
|
+
if (document.visibilityState === "visible") void this.drain();
|
|
592
|
+
}, SAFETY_POLL_MS);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** Call after enqueueing a new item so a high-priority item is picked up without waiting for the next trigger. */
|
|
596
|
+
notifyEnqueued() {
|
|
597
|
+
if (this.opts.connection.getStatus().quality !== "offline") void this.drain();
|
|
598
|
+
}
|
|
599
|
+
cancel(id) {
|
|
600
|
+
var _a;
|
|
601
|
+
(_a = this.inFlight.get(id)) == null ? void 0 : _a.abort(CANCELLED);
|
|
602
|
+
this.inFlight.delete(id);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Never throws/rejects — `drain()` is always invoked fire-and-forget (`void this.drain()`) from
|
|
606
|
+
* event listeners, so any failure here (e.g. the shared IndexedDB connection was closed
|
|
607
|
+
* mid-drain) is swallowed rather than surfacing as an unhandled rejection; the next reconnect
|
|
608
|
+
* or safety poll simply tries again.
|
|
609
|
+
*/
|
|
610
|
+
async drain() {
|
|
611
|
+
var _a, _b, _c, _d;
|
|
612
|
+
if (this.draining || this.disposed) return;
|
|
613
|
+
if (this.opts.connection.getStatus().quality === "offline") return;
|
|
614
|
+
this.draining = true;
|
|
615
|
+
try {
|
|
616
|
+
const db = await this.opts.getDb().catch(() => void 0);
|
|
617
|
+
const lock = await acquireSyncLock(db, SYNC_LOCK_NAME, this.ownerId);
|
|
618
|
+
if (!lock) return;
|
|
619
|
+
let succeeded = 0;
|
|
620
|
+
let failed = 0;
|
|
621
|
+
try {
|
|
622
|
+
await this.opts.queue.sweepStale(STALE_SENDING_MS, Date.now());
|
|
623
|
+
const initialEligible = await this.opts.queue.selectEligible(Date.now());
|
|
624
|
+
if (initialEligible.length === 0) return;
|
|
625
|
+
(_b = (_a = this.opts).onEvent) == null ? void 0 : _b.call(_a, { type: "sync-start", pending: initialEligible.length });
|
|
626
|
+
while (!this.disposed && this.opts.connection.getStatus().quality !== "offline") {
|
|
627
|
+
const eligible = await this.opts.queue.selectEligible(Date.now());
|
|
628
|
+
if (eligible.length === 0) break;
|
|
629
|
+
const batch = eligible.slice(0, this.syncConcurrency);
|
|
630
|
+
const results = await Promise.all(batch.map((item) => this.sendItem(item)));
|
|
631
|
+
succeeded += results.filter(Boolean).length;
|
|
632
|
+
failed += results.filter((ok) => !ok).length;
|
|
633
|
+
}
|
|
634
|
+
(_d = (_c = this.opts).onEvent) == null ? void 0 : _d.call(_c, { type: "sync-complete", succeeded, failed });
|
|
635
|
+
} finally {
|
|
636
|
+
await lock.release().catch(() => {
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
} finally {
|
|
641
|
+
this.draining = false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
destroy() {
|
|
645
|
+
this.disposed = true;
|
|
646
|
+
this.unsubscribeConnection();
|
|
647
|
+
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
648
|
+
for (const controller of this.inFlight.values()) controller.abort(CANCELLED);
|
|
649
|
+
this.inFlight.clear();
|
|
650
|
+
}
|
|
651
|
+
async sendItem(item) {
|
|
652
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
|
|
653
|
+
const controller = new AbortController();
|
|
654
|
+
this.inFlight.set(item.id, controller);
|
|
655
|
+
const timeoutMs = (_a = item.timeoutMs) != null ? _a : DEFAULT_TIMEOUT_MS;
|
|
656
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
657
|
+
const sendingItem = { ...item, status: "sending", updatedAt: Date.now() };
|
|
658
|
+
await this.opts.queue.update(sendingItem);
|
|
659
|
+
(_c = (_b = this.opts).onEvent) == null ? void 0 : _c.call(_b, { type: "item-start", item: sendingItem });
|
|
660
|
+
const retryConfig = { ...this.retryConfig, ...item.retry };
|
|
661
|
+
const retryOn = (_d = retryConfig.retryOn) != null ? _d : defaultRetryOn;
|
|
662
|
+
let requestError;
|
|
663
|
+
try {
|
|
664
|
+
const response = await fetch(item.url, {
|
|
665
|
+
method: item.method,
|
|
666
|
+
headers: item.headers,
|
|
667
|
+
body: (_e = item.body) != null ? _e : void 0,
|
|
668
|
+
signal: controller.signal
|
|
669
|
+
});
|
|
670
|
+
if (response.ok || !isRetryableStatus(response.status)) {
|
|
671
|
+
const doneItem = { ...sendingItem, status: "done", updatedAt: Date.now() };
|
|
672
|
+
await this.opts.queue.remove(doneItem.id);
|
|
673
|
+
(_g = (_f = this.opts).onEvent) == null ? void 0 : _g.call(_f, { type: "item-success", item: doneItem });
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
requestError = new LowdataRequestError(`Request failed with status ${response.status}`, {
|
|
677
|
+
status: response.status,
|
|
678
|
+
attempt: item.attempts,
|
|
679
|
+
retryAfterMs: parseRetryAfterMs(response)
|
|
680
|
+
});
|
|
681
|
+
} catch (cause) {
|
|
682
|
+
if (controller.signal.reason === CANCELLED) {
|
|
683
|
+
const cancelledItem = {
|
|
684
|
+
...sendingItem,
|
|
685
|
+
status: "cancelled",
|
|
686
|
+
updatedAt: Date.now()
|
|
687
|
+
};
|
|
688
|
+
await this.opts.queue.update(cancelledItem);
|
|
689
|
+
(_i = (_h = this.opts).onEvent) == null ? void 0 : _i.call(_h, { type: "item-failed", item: cancelledItem, willRetry: false });
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
const isTimeout = controller.signal.aborted && controller.signal.reason !== CANCELLED;
|
|
693
|
+
requestError = new LowdataRequestError(
|
|
694
|
+
isTimeout ? "Request timed out" : "Network request failed",
|
|
695
|
+
{ isNetworkError: !isTimeout, isTimeout, attempt: item.attempts, cause }
|
|
696
|
+
);
|
|
697
|
+
} finally {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
this.inFlight.delete(item.id);
|
|
700
|
+
}
|
|
701
|
+
const attempts = item.attempts + 1;
|
|
702
|
+
const willRetry = attempts <= retryConfig.maxRetries && retryOn(requestError, item.attempts);
|
|
703
|
+
const rawDelay = (_j = requestError.retryAfterMs) != null ? _j : computeBackoffDelay(item.attempts, retryConfig);
|
|
704
|
+
const nextAttemptAt = willRetry ? Date.now() + Math.min(rawDelay, retryConfig.maxDelayMs) : sendingItem.nextAttemptAt;
|
|
705
|
+
const resultItem = {
|
|
706
|
+
...sendingItem,
|
|
707
|
+
attempts,
|
|
708
|
+
status: willRetry ? "pending" : "failed",
|
|
709
|
+
nextAttemptAt,
|
|
710
|
+
lastError: requestError.message,
|
|
711
|
+
updatedAt: Date.now()
|
|
712
|
+
};
|
|
713
|
+
await this.opts.queue.update(resultItem);
|
|
714
|
+
(_l = (_k = this.opts).onEvent) == null ? void 0 : _l.call(_k, { type: "item-failed", item: resultItem, willRetry });
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// src/network/client.ts
|
|
720
|
+
var DEFAULT_MAX_QUEUE_ITEM_BYTES = 5 * 1024 * 1024;
|
|
721
|
+
var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
722
|
+
function defaultShouldQueueOffline({ method }) {
|
|
723
|
+
return MUTATING_METHODS.has(method);
|
|
724
|
+
}
|
|
725
|
+
function normalizeHeaders(headers) {
|
|
726
|
+
if (!headers) return void 0;
|
|
727
|
+
if (headers instanceof Headers) {
|
|
728
|
+
const out = {};
|
|
729
|
+
headers.forEach((value, key) => {
|
|
730
|
+
out[key] = value;
|
|
731
|
+
});
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
|
735
|
+
return { ...headers };
|
|
736
|
+
}
|
|
737
|
+
function normalizeQueueableBody(body) {
|
|
738
|
+
if (body == null) return null;
|
|
739
|
+
if (typeof body === "string" || body instanceof Blob) return body;
|
|
740
|
+
throw new TypeError(
|
|
741
|
+
"lowdata: only string or Blob request bodies can be queued for offline delivery. Serialize your payload (e.g. JSON.stringify) or pass a Blob/File before queuing."
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
function bodySizeBytes(body) {
|
|
745
|
+
if (body === null) return 0;
|
|
746
|
+
return typeof body === "string" ? new Blob([body]).size : body.size;
|
|
747
|
+
}
|
|
748
|
+
var LowdataClient = class {
|
|
749
|
+
constructor(config = {}) {
|
|
750
|
+
this.config = config;
|
|
751
|
+
this.syncEmitter = new Emitter();
|
|
752
|
+
this.destroyed = false;
|
|
753
|
+
this.monitor = new ConnectionMonitor(config.connection);
|
|
754
|
+
this.requestQueue = new RequestQueue(() => getSharedDb());
|
|
755
|
+
this.syncManager = new SyncManager({
|
|
756
|
+
queue: this.requestQueue,
|
|
757
|
+
connection: this.monitor,
|
|
758
|
+
getDb: () => getSharedDb(),
|
|
759
|
+
retryConfig: config.retry,
|
|
760
|
+
syncConcurrency: config.syncConcurrency,
|
|
761
|
+
onEvent: (event) => this.syncEmitter.emit(event)
|
|
762
|
+
});
|
|
763
|
+
this.connection = {
|
|
764
|
+
getStatus: () => this.monitor.getStatus(),
|
|
765
|
+
subscribe: (listener) => this.monitor.subscribe(listener)
|
|
766
|
+
};
|
|
767
|
+
this.queue = {
|
|
768
|
+
add: (item) => this.enqueue(item),
|
|
769
|
+
cancel: (id) => this.cancelQueued(id),
|
|
770
|
+
list: (filter) => this.requestQueue.list(filter),
|
|
771
|
+
clear: () => this.requestQueue.clear()
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
onSync(listener) {
|
|
775
|
+
return this.syncEmitter.subscribe(listener);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Drop-in `fetch()` wrapper: retries transient failures with backoff, and — for mutating
|
|
779
|
+
* requests that still can't get through — falls back to the persistent offline queue instead
|
|
780
|
+
* of losing the request. Returns a `QueuedResult` (check with `isQueued()`) when queued.
|
|
781
|
+
*/
|
|
782
|
+
async fetch(url, init = {}) {
|
|
783
|
+
var _a, _b, _c, _d;
|
|
784
|
+
if (this.destroyed) throw new Error("lowdata: this client has been destroyed");
|
|
785
|
+
const fullUrl = this.resolveUrl(url);
|
|
786
|
+
const method = ((_a = init.method) != null ? _a : "GET").toUpperCase();
|
|
787
|
+
const shouldQueueOffline = (_b = this.config.shouldQueueOffline) != null ? _b : defaultShouldQueueOffline;
|
|
788
|
+
const canQueue = shouldQueueOffline({ url: fullUrl, method });
|
|
789
|
+
const isOffline = this.monitor.getStatus().quality === "offline";
|
|
790
|
+
if ((init.forceQueue || isOffline) && canQueue) {
|
|
791
|
+
return this.enqueueFromInit(fullUrl, method, init);
|
|
792
|
+
}
|
|
793
|
+
if (init.forceQueue || isOffline) {
|
|
794
|
+
throw new LowdataRequestError("Offline and this request is not configured to be queued", {
|
|
795
|
+
isNetworkError: true,
|
|
796
|
+
attempt: 0
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
return await attemptWithRetry({
|
|
801
|
+
url: fullUrl,
|
|
802
|
+
init: {
|
|
803
|
+
...init,
|
|
804
|
+
headers: { ...this.config.defaultHeaders, ...normalizeHeaders(init.headers) }
|
|
805
|
+
},
|
|
806
|
+
retryConfig: { ...this.config.retry, ...init.retry },
|
|
807
|
+
timeoutMs: init.timeoutMs,
|
|
808
|
+
signal: (_c = init.signal) != null ? _c : void 0,
|
|
809
|
+
shouldContinue: () => this.monitor.getStatus().quality !== "offline"
|
|
810
|
+
});
|
|
811
|
+
} catch (err) {
|
|
812
|
+
if ((_d = init.signal) == null ? void 0 : _d.aborted) throw err;
|
|
813
|
+
if (canQueue) {
|
|
814
|
+
return this.enqueueFromInit(fullUrl, method, init);
|
|
815
|
+
}
|
|
816
|
+
throw err;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
destroy() {
|
|
820
|
+
if (this.destroyed) return;
|
|
821
|
+
this.destroyed = true;
|
|
822
|
+
this.syncManager.destroy();
|
|
823
|
+
this.monitor.destroy();
|
|
824
|
+
this.syncEmitter.clear();
|
|
825
|
+
}
|
|
826
|
+
resolveUrl(url) {
|
|
827
|
+
if (!this.config.baseUrl || /^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return url;
|
|
828
|
+
return `${this.config.baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
|
|
829
|
+
}
|
|
830
|
+
maxQueueItemBytes() {
|
|
831
|
+
var _a;
|
|
832
|
+
return (_a = this.config.maxQueueItemSizeBytes) != null ? _a : DEFAULT_MAX_QUEUE_ITEM_BYTES;
|
|
833
|
+
}
|
|
834
|
+
assertWithinSizeBudget(body) {
|
|
835
|
+
const size = bodySizeBytes(body);
|
|
836
|
+
const max = this.maxQueueItemBytes();
|
|
837
|
+
if (size > max) {
|
|
838
|
+
throw new RangeError(
|
|
839
|
+
`lowdata: request body (${size} bytes) exceeds maxQueueItemSizeBytes (${max} bytes).`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Shared by `enqueueFromInit` (the `fetch()` fallback path) and `enqueue` (the direct
|
|
845
|
+
* `queue.add()` path) — the only difference between the two call sites is where the fields come
|
|
846
|
+
* from, not how a queue item gets constructed, persisted, and announced to the sync manager.
|
|
847
|
+
*/
|
|
848
|
+
async persistNewQueueItem(fields) {
|
|
849
|
+
const body = normalizeQueueableBody(fields.body);
|
|
850
|
+
this.assertWithinSizeBudget(body);
|
|
851
|
+
const now = Date.now();
|
|
852
|
+
const item = {
|
|
853
|
+
...fields,
|
|
854
|
+
body,
|
|
855
|
+
id: createId(),
|
|
856
|
+
createdAt: now,
|
|
857
|
+
updatedAt: now,
|
|
858
|
+
attempts: 0,
|
|
859
|
+
status: "pending",
|
|
860
|
+
nextAttemptAt: now
|
|
861
|
+
};
|
|
862
|
+
const saved = await this.requestQueue.add(item);
|
|
863
|
+
this.syncManager.notifyEnqueued();
|
|
864
|
+
return saved;
|
|
865
|
+
}
|
|
866
|
+
async enqueueFromInit(url, method, init) {
|
|
867
|
+
var _a;
|
|
868
|
+
const saved = await this.persistNewQueueItem({
|
|
869
|
+
url,
|
|
870
|
+
method,
|
|
871
|
+
headers: { ...this.config.defaultHeaders, ...normalizeHeaders(init.headers) },
|
|
872
|
+
body: init.body,
|
|
873
|
+
priority: (_a = init.priority) != null ? _a : "normal",
|
|
874
|
+
meta: init.meta,
|
|
875
|
+
timeoutMs: init.timeoutMs,
|
|
876
|
+
retry: init.retry,
|
|
877
|
+
idempotencyKey: init.idempotencyKey
|
|
878
|
+
});
|
|
879
|
+
return { queued: true, id: saved.id, item: saved };
|
|
880
|
+
}
|
|
881
|
+
async enqueue(partial) {
|
|
882
|
+
return this.persistNewQueueItem(partial);
|
|
883
|
+
}
|
|
884
|
+
async cancelQueued(id) {
|
|
885
|
+
this.syncManager.cancel(id);
|
|
886
|
+
const item = await this.requestQueue.get(id);
|
|
887
|
+
if (item && item.status !== "done" && item.status !== "cancelled") {
|
|
888
|
+
await this.requestQueue.update({ ...item, status: "cancelled", updatedAt: Date.now() });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
function createLowdataClient(config) {
|
|
893
|
+
return new LowdataClient(config);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/network/types.ts
|
|
897
|
+
function isQueued(result) {
|
|
898
|
+
return typeof result === "object" && result !== null && result.queued === true;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/forms/storage.ts
|
|
902
|
+
var STORE2 = "formDrafts";
|
|
903
|
+
function draftKey(formId) {
|
|
904
|
+
return `draft:${formId}`;
|
|
905
|
+
}
|
|
906
|
+
var memory = /* @__PURE__ */ new Map();
|
|
907
|
+
var accessor = createDbFallbackAccessor(getSharedDb);
|
|
908
|
+
async function saveDraft(formId, values) {
|
|
909
|
+
var _a;
|
|
910
|
+
const key = draftKey(formId);
|
|
911
|
+
const now = Date.now();
|
|
912
|
+
const existing = await getSubmission(key);
|
|
913
|
+
const record = {
|
|
914
|
+
submissionId: key,
|
|
915
|
+
formId,
|
|
916
|
+
kind: "draft",
|
|
917
|
+
values,
|
|
918
|
+
status: "saved",
|
|
919
|
+
createdAt: (_a = existing == null ? void 0 : existing.createdAt) != null ? _a : now,
|
|
920
|
+
updatedAt: now
|
|
921
|
+
};
|
|
922
|
+
await saveSubmission(record);
|
|
923
|
+
return record;
|
|
924
|
+
}
|
|
925
|
+
async function loadDraft(formId) {
|
|
926
|
+
return getSubmission(draftKey(formId));
|
|
927
|
+
}
|
|
928
|
+
async function discardDraft(formId) {
|
|
929
|
+
const key = draftKey(formId);
|
|
930
|
+
await accessor.run(
|
|
931
|
+
(db) => idbDelete(db, STORE2, key),
|
|
932
|
+
() => {
|
|
933
|
+
memory.delete(key);
|
|
934
|
+
}
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
async function saveSubmission(record) {
|
|
938
|
+
await accessor.run(
|
|
939
|
+
(db) => idbPut(db, STORE2, record),
|
|
940
|
+
() => {
|
|
941
|
+
memory.set(record.submissionId, record);
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
async function getSubmission(submissionId) {
|
|
946
|
+
return accessor.run(
|
|
947
|
+
(db) => idbGet(db, STORE2, submissionId),
|
|
948
|
+
() => memory.get(submissionId)
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/forms/offlineForm.ts
|
|
953
|
+
var defaultClient;
|
|
954
|
+
function getDefaultClient() {
|
|
955
|
+
if (!defaultClient) defaultClient = createLowdataClient();
|
|
956
|
+
return defaultClient;
|
|
957
|
+
}
|
|
958
|
+
function createOfflineForm(config) {
|
|
959
|
+
var _a;
|
|
960
|
+
const client = (_a = config.client) != null ? _a : getDefaultClient();
|
|
961
|
+
const emitter = new Emitter();
|
|
962
|
+
let status = "idle";
|
|
963
|
+
let lastValues;
|
|
964
|
+
let activeSubmissionId;
|
|
965
|
+
let unsubscribeSync;
|
|
966
|
+
void loadDraft(config.id).then((draft) => {
|
|
967
|
+
if (draft && status === "idle") {
|
|
968
|
+
lastValues = draft.values;
|
|
969
|
+
setStatus("saved");
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
function setStatus(next, detail) {
|
|
973
|
+
var _a2;
|
|
974
|
+
status = next;
|
|
975
|
+
(_a2 = config.onStatusChange) == null ? void 0 : _a2.call(config, next, detail);
|
|
976
|
+
emitter.emit({ status: next, detail });
|
|
977
|
+
}
|
|
978
|
+
async function patchSubmission(submissionId, patch) {
|
|
979
|
+
const existing = await getSubmission(submissionId);
|
|
980
|
+
if (!existing) return;
|
|
981
|
+
await saveSubmission({ ...existing, ...patch, updatedAt: Date.now() });
|
|
982
|
+
}
|
|
983
|
+
async function onSubmissionSettled(submissionId, next, error) {
|
|
984
|
+
await patchSubmission(submissionId, { status: next, lastError: error });
|
|
985
|
+
const isActive = submissionId === activeSubmissionId;
|
|
986
|
+
if (next === "success" && isActive) {
|
|
987
|
+
await discardDraft(config.id);
|
|
988
|
+
}
|
|
989
|
+
if (isActive) {
|
|
990
|
+
setStatus(next, { submissionId, error });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function ensureSyncSubscription() {
|
|
994
|
+
if (unsubscribeSync) return;
|
|
995
|
+
unsubscribeSync = client.onSync((event) => {
|
|
996
|
+
var _a2;
|
|
997
|
+
if (event.type === "sync-start" || event.type === "sync-complete") return;
|
|
998
|
+
const submissionId = (_a2 = event.item.meta) == null ? void 0 : _a2["submissionId"];
|
|
999
|
+
if (typeof submissionId !== "string" || submissionId !== activeSubmissionId) return;
|
|
1000
|
+
if (event.type === "item-start") {
|
|
1001
|
+
void patchSubmission(submissionId, { status: "syncing" });
|
|
1002
|
+
setStatus("syncing", { submissionId });
|
|
1003
|
+
} else if (event.type === "item-success") {
|
|
1004
|
+
void onSubmissionSettled(submissionId, "success");
|
|
1005
|
+
} else if (event.type === "item-failed") {
|
|
1006
|
+
if (event.willRetry) {
|
|
1007
|
+
void patchSubmission(submissionId, {
|
|
1008
|
+
status: "pending",
|
|
1009
|
+
lastError: event.item.lastError
|
|
1010
|
+
});
|
|
1011
|
+
setStatus("pending", { submissionId, error: event.item.lastError });
|
|
1012
|
+
} else {
|
|
1013
|
+
void onSubmissionSettled(submissionId, "failed", event.item.lastError);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
async function save(values) {
|
|
1019
|
+
lastValues = values;
|
|
1020
|
+
await saveDraft(config.id, values);
|
|
1021
|
+
setStatus("saved");
|
|
1022
|
+
}
|
|
1023
|
+
async function submit(values) {
|
|
1024
|
+
var _a2;
|
|
1025
|
+
await save(values);
|
|
1026
|
+
const submissionId = createId();
|
|
1027
|
+
activeSubmissionId = submissionId;
|
|
1028
|
+
const payload = config.transform ? config.transform(values) : JSON.stringify(values);
|
|
1029
|
+
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
1030
|
+
await saveSubmission({
|
|
1031
|
+
submissionId,
|
|
1032
|
+
formId: config.id,
|
|
1033
|
+
kind: "submission",
|
|
1034
|
+
values,
|
|
1035
|
+
status: "pending",
|
|
1036
|
+
createdAt: Date.now(),
|
|
1037
|
+
updatedAt: Date.now()
|
|
1038
|
+
});
|
|
1039
|
+
setStatus("pending", { submissionId });
|
|
1040
|
+
ensureSyncSubscription();
|
|
1041
|
+
const result = await client.fetch(config.endpoint, {
|
|
1042
|
+
method: (_a2 = config.method) != null ? _a2 : "POST",
|
|
1043
|
+
headers: { "Content-Type": "application/json" },
|
|
1044
|
+
body,
|
|
1045
|
+
meta: { formId: config.id, submissionId },
|
|
1046
|
+
idempotencyKey: submissionId
|
|
1047
|
+
});
|
|
1048
|
+
if (isQueued(result)) {
|
|
1049
|
+
await patchSubmission(submissionId, { status: "pending", queueId: result.id });
|
|
1050
|
+
setStatus("pending", { submissionId });
|
|
1051
|
+
} else if (result.ok) {
|
|
1052
|
+
await onSubmissionSettled(submissionId, "success");
|
|
1053
|
+
} else {
|
|
1054
|
+
await onSubmissionSettled(
|
|
1055
|
+
submissionId,
|
|
1056
|
+
"failed",
|
|
1057
|
+
`Request failed with status ${result.status}`
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
return { status };
|
|
1061
|
+
}
|
|
1062
|
+
async function retry() {
|
|
1063
|
+
if (!lastValues) return;
|
|
1064
|
+
await submit(lastValues);
|
|
1065
|
+
}
|
|
1066
|
+
function destroy() {
|
|
1067
|
+
unsubscribeSync == null ? void 0 : unsubscribeSync();
|
|
1068
|
+
unsubscribeSync = void 0;
|
|
1069
|
+
}
|
|
1070
|
+
async function discard() {
|
|
1071
|
+
await discardDraft(config.id);
|
|
1072
|
+
lastValues = void 0;
|
|
1073
|
+
activeSubmissionId = void 0;
|
|
1074
|
+
destroy();
|
|
1075
|
+
setStatus("idle");
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
save,
|
|
1079
|
+
submit,
|
|
1080
|
+
retry,
|
|
1081
|
+
getStatus: () => status,
|
|
1082
|
+
subscribe: (callback) => emitter.subscribe(({ status: s, detail }) => callback(s, detail)),
|
|
1083
|
+
discard,
|
|
1084
|
+
destroy
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
exports.createOfflineForm = createOfflineForm;
|
|
1089
|
+
//# sourceMappingURL=forms.cjs.map
|
|
1090
|
+
//# sourceMappingURL=forms.cjs.map
|