@invonetwork/web-sdk 0.2.0 → 0.3.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.
@@ -0,0 +1,231 @@
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);
100
+ }
101
+ // GET is always idempotent → safe to retry.
102
+ async get(path, auth) {
103
+ return this.request("GET", path, void 0, auth, true);
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) {
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
+ const start = Date.now();
126
+ this.fire("onRequest", { method, url, attempt });
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
129
+ let res;
130
+ let networkError;
131
+ try {
132
+ res = await this.fetchImpl(url, { method, headers, body: payload, signal: controller.signal });
133
+ } catch (err) {
134
+ networkError = new InvoError({
135
+ message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
136
+ status: 0,
137
+ body: null
138
+ });
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ if (networkError) {
143
+ const willRetry = idempotent && attempt < this.maxRetries;
144
+ this.fire("onError", { method, url, attempt, error: networkError, willRetry });
145
+ if (willRetry) {
146
+ await sleep(this.backoff(attempt));
147
+ continue;
148
+ }
149
+ throw networkError;
150
+ }
151
+ const requestId = pickRequestId(res.headers);
152
+ const text = await res.text();
153
+ let parsed = null;
154
+ if (text) {
155
+ try {
156
+ parsed = JSON.parse(text);
157
+ } catch {
158
+ parsed = text;
159
+ }
160
+ }
161
+ this.fire("onResponse", {
162
+ method,
163
+ url,
164
+ attempt,
165
+ status: res.status,
166
+ durationMs: Date.now() - start,
167
+ requestId
168
+ });
169
+ if (!res.ok) {
170
+ const err = errorFromResponse(res.status, parsed, requestId);
171
+ let wait;
172
+ if (idempotent && attempt < this.maxRetries && _Http.RETRIABLE_STATUS.has(res.status)) {
173
+ if (res.status === 429) {
174
+ const ra = retryAfterMs(parsed, res.headers);
175
+ wait = ra === void 0 ? this.backoff(attempt) : ra <= MAX_RETRY_AFTER_MS ? ra : void 0;
176
+ } else {
177
+ wait = this.backoff(attempt);
178
+ }
179
+ }
180
+ this.fire("onError", { method, url, attempt, error: err, willRetry: wait !== void 0 });
181
+ if (wait !== void 0) {
182
+ await sleep(wait);
183
+ continue;
184
+ }
185
+ throw err;
186
+ }
187
+ return parsed ?? {};
188
+ }
189
+ }
190
+ /** Invoke an observability hook, swallowing any error it throws (best-effort). */
191
+ fire(name, info) {
192
+ const hook = this.hooks?.[name];
193
+ if (!hook) return;
194
+ try {
195
+ hook(info);
196
+ } catch {
197
+ }
198
+ }
199
+ /** Exponential backoff with full jitter: base * 2^attempt, randomized. */
200
+ backoff(attempt) {
201
+ const ceil = this.retryBaseDelayMs * 2 ** attempt;
202
+ return Math.floor(Math.random() * ceil) + this.retryBaseDelayMs;
203
+ }
204
+ };
205
+ /** HTTP statuses worth retrying — rate limit + transient gateway/server errors. */
206
+ _Http.RETRIABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
207
+ var Http = _Http;
208
+ function sleep(ms) {
209
+ return new Promise((resolve) => setTimeout(resolve, ms));
210
+ }
211
+ function pickRequestId(headers) {
212
+ if (!headers || typeof headers.get !== "function") return void 0;
213
+ return headers.get("x-invo-request-id") ?? headers.get("x-request-id") ?? void 0;
214
+ }
215
+ function retryAfterMs(parsed, headers) {
216
+ if (parsed && typeof parsed === "object") {
217
+ const v = parsed["retry_after"];
218
+ const n = typeof v === "string" ? Number(v) : v;
219
+ if (typeof n === "number" && Number.isFinite(n) && n >= 0) return n * 1e3;
220
+ }
221
+ const header = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
222
+ if (header) {
223
+ const n = Number(header);
224
+ if (Number.isFinite(n) && n >= 0) return n * 1e3;
225
+ }
226
+ return void 0;
227
+ }
228
+
229
+ export { Http, InvoError, assertSecureBaseUrl };
230
+ //# sourceMappingURL=chunk-DV3WZGMH.js.map
231
+ //# sourceMappingURL=chunk-DV3WZGMH.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 Http = class {
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
- async post(path, body, auth) {
91
- return this.request("POST", path, body, auth);
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);
92
102
  }
103
+ // GET is always idempotent → safe to retry.
93
104
  async get(path, auth) {
94
- return this.request("GET", path, void 0, auth);
105
+ return this.request("GET", path, void 0, auth, true);
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) {
107
118
  const url = `${this.baseUrl}${path}`;
108
119
  const headers = {
109
120
  Accept: "application/json",
@@ -111,38 +122,111 @@ 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 controller = new AbortController();
115
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
116
- let res;
117
- try {
118
- res = await this.fetchImpl(url, {
125
+ const payload = body !== void 0 ? JSON.stringify(body) : void 0;
126
+ for (let attempt = 0; ; attempt++) {
127
+ const start = Date.now();
128
+ this.fire("onRequest", { method, url, attempt });
129
+ const controller = new AbortController();
130
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
131
+ let res;
132
+ let networkError;
133
+ try {
134
+ res = await this.fetchImpl(url, { method, headers, body: payload, signal: controller.signal });
135
+ } catch (err) {
136
+ networkError = new InvoError({
137
+ message: err instanceof Error && err.name === "AbortError" ? `Request to ${path} timed out after ${this.timeoutMs}ms` : `Network error calling ${path}: ${err?.message ?? err}`,
138
+ status: 0,
139
+ body: null
140
+ });
141
+ } finally {
142
+ clearTimeout(timer);
143
+ }
144
+ if (networkError) {
145
+ const willRetry = idempotent && attempt < this.maxRetries;
146
+ this.fire("onError", { method, url, attempt, error: networkError, willRetry });
147
+ if (willRetry) {
148
+ await sleep(this.backoff(attempt));
149
+ continue;
150
+ }
151
+ throw networkError;
152
+ }
153
+ const requestId = pickRequestId(res.headers);
154
+ const text = await res.text();
155
+ let parsed = null;
156
+ if (text) {
157
+ try {
158
+ parsed = JSON.parse(text);
159
+ } catch {
160
+ parsed = text;
161
+ }
162
+ }
163
+ this.fire("onResponse", {
119
164
  method,
120
- headers,
121
- body: body !== void 0 ? JSON.stringify(body) : void 0,
122
- signal: controller.signal
123
- });
124
- } catch (err) {
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
165
+ url,
166
+ attempt,
167
+ status: res.status,
168
+ durationMs: Date.now() - start,
169
+ requestId
129
170
  });
130
- } finally {
131
- clearTimeout(timer);
132
- }
133
- const text = await res.text();
134
- let parsed = null;
135
- if (text) {
136
- try {
137
- parsed = JSON.parse(text);
138
- } catch {
139
- parsed = text;
171
+ if (!res.ok) {
172
+ const err = errorFromResponse(res.status, parsed, requestId);
173
+ let wait;
174
+ if (idempotent && attempt < this.maxRetries && _Http.RETRIABLE_STATUS.has(res.status)) {
175
+ if (res.status === 429) {
176
+ const ra = retryAfterMs(parsed, res.headers);
177
+ wait = ra === void 0 ? this.backoff(attempt) : ra <= MAX_RETRY_AFTER_MS ? ra : void 0;
178
+ } else {
179
+ wait = this.backoff(attempt);
180
+ }
181
+ }
182
+ this.fire("onError", { method, url, attempt, error: err, willRetry: wait !== void 0 });
183
+ if (wait !== void 0) {
184
+ await sleep(wait);
185
+ continue;
186
+ }
187
+ throw err;
140
188
  }
189
+ return parsed ?? {};
141
190
  }
142
- if (!res.ok) throw errorFromResponse(res.status, parsed);
143
- return parsed ?? {};
191
+ }
192
+ /** Invoke an observability hook, swallowing any error it throws (best-effort). */
193
+ fire(name, info) {
194
+ const hook = this.hooks?.[name];
195
+ if (!hook) return;
196
+ try {
197
+ hook(info);
198
+ } catch {
199
+ }
200
+ }
201
+ /** Exponential backoff with full jitter: base * 2^attempt, randomized. */
202
+ backoff(attempt) {
203
+ const ceil = this.retryBaseDelayMs * 2 ** attempt;
204
+ return Math.floor(Math.random() * ceil) + this.retryBaseDelayMs;
144
205
  }
145
206
  };
207
+ /** HTTP statuses worth retrying — rate limit + transient gateway/server errors. */
208
+ _Http.RETRIABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
209
+ var Http = _Http;
210
+ function sleep(ms) {
211
+ return new Promise((resolve) => setTimeout(resolve, ms));
212
+ }
213
+ function pickRequestId(headers) {
214
+ if (!headers || typeof headers.get !== "function") return void 0;
215
+ return headers.get("x-invo-request-id") ?? headers.get("x-request-id") ?? void 0;
216
+ }
217
+ function retryAfterMs(parsed, headers) {
218
+ if (parsed && typeof parsed === "object") {
219
+ const v = parsed["retry_after"];
220
+ const n = typeof v === "string" ? Number(v) : v;
221
+ if (typeof n === "number" && Number.isFinite(n) && n >= 0) return n * 1e3;
222
+ }
223
+ const header = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
224
+ if (header) {
225
+ const n = Number(header);
226
+ if (Number.isFinite(n) && n >= 0) return n * 1e3;
227
+ }
228
+ return void 0;
229
+ }
146
230
 
147
231
  // src/shared/webauthn.ts
148
232
  function b64urlToBuffer(value) {
@@ -234,7 +318,10 @@ var InvoClient = class {
234
318
  this.http = new Http({
235
319
  baseUrl: config.baseUrl,
236
320
  timeoutMs: config.timeoutMs,
237
- fetchImpl: config.fetch
321
+ fetchImpl: config.fetch,
322
+ maxRetries: config.maxRetries,
323
+ retryBaseDelayMs: config.retryBaseDelayMs,
324
+ hooks: config.hooks
238
325
  // Browser: do NOT set User-Agent (forbidden header); the browser's own UA is fine.
239
326
  });
240
327
  this.auth = { kind: "bearer", token: config.token };
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-DV5QsftP.cjs';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.cjs';
1
+ import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './types-CBkoUymV.cjs';
2
+ export { I as InvoError, b as InvoErrorInfo, c as InvoHooks, d as InvoRequestInfo, e as InvoResponseInfo, R as Rail, V as VerificationMethod } from './types-CBkoUymV.cjs';
3
3
 
4
4
  declare class InvoClient {
5
5
  private readonly http;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-DV5QsftP.js';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.js';
1
+ import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './types-CBkoUymV.js';
2
+ export { I as InvoError, b as InvoErrorInfo, c as InvoHooks, d as InvoRequestInfo, e as InvoResponseInfo, R as Rail, V as VerificationMethod } from './types-CBkoUymV.js';
3
3
 
4
4
  declare class InvoClient {
5
5
  private readonly http;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { assertSecureBaseUrl, Http, InvoError } from './chunk-A44O4KC3.js';
2
- export { InvoError } from './chunk-A44O4KC3.js';
1
+ import { assertSecureBaseUrl, Http, InvoError } from './chunk-DV3WZGMH.js';
2
+ export { InvoError } from './chunk-DV3WZGMH.js';
3
3
 
4
4
  // src/shared/webauthn.ts
5
5
  function b64urlToBuffer(value) {
@@ -91,7 +91,10 @@ var InvoClient = class {
91
91
  this.http = new Http({
92
92
  baseUrl: config.baseUrl,
93
93
  timeoutMs: config.timeoutMs,
94
- fetchImpl: config.fetch
94
+ fetchImpl: config.fetch,
95
+ maxRetries: config.maxRetries,
96
+ retryBaseDelayMs: config.retryBaseDelayMs,
97
+ hooks: config.hooks
95
98
  // Browser: do NOT set User-Agent (forbidden header); the browser's own UA is fine.
96
99
  });
97
100
  this.auth = { kind: "bearer", token: config.token };