@mushi-mushi/core 1.27.0 → 1.27.2
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 +1 -1
- package/dist/index.cjs +151 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +151 -68
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -109,6 +109,7 @@ var DEFAULT_TIMEOUT = 1e4;
|
|
|
109
109
|
var DEFAULT_MAX_RETRIES = 2;
|
|
110
110
|
var DEFAULT_CIRCUIT_THRESHOLD = 4;
|
|
111
111
|
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
112
|
+
var MAX_INLINE_RETRY_WAIT_MS = 1e4;
|
|
112
113
|
function createApiClient(options) {
|
|
113
114
|
const {
|
|
114
115
|
projectId,
|
|
@@ -183,11 +184,15 @@ function createApiClient(options) {
|
|
|
183
184
|
`[Mushi] Credentials rejected (HTTP ${response.status}). Check your Project ID and API key scope (must be "report:write"). ` + where
|
|
184
185
|
);
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
const rateLimited = response.status === 429;
|
|
188
|
+
const unusable = response.status >= 500 || rateLimited;
|
|
189
|
+
const retryAfterMs = rateLimited ? parseRetryAfter(response.headers.get("Retry-After")) : null;
|
|
190
|
+
const canWaitInline = retryAfterMs === null || retryAfterMs <= MAX_INLINE_RETRY_WAIT_MS;
|
|
191
|
+
if (unusable && retries > 0 && canWaitInline) {
|
|
192
|
+
await sleep(retryAfterMs ?? getBackoffDelay(maxRetries - retries));
|
|
188
193
|
return request(method, path, body, retries - 1, internalKind, extraHeaders);
|
|
189
194
|
}
|
|
190
|
-
if (
|
|
195
|
+
if (unusable) cbRecordUnreachable();
|
|
191
196
|
else cbRecordReachable();
|
|
192
197
|
return {
|
|
193
198
|
ok: false,
|
|
@@ -205,7 +210,7 @@ function createApiClient(options) {
|
|
|
205
210
|
clearTimeout(timer);
|
|
206
211
|
if (retries > 0 && isRetryable(error)) {
|
|
207
212
|
await sleep(getBackoffDelay(maxRetries - retries));
|
|
208
|
-
return request(method, path, body, retries - 1, internalKind);
|
|
213
|
+
return request(method, path, body, retries - 1, internalKind, extraHeaders);
|
|
209
214
|
}
|
|
210
215
|
cbRecordUnreachable();
|
|
211
216
|
return {
|
|
@@ -495,6 +500,16 @@ function sleep(ms) {
|
|
|
495
500
|
function getBackoffDelay(attempt) {
|
|
496
501
|
return Math.min(1e3 * 2 ** attempt + Math.random() * 500, 1e4);
|
|
497
502
|
}
|
|
503
|
+
function parseRetryAfter(value, now2 = Date.now()) {
|
|
504
|
+
if (!value) return null;
|
|
505
|
+
const raw = value.trim();
|
|
506
|
+
if (!raw) return null;
|
|
507
|
+
const seconds = Number(raw);
|
|
508
|
+
if (Number.isFinite(seconds)) return seconds < 0 ? null : seconds * 1e3;
|
|
509
|
+
const at = Date.parse(raw);
|
|
510
|
+
if (Number.isNaN(at)) return null;
|
|
511
|
+
return Math.max(at - now2, 0);
|
|
512
|
+
}
|
|
498
513
|
function isRetryable(error) {
|
|
499
514
|
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
500
515
|
if (error instanceof TypeError) return true;
|
|
@@ -1054,12 +1069,27 @@ function openKeyDb() {
|
|
|
1054
1069
|
return new Promise((resolve, reject) => {
|
|
1055
1070
|
const req = indexedDB.open(KEY_DB, 1);
|
|
1056
1071
|
req.onupgradeneeded = () => {
|
|
1072
|
+
try {
|
|
1073
|
+
const db = req.result;
|
|
1074
|
+
if (!db) return;
|
|
1075
|
+
if (!db.objectStoreNames.contains(KEY_STORE)) {
|
|
1076
|
+
db.createObjectStore(KEY_STORE);
|
|
1077
|
+
}
|
|
1078
|
+
} catch {
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
req.onsuccess = () => {
|
|
1057
1082
|
const db = req.result;
|
|
1058
|
-
if (!db.objectStoreNames.contains(KEY_STORE)) {
|
|
1059
|
-
|
|
1083
|
+
if (!db || !db.objectStoreNames.contains(KEY_STORE)) {
|
|
1084
|
+
try {
|
|
1085
|
+
db?.close();
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
reject(new Error("key object store unavailable"));
|
|
1089
|
+
return;
|
|
1060
1090
|
}
|
|
1091
|
+
resolve(db);
|
|
1061
1092
|
};
|
|
1062
|
-
req.onsuccess = () => resolve(req.result);
|
|
1063
1093
|
req.onerror = () => reject(req.error);
|
|
1064
1094
|
});
|
|
1065
1095
|
}
|
|
@@ -1149,6 +1179,8 @@ function createOfflineQueue(config = {}) {
|
|
|
1149
1179
|
let syncCleanup = null;
|
|
1150
1180
|
let flushInterval = null;
|
|
1151
1181
|
let backendType = null;
|
|
1182
|
+
let inFlight = null;
|
|
1183
|
+
let followUp = null;
|
|
1152
1184
|
async function wrapForStorage(report) {
|
|
1153
1185
|
const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1154
1186
|
if (!encryptAtRest) {
|
|
@@ -1202,12 +1234,28 @@ function createOfflineQueue(config = {}) {
|
|
|
1202
1234
|
return new Promise((resolve, reject) => {
|
|
1203
1235
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
1204
1236
|
request.onupgradeneeded = () => {
|
|
1237
|
+
try {
|
|
1238
|
+
const db = request.result;
|
|
1239
|
+
if (!db) return;
|
|
1240
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
1241
|
+
db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
|
1242
|
+
}
|
|
1243
|
+
} catch {
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
request.onsuccess = () => {
|
|
1205
1247
|
const db = request.result;
|
|
1206
|
-
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
1207
|
-
|
|
1248
|
+
if (!db || !db.objectStoreNames.contains(STORE_NAME)) {
|
|
1249
|
+
backendType = "localstorage";
|
|
1250
|
+
try {
|
|
1251
|
+
db?.close();
|
|
1252
|
+
} catch {
|
|
1253
|
+
}
|
|
1254
|
+
reject(new Error("queue object store unavailable"));
|
|
1255
|
+
return;
|
|
1208
1256
|
}
|
|
1257
|
+
resolve(db);
|
|
1209
1258
|
};
|
|
1210
|
-
request.onsuccess = () => resolve(request.result);
|
|
1211
1259
|
request.onerror = () => {
|
|
1212
1260
|
backendType = "localstorage";
|
|
1213
1261
|
reject(request.error);
|
|
@@ -1285,7 +1333,10 @@ function createOfflineQueue(config = {}) {
|
|
|
1285
1333
|
}
|
|
1286
1334
|
async function lsEnqueue(report) {
|
|
1287
1335
|
const rows = lsRead();
|
|
1288
|
-
|
|
1336
|
+
const row = await wrapForStorage(report);
|
|
1337
|
+
const idx = rows.findIndex((r) => r.id === report.id);
|
|
1338
|
+
if (idx >= 0) rows[idx] = row;
|
|
1339
|
+
else rows.push(row);
|
|
1289
1340
|
lsWrite(rows);
|
|
1290
1341
|
}
|
|
1291
1342
|
function lsDelete(id) {
|
|
@@ -1324,26 +1375,43 @@ function createOfflineQueue(config = {}) {
|
|
|
1324
1375
|
return false;
|
|
1325
1376
|
}
|
|
1326
1377
|
}
|
|
1327
|
-
async function
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
if (
|
|
1331
|
-
|
|
1332
|
-
|
|
1378
|
+
async function readRows() {
|
|
1379
|
+
const backend = detectBackend();
|
|
1380
|
+
if (backend === "none") return { backend, rows: [] };
|
|
1381
|
+
if (backend === "indexeddb") {
|
|
1382
|
+
try {
|
|
1383
|
+
return { backend, rows: await idbGetAll() };
|
|
1384
|
+
} catch {
|
|
1385
|
+
backendType = "localstorage";
|
|
1386
|
+
}
|
|
1333
1387
|
}
|
|
1388
|
+
return { backend: "localstorage", rows: lsRead() };
|
|
1389
|
+
}
|
|
1390
|
+
async function writeRow(report) {
|
|
1334
1391
|
const backend = detectBackend();
|
|
1392
|
+
if (backend === "none") {
|
|
1393
|
+
queueLog.debug("Offline queue: no storage backend, dropping report", { id: report.id });
|
|
1394
|
+
return backend;
|
|
1395
|
+
}
|
|
1335
1396
|
if (backend === "indexeddb") {
|
|
1336
1397
|
try {
|
|
1337
1398
|
await idbEnqueue(report);
|
|
1338
|
-
return;
|
|
1399
|
+
return backend;
|
|
1339
1400
|
} catch {
|
|
1340
1401
|
backendType = "localstorage";
|
|
1341
1402
|
}
|
|
1342
1403
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1404
|
+
await lsEnqueue(report);
|
|
1405
|
+
return "localstorage";
|
|
1406
|
+
}
|
|
1407
|
+
async function enqueue(report) {
|
|
1408
|
+
if (!enabled) return;
|
|
1409
|
+
const currentSize = await size();
|
|
1410
|
+
if (currentSize >= maxQueueSize) {
|
|
1411
|
+
queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
|
|
1345
1412
|
return;
|
|
1346
1413
|
}
|
|
1414
|
+
await writeRow(report);
|
|
1347
1415
|
}
|
|
1348
1416
|
function getBackoffDelay2(attempt) {
|
|
1349
1417
|
return Math.min(1e3 * 2 ** attempt + Math.random() * 500, MAX_BACKOFF_MS);
|
|
@@ -1351,19 +1419,9 @@ function createOfflineQueue(config = {}) {
|
|
|
1351
1419
|
function sleep2(ms) {
|
|
1352
1420
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1353
1421
|
}
|
|
1354
|
-
async function
|
|
1355
|
-
|
|
1356
|
-
let rows;
|
|
1357
|
-
const backend = detectBackend();
|
|
1358
|
-
if (backend === "indexeddb") {
|
|
1359
|
-
try {
|
|
1360
|
-
rows = await idbGetAll();
|
|
1361
|
-
} catch {
|
|
1362
|
-
rows = lsRead();
|
|
1363
|
-
}
|
|
1364
|
-
} else {
|
|
1365
|
-
rows = lsRead();
|
|
1366
|
-
}
|
|
1422
|
+
async function runFlush(client) {
|
|
1423
|
+
const { backend, rows: storedRows } = await readRows();
|
|
1424
|
+
let rows = storedRows;
|
|
1367
1425
|
const now2 = Date.now();
|
|
1368
1426
|
const fresh = [];
|
|
1369
1427
|
for (const row of rows) {
|
|
@@ -1392,59 +1450,84 @@ function createOfflineQueue(config = {}) {
|
|
|
1392
1450
|
if (result.ok) {
|
|
1393
1451
|
await removeRow(backend, rowId);
|
|
1394
1452
|
sent++;
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
failed++;
|
|
1456
|
+
if (result.error?.code === "CIRCUIT_OPEN") {
|
|
1457
|
+
queueLog.debug("Offline queue: circuit open, deferring the rest of the batch", {
|
|
1458
|
+
id: rowId,
|
|
1459
|
+
remaining: batch.length - i
|
|
1460
|
+
});
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
const permanent = result.error?.code === "HTTP_400" || result.error?.code === "HTTP_413" || result.error?.code === "HTTP_422" || result.error?.code === "INGEST_ERROR" || result.error?.code === "VALIDATION_ERROR" || // A payload that exceeds the size guard will never shrink on its own;
|
|
1464
|
+
// retrying re-serialises the multi-MB body every sync tick and wedges
|
|
1465
|
+
// the queue (it matches neither permanent nor transient otherwise).
|
|
1466
|
+
// SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
|
|
1467
|
+
result.error?.code === "PAYLOAD_TOO_LARGE" || result.error?.code === "SERIALIZE_FAILED" || typeof result.error?.message === "string" && /invalid payload|description must be at least|validation/i.test(
|
|
1468
|
+
result.error.message
|
|
1469
|
+
);
|
|
1470
|
+
const transient = !permanent && (result.error?.code === "NETWORK_ERROR" || result.error?.code === "HTTP_403" || result.error?.code === "HTTP_429" || result.error?.code === "HTTP_502" || result.error?.code === "HTTP_503" || result.error?.code === "HTTP_504" || typeof result.error?.code === "string" && result.error.code.startsWith("HTTP_5"));
|
|
1471
|
+
if (permanent) {
|
|
1472
|
+
await removeRow(backend, rowId);
|
|
1395
1473
|
} else {
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1398
|
-
// the queue (it matches neither permanent nor transient otherwise).
|
|
1399
|
-
// SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
|
|
1400
|
-
result.error?.code === "PAYLOAD_TOO_LARGE" || result.error?.code === "SERIALIZE_FAILED" || typeof result.error?.message === "string" && /invalid payload|description must be at least|validation/i.test(
|
|
1401
|
-
result.error.message
|
|
1402
|
-
);
|
|
1403
|
-
const transient = !permanent && (result.error?.code === "NETWORK_ERROR" || result.error?.code === "HTTP_403" || result.error?.code === "HTTP_429" || result.error?.code === "HTTP_502" || result.error?.code === "HTTP_503" || result.error?.code === "HTTP_504" || typeof result.error?.code === "string" && result.error.code.startsWith("HTTP_5"));
|
|
1404
|
-
if (permanent) {
|
|
1474
|
+
const nextAttempts = rowAttempts(row) + 1;
|
|
1475
|
+
if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
1405
1476
|
await removeRow(backend, rowId);
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1477
|
+
queueLog.warn("Offline queue: giving up on report after repeated failures", {
|
|
1478
|
+
id: rowId,
|
|
1479
|
+
attempts: nextAttempts,
|
|
1480
|
+
code: result.error?.code
|
|
1481
|
+
});
|
|
1482
|
+
} else {
|
|
1483
|
+
row.attempts = nextAttempts;
|
|
1484
|
+
const persisted = await persistRow(backend, row);
|
|
1485
|
+
if (persisted) {
|
|
1486
|
+
queueLog.debug("Offline queue: delivery failed, will retry", {
|
|
1411
1487
|
id: rowId,
|
|
1412
1488
|
attempts: nextAttempts,
|
|
1413
|
-
code: result.error?.code
|
|
1489
|
+
code: result.error?.code,
|
|
1490
|
+
classified: transient ? "transient" : "unclassified"
|
|
1414
1491
|
});
|
|
1415
1492
|
} else {
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
attempts: nextAttempts,
|
|
1422
|
-
code: result.error?.code
|
|
1423
|
-
});
|
|
1424
|
-
} else {
|
|
1425
|
-
await removeRow(backend, rowId);
|
|
1426
|
-
queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
|
|
1427
|
-
id: rowId,
|
|
1428
|
-
code: result.error?.code
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1493
|
+
await removeRow(backend, rowId);
|
|
1494
|
+
queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
|
|
1495
|
+
id: rowId,
|
|
1496
|
+
code: result.error?.code
|
|
1497
|
+
});
|
|
1431
1498
|
}
|
|
1432
1499
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
}
|
|
1500
|
+
}
|
|
1501
|
+
if (i < batch.length - 1) {
|
|
1502
|
+
await sleep2(getBackoffDelay2(i));
|
|
1437
1503
|
}
|
|
1438
1504
|
}
|
|
1439
1505
|
return { sent, failed };
|
|
1440
1506
|
}
|
|
1507
|
+
function flush(client) {
|
|
1508
|
+
if (!enabled) return Promise.resolve({ sent: 0, failed: 0 });
|
|
1509
|
+
if (inFlight) {
|
|
1510
|
+
if (!followUp) {
|
|
1511
|
+
followUp = inFlight.catch(() => void 0).then(() => {
|
|
1512
|
+
followUp = null;
|
|
1513
|
+
return flush(client);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
return followUp;
|
|
1517
|
+
}
|
|
1518
|
+
const pass = runFlush(client).finally(() => {
|
|
1519
|
+
if (inFlight === pass) inFlight = null;
|
|
1520
|
+
});
|
|
1521
|
+
inFlight = pass;
|
|
1522
|
+
return pass;
|
|
1523
|
+
}
|
|
1441
1524
|
async function size() {
|
|
1442
1525
|
const backend = detectBackend();
|
|
1443
1526
|
if (backend === "indexeddb") {
|
|
1444
1527
|
try {
|
|
1445
1528
|
return await idbSize();
|
|
1446
1529
|
} catch {
|
|
1447
|
-
|
|
1530
|
+
backendType = "localstorage";
|
|
1448
1531
|
}
|
|
1449
1532
|
}
|
|
1450
1533
|
return lsRead().length;
|