@mushi-mushi/core 1.27.1 → 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 +114 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +114 -62
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -138,4 +138,4 @@ MIT
|
|
|
138
138
|
<!-- mushi-readme-stats-footer -->
|
|
139
139
|
---
|
|
140
140
|
|
|
141
|
-
<sub>Monorepo scale (July 2026): 55 edge functions ·
|
|
141
|
+
<sub>Monorepo scale (July 2026): 55 edge functions · 336 SQL migrations · 13 outbound plugins · 11 inbound adapters · 19 pipeline agents. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
|
package/dist/index.cjs
CHANGED
|
@@ -111,6 +111,7 @@ var DEFAULT_TIMEOUT = 1e4;
|
|
|
111
111
|
var DEFAULT_MAX_RETRIES = 2;
|
|
112
112
|
var DEFAULT_CIRCUIT_THRESHOLD = 4;
|
|
113
113
|
var DEFAULT_CIRCUIT_COOLDOWN_MS = 3e4;
|
|
114
|
+
var MAX_INLINE_RETRY_WAIT_MS = 1e4;
|
|
114
115
|
function createApiClient(options) {
|
|
115
116
|
const {
|
|
116
117
|
projectId,
|
|
@@ -185,11 +186,15 @@ function createApiClient(options) {
|
|
|
185
186
|
`[Mushi] Credentials rejected (HTTP ${response.status}). Check your Project ID and API key scope (must be "report:write"). ` + where
|
|
186
187
|
);
|
|
187
188
|
}
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
const rateLimited = response.status === 429;
|
|
190
|
+
const unusable = response.status >= 500 || rateLimited;
|
|
191
|
+
const retryAfterMs = rateLimited ? parseRetryAfter(response.headers.get("Retry-After")) : null;
|
|
192
|
+
const canWaitInline = retryAfterMs === null || retryAfterMs <= MAX_INLINE_RETRY_WAIT_MS;
|
|
193
|
+
if (unusable && retries > 0 && canWaitInline) {
|
|
194
|
+
await sleep(retryAfterMs ?? getBackoffDelay(maxRetries - retries));
|
|
190
195
|
return request(method, path, body, retries - 1, internalKind, extraHeaders);
|
|
191
196
|
}
|
|
192
|
-
if (
|
|
197
|
+
if (unusable) cbRecordUnreachable();
|
|
193
198
|
else cbRecordReachable();
|
|
194
199
|
return {
|
|
195
200
|
ok: false,
|
|
@@ -207,7 +212,7 @@ function createApiClient(options) {
|
|
|
207
212
|
clearTimeout(timer);
|
|
208
213
|
if (retries > 0 && isRetryable(error)) {
|
|
209
214
|
await sleep(getBackoffDelay(maxRetries - retries));
|
|
210
|
-
return request(method, path, body, retries - 1, internalKind);
|
|
215
|
+
return request(method, path, body, retries - 1, internalKind, extraHeaders);
|
|
211
216
|
}
|
|
212
217
|
cbRecordUnreachable();
|
|
213
218
|
return {
|
|
@@ -497,6 +502,16 @@ function sleep(ms) {
|
|
|
497
502
|
function getBackoffDelay(attempt) {
|
|
498
503
|
return Math.min(1e3 * 2 ** attempt + Math.random() * 500, 1e4);
|
|
499
504
|
}
|
|
505
|
+
function parseRetryAfter(value, now2 = Date.now()) {
|
|
506
|
+
if (!value) return null;
|
|
507
|
+
const raw = value.trim();
|
|
508
|
+
if (!raw) return null;
|
|
509
|
+
const seconds = Number(raw);
|
|
510
|
+
if (Number.isFinite(seconds)) return seconds < 0 ? null : seconds * 1e3;
|
|
511
|
+
const at = Date.parse(raw);
|
|
512
|
+
if (Number.isNaN(at)) return null;
|
|
513
|
+
return Math.max(at - now2, 0);
|
|
514
|
+
}
|
|
500
515
|
function isRetryable(error) {
|
|
501
516
|
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
502
517
|
if (error instanceof TypeError) return true;
|
|
@@ -1166,6 +1181,8 @@ function createOfflineQueue(config = {}) {
|
|
|
1166
1181
|
let syncCleanup = null;
|
|
1167
1182
|
let flushInterval = null;
|
|
1168
1183
|
let backendType = null;
|
|
1184
|
+
let inFlight = null;
|
|
1185
|
+
let followUp = null;
|
|
1169
1186
|
async function wrapForStorage(report) {
|
|
1170
1187
|
const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1171
1188
|
if (!encryptAtRest) {
|
|
@@ -1318,7 +1335,10 @@ function createOfflineQueue(config = {}) {
|
|
|
1318
1335
|
}
|
|
1319
1336
|
async function lsEnqueue(report) {
|
|
1320
1337
|
const rows = lsRead();
|
|
1321
|
-
|
|
1338
|
+
const row = await wrapForStorage(report);
|
|
1339
|
+
const idx = rows.findIndex((r) => r.id === report.id);
|
|
1340
|
+
if (idx >= 0) rows[idx] = row;
|
|
1341
|
+
else rows.push(row);
|
|
1322
1342
|
lsWrite(rows);
|
|
1323
1343
|
}
|
|
1324
1344
|
function lsDelete(id) {
|
|
@@ -1357,26 +1377,43 @@ function createOfflineQueue(config = {}) {
|
|
|
1357
1377
|
return false;
|
|
1358
1378
|
}
|
|
1359
1379
|
}
|
|
1360
|
-
async function
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
if (
|
|
1364
|
-
|
|
1365
|
-
|
|
1380
|
+
async function readRows() {
|
|
1381
|
+
const backend = detectBackend();
|
|
1382
|
+
if (backend === "none") return { backend, rows: [] };
|
|
1383
|
+
if (backend === "indexeddb") {
|
|
1384
|
+
try {
|
|
1385
|
+
return { backend, rows: await idbGetAll() };
|
|
1386
|
+
} catch {
|
|
1387
|
+
backendType = "localstorage";
|
|
1388
|
+
}
|
|
1366
1389
|
}
|
|
1390
|
+
return { backend: "localstorage", rows: lsRead() };
|
|
1391
|
+
}
|
|
1392
|
+
async function writeRow(report) {
|
|
1367
1393
|
const backend = detectBackend();
|
|
1394
|
+
if (backend === "none") {
|
|
1395
|
+
queueLog.debug("Offline queue: no storage backend, dropping report", { id: report.id });
|
|
1396
|
+
return backend;
|
|
1397
|
+
}
|
|
1368
1398
|
if (backend === "indexeddb") {
|
|
1369
1399
|
try {
|
|
1370
1400
|
await idbEnqueue(report);
|
|
1371
|
-
return;
|
|
1401
|
+
return backend;
|
|
1372
1402
|
} catch {
|
|
1373
1403
|
backendType = "localstorage";
|
|
1374
1404
|
}
|
|
1375
1405
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1406
|
+
await lsEnqueue(report);
|
|
1407
|
+
return "localstorage";
|
|
1408
|
+
}
|
|
1409
|
+
async function enqueue(report) {
|
|
1410
|
+
if (!enabled) return;
|
|
1411
|
+
const currentSize = await size();
|
|
1412
|
+
if (currentSize >= maxQueueSize) {
|
|
1413
|
+
queueLog.warn("Offline queue full \u2014 dropping report", { maxQueueSize });
|
|
1378
1414
|
return;
|
|
1379
1415
|
}
|
|
1416
|
+
await writeRow(report);
|
|
1380
1417
|
}
|
|
1381
1418
|
function getBackoffDelay2(attempt) {
|
|
1382
1419
|
return Math.min(1e3 * 2 ** attempt + Math.random() * 500, MAX_BACKOFF_MS);
|
|
@@ -1384,19 +1421,9 @@ function createOfflineQueue(config = {}) {
|
|
|
1384
1421
|
function sleep2(ms) {
|
|
1385
1422
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1386
1423
|
}
|
|
1387
|
-
async function
|
|
1388
|
-
|
|
1389
|
-
let rows;
|
|
1390
|
-
const backend = detectBackend();
|
|
1391
|
-
if (backend === "indexeddb") {
|
|
1392
|
-
try {
|
|
1393
|
-
rows = await idbGetAll();
|
|
1394
|
-
} catch {
|
|
1395
|
-
rows = lsRead();
|
|
1396
|
-
}
|
|
1397
|
-
} else {
|
|
1398
|
-
rows = lsRead();
|
|
1399
|
-
}
|
|
1424
|
+
async function runFlush(client) {
|
|
1425
|
+
const { backend, rows: storedRows } = await readRows();
|
|
1426
|
+
let rows = storedRows;
|
|
1400
1427
|
const now2 = Date.now();
|
|
1401
1428
|
const fresh = [];
|
|
1402
1429
|
for (const row of rows) {
|
|
@@ -1425,59 +1452,84 @@ function createOfflineQueue(config = {}) {
|
|
|
1425
1452
|
if (result.ok) {
|
|
1426
1453
|
await removeRow(backend, rowId);
|
|
1427
1454
|
sent++;
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
failed++;
|
|
1458
|
+
if (result.error?.code === "CIRCUIT_OPEN") {
|
|
1459
|
+
queueLog.debug("Offline queue: circuit open, deferring the rest of the batch", {
|
|
1460
|
+
id: rowId,
|
|
1461
|
+
remaining: batch.length - i
|
|
1462
|
+
});
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
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;
|
|
1466
|
+
// retrying re-serialises the multi-MB body every sync tick and wedges
|
|
1467
|
+
// the queue (it matches neither permanent nor transient otherwise).
|
|
1468
|
+
// SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
|
|
1469
|
+
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(
|
|
1470
|
+
result.error.message
|
|
1471
|
+
);
|
|
1472
|
+
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"));
|
|
1473
|
+
if (permanent) {
|
|
1474
|
+
await removeRow(backend, rowId);
|
|
1428
1475
|
} else {
|
|
1429
|
-
const
|
|
1430
|
-
|
|
1431
|
-
// the queue (it matches neither permanent nor transient otherwise).
|
|
1432
|
-
// SERIALIZE_FAILED (circular ref) is likewise unrecoverable on retry.
|
|
1433
|
-
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(
|
|
1434
|
-
result.error.message
|
|
1435
|
-
);
|
|
1436
|
-
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"));
|
|
1437
|
-
if (permanent) {
|
|
1476
|
+
const nextAttempts = rowAttempts(row) + 1;
|
|
1477
|
+
if (nextAttempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
1438
1478
|
await removeRow(backend, rowId);
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1479
|
+
queueLog.warn("Offline queue: giving up on report after repeated failures", {
|
|
1480
|
+
id: rowId,
|
|
1481
|
+
attempts: nextAttempts,
|
|
1482
|
+
code: result.error?.code
|
|
1483
|
+
});
|
|
1484
|
+
} else {
|
|
1485
|
+
row.attempts = nextAttempts;
|
|
1486
|
+
const persisted = await persistRow(backend, row);
|
|
1487
|
+
if (persisted) {
|
|
1488
|
+
queueLog.debug("Offline queue: delivery failed, will retry", {
|
|
1444
1489
|
id: rowId,
|
|
1445
1490
|
attempts: nextAttempts,
|
|
1446
|
-
code: result.error?.code
|
|
1491
|
+
code: result.error?.code,
|
|
1492
|
+
classified: transient ? "transient" : "unclassified"
|
|
1447
1493
|
});
|
|
1448
1494
|
} else {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
attempts: nextAttempts,
|
|
1455
|
-
code: result.error?.code
|
|
1456
|
-
});
|
|
1457
|
-
} else {
|
|
1458
|
-
await removeRow(backend, rowId);
|
|
1459
|
-
queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
|
|
1460
|
-
id: rowId,
|
|
1461
|
-
code: result.error?.code
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1495
|
+
await removeRow(backend, rowId);
|
|
1496
|
+
queueLog.warn("Offline queue: dropping report (attempt counter unpersistable)", {
|
|
1497
|
+
id: rowId,
|
|
1498
|
+
code: result.error?.code
|
|
1499
|
+
});
|
|
1464
1500
|
}
|
|
1465
1501
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1502
|
+
}
|
|
1503
|
+
if (i < batch.length - 1) {
|
|
1504
|
+
await sleep2(getBackoffDelay2(i));
|
|
1470
1505
|
}
|
|
1471
1506
|
}
|
|
1472
1507
|
return { sent, failed };
|
|
1473
1508
|
}
|
|
1509
|
+
function flush(client) {
|
|
1510
|
+
if (!enabled) return Promise.resolve({ sent: 0, failed: 0 });
|
|
1511
|
+
if (inFlight) {
|
|
1512
|
+
if (!followUp) {
|
|
1513
|
+
followUp = inFlight.catch(() => void 0).then(() => {
|
|
1514
|
+
followUp = null;
|
|
1515
|
+
return flush(client);
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
return followUp;
|
|
1519
|
+
}
|
|
1520
|
+
const pass = runFlush(client).finally(() => {
|
|
1521
|
+
if (inFlight === pass) inFlight = null;
|
|
1522
|
+
});
|
|
1523
|
+
inFlight = pass;
|
|
1524
|
+
return pass;
|
|
1525
|
+
}
|
|
1474
1526
|
async function size() {
|
|
1475
1527
|
const backend = detectBackend();
|
|
1476
1528
|
if (backend === "indexeddb") {
|
|
1477
1529
|
try {
|
|
1478
1530
|
return await idbSize();
|
|
1479
1531
|
} catch {
|
|
1480
|
-
|
|
1532
|
+
backendType = "localstorage";
|
|
1481
1533
|
}
|
|
1482
1534
|
}
|
|
1483
1535
|
return lsRead().length;
|