vibeusage 0.2.8
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 +182 -0
- package/README.zh-CN.md +182 -0
- package/bin/tracker.js +28 -0
- package/package.json +46 -0
- package/src/cli.js +64 -0
- package/src/commands/diagnostics.js +39 -0
- package/src/commands/init.js +798 -0
- package/src/commands/status.js +155 -0
- package/src/commands/sync.js +479 -0
- package/src/commands/uninstall.js +153 -0
- package/src/lib/browser-auth.js +175 -0
- package/src/lib/claude-config.js +190 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +224 -0
- package/src/lib/debug-flags.js +9 -0
- package/src/lib/diagnostics.js +190 -0
- package/src/lib/fs.js +62 -0
- package/src/lib/gemini-config.js +284 -0
- package/src/lib/init-flow.js +48 -0
- package/src/lib/insforge-client.js +75 -0
- package/src/lib/insforge.js +23 -0
- package/src/lib/opencode-config.js +98 -0
- package/src/lib/progress.js +77 -0
- package/src/lib/prompt.js +20 -0
- package/src/lib/rollout.js +1317 -0
- package/src/lib/tracker-paths.js +66 -0
- package/src/lib/upload-throttle.js +129 -0
- package/src/lib/uploader.js +116 -0
- package/src/lib/vibescore-api.js +222 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
async function resolveTrackerPaths({ home = os.homedir(), migrate = true } = {}) {
|
|
6
|
+
const legacyRootDir = path.join(home, '.vibescore');
|
|
7
|
+
const rootDir = path.join(home, '.vibeusage');
|
|
8
|
+
const legacyTrackerDir = path.join(legacyRootDir, 'tracker');
|
|
9
|
+
const legacyBinDir = path.join(legacyRootDir, 'bin');
|
|
10
|
+
const trackerDir = path.join(rootDir, 'tracker');
|
|
11
|
+
const binDir = path.join(rootDir, 'bin');
|
|
12
|
+
|
|
13
|
+
const legacyExists = await pathExists(legacyRootDir);
|
|
14
|
+
const newExists = await pathExists(rootDir);
|
|
15
|
+
|
|
16
|
+
let usingLegacy = false;
|
|
17
|
+
let migrated = false;
|
|
18
|
+
|
|
19
|
+
if (migrate && legacyExists && !newExists) {
|
|
20
|
+
const result = await migrateLegacyRoot({ legacyRootDir, rootDir });
|
|
21
|
+
usingLegacy = result.usingLegacy;
|
|
22
|
+
migrated = result.migrated;
|
|
23
|
+
} else if (!newExists && legacyExists) {
|
|
24
|
+
usingLegacy = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const activeRootDir = usingLegacy ? legacyRootDir : rootDir;
|
|
28
|
+
return {
|
|
29
|
+
rootDir: activeRootDir,
|
|
30
|
+
trackerDir: path.join(activeRootDir, 'tracker'),
|
|
31
|
+
binDir: path.join(activeRootDir, 'bin'),
|
|
32
|
+
legacyRootDir,
|
|
33
|
+
legacyTrackerDir,
|
|
34
|
+
legacyBinDir,
|
|
35
|
+
migrated,
|
|
36
|
+
usingLegacy
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function migrateLegacyRoot({ legacyRootDir, rootDir }) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.rename(legacyRootDir, rootDir);
|
|
43
|
+
return { migrated: true, usingLegacy: false };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
try {
|
|
46
|
+
await fs.cp(legacyRootDir, rootDir, { recursive: true });
|
|
47
|
+
return { migrated: true, usingLegacy: false };
|
|
48
|
+
} catch (copyErr) {
|
|
49
|
+
return { migrated: false, usingLegacy: true, error: copyErr };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function pathExists(target) {
|
|
55
|
+
try {
|
|
56
|
+
await fs.stat(target);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err && err.code === 'ENOENT') return false;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
resolveTrackerPaths
|
|
66
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
intervalMs: 30 * 60_000,
|
|
3
|
+
jitterMsMax: 60_000,
|
|
4
|
+
backlogBytes: 1_000_000,
|
|
5
|
+
batchSize: 300,
|
|
6
|
+
maxBatchesSmall: 2,
|
|
7
|
+
maxBatchesLarge: 4,
|
|
8
|
+
backoffInitialMs: 60_000,
|
|
9
|
+
backoffMaxMs: 30 * 60_000
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function normalizeState(raw) {
|
|
13
|
+
const s = raw && typeof raw === 'object' ? raw : {};
|
|
14
|
+
return {
|
|
15
|
+
version: 1,
|
|
16
|
+
lastSuccessMs: toSafeInt(s.lastSuccessMs),
|
|
17
|
+
nextAllowedAtMs: toSafeInt(s.nextAllowedAtMs),
|
|
18
|
+
backoffUntilMs: toSafeInt(s.backoffUntilMs),
|
|
19
|
+
backoffStep: toSafeInt(s.backoffStep),
|
|
20
|
+
lastErrorAt: typeof s.lastErrorAt === 'string' ? s.lastErrorAt : null,
|
|
21
|
+
lastError: typeof s.lastError === 'string' ? s.lastError : null,
|
|
22
|
+
updatedAt: typeof s.updatedAt === 'string' ? s.updatedAt : null
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decideAutoUpload({ nowMs, pendingBytes, state, config }) {
|
|
27
|
+
const cfg = { ...DEFAULTS, ...(config || {}) };
|
|
28
|
+
const s = normalizeState(state);
|
|
29
|
+
const pending = Number(pendingBytes || 0);
|
|
30
|
+
|
|
31
|
+
if (pending <= 0) {
|
|
32
|
+
return { allowed: false, reason: 'no-pending', maxBatches: 0, batchSize: cfg.batchSize, blockedUntilMs: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const blockedUntilMs = Math.max(s.nextAllowedAtMs || 0, s.backoffUntilMs || 0);
|
|
36
|
+
if (blockedUntilMs > 0 && nowMs < blockedUntilMs) {
|
|
37
|
+
return { allowed: false, reason: 'throttled', maxBatches: 0, batchSize: cfg.batchSize, blockedUntilMs };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const maxBatches = pending >= cfg.backlogBytes ? cfg.maxBatchesLarge : cfg.maxBatchesSmall;
|
|
41
|
+
return { allowed: true, reason: 'allowed', maxBatches, batchSize: cfg.batchSize, blockedUntilMs: 0 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recordUploadSuccess({ nowMs, state, config, randInt }) {
|
|
45
|
+
const cfg = { ...DEFAULTS, ...(config || {}) };
|
|
46
|
+
const s = normalizeState(state);
|
|
47
|
+
const jitter = typeof randInt === 'function' ? randInt(0, cfg.jitterMsMax) : randomInt(0, cfg.jitterMsMax);
|
|
48
|
+
const nextAllowedAtMs = nowMs + cfg.intervalMs + jitter;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
...s,
|
|
52
|
+
lastSuccessMs: nowMs,
|
|
53
|
+
nextAllowedAtMs,
|
|
54
|
+
backoffUntilMs: 0,
|
|
55
|
+
backoffStep: 0,
|
|
56
|
+
lastErrorAt: null,
|
|
57
|
+
lastError: null,
|
|
58
|
+
updatedAt: new Date(nowMs).toISOString()
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function recordUploadFailure({ nowMs, state, error, config }) {
|
|
63
|
+
const cfg = { ...DEFAULTS, ...(config || {}) };
|
|
64
|
+
const s = normalizeState(state);
|
|
65
|
+
|
|
66
|
+
const retryAfterMs = toSafeInt(error?.retryAfterMs);
|
|
67
|
+
const status = toSafeInt(error?.status);
|
|
68
|
+
|
|
69
|
+
let backoffMs = 0;
|
|
70
|
+
if (retryAfterMs > 0) {
|
|
71
|
+
backoffMs = Math.min(cfg.backoffMaxMs, Math.max(cfg.backoffInitialMs, retryAfterMs));
|
|
72
|
+
} else {
|
|
73
|
+
const step = Math.min(10, Math.max(0, s.backoffStep || 0));
|
|
74
|
+
backoffMs = Math.min(cfg.backoffMaxMs, cfg.backoffInitialMs * Math.pow(2, step));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const backoffUntilMs = nowMs + backoffMs;
|
|
78
|
+
const nextAllowedAtMs = Math.max(s.nextAllowedAtMs || 0, backoffUntilMs);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...s,
|
|
82
|
+
nextAllowedAtMs,
|
|
83
|
+
backoffUntilMs,
|
|
84
|
+
backoffStep: Math.min(20, (s.backoffStep || 0) + 1),
|
|
85
|
+
lastErrorAt: new Date(nowMs).toISOString(),
|
|
86
|
+
lastError: truncate(String(error?.message || 'upload failed'), 200),
|
|
87
|
+
updatedAt: new Date(nowMs).toISOString()
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseRetryAfterMs(headerValue, nowMs = Date.now()) {
|
|
92
|
+
if (typeof headerValue !== 'string' || headerValue.trim().length === 0) return null;
|
|
93
|
+
const v = headerValue.trim();
|
|
94
|
+
const seconds = Number(v);
|
|
95
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.floor(seconds * 1000);
|
|
96
|
+
const d = new Date(v);
|
|
97
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
98
|
+
const delta = d.getTime() - nowMs;
|
|
99
|
+
return delta > 0 ? delta : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function randomInt(min, maxInclusive) {
|
|
103
|
+
const lo = Math.floor(min);
|
|
104
|
+
const hi = Math.floor(maxInclusive);
|
|
105
|
+
if (hi <= lo) return lo;
|
|
106
|
+
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toSafeInt(v) {
|
|
110
|
+
const n = Number(v);
|
|
111
|
+
if (!Number.isFinite(n)) return 0;
|
|
112
|
+
if (n <= 0) return 0;
|
|
113
|
+
return Math.floor(n);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function truncate(s, maxLen) {
|
|
117
|
+
if (typeof s !== 'string') return '';
|
|
118
|
+
if (s.length <= maxLen) return s;
|
|
119
|
+
return s.slice(0, maxLen - 1) + '…';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
DEFAULTS,
|
|
124
|
+
normalizeState,
|
|
125
|
+
decideAutoUpload,
|
|
126
|
+
recordUploadSuccess,
|
|
127
|
+
recordUploadFailure,
|
|
128
|
+
parseRetryAfterMs
|
|
129
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const fssync = require('node:fs');
|
|
3
|
+
const readline = require('node:readline');
|
|
4
|
+
|
|
5
|
+
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
6
|
+
const { ingestHourly } = require('./vibescore-api');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SOURCE = 'codex';
|
|
9
|
+
const DEFAULT_MODEL = 'unknown';
|
|
10
|
+
const BUCKET_SEPARATOR = '|';
|
|
11
|
+
|
|
12
|
+
async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches, batchSize, onProgress }) {
|
|
13
|
+
await ensureDir(require('node:path').dirname(queueStatePath));
|
|
14
|
+
|
|
15
|
+
const state = (await readJson(queueStatePath)) || { offset: 0 };
|
|
16
|
+
let offset = Number(state.offset || 0);
|
|
17
|
+
let inserted = 0;
|
|
18
|
+
let skipped = 0;
|
|
19
|
+
let attempted = 0;
|
|
20
|
+
|
|
21
|
+
const cb = typeof onProgress === 'function' ? onProgress : null;
|
|
22
|
+
const queueSize = await safeFileSize(queuePath);
|
|
23
|
+
const maxBuckets = Math.max(1, Math.floor(Number(batchSize || 200)));
|
|
24
|
+
|
|
25
|
+
for (let batch = 0; batch < maxBatches; batch++) {
|
|
26
|
+
const res = await readBatch(queuePath, offset, maxBuckets);
|
|
27
|
+
if (res.buckets.length === 0) break;
|
|
28
|
+
|
|
29
|
+
attempted += res.buckets.length;
|
|
30
|
+
const ingest = await ingestHourly({ baseUrl, deviceToken, hourly: res.buckets });
|
|
31
|
+
inserted += ingest.inserted || 0;
|
|
32
|
+
skipped += ingest.skipped || 0;
|
|
33
|
+
|
|
34
|
+
offset = res.nextOffset;
|
|
35
|
+
state.offset = offset;
|
|
36
|
+
state.updatedAt = new Date().toISOString();
|
|
37
|
+
await writeJson(queueStatePath, state);
|
|
38
|
+
|
|
39
|
+
if (cb) {
|
|
40
|
+
cb({
|
|
41
|
+
batch: batch + 1,
|
|
42
|
+
maxBatches,
|
|
43
|
+
offset,
|
|
44
|
+
queueSize,
|
|
45
|
+
inserted,
|
|
46
|
+
skipped
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { inserted, skipped, attempted };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readBatch(queuePath, startOffset, maxBuckets) {
|
|
55
|
+
const st = await fs.stat(queuePath).catch(() => null);
|
|
56
|
+
if (!st || !st.isFile()) return { buckets: [], nextOffset: startOffset };
|
|
57
|
+
if (startOffset >= st.size) return { buckets: [], nextOffset: startOffset };
|
|
58
|
+
|
|
59
|
+
const stream = fssync.createReadStream(queuePath, { encoding: 'utf8', start: startOffset });
|
|
60
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
61
|
+
|
|
62
|
+
const bucketMap = new Map();
|
|
63
|
+
let offset = startOffset;
|
|
64
|
+
let linesRead = 0;
|
|
65
|
+
for await (const line of rl) {
|
|
66
|
+
const bytes = Buffer.byteLength(line, 'utf8') + 1;
|
|
67
|
+
offset += bytes;
|
|
68
|
+
if (!line.trim()) continue;
|
|
69
|
+
let bucket;
|
|
70
|
+
try {
|
|
71
|
+
bucket = JSON.parse(line);
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const hourStart = typeof bucket?.hour_start === 'string' ? bucket.hour_start : null;
|
|
76
|
+
if (!hourStart) continue;
|
|
77
|
+
const source = normalizeSource(bucket?.source) || DEFAULT_SOURCE;
|
|
78
|
+
const model = normalizeModel(bucket?.model) || DEFAULT_MODEL;
|
|
79
|
+
bucket.source = source;
|
|
80
|
+
bucket.model = model;
|
|
81
|
+
bucketMap.set(bucketKey(source, model, hourStart), bucket);
|
|
82
|
+
linesRead += 1;
|
|
83
|
+
if (linesRead >= maxBuckets) break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
rl.close();
|
|
87
|
+
stream.close?.();
|
|
88
|
+
return { buckets: Array.from(bucketMap.values()), nextOffset: offset };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function safeFileSize(p) {
|
|
92
|
+
try {
|
|
93
|
+
const st = await fs.stat(p);
|
|
94
|
+
return st && st.isFile() ? st.size : 0;
|
|
95
|
+
} catch (_e) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function bucketKey(source, model, hourStart) {
|
|
101
|
+
return `${source}${BUCKET_SEPARATOR}${model}${BUCKET_SEPARATOR}${hourStart}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeSource(value) {
|
|
105
|
+
if (typeof value !== 'string') return null;
|
|
106
|
+
const trimmed = value.trim().toLowerCase();
|
|
107
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeModel(value) {
|
|
111
|
+
if (typeof value !== 'string') return null;
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { drainQueueToCloud };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createInsforgeClient } = require('./insforge-client');
|
|
4
|
+
|
|
5
|
+
async function signInWithPassword({ baseUrl, email, password }) {
|
|
6
|
+
const client = createInsforgeClient({ baseUrl });
|
|
7
|
+
const { data, error } = await client.auth.signInWithPassword({ email, password });
|
|
8
|
+
if (error) throw normalizeSdkError(error, 'Sign-in failed');
|
|
9
|
+
|
|
10
|
+
const accessToken = data?.accessToken;
|
|
11
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
|
12
|
+
throw new Error('Sign-in failed: missing accessToken');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return accessToken;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function issueDeviceToken({ baseUrl, accessToken, deviceName, platform = 'macos' }) {
|
|
19
|
+
const data = await invokeFunction({
|
|
20
|
+
baseUrl,
|
|
21
|
+
accessToken,
|
|
22
|
+
slug: 'vibescore-device-token-issue',
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body: { device_name: deviceName, platform },
|
|
25
|
+
errorPrefix: 'Device token issue failed'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const token = data?.token;
|
|
29
|
+
const deviceId = data?.device_id;
|
|
30
|
+
if (typeof token !== 'string' || token.length === 0) throw new Error('Device token issue failed: missing token');
|
|
31
|
+
if (typeof deviceId !== 'string' || deviceId.length === 0) {
|
|
32
|
+
throw new Error('Device token issue failed: missing device_id');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { token, deviceId };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function exchangeLinkCode({ baseUrl, linkCode, requestId, deviceName, platform = 'macos' }) {
|
|
39
|
+
const data = await invokeFunction({
|
|
40
|
+
baseUrl,
|
|
41
|
+
accessToken: null,
|
|
42
|
+
slug: 'vibescore-link-code-exchange',
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: {
|
|
45
|
+
link_code: linkCode,
|
|
46
|
+
request_id: requestId,
|
|
47
|
+
device_name: deviceName,
|
|
48
|
+
platform
|
|
49
|
+
},
|
|
50
|
+
errorPrefix: 'Link code exchange failed'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const token = data?.token;
|
|
54
|
+
const deviceId = data?.device_id;
|
|
55
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
56
|
+
throw new Error('Link code exchange failed: missing token');
|
|
57
|
+
}
|
|
58
|
+
if (typeof deviceId !== 'string' || deviceId.length === 0) {
|
|
59
|
+
throw new Error('Link code exchange failed: missing device_id');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { token, deviceId, userId: data?.user_id || null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function ingestHourly({ baseUrl, deviceToken, hourly }) {
|
|
66
|
+
const data = await invokeFunctionWithRetry({
|
|
67
|
+
baseUrl,
|
|
68
|
+
accessToken: deviceToken,
|
|
69
|
+
slug: 'vibescore-ingest',
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: { hourly },
|
|
72
|
+
errorPrefix: 'Ingest failed',
|
|
73
|
+
retry: { maxRetries: 3, baseDelayMs: 500, maxDelayMs: 5000 }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
inserted: Number(data?.inserted || 0),
|
|
78
|
+
skipped: Number(data?.skipped || 0)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function syncHeartbeat({ baseUrl, deviceToken }) {
|
|
83
|
+
const data = await invokeFunction({
|
|
84
|
+
baseUrl,
|
|
85
|
+
accessToken: deviceToken,
|
|
86
|
+
slug: 'vibescore-sync-ping',
|
|
87
|
+
method: 'POST',
|
|
88
|
+
body: {},
|
|
89
|
+
errorPrefix: 'Sync heartbeat failed'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
updated: Boolean(data?.updated),
|
|
94
|
+
last_sync_at: typeof data?.last_sync_at === 'string' ? data.last_sync_at : null,
|
|
95
|
+
min_interval_minutes: Number(data?.min_interval_minutes || 0)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
signInWithPassword,
|
|
101
|
+
issueDeviceToken,
|
|
102
|
+
exchangeLinkCode,
|
|
103
|
+
ingestHourly,
|
|
104
|
+
syncHeartbeat
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
async function invokeFunction({ baseUrl, accessToken, slug, method, body, errorPrefix }) {
|
|
108
|
+
const client = createInsforgeClient({ baseUrl, accessToken });
|
|
109
|
+
const { data, error } = await client.functions.invoke(slug, { method, body });
|
|
110
|
+
if (error) throw normalizeSdkError(error, errorPrefix);
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function invokeFunctionWithRetry({ baseUrl, accessToken, slug, method, body, errorPrefix, retry }) {
|
|
115
|
+
const retryOptions = normalizeRetryOptions(retry);
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
|
|
118
|
+
while (true) {
|
|
119
|
+
try {
|
|
120
|
+
return await invokeFunction({ baseUrl, accessToken, slug, method, body, errorPrefix });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (!shouldRetry({ err: e, attempt, retryOptions })) throw e;
|
|
123
|
+
const delayMs = computeRetryDelayMs({ retryOptions, attempt, err: e });
|
|
124
|
+
await sleep(delayMs);
|
|
125
|
+
attempt += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeSdkError(error, errorPrefix) {
|
|
131
|
+
const raw = extractSdkErrorMessage(error);
|
|
132
|
+
const msg = normalizeBackendErrorMessage(raw);
|
|
133
|
+
const err = new Error(errorPrefix ? `${errorPrefix}: ${msg}` : msg);
|
|
134
|
+
const status = error?.statusCode ?? error?.status;
|
|
135
|
+
const code = typeof error?.error === 'string' ? error.error.trim() : '';
|
|
136
|
+
if (typeof status === 'number') err.status = status;
|
|
137
|
+
if (code) err.code = code;
|
|
138
|
+
err.retryable = isRetryableStatus(status) || isRetryableMessage(raw);
|
|
139
|
+
if (msg !== raw) err.originalMessage = raw;
|
|
140
|
+
if (error?.nextActions) err.nextActions = error.nextActions;
|
|
141
|
+
return err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function extractSdkErrorMessage(error) {
|
|
145
|
+
if (!error) return 'Unknown error';
|
|
146
|
+
const message = typeof error.message === 'string' ? error.message.trim() : '';
|
|
147
|
+
const code = typeof error.error === 'string' ? error.error.trim() : '';
|
|
148
|
+
if (message && message !== 'InsForgeError') return message;
|
|
149
|
+
if (code && code !== 'REQUEST_FAILED') return code;
|
|
150
|
+
if (message) return message;
|
|
151
|
+
if (code) return code;
|
|
152
|
+
return String(error);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeBackendErrorMessage(message) {
|
|
156
|
+
if (!isBackendRuntimeDownMessage(message)) return String(message || 'Unknown error');
|
|
157
|
+
return 'Backend runtime unavailable (InsForge). Please retry later.';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isBackendRuntimeDownMessage(message) {
|
|
161
|
+
const s = String(message || '').toLowerCase();
|
|
162
|
+
if (!s) return false;
|
|
163
|
+
if (s.includes('deno:') || s.includes('deno')) return true;
|
|
164
|
+
if (s.includes('econnreset') || s.includes('econnrefused')) return true;
|
|
165
|
+
if (s.includes('etimedout')) return true;
|
|
166
|
+
if (s.includes('timeout') && s.includes('request')) return true;
|
|
167
|
+
if (s.includes('upstream') && (s.includes('deno') || s.includes('connect'))) return true;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isRetryableStatus(status) {
|
|
172
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRetryableMessage(message) {
|
|
176
|
+
const s = String(message || '').toLowerCase();
|
|
177
|
+
if (!s) return false;
|
|
178
|
+
if (isBackendRuntimeDownMessage(s)) return true;
|
|
179
|
+
if (s.includes('econnreset') || s.includes('econnrefused')) return true;
|
|
180
|
+
if (s.includes('etimedout') || s.includes('timeout')) return true;
|
|
181
|
+
if (s.includes('networkerror') || s.includes('failed to fetch')) return true;
|
|
182
|
+
if (s.includes('socket hang up') || s.includes('connection reset')) return true;
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeRetryOptions(retry) {
|
|
187
|
+
if (!retry) {
|
|
188
|
+
return { maxRetries: 0, baseDelayMs: 0, maxDelayMs: 0, jitterRatio: 0.0 };
|
|
189
|
+
}
|
|
190
|
+
const maxRetries = clampInt(retry.maxRetries, 0, 10);
|
|
191
|
+
const baseDelayMs = clampInt(retry.baseDelayMs ?? 300, 50, 60_000);
|
|
192
|
+
const maxDelayMs = clampInt(retry.maxDelayMs ?? baseDelayMs * 4, baseDelayMs, 120_000);
|
|
193
|
+
const jitterRatio = typeof retry.jitterRatio === 'number' ? Math.max(0, Math.min(0.5, retry.jitterRatio)) : 0.2;
|
|
194
|
+
return { maxRetries, baseDelayMs, maxDelayMs, jitterRatio };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function shouldRetry({ err, attempt, retryOptions }) {
|
|
198
|
+
if (!retryOptions || retryOptions.maxRetries <= 0) return false;
|
|
199
|
+
if (attempt >= retryOptions.maxRetries) return false;
|
|
200
|
+
return Boolean(err && err.retryable);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function computeRetryDelayMs({ retryOptions, attempt, err }) {
|
|
204
|
+
if (!retryOptions || retryOptions.maxRetries <= 0) return 0;
|
|
205
|
+
const exp = retryOptions.baseDelayMs * Math.pow(2, attempt);
|
|
206
|
+
const capped = Math.min(retryOptions.maxDelayMs, exp);
|
|
207
|
+
const jitter = capped * retryOptions.jitterRatio * Math.random();
|
|
208
|
+
const backoff = Math.round(capped + jitter);
|
|
209
|
+
const retryAfter = typeof err?.retryAfterMs === 'number' ? err.retryAfterMs : 0;
|
|
210
|
+
return Math.max(backoff, retryAfter || 0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function clampInt(value, min, max) {
|
|
214
|
+
const n = Number(value);
|
|
215
|
+
if (!Number.isFinite(n)) return min;
|
|
216
|
+
return Math.min(max, Math.max(min, Math.floor(n)));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sleep(ms) {
|
|
220
|
+
if (!ms || ms <= 0) return Promise.resolve();
|
|
221
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
222
|
+
}
|