@invonetwork/web-sdk 0.2.1 → 0.4.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/CHANGELOG.md +48 -1
- package/LICENSE +18 -17
- package/README.md +495 -393
- package/dist/chunk-EEWOAUXO.js +249 -0
- package/dist/index.cjs +188 -63
- package/dist/index.d.cts +9 -9
- package/dist/index.d.ts +9 -9
- package/dist/index.js +54 -31
- package/dist/server.cjs +474 -59
- package/dist/server.d.cts +177 -13
- package/dist/server.d.ts +177 -13
- package/dist/server.js +339 -29
- package/dist/{errors-DV5QsftP.d.cts → types-CBMLNwbe.d.cts} +152 -42
- package/dist/{errors-DV5QsftP.d.ts → types-CBMLNwbe.d.ts} +152 -42
- package/package.json +10 -2
- package/dist/chunk-A44O4KC3.js +0 -147
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// src/shared/errors.ts
|
|
2
|
+
var InvoError = class _InvoError extends Error {
|
|
3
|
+
constructor(args) {
|
|
4
|
+
super(args.message);
|
|
5
|
+
this.name = "InvoError";
|
|
6
|
+
this.code = args.code;
|
|
7
|
+
this.status = args.status;
|
|
8
|
+
this.body = args.body ?? null;
|
|
9
|
+
this.requestId = args.requestId;
|
|
10
|
+
Object.setPrototypeOf(this, _InvoError.prototype);
|
|
11
|
+
}
|
|
12
|
+
/** True if this is the "recipient isn't passkey-enrolled, fall back to claim code" signal. */
|
|
13
|
+
get isReceiverNotEnrolled() {
|
|
14
|
+
return this.code === "receiver_not_enrolled_use_claim_code" || /receiver_not_enrolled_use_claim_code/i.test(this.message);
|
|
15
|
+
}
|
|
16
|
+
/** True if the session/SDK token has expired and the caller should re-mint + retry. */
|
|
17
|
+
get isTokenExpired() {
|
|
18
|
+
return this.code === "SDK_TOKEN_EXPIRED";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True if an item purchase failed because the player's balance was too low (§4.8 → 400).
|
|
22
|
+
* The backend carries `required_amount` + `current_balance` on the body for the UI.
|
|
23
|
+
* Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
|
|
24
|
+
* is a rate-limit, not a top-up condition) is NOT misclassified as this.
|
|
25
|
+
*/
|
|
26
|
+
get isInsufficientBalance() {
|
|
27
|
+
if (this.status !== 400) return false;
|
|
28
|
+
const b = this.bodyObject();
|
|
29
|
+
return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
|
|
30
|
+
}
|
|
31
|
+
/** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
|
|
32
|
+
get isDuplicateRequest() {
|
|
33
|
+
return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
|
|
34
|
+
}
|
|
35
|
+
/** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
|
|
36
|
+
get retryAfter() {
|
|
37
|
+
const v = this.bodyObject()["retry_after"];
|
|
38
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
39
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
40
|
+
}
|
|
41
|
+
/** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
|
|
42
|
+
* The `in` operator throws on primitives, so callers must go through this. */
|
|
43
|
+
bodyObject() {
|
|
44
|
+
return this.body && typeof this.body === "object" ? this.body : {};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function errorFromResponse(status, body, requestId) {
|
|
48
|
+
let message = `INVO request failed (HTTP ${status})`;
|
|
49
|
+
let code;
|
|
50
|
+
if (body && typeof body === "object") {
|
|
51
|
+
const b = body;
|
|
52
|
+
if (typeof b["code"] === "string") code = b["code"];
|
|
53
|
+
if (typeof b["error"] === "string") message = b["error"];
|
|
54
|
+
else if (typeof b["message"] === "string") message = b["message"];
|
|
55
|
+
} else if (typeof body === "string" && body.trim()) {
|
|
56
|
+
message = body;
|
|
57
|
+
}
|
|
58
|
+
return new InvoError({ message, code, status, body, requestId });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/shared/http.ts
|
|
62
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
63
|
+
var MAX_RETRY_AFTER_MS = 2e4;
|
|
64
|
+
function assertSecureBaseUrl(baseUrl) {
|
|
65
|
+
let u;
|
|
66
|
+
try {
|
|
67
|
+
u = new URL(baseUrl);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error(`Invalid baseUrl: ${baseUrl}`);
|
|
70
|
+
}
|
|
71
|
+
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
72
|
+
if (u.protocol === "https:" || u.protocol === "http:" && isLocal) return;
|
|
73
|
+
throw new Error(
|
|
74
|
+
`baseUrl must use https:// (got "${u.protocol}//"). Plaintext would expose the token/secret on the wire.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
var _Http = class _Http {
|
|
78
|
+
constructor(opts) {
|
|
79
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
80
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
81
|
+
const f = opts.fetchImpl ?? globalThis.fetch;
|
|
82
|
+
if (typeof f !== "function") {
|
|
83
|
+
throw new Error(
|
|
84
|
+
"No fetch implementation available. Use Node >=18, or pass `fetch` in the config."
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
this.fetchImpl = f;
|
|
88
|
+
this.userAgent = opts.userAgent;
|
|
89
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
|
|
90
|
+
this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 250;
|
|
91
|
+
this.hooks = opts.hooks;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* @param opts.idempotent - mark this POST safe to auto-retry (it carries an
|
|
95
|
+
* idempotency key, or has no side effect). Default false: non-idempotent POSTs
|
|
96
|
+
* (e.g. single-use WebAuthn assertions) are NEVER auto-retried.
|
|
97
|
+
*/
|
|
98
|
+
async post(path, body, auth, opts) {
|
|
99
|
+
return this.request("POST", path, body, auth, opts?.idempotent ?? false, opts?.signal);
|
|
100
|
+
}
|
|
101
|
+
// GET is always idempotent → safe to retry.
|
|
102
|
+
async get(path, auth, opts) {
|
|
103
|
+
return this.request("GET", path, void 0, auth, true, opts?.signal);
|
|
104
|
+
}
|
|
105
|
+
authHeaders(auth) {
|
|
106
|
+
switch (auth.kind) {
|
|
107
|
+
case "game-secret":
|
|
108
|
+
return { "X-Game-Secret-Key": auth.secret };
|
|
109
|
+
case "bearer":
|
|
110
|
+
return { Authorization: `Bearer ${auth.token}` };
|
|
111
|
+
case "none":
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async request(method, path, body, auth, idempotent, signal) {
|
|
116
|
+
const url = `${this.baseUrl}${path}`;
|
|
117
|
+
const headers = {
|
|
118
|
+
Accept: "application/json",
|
|
119
|
+
...this.authHeaders(auth)
|
|
120
|
+
};
|
|
121
|
+
if (this.userAgent) headers["User-Agent"] = this.userAgent;
|
|
122
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
123
|
+
const payload = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
124
|
+
for (let attempt = 0; ; attempt++) {
|
|
125
|
+
if (signal?.aborted) throw abortError(path);
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
this.fire("onRequest", { method, url, attempt });
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
130
|
+
const onAbort = () => controller.abort();
|
|
131
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
let res;
|
|
133
|
+
let networkError;
|
|
134
|
+
try {
|
|
135
|
+
res = await this.fetchImpl(url, { method, headers, body: payload, signal: controller.signal });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
networkError = new InvoError({
|
|
138
|
+
message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
|
|
139
|
+
status: 0,
|
|
140
|
+
body: null
|
|
141
|
+
});
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
signal?.removeEventListener("abort", onAbort);
|
|
145
|
+
}
|
|
146
|
+
if (networkError) {
|
|
147
|
+
if (signal?.aborted) throw abortError(path);
|
|
148
|
+
const willRetry = idempotent && attempt < this.maxRetries;
|
|
149
|
+
this.fire("onError", { method, url, attempt, error: networkError, willRetry });
|
|
150
|
+
if (willRetry) {
|
|
151
|
+
await sleep(this.backoff(attempt), signal);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
throw networkError;
|
|
155
|
+
}
|
|
156
|
+
const requestId = pickRequestId(res.headers);
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
let parsed = null;
|
|
159
|
+
if (text) {
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
parsed = text;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.fire("onResponse", {
|
|
167
|
+
method,
|
|
168
|
+
url,
|
|
169
|
+
attempt,
|
|
170
|
+
status: res.status,
|
|
171
|
+
durationMs: Date.now() - start,
|
|
172
|
+
requestId
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
const err = errorFromResponse(res.status, parsed, requestId);
|
|
176
|
+
let wait;
|
|
177
|
+
if (idempotent && attempt < this.maxRetries && _Http.RETRIABLE_STATUS.has(res.status)) {
|
|
178
|
+
if (res.status === 429) {
|
|
179
|
+
const ra = retryAfterMs(parsed, res.headers);
|
|
180
|
+
wait = ra === void 0 ? this.backoff(attempt) : ra <= MAX_RETRY_AFTER_MS ? ra : void 0;
|
|
181
|
+
} else {
|
|
182
|
+
wait = this.backoff(attempt);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
this.fire("onError", { method, url, attempt, error: err, willRetry: wait !== void 0 });
|
|
186
|
+
if (wait !== void 0) {
|
|
187
|
+
await sleep(wait, signal);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
return parsed ?? {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Invoke an observability hook, swallowing any error it throws (best-effort). */
|
|
196
|
+
fire(name, info) {
|
|
197
|
+
const hook = this.hooks?.[name];
|
|
198
|
+
if (!hook) return;
|
|
199
|
+
try {
|
|
200
|
+
hook(info);
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Exponential backoff with full jitter: base * 2^attempt, randomized. */
|
|
205
|
+
backoff(attempt) {
|
|
206
|
+
const ceil = this.retryBaseDelayMs * 2 ** attempt;
|
|
207
|
+
return Math.floor(Math.random() * ceil) + this.retryBaseDelayMs;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/** HTTP statuses worth retrying — rate limit + transient gateway/server errors. */
|
|
211
|
+
_Http.RETRIABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
212
|
+
var Http = _Http;
|
|
213
|
+
function sleep(ms, signal) {
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
if (signal?.aborted) return resolve();
|
|
216
|
+
const timer = setTimeout(done, ms);
|
|
217
|
+
const onAbort = () => done();
|
|
218
|
+
function done() {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
signal?.removeEventListener("abort", onAbort);
|
|
221
|
+
resolve();
|
|
222
|
+
}
|
|
223
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function abortError(path) {
|
|
227
|
+
return new InvoError({ message: `Request to ${path} was aborted`, code: "ABORTED", status: 0 });
|
|
228
|
+
}
|
|
229
|
+
function pickRequestId(headers) {
|
|
230
|
+
if (!headers || typeof headers.get !== "function") return void 0;
|
|
231
|
+
return headers.get("x-invo-request-id") ?? headers.get("x-request-id") ?? void 0;
|
|
232
|
+
}
|
|
233
|
+
function retryAfterMs(parsed, headers) {
|
|
234
|
+
if (parsed && typeof parsed === "object") {
|
|
235
|
+
const v = parsed["retry_after"];
|
|
236
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
237
|
+
if (typeof n === "number" && Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
238
|
+
}
|
|
239
|
+
const header = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
|
|
240
|
+
if (header) {
|
|
241
|
+
const n = Number(header);
|
|
242
|
+
if (Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
243
|
+
}
|
|
244
|
+
return void 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export { Http, InvoError, assertSecureBaseUrl };
|
|
248
|
+
//# sourceMappingURL=chunk-EEWOAUXO.js.map
|
|
249
|
+
//# sourceMappingURL=chunk-EEWOAUXO.js.map
|
package/dist/index.cjs
CHANGED
|
@@ -8,6 +8,7 @@ var InvoError = class _InvoError extends Error {
|
|
|
8
8
|
this.code = args.code;
|
|
9
9
|
this.status = args.status;
|
|
10
10
|
this.body = args.body ?? null;
|
|
11
|
+
this.requestId = args.requestId;
|
|
11
12
|
Object.setPrototypeOf(this, _InvoError.prototype);
|
|
12
13
|
}
|
|
13
14
|
/** True if this is the "recipient isn't passkey-enrolled, fall back to claim code" signal. */
|
|
@@ -45,7 +46,7 @@ var InvoError = class _InvoError extends Error {
|
|
|
45
46
|
return this.body && typeof this.body === "object" ? this.body : {};
|
|
46
47
|
}
|
|
47
48
|
};
|
|
48
|
-
function errorFromResponse(status, body) {
|
|
49
|
+
function errorFromResponse(status, body, requestId) {
|
|
49
50
|
let message = `INVO request failed (HTTP ${status})`;
|
|
50
51
|
let code;
|
|
51
52
|
if (body && typeof body === "object") {
|
|
@@ -56,11 +57,12 @@ function errorFromResponse(status, body) {
|
|
|
56
57
|
} else if (typeof body === "string" && body.trim()) {
|
|
57
58
|
message = body;
|
|
58
59
|
}
|
|
59
|
-
return new InvoError({ message, code, status, body });
|
|
60
|
+
return new InvoError({ message, code, status, body, requestId });
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
// src/shared/http.ts
|
|
63
64
|
var DEFAULT_TIMEOUT = 3e4;
|
|
65
|
+
var MAX_RETRY_AFTER_MS = 2e4;
|
|
64
66
|
function assertSecureBaseUrl(baseUrl) {
|
|
65
67
|
let u;
|
|
66
68
|
try {
|
|
@@ -74,7 +76,7 @@ function assertSecureBaseUrl(baseUrl) {
|
|
|
74
76
|
`baseUrl must use https:// (got "${u.protocol}//"). Plaintext would expose the token/secret on the wire.`
|
|
75
77
|
);
|
|
76
78
|
}
|
|
77
|
-
var
|
|
79
|
+
var _Http = class _Http {
|
|
78
80
|
constructor(opts) {
|
|
79
81
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
80
82
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
@@ -86,12 +88,21 @@ var Http = class {
|
|
|
86
88
|
}
|
|
87
89
|
this.fetchImpl = f;
|
|
88
90
|
this.userAgent = opts.userAgent;
|
|
91
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
|
|
92
|
+
this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 250;
|
|
93
|
+
this.hooks = opts.hooks;
|
|
89
94
|
}
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
/**
|
|
96
|
+
* @param opts.idempotent - mark this POST safe to auto-retry (it carries an
|
|
97
|
+
* idempotency key, or has no side effect). Default false: non-idempotent POSTs
|
|
98
|
+
* (e.g. single-use WebAuthn assertions) are NEVER auto-retried.
|
|
99
|
+
*/
|
|
100
|
+
async post(path, body, auth, opts) {
|
|
101
|
+
return this.request("POST", path, body, auth, opts?.idempotent ?? false, opts?.signal);
|
|
92
102
|
}
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
// GET is always idempotent → safe to retry.
|
|
104
|
+
async get(path, auth, opts) {
|
|
105
|
+
return this.request("GET", path, void 0, auth, true, opts?.signal);
|
|
95
106
|
}
|
|
96
107
|
authHeaders(auth) {
|
|
97
108
|
switch (auth.kind) {
|
|
@@ -103,7 +114,7 @@ var Http = class {
|
|
|
103
114
|
return {};
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
|
-
async request(method, path, body, auth) {
|
|
117
|
+
async request(method, path, body, auth, idempotent, signal) {
|
|
107
118
|
const url = `${this.baseUrl}${path}`;
|
|
108
119
|
const headers = {
|
|
109
120
|
Accept: "application/json",
|
|
@@ -111,38 +122,129 @@ var Http = class {
|
|
|
111
122
|
};
|
|
112
123
|
if (this.userAgent) headers["User-Agent"] = this.userAgent;
|
|
113
124
|
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
const payload = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
126
|
+
for (let attempt = 0; ; attempt++) {
|
|
127
|
+
if (signal?.aborted) throw abortError(path);
|
|
128
|
+
const start = Date.now();
|
|
129
|
+
this.fire("onRequest", { method, url, attempt });
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
132
|
+
const onAbort = () => controller.abort();
|
|
133
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
let res;
|
|
135
|
+
let networkError;
|
|
136
|
+
try {
|
|
137
|
+
res = await this.fetchImpl(url, { method, headers, body: payload, signal: controller.signal });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
networkError = new InvoError({
|
|
140
|
+
message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
|
|
141
|
+
status: 0,
|
|
142
|
+
body: null
|
|
143
|
+
});
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
signal?.removeEventListener("abort", onAbort);
|
|
147
|
+
}
|
|
148
|
+
if (networkError) {
|
|
149
|
+
if (signal?.aborted) throw abortError(path);
|
|
150
|
+
const willRetry = idempotent && attempt < this.maxRetries;
|
|
151
|
+
this.fire("onError", { method, url, attempt, error: networkError, willRetry });
|
|
152
|
+
if (willRetry) {
|
|
153
|
+
await sleep(this.backoff(attempt), signal);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
throw networkError;
|
|
157
|
+
}
|
|
158
|
+
const requestId = pickRequestId(res.headers);
|
|
159
|
+
const text = await res.text();
|
|
160
|
+
let parsed = null;
|
|
161
|
+
if (text) {
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(text);
|
|
164
|
+
} catch {
|
|
165
|
+
parsed = text;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.fire("onResponse", {
|
|
119
169
|
method,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
throw new InvoError({
|
|
126
|
-
message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
|
|
127
|
-
status: 0,
|
|
128
|
-
body: null
|
|
170
|
+
url,
|
|
171
|
+
attempt,
|
|
172
|
+
status: res.status,
|
|
173
|
+
durationMs: Date.now() - start,
|
|
174
|
+
requestId
|
|
129
175
|
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const err = errorFromResponse(res.status, parsed, requestId);
|
|
178
|
+
let wait;
|
|
179
|
+
if (idempotent && attempt < this.maxRetries && _Http.RETRIABLE_STATUS.has(res.status)) {
|
|
180
|
+
if (res.status === 429) {
|
|
181
|
+
const ra = retryAfterMs(parsed, res.headers);
|
|
182
|
+
wait = ra === void 0 ? this.backoff(attempt) : ra <= MAX_RETRY_AFTER_MS ? ra : void 0;
|
|
183
|
+
} else {
|
|
184
|
+
wait = this.backoff(attempt);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
this.fire("onError", { method, url, attempt, error: err, willRetry: wait !== void 0 });
|
|
188
|
+
if (wait !== void 0) {
|
|
189
|
+
await sleep(wait, signal);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
140
193
|
}
|
|
194
|
+
return parsed ?? {};
|
|
141
195
|
}
|
|
142
|
-
|
|
143
|
-
|
|
196
|
+
}
|
|
197
|
+
/** Invoke an observability hook, swallowing any error it throws (best-effort). */
|
|
198
|
+
fire(name, info) {
|
|
199
|
+
const hook = this.hooks?.[name];
|
|
200
|
+
if (!hook) return;
|
|
201
|
+
try {
|
|
202
|
+
hook(info);
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Exponential backoff with full jitter: base * 2^attempt, randomized. */
|
|
207
|
+
backoff(attempt) {
|
|
208
|
+
const ceil = this.retryBaseDelayMs * 2 ** attempt;
|
|
209
|
+
return Math.floor(Math.random() * ceil) + this.retryBaseDelayMs;
|
|
144
210
|
}
|
|
145
211
|
};
|
|
212
|
+
/** HTTP statuses worth retrying — rate limit + transient gateway/server errors. */
|
|
213
|
+
_Http.RETRIABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
214
|
+
var Http = _Http;
|
|
215
|
+
function sleep(ms, signal) {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
if (signal?.aborted) return resolve();
|
|
218
|
+
const timer = setTimeout(done, ms);
|
|
219
|
+
const onAbort = () => done();
|
|
220
|
+
function done() {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
signal?.removeEventListener("abort", onAbort);
|
|
223
|
+
resolve();
|
|
224
|
+
}
|
|
225
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function abortError(path) {
|
|
229
|
+
return new InvoError({ message: `Request to ${path} was aborted`, code: "ABORTED", status: 0 });
|
|
230
|
+
}
|
|
231
|
+
function pickRequestId(headers) {
|
|
232
|
+
if (!headers || typeof headers.get !== "function") return void 0;
|
|
233
|
+
return headers.get("x-invo-request-id") ?? headers.get("x-request-id") ?? void 0;
|
|
234
|
+
}
|
|
235
|
+
function retryAfterMs(parsed, headers) {
|
|
236
|
+
if (parsed && typeof parsed === "object") {
|
|
237
|
+
const v = parsed["retry_after"];
|
|
238
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
239
|
+
if (typeof n === "number" && Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
240
|
+
}
|
|
241
|
+
const header = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
|
|
242
|
+
if (header) {
|
|
243
|
+
const n = Number(header);
|
|
244
|
+
if (Number.isFinite(n) && n >= 0) return n * 1e3;
|
|
245
|
+
}
|
|
246
|
+
return void 0;
|
|
247
|
+
}
|
|
146
248
|
|
|
147
249
|
// src/shared/webauthn.ts
|
|
148
250
|
function b64urlToBuffer(value) {
|
|
@@ -234,7 +336,10 @@ var InvoClient = class {
|
|
|
234
336
|
this.http = new Http({
|
|
235
337
|
baseUrl: config.baseUrl,
|
|
236
338
|
timeoutMs: config.timeoutMs,
|
|
237
|
-
fetchImpl: config.fetch
|
|
339
|
+
fetchImpl: config.fetch,
|
|
340
|
+
maxRetries: config.maxRetries,
|
|
341
|
+
retryBaseDelayMs: config.retryBaseDelayMs,
|
|
342
|
+
hooks: config.hooks
|
|
238
343
|
// Browser: do NOT set User-Agent (forbidden header); the browser's own UA is fine.
|
|
239
344
|
});
|
|
240
345
|
this.auth = { kind: "bearer", token: config.token };
|
|
@@ -246,38 +351,43 @@ var InvoClient = class {
|
|
|
246
351
|
}
|
|
247
352
|
}
|
|
248
353
|
/** Enroll a passkey for the token's identity (register/begin -> create() -> register/complete). */
|
|
249
|
-
async enrollPasskey() {
|
|
354
|
+
async enrollPasskey(opts) {
|
|
250
355
|
this.assertWebAuthn();
|
|
356
|
+
const signal = opts?.signal;
|
|
251
357
|
return this.withTokenRetry(async () => {
|
|
252
358
|
const options = await this.post(
|
|
253
|
-
"/api/sdk/webauthn/register/begin"
|
|
359
|
+
"/api/sdk/webauthn/register/begin",
|
|
360
|
+
void 0,
|
|
361
|
+
signal
|
|
254
362
|
);
|
|
255
363
|
const cred = await navigator.credentials.create({
|
|
256
|
-
publicKey: toCreationOptions(options)
|
|
364
|
+
publicKey: toCreationOptions(options),
|
|
365
|
+
signal
|
|
257
366
|
});
|
|
258
367
|
if (!cred) throw new Error("Passkey creation was cancelled or returned no credential.");
|
|
259
368
|
const raw = await this.post(
|
|
260
369
|
"/api/sdk/webauthn/register/complete",
|
|
261
|
-
{ credential: registrationToJSON(cred) }
|
|
370
|
+
{ credential: registrationToJSON(cred) },
|
|
371
|
+
signal
|
|
262
372
|
);
|
|
263
373
|
return { status: String(raw["status"] ?? ""), device: raw["device"] ?? null, raw };
|
|
264
374
|
});
|
|
265
375
|
}
|
|
266
376
|
/** Approve a SEND with the player's passkey. */
|
|
267
|
-
async approveSend(transactionId) {
|
|
268
|
-
return this.approve("send", transactionId);
|
|
377
|
+
async approveSend(transactionId, opts) {
|
|
378
|
+
return this.approve("send", transactionId, opts);
|
|
269
379
|
}
|
|
270
380
|
/** Approve a TRANSFER with the player's passkey (returns the sender's claim code). */
|
|
271
|
-
async approveTransfer(transactionId) {
|
|
272
|
-
return this.approve("transfers", transactionId);
|
|
381
|
+
async approveTransfer(transactionId, opts) {
|
|
382
|
+
return this.approve("transfers", transactionId, opts);
|
|
273
383
|
}
|
|
274
384
|
/** Recipient self-claims a SEND with their passkey. */
|
|
275
|
-
async confirmReceiptSend(transactionId) {
|
|
276
|
-
return this.confirmReceipt("send", transactionId);
|
|
385
|
+
async confirmReceiptSend(transactionId, opts) {
|
|
386
|
+
return this.confirmReceipt("send", transactionId, opts);
|
|
277
387
|
}
|
|
278
388
|
/** Recipient self-claims a TRANSFER with their passkey. */
|
|
279
|
-
async confirmReceiptTransfer(transactionId) {
|
|
280
|
-
return this.confirmReceipt("transfers", transactionId);
|
|
389
|
+
async confirmReceiptTransfer(transactionId, opts) {
|
|
390
|
+
return this.confirmReceipt("transfers", transactionId, opts);
|
|
281
391
|
}
|
|
282
392
|
/**
|
|
283
393
|
* Interchangeable methods (§4.6): prove an *already-enrolled* method (e.g. the
|
|
@@ -287,15 +397,19 @@ var InvoClient = class {
|
|
|
287
397
|
*
|
|
288
398
|
* begin -> navigator.credentials.get() -> complete with { link_id, webauthn_assertion }.
|
|
289
399
|
*/
|
|
290
|
-
async linkDevice(linkId) {
|
|
400
|
+
async linkDevice(linkId, opts) {
|
|
291
401
|
if (!linkId) throw new Error("linkDevice requires a `linkId`.");
|
|
402
|
+
const signal = opts?.signal;
|
|
292
403
|
return this.withTokenRetry(async () => {
|
|
293
|
-
const assertion = await this.runAssertion(
|
|
294
|
-
|
|
295
|
-
|
|
404
|
+
const assertion = await this.runAssertion(
|
|
405
|
+
"/api/sdk/device/link/webauthn/begin",
|
|
406
|
+
{ link_id: linkId },
|
|
407
|
+
signal
|
|
408
|
+
);
|
|
296
409
|
const raw = await this.post(
|
|
297
410
|
"/api/sdk/device/link/webauthn/complete",
|
|
298
|
-
{ link_id: linkId, webauthn_assertion: assertion }
|
|
411
|
+
{ link_id: linkId, webauthn_assertion: assertion },
|
|
412
|
+
signal
|
|
299
413
|
);
|
|
300
414
|
return { status: String(raw["status"] ?? ""), raw };
|
|
301
415
|
});
|
|
@@ -303,8 +417,8 @@ var InvoClient = class {
|
|
|
303
417
|
// --- internals ---
|
|
304
418
|
/** POST with the current player token. Token-expiry retry is handled one level
|
|
305
419
|
* up by withTokenRetry (which re-runs the whole ceremony, not a single call). */
|
|
306
|
-
async post(path, body) {
|
|
307
|
-
return this.http.post(path, body, this.auth);
|
|
420
|
+
async post(path, body, signal) {
|
|
421
|
+
return this.http.post(path, body, this.auth, { signal });
|
|
308
422
|
}
|
|
309
423
|
/**
|
|
310
424
|
* Run a whole flow, retrying it ONCE if any call fails with SDK_TOKEN_EXPIRED
|
|
@@ -335,22 +449,29 @@ var InvoClient = class {
|
|
|
335
449
|
this.auth = { kind: "bearer", token: fresh };
|
|
336
450
|
return true;
|
|
337
451
|
}
|
|
338
|
-
async runAssertion(beginPath, beginBody) {
|
|
452
|
+
async runAssertion(beginPath, beginBody, signal) {
|
|
339
453
|
this.assertWebAuthn();
|
|
340
|
-
const options = await this.post(beginPath, beginBody);
|
|
454
|
+
const options = await this.post(beginPath, beginBody, signal);
|
|
341
455
|
const cred = await navigator.credentials.get({
|
|
342
|
-
publicKey: toRequestOptions(options)
|
|
456
|
+
publicKey: toRequestOptions(options),
|
|
457
|
+
signal
|
|
343
458
|
});
|
|
344
459
|
if (!cred) throw new Error("Passkey assertion was cancelled or returned no credential.");
|
|
345
460
|
return assertionToJSON(cred);
|
|
346
461
|
}
|
|
347
|
-
async approve(flow, transactionId) {
|
|
462
|
+
async approve(flow, transactionId, opts) {
|
|
348
463
|
const id = encodeURIComponent(transactionId);
|
|
464
|
+
const signal = opts?.signal;
|
|
349
465
|
return this.withTokenRetry(async () => {
|
|
350
|
-
const assertion = await this.runAssertion(
|
|
466
|
+
const assertion = await this.runAssertion(
|
|
467
|
+
`/api/sdk/${flow}/${id}/approve/webauthn/begin`,
|
|
468
|
+
void 0,
|
|
469
|
+
signal
|
|
470
|
+
);
|
|
351
471
|
const raw = await this.post(
|
|
352
472
|
`/api/sdk/${flow}/${id}/approve`,
|
|
353
|
-
{ webauthn_assertion: assertion }
|
|
473
|
+
{ webauthn_assertion: assertion },
|
|
474
|
+
signal
|
|
354
475
|
);
|
|
355
476
|
return {
|
|
356
477
|
status: String(raw["status"] ?? ""),
|
|
@@ -362,15 +483,19 @@ var InvoClient = class {
|
|
|
362
483
|
};
|
|
363
484
|
});
|
|
364
485
|
}
|
|
365
|
-
async confirmReceipt(flow, transactionId) {
|
|
486
|
+
async confirmReceipt(flow, transactionId, opts) {
|
|
366
487
|
const id = encodeURIComponent(transactionId);
|
|
488
|
+
const signal = opts?.signal;
|
|
367
489
|
return this.withTokenRetry(async () => {
|
|
368
490
|
const assertion = await this.runAssertion(
|
|
369
|
-
`/api/sdk/${flow}/${id}/confirm-receipt/webauthn/begin
|
|
491
|
+
`/api/sdk/${flow}/${id}/confirm-receipt/webauthn/begin`,
|
|
492
|
+
void 0,
|
|
493
|
+
signal
|
|
370
494
|
);
|
|
371
495
|
const raw = await this.post(
|
|
372
496
|
`/api/sdk/${flow}/${id}/confirm-receipt`,
|
|
373
|
-
{ webauthn_assertion: assertion }
|
|
497
|
+
{ webauthn_assertion: assertion },
|
|
498
|
+
signal
|
|
374
499
|
);
|
|
375
500
|
return { status: String(raw["status"] ?? ""), raw };
|
|
376
501
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as ClientConfig, A as ApproveResult,
|
|
2
|
-
export { I as InvoError, R as Rail, V as VerificationMethod } from './
|
|
1
|
+
import { C as ClientConfig, a as CallOptions, A as ApproveResult, b as ConfirmReceiptResult, L as LinkDeviceResult } from './types-CBMLNwbe.cjs';
|
|
2
|
+
export { I as InvoError, c as InvoErrorInfo, d as InvoHooks, e as InvoRequestInfo, f as InvoResponseInfo, R as Rail, V as VerificationMethod } from './types-CBMLNwbe.cjs';
|
|
3
3
|
|
|
4
4
|
declare class InvoClient {
|
|
5
5
|
private readonly http;
|
|
@@ -10,19 +10,19 @@ declare class InvoClient {
|
|
|
10
10
|
constructor(config: ClientConfig);
|
|
11
11
|
private assertWebAuthn;
|
|
12
12
|
/** Enroll a passkey for the token's identity (register/begin -> create() -> register/complete). */
|
|
13
|
-
enrollPasskey(): Promise<{
|
|
13
|
+
enrollPasskey(opts?: CallOptions): Promise<{
|
|
14
14
|
status: string;
|
|
15
15
|
device: unknown;
|
|
16
16
|
raw: Record<string, unknown>;
|
|
17
17
|
}>;
|
|
18
18
|
/** Approve a SEND with the player's passkey. */
|
|
19
|
-
approveSend(transactionId: string): Promise<ApproveResult>;
|
|
19
|
+
approveSend(transactionId: string, opts?: CallOptions): Promise<ApproveResult>;
|
|
20
20
|
/** Approve a TRANSFER with the player's passkey (returns the sender's claim code). */
|
|
21
|
-
approveTransfer(transactionId: string): Promise<ApproveResult>;
|
|
21
|
+
approveTransfer(transactionId: string, opts?: CallOptions): Promise<ApproveResult>;
|
|
22
22
|
/** Recipient self-claims a SEND with their passkey. */
|
|
23
|
-
confirmReceiptSend(transactionId: string): Promise<ConfirmReceiptResult>;
|
|
23
|
+
confirmReceiptSend(transactionId: string, opts?: CallOptions): Promise<ConfirmReceiptResult>;
|
|
24
24
|
/** Recipient self-claims a TRANSFER with their passkey. */
|
|
25
|
-
confirmReceiptTransfer(transactionId: string): Promise<ConfirmReceiptResult>;
|
|
25
|
+
confirmReceiptTransfer(transactionId: string, opts?: CallOptions): Promise<ConfirmReceiptResult>;
|
|
26
26
|
/**
|
|
27
27
|
* Interchangeable methods (§4.6): prove an *already-enrolled* method (e.g. the
|
|
28
28
|
* INVO app device key) to authorize adding a new partner passkey. The returned
|
|
@@ -31,7 +31,7 @@ declare class InvoClient {
|
|
|
31
31
|
*
|
|
32
32
|
* begin -> navigator.credentials.get() -> complete with { link_id, webauthn_assertion }.
|
|
33
33
|
*/
|
|
34
|
-
linkDevice(linkId: string): Promise<LinkDeviceResult>;
|
|
34
|
+
linkDevice(linkId: string, opts?: CallOptions): Promise<LinkDeviceResult>;
|
|
35
35
|
/** POST with the current player token. Token-expiry retry is handled one level
|
|
36
36
|
* up by withTokenRetry (which re-runs the whole ceremony, not a single call). */
|
|
37
37
|
private post;
|
|
@@ -49,4 +49,4 @@ declare class InvoClient {
|
|
|
49
49
|
private confirmReceipt;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export { ApproveResult, ClientConfig, ConfirmReceiptResult, InvoClient, LinkDeviceResult };
|
|
52
|
+
export { ApproveResult, CallOptions, ClientConfig, ConfirmReceiptResult, InvoClient, LinkDeviceResult };
|