@joryio/web-sdk 0.0.0-stage → 1.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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- package/src/worker/joryio-sw.js +218 -0
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Layer with retry logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SubscriptionChannel } from './user';
|
|
6
|
+
import { Logger } from '../utils/logger';
|
|
7
|
+
|
|
8
|
+
export interface NetworkRequest {
|
|
9
|
+
url: string;
|
|
10
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: any;
|
|
13
|
+
/**
|
|
14
|
+
* The user this request is about (`null`: an anonymous visitor). Not sent.
|
|
15
|
+
* An SDK-authentication 401 for a user who is no longer the current one
|
|
16
|
+
* never reaches the host's auth error handler - it must not make the app
|
|
17
|
+
* sign out the CURRENT user over a previous user's leftovers (contract
|
|
18
|
+
* item 39). Omitted: the current user's.
|
|
19
|
+
*/
|
|
20
|
+
subjectUserId?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NetworkResponse {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
/** HTTP status of the last attempt; 0 when no response arrived (offline, timeout, circuit open). */
|
|
26
|
+
status: number;
|
|
27
|
+
data?: any;
|
|
28
|
+
error?: string;
|
|
29
|
+
/** Server-requested wait from a `Retry-After` header, in ms. */
|
|
30
|
+
retryAfterMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* The server answered 2xx but its body said `success: false`: it did NOT do
|
|
33
|
+
* what was asked (a database error, a refused registration). `ok` is false
|
|
34
|
+
* for these, so no caller can acknowledge the write by accident; the batch
|
|
35
|
+
* path reads `data.failedIndices` to keep only what was not stored.
|
|
36
|
+
*/
|
|
37
|
+
softFailure?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when a parsed 2xx body reports that the request was NOT carried out. */
|
|
41
|
+
export function isSoftFailure(data: any): boolean {
|
|
42
|
+
return !!data && typeof data === 'object' && !Array.isArray(data) && data.success === false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Only the fields the API reads: bookkeeping (attempt counts) stays on the device. */
|
|
46
|
+
function wireIdentify(identify: { userId: string; anonymousId: string; attributes?: Record<string, any> }) {
|
|
47
|
+
return { userId: identify.userId, anonymousId: identify.anonymousId, attributes: identify.attributes || {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `code` of the 402 every SDK route returns while the workspace's ingestion is paused. */
|
|
51
|
+
export const SDK_INGESTION_SUSPENDED_CODE = 'SDK_INGESTION_SUSPENDED';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The most a keepalive request may carry. Browsers cap the SUM of in-flight
|
|
55
|
+
* keepalive bodies at 64 KiB per page; staying under it leaves room for the
|
|
56
|
+
* request line and for a concurrent keepalive the host page may have sent.
|
|
57
|
+
*/
|
|
58
|
+
export const KEEPALIVE_MAX_BYTES = 60 * 1024;
|
|
59
|
+
|
|
60
|
+
/** UTF-8 byte length of a string, without allocating an encoder. */
|
|
61
|
+
export function utf8Length(str: string): number {
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
for (let i = 0; i < str.length; i++) {
|
|
64
|
+
const code = str.charCodeAt(i);
|
|
65
|
+
if (code < 0x80) bytes += 1;
|
|
66
|
+
else if (code < 0x800) bytes += 2;
|
|
67
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
68
|
+
bytes += 4;
|
|
69
|
+
i++; // the low surrogate is part of the same code point
|
|
70
|
+
} else bytes += 3;
|
|
71
|
+
}
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse a `Retry-After` header (delta-seconds or an HTTP date) to ms. */
|
|
76
|
+
export function parseRetryAfter(value: string | null | undefined, now: number = Date.now()): number | undefined {
|
|
77
|
+
if (!value) return undefined;
|
|
78
|
+
const seconds = Number(value);
|
|
79
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
|
|
80
|
+
const date = Date.parse(value);
|
|
81
|
+
if (Number.isFinite(date)) return Math.max(0, date - now);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Atomic attribute-operation sections accepted by `POST /v1/attributes`.
|
|
87
|
+
* Every field is optional; send only the ones a given method needs.
|
|
88
|
+
*/
|
|
89
|
+
export interface AttributeOps {
|
|
90
|
+
attributes?: Record<string, any>; // $set (absolute)
|
|
91
|
+
increment?: Record<string, number>; // $inc (delta)
|
|
92
|
+
append?: Record<string, any>; // $addToSet
|
|
93
|
+
remove?: Record<string, any>; // $pull
|
|
94
|
+
unset?: string[]; // $unset
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RetryConfig {
|
|
98
|
+
maxRetries: number;
|
|
99
|
+
backoffMs: number;
|
|
100
|
+
maxBackoffMs?: number;
|
|
101
|
+
/** Per-attempt timeout (the `requestTimeout` init option). Default 10 s. */
|
|
102
|
+
timeoutMs?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Header the SDK attaches carrying the customer-minted SDK-auth JWT. */
|
|
106
|
+
export const SDK_AUTH_HEADER = 'X-Joryio-Auth';
|
|
107
|
+
|
|
108
|
+
/** Logged-out users whose token is kept for their queued leftovers. */
|
|
109
|
+
const MAX_RETIRED_SDK_AUTH_TOKENS = 4;
|
|
110
|
+
/**
|
|
111
|
+
* A retired token whose user has nothing left queued is forgotten only once
|
|
112
|
+
* it has been retired this long (rule 61): a write queued right after the
|
|
113
|
+
* logout - or by another tab - still finds it.
|
|
114
|
+
*/
|
|
115
|
+
export const RETIRED_SDK_AUTH_TOKEN_GRACE_MS = 60_000;
|
|
116
|
+
/** ...and never kept longer than this, leftovers or not (the server's own withdrawal window). */
|
|
117
|
+
export const RETIRED_SDK_AUTH_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
118
|
+
|
|
119
|
+
/** One retired token: the token and when it was retired (epoch ms). */
|
|
120
|
+
export interface RetiredSdkAuthToken {
|
|
121
|
+
token: string;
|
|
122
|
+
at: number;
|
|
123
|
+
/**
|
|
124
|
+
* Set for the NEXT user before a reset() (rule 72, same on iOS and Android):
|
|
125
|
+
* nothing of theirs is queued yet, so the "nothing left" prune keeps it
|
|
126
|
+
* until their identify() adopts it (still capped at 30 days).
|
|
127
|
+
*/
|
|
128
|
+
standby?: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Where the retired tokens are kept (owner decision D-a, 2026-10-07): in
|
|
133
|
+
* localStorage next to the outbox, so a logged-out user's queued withdrawals
|
|
134
|
+
* and events still authenticate after a reload. The SDK wires it.
|
|
135
|
+
*
|
|
136
|
+
* `update` is a read-modify-write of the STORED map (rule 62): every tab of
|
|
137
|
+
* the site shares it, so a tab never writes its own map over another tab's
|
|
138
|
+
* entries - it adds and removes only its own.
|
|
139
|
+
*/
|
|
140
|
+
export interface RetiredTokenStore {
|
|
141
|
+
load(): Record<string, unknown> | null;
|
|
142
|
+
update(mutate: (stored: Record<string, unknown>) => Record<string, unknown>): void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A stored entry, or null when it is malformed (one bad entry never loses the others). */
|
|
146
|
+
export function parseRetiredToken(raw: unknown, now: number = Date.now()): RetiredSdkAuthToken | null {
|
|
147
|
+
// Stored by an earlier 1.3.0 build: the token alone. Its age is unknown, so it starts now.
|
|
148
|
+
if (typeof raw === 'string') return raw ? { token: raw, at: now } : null;
|
|
149
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
150
|
+
const { token, at, standby } = raw as any;
|
|
151
|
+
if (typeof token !== 'string' || !token || typeof at !== 'number' || !Number.isFinite(at)) return null;
|
|
152
|
+
return standby === true ? { token, at, standby: true } : { token, at };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `sub` claim of an SDK-authentication JWT, read WITHOUT verifying it
|
|
157
|
+
* (the server verifies). Used only to avoid retiring a token that already
|
|
158
|
+
* belongs to the user being identified (the host set B's token before
|
|
159
|
+
* identify('B')). Null when it cannot be read.
|
|
160
|
+
*/
|
|
161
|
+
export function jwtSubject(token: string | undefined): string | null {
|
|
162
|
+
const sub = jwtClaims(token)?.sub;
|
|
163
|
+
return typeof sub === 'string' && sub ? sub : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which of two tokens of one user is the newer (rule 77, scan 7 N2): the one
|
|
168
|
+
* that expires later, then the one issued later. Null when the claims do not
|
|
169
|
+
* tell (the caller falls back to when each was retired).
|
|
170
|
+
*/
|
|
171
|
+
export function newerToken(a: string, b: string): string | null {
|
|
172
|
+
const ca = jwtClaims(a);
|
|
173
|
+
const cb = jwtClaims(b);
|
|
174
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
175
|
+
for (const claim of ['exp', 'iat']) {
|
|
176
|
+
const x = num(ca?.[claim]);
|
|
177
|
+
const y = num(cb?.[claim]);
|
|
178
|
+
if (x !== null && y !== null && x !== y) return x > y ? a : b;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The claims of an SDK-authentication JWT, read WITHOUT verifying it; null when unreadable. */
|
|
184
|
+
function jwtClaims(token: string | undefined): Record<string, unknown> | null {
|
|
185
|
+
if (!token) return null;
|
|
186
|
+
try {
|
|
187
|
+
const part = token.split('.')[1];
|
|
188
|
+
if (!part) return null;
|
|
189
|
+
const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
190
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
191
|
+
const decode =
|
|
192
|
+
typeof atob === 'function'
|
|
193
|
+
? atob
|
|
194
|
+
: (x: string) => (globalThis as any).Buffer.from(x, 'base64').toString('binary');
|
|
195
|
+
const json = decodeURIComponent(
|
|
196
|
+
Array.prototype.map
|
|
197
|
+
.call(decode(padded), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
|
198
|
+
.join(''),
|
|
199
|
+
);
|
|
200
|
+
const claims = JSON.parse(json);
|
|
201
|
+
return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* True when a request only WITHDRAWS: a channel unsubscribe, a group
|
|
209
|
+
* unsubscribe, the opt-out marker alone, a push unregister. The server
|
|
210
|
+
* accepts these even while the workspace's ingestion is paused (402), so a
|
|
211
|
+
* suspension never holds them back. Mirrors the backend's isSdkWithdrawal.
|
|
212
|
+
*/
|
|
213
|
+
export function isWithdrawalRequest(url: string, body: any): boolean {
|
|
214
|
+
const path = String(url || '').split('?')[0];
|
|
215
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
216
|
+
if (path.endsWith('/v1/push/unregister')) return true;
|
|
217
|
+
if (path.endsWith('/v1/subscriptions/channel')) return b.status === 'unsubscribed';
|
|
218
|
+
if (path.endsWith('/v1/subscriptions/group')) return b.action === 'unsubscribe';
|
|
219
|
+
if (path.endsWith('/v1/attributes')) {
|
|
220
|
+
const ops = ['increment', 'append', 'remove', 'unset'];
|
|
221
|
+
if (ops.some((k) => b[k] != null && Object.keys(b[k]).length > 0)) return false;
|
|
222
|
+
const attrs = b.attributes;
|
|
223
|
+
if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) return false;
|
|
224
|
+
const keys = Object.keys(attrs);
|
|
225
|
+
return (
|
|
226
|
+
keys.length > 0 &&
|
|
227
|
+
keys.every((k) => k === 'tracking_opted_out' && attrs[k] === true)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The identified user a request body is about (null: anonymous). */
|
|
234
|
+
function identifiedUserOf(body: any): string | null {
|
|
235
|
+
if (!body || typeof body !== 'object') return null;
|
|
236
|
+
if (typeof body.userId === 'string' && body.userId) return body.userId;
|
|
237
|
+
if (typeof body.externalId === 'string' && body.externalId) return body.externalId;
|
|
238
|
+
if (body.identify && typeof body.identify.userId === 'string' && body.identify.userId) return body.identify.userId;
|
|
239
|
+
for (const list of [body.events, body.impressions]) {
|
|
240
|
+
if (Array.isArray(list)) {
|
|
241
|
+
const hit = list.find((e: any) => e && typeof e.userId === 'string' && e.userId);
|
|
242
|
+
if (hit) return hit.userId;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Why an SDK-authentication token was rejected by the backend. Mirrors the
|
|
250
|
+
* `reason` field of a `401 { error: 'sdk_authentication_error', reason }` body.
|
|
251
|
+
*/
|
|
252
|
+
export type SdkAuthFailureReason = 'missing' | 'invalid' | 'expired' | 'sub_mismatch';
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Passed to the host-app SDK-authentication failure callback so it can mint a
|
|
256
|
+
* fresh JWT (and hand it back, or call setSdkAuthenticationToken).
|
|
257
|
+
*/
|
|
258
|
+
export interface SdkAuthFailureInfo {
|
|
259
|
+
reason: SdkAuthFailureReason;
|
|
260
|
+
/** The ingest endpoint that was rejected (full URL). */
|
|
261
|
+
endpoint: string;
|
|
262
|
+
/** The current user id at the time of failure, if identified. */
|
|
263
|
+
userId?: string | null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Host-app callback fired on a `sdk_authentication_error`.
|
|
268
|
+
* The app may either:
|
|
269
|
+
* - synchronously call `setSdkAuthenticationToken(fresh)` inside it, or
|
|
270
|
+
* - return / resolve the fresh token string.
|
|
271
|
+
* Returning nothing (or the same token) means "no new token" → no retry.
|
|
272
|
+
*/
|
|
273
|
+
export type SdkAuthFailureCallback = (
|
|
274
|
+
info: SdkAuthFailureInfo,
|
|
275
|
+
) => void | string | Promise<void | string>;
|
|
276
|
+
|
|
277
|
+
export interface CircuitBreakerConfig {
|
|
278
|
+
/** Number of consecutive failures before opening the circuit. Default: 5 */
|
|
279
|
+
failureThreshold: number;
|
|
280
|
+
/** Cooldown period in milliseconds before allowing a probe request. Default: 30000 (30s) */
|
|
281
|
+
cooldownMs: number;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
type CircuitState = 'closed' | 'open' | 'half-open';
|
|
285
|
+
|
|
286
|
+
const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = {
|
|
287
|
+
failureThreshold: 5,
|
|
288
|
+
cooldownMs: 30_000,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export class NetworkClient {
|
|
292
|
+
private logger: Logger;
|
|
293
|
+
private sdkKey: string;
|
|
294
|
+
private retryConfig: RetryConfig;
|
|
295
|
+
|
|
296
|
+
// Circuit breaker state
|
|
297
|
+
private circuitBreakerConfig: CircuitBreakerConfig;
|
|
298
|
+
private circuitState: CircuitState = 'closed';
|
|
299
|
+
private consecutiveFailures = 0;
|
|
300
|
+
private circuitOpenedAt = 0;
|
|
301
|
+
|
|
302
|
+
// SDK-authentication state (opt-in). When disabled or token-less, no
|
|
303
|
+
// X-Joryio-Auth header is attached and none of the refresh logic runs.
|
|
304
|
+
private sdkAuthEnabled = false;
|
|
305
|
+
private sdkAuthToken: string | undefined;
|
|
306
|
+
private sdkAuthFailureCallback: SdkAuthFailureCallback | null = null;
|
|
307
|
+
/**
|
|
308
|
+
* Tokens of users who logged out (reset()) while some of their events or
|
|
309
|
+
* writes were still queued, keyed by that user's id. Their leftovers go out
|
|
310
|
+
* with THEIR token - the token is cleared from the current session, not
|
|
311
|
+
* from the requests it was minted for (contract item 39). Persisted through
|
|
312
|
+
* `retiredTokenStore` (D-a) and forgotten once that user's last queued item
|
|
313
|
+
* settled (see forgetRetiredSdkAuthTokensExcept); the oldest goes past
|
|
314
|
+
* MAX_RETIRED_SDK_AUTH_TOKENS.
|
|
315
|
+
*/
|
|
316
|
+
private retiredSdkAuthTokens = new Map<string, RetiredSdkAuthToken>();
|
|
317
|
+
private retiredTokenStore: RetiredTokenStore | null = null;
|
|
318
|
+
/** The current user when the current token was set (whose token it is when its `sub` cannot be read). */
|
|
319
|
+
private sdkAuthTokenUser: string | null = null;
|
|
320
|
+
/**
|
|
321
|
+
* Bytes of keepalive requests this page has handed to the browser and that
|
|
322
|
+
* have not settled. The browser refuses a keepalive past 64 KiB in flight -
|
|
323
|
+
* and an unload runs `beforeunload` AND `pagehide` - so the SDK shares one
|
|
324
|
+
* budget across every hand-off instead of spending 60 KB per call.
|
|
325
|
+
*/
|
|
326
|
+
private keepaliveBytesInFlight = 0;
|
|
327
|
+
/** Current user id, kept in sync so failure info can carry it. */
|
|
328
|
+
private currentUserId: string | null = null;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Set by a 402 `SDK_INGESTION_SUSPENDED`: the workspace's ingestion is
|
|
332
|
+
* paused, so every further request this page makes would be refused the same
|
|
333
|
+
* way. Stop asking for the rest of the session instead of hammering the API.
|
|
334
|
+
*/
|
|
335
|
+
private suspended = false;
|
|
336
|
+
|
|
337
|
+
constructor(sdkKey: string, logger: Logger, retryConfig: RetryConfig, circuitBreakerConfig?: Partial<CircuitBreakerConfig>) {
|
|
338
|
+
this.sdkKey = sdkKey;
|
|
339
|
+
this.logger = logger;
|
|
340
|
+
this.retryConfig = retryConfig;
|
|
341
|
+
this.circuitBreakerConfig = {
|
|
342
|
+
...DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
343
|
+
...circuitBreakerConfig,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- SDK Authentication wiring ---------------------------------------
|
|
348
|
+
|
|
349
|
+
/** Enable/disable SDK authentication (attach + refresh the auth JWT). */
|
|
350
|
+
setSdkAuthenticationEnabled(enabled: boolean): void {
|
|
351
|
+
this.sdkAuthEnabled = enabled;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Set / update the current SDK-authentication JWT (undefined: forget it).
|
|
356
|
+
*
|
|
357
|
+
* Replacing a token that belongs to someone else retires it (rule 61): the
|
|
358
|
+
* host may set the incoming user's token BEFORE identify() - the order the
|
|
359
|
+
* docs allow - and the outgoing user's queued events and withdrawals must
|
|
360
|
+
* keep THEIR token. Retired when the two tokens' `sub` differ, or (a `sub`
|
|
361
|
+
* that cannot be read) when the token was set for a user who is no longer
|
|
362
|
+
* the current one. A refresh for the same user retires nothing.
|
|
363
|
+
*/
|
|
364
|
+
setSdkAuthenticationToken(token: string | undefined): void {
|
|
365
|
+
const next = typeof token === 'string' && token ? token : undefined;
|
|
366
|
+
const previous = this.sdkAuthToken;
|
|
367
|
+
if (previous && next && previous !== next) {
|
|
368
|
+
const previousSub = jwtSubject(previous);
|
|
369
|
+
const nextSub = jwtSubject(next);
|
|
370
|
+
const owner = previousSub || this.sdkAuthTokenUser;
|
|
371
|
+
const someoneElse =
|
|
372
|
+
previousSub && nextSub ? previousSub !== nextSub : !!owner && owner !== this.currentUserId;
|
|
373
|
+
if (owner && someoneElse) this.retireToken(owner, previous);
|
|
374
|
+
}
|
|
375
|
+
this.sdkAuthToken = next;
|
|
376
|
+
this.sdkAuthTokenUser = next ? this.currentUserId : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Logout (reset()): the current token stops being the session's, but stays
|
|
381
|
+
* bound to the outgoing user so their queued events and writes still
|
|
382
|
+
* authenticate. Call BEFORE the current user id changes.
|
|
383
|
+
*/
|
|
384
|
+
retireSdkAuthToken(): void {
|
|
385
|
+
const sub = jwtSubject(this.sdkAuthToken);
|
|
386
|
+
const user = sub || this.currentUserId;
|
|
387
|
+
// A token naming someone other than the current user - also while no one
|
|
388
|
+
// is identified - was set for the NEXT user (rule 82): kept on standby
|
|
389
|
+
// for that `sub` until identify() adopts it, not pruned after the grace.
|
|
390
|
+
const standby = !!sub && sub !== this.currentUserId;
|
|
391
|
+
if (user && this.sdkAuthToken) this.retireToken(user, this.sdkAuthToken, standby);
|
|
392
|
+
this.sdkAuthToken = undefined;
|
|
393
|
+
this.sdkAuthTokenUser = null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private retireToken(user: string, token: string, standby = false): void {
|
|
397
|
+
const removed = new Map<string, string>();
|
|
398
|
+
const replaced = this.retiredSdkAuthTokens.get(user);
|
|
399
|
+
if (replaced && replaced.token !== token) removed.set(user, replaced.token);
|
|
400
|
+
this.retiredSdkAuthTokens.delete(user);
|
|
401
|
+
this.retiredSdkAuthTokens.set(user, standby ? { token, at: Date.now(), standby: true } : { token, at: Date.now() });
|
|
402
|
+
while (this.retiredSdkAuthTokens.size > MAX_RETIRED_SDK_AUTH_TOKENS) {
|
|
403
|
+
const oldest = this.retiredSdkAuthTokens.keys().next().value as string;
|
|
404
|
+
removed.set(oldest, this.retiredSdkAuthTokens.get(oldest)!.token);
|
|
405
|
+
this.retiredSdkAuthTokens.delete(oldest);
|
|
406
|
+
}
|
|
407
|
+
this.saveRetiredTokens(removed);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The signed-in user changes WITHOUT a logout (identify('B') while A was
|
|
412
|
+
* identified - rule 53): A's token is retired to A's queued items, exactly
|
|
413
|
+
* as reset() does. Not when the token already set is the incoming user's
|
|
414
|
+
* own (the host may set B's token before calling identify('B')).
|
|
415
|
+
*/
|
|
416
|
+
switchUser(userId: string | null): void {
|
|
417
|
+
const previous = this.currentUserId;
|
|
418
|
+
if (previous && previous !== userId && this.sdkAuthToken) {
|
|
419
|
+
const sub = jwtSubject(this.sdkAuthToken);
|
|
420
|
+
if (!(userId && sub === userId)) this.retireSdkAuthToken();
|
|
421
|
+
}
|
|
422
|
+
this.currentUserId = userId;
|
|
423
|
+
// The incoming user's own token, retired earlier on this device (the host
|
|
424
|
+
// set B's token, then reset(), then identify('B') - rule 72): it is the
|
|
425
|
+
// session's again, so B's requests keep carrying it after the retired
|
|
426
|
+
// copy is pruned.
|
|
427
|
+
if (!this.sdkAuthToken && userId) {
|
|
428
|
+
const retired = this.retiredSdkAuthTokens.get(userId);
|
|
429
|
+
if (retired && jwtSubject(retired.token) === userId) {
|
|
430
|
+
this.sdkAuthToken = retired.token;
|
|
431
|
+
this.sdkAuthTokenUser = userId;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.adoptStandbyToken(userId);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The user `userId` is signed in (or held for withdrawals, D-b): a token
|
|
439
|
+
* kept on standby for them is theirs now, not a next user's (rule 89). Its
|
|
440
|
+
* standby flag is cleared - here and in the stored map - so wipeData()
|
|
441
|
+
* keeps it for their withdrawals instead of deleting it as someone else's.
|
|
442
|
+
*/
|
|
443
|
+
private adoptStandbyToken(userId: string | null): void {
|
|
444
|
+
if (!userId) return;
|
|
445
|
+
const entry = this.retiredSdkAuthTokens.get(userId);
|
|
446
|
+
if (!entry || !entry.standby || jwtSubject(entry.token) !== userId) return;
|
|
447
|
+
this.retiredSdkAuthTokens.set(userId, { token: entry.token, at: entry.at });
|
|
448
|
+
this.saveRetiredTokens();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* wipeData(): forget every token kept on standby for a next user (rule
|
|
453
|
+
* 82) - in this tab and in the stored map every tab shares. An erasure
|
|
454
|
+
* keeps no credential of anyone; the erased user's own retired token (not
|
|
455
|
+
* standby) stays for the withdrawals still waiting.
|
|
456
|
+
*
|
|
457
|
+
* `keep`: users whose token stays even if it is still flagged standby
|
|
458
|
+
* (rule 89) - the erased user and anyone with a withdrawal still queued.
|
|
459
|
+
* Their token is theirs, not a next user's.
|
|
460
|
+
*/
|
|
461
|
+
dropStandbySdkAuthTokens(keep: Set<string> = new Set()): void {
|
|
462
|
+
this.retiredSdkAuthTokens.forEach((entry, user) => {
|
|
463
|
+
if (entry.standby && !keep.has(user)) this.retiredSdkAuthTokens.delete(user);
|
|
464
|
+
});
|
|
465
|
+
if (!this.retiredTokenStore) return;
|
|
466
|
+
try {
|
|
467
|
+
this.retiredTokenStore.update((stored) => {
|
|
468
|
+
const out: Record<string, unknown> = {};
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
for (const user of Object.keys(stored || {})) {
|
|
471
|
+
const entry = parseRetiredToken(stored[user], now);
|
|
472
|
+
if (!entry || (entry.standby && !keep.has(user)) || now - entry.at > RETIRED_SDK_AUTH_TOKEN_MAX_AGE_MS) continue;
|
|
473
|
+
out[user] = entry;
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
});
|
|
477
|
+
} catch (error) {
|
|
478
|
+
this.logger.warn('Could not store the SDK authentication tokens:', error);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Persist retired tokens through this store (and load what it holds). */
|
|
483
|
+
setRetiredTokenStore(store: RetiredTokenStore | null): void {
|
|
484
|
+
this.retiredTokenStore = store;
|
|
485
|
+
if (!store) return;
|
|
486
|
+
let stored: Record<string, unknown> | null = null;
|
|
487
|
+
try {
|
|
488
|
+
stored = store.load();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
this.logger.warn('Could not read the stored SDK authentication tokens:', error);
|
|
491
|
+
}
|
|
492
|
+
if (!stored || typeof stored !== 'object') return;
|
|
493
|
+
const now = Date.now();
|
|
494
|
+
for (const user of Object.keys(stored)) {
|
|
495
|
+
// Entry by entry: one malformed or expired entry never loses the others.
|
|
496
|
+
const entry = parseRetiredToken(stored[user], now);
|
|
497
|
+
if (!entry || now - entry.at > RETIRED_SDK_AUTH_TOKEN_MAX_AGE_MS) continue;
|
|
498
|
+
if (!this.retiredSdkAuthTokens.has(user)) this.retiredSdkAuthTokens.set(user, entry);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Users that still hold a retired token (diagnostics and the SDK's pruning). */
|
|
503
|
+
retiredSdkAuthUsers(): string[] {
|
|
504
|
+
return Array.from(this.retiredSdkAuthTokens.keys());
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Forget the retired token of every user with nothing left queued (D-a): a
|
|
509
|
+
* logged-out user's token lives as long as their leftovers - and at least
|
|
510
|
+
* RETIRED_SDK_AUTH_TOKEN_GRACE_MS, at most RETIRED_SDK_AUTH_TOKEN_MAX_AGE_MS.
|
|
511
|
+
* `stillQueued` must cover this tab's memory AND what every tab persisted.
|
|
512
|
+
* Returns the ms until a token kept only by its grace period may go, or null.
|
|
513
|
+
*/
|
|
514
|
+
forgetRetiredSdkAuthTokensExcept(stillQueued: Set<string>, now: number = Date.now()): number | null {
|
|
515
|
+
const removed = new Map<string, string>();
|
|
516
|
+
let wake: number | null = null;
|
|
517
|
+
this.retiredSdkAuthTokens.forEach((entry, user) => {
|
|
518
|
+
const age = now - entry.at;
|
|
519
|
+
if (age > RETIRED_SDK_AUTH_TOKEN_MAX_AGE_MS) {
|
|
520
|
+
removed.set(user, entry.token);
|
|
521
|
+
} else if (!entry.standby && !stillQueued.has(user)) {
|
|
522
|
+
if (age >= RETIRED_SDK_AUTH_TOKEN_GRACE_MS) removed.set(user, entry.token);
|
|
523
|
+
else wake = Math.min(wake ?? Infinity, RETIRED_SDK_AUTH_TOKEN_GRACE_MS - age);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
removed.forEach((_, user) => this.retiredSdkAuthTokens.delete(user));
|
|
527
|
+
if (removed.size) this.saveRetiredTokens(removed);
|
|
528
|
+
return wake;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Write this tab's changes into the STORED map (rule 62): add/refresh its
|
|
533
|
+
* own entries, delete exactly the ones it forgot (only while the stored
|
|
534
|
+
* token is still the one it forgot - another tab may have retired a newer
|
|
535
|
+
* one), and leave every other tab's entries alone.
|
|
536
|
+
*/
|
|
537
|
+
private saveRetiredTokens(removed: Map<string, string> = new Map()): void {
|
|
538
|
+
if (!this.retiredTokenStore) return;
|
|
539
|
+
try {
|
|
540
|
+
this.retiredTokenStore.update((stored) => {
|
|
541
|
+
const out: Record<string, unknown> = {};
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
for (const user of Object.keys(stored || {})) {
|
|
544
|
+
const entry = parseRetiredToken(stored[user], now);
|
|
545
|
+
if (!entry || now - entry.at > RETIRED_SDK_AUTH_TOKEN_MAX_AGE_MS) continue;
|
|
546
|
+
if (removed.get(user) === entry.token) continue;
|
|
547
|
+
out[user] = entry;
|
|
548
|
+
}
|
|
549
|
+
this.retiredSdkAuthTokens.forEach((entry, user) => {
|
|
550
|
+
const theirs = parseRetiredToken(out[user], now);
|
|
551
|
+
if (!theirs || theirs.token === entry.token) {
|
|
552
|
+
out[user] = { ...entry };
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// Two tabs retired a token for one user: the NEWER token wins (by
|
|
556
|
+
// its exp / iat), not the later retirement (rule 77) - a tab that
|
|
557
|
+
// followed a logout retires the token it loaded with, which may be
|
|
558
|
+
// older than the one the other tab refreshed since. Unreadable
|
|
559
|
+
// claims: the later retirement.
|
|
560
|
+
const newer = newerToken(theirs.token, entry.token);
|
|
561
|
+
const ours = newer ? newer === entry.token : theirs.at <= entry.at;
|
|
562
|
+
if (ours) out[user] = { ...entry };
|
|
563
|
+
else this.retiredSdkAuthTokens.set(user, theirs);
|
|
564
|
+
});
|
|
565
|
+
return out;
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {
|
|
568
|
+
this.logger.warn('Could not store the SDK authentication tokens:', error);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The token a request about `subjectUserId` carries: the current token for
|
|
574
|
+
* the current user (or an anonymous request), the outgoing user's own token
|
|
575
|
+
* for their leftovers after a logout - also in the window between the host
|
|
576
|
+
* setting the next user's token and calling identify() for them.
|
|
577
|
+
*/
|
|
578
|
+
private sdkAuthTokenFor(subjectUserId: string | null | undefined): string | undefined {
|
|
579
|
+
// Routed by the token's subject (rule 72): a request about user U carries
|
|
580
|
+
// a token whose `sub` is U - the session token when it is U's, else U's
|
|
581
|
+
// retired token - and never a token naming someone else. (The host may
|
|
582
|
+
// set B's token before identify('B'): A's requests in between must not
|
|
583
|
+
// carry it.) No token for U: none at all, and a 401 asks the host's
|
|
584
|
+
// handler only for the current user.
|
|
585
|
+
const user = subjectUserId === undefined ? this.currentUserId : subjectUserId;
|
|
586
|
+
// Anonymous: the server checks no token, and none names this visitor.
|
|
587
|
+
if (!user) return undefined;
|
|
588
|
+
const session = this.sdkAuthEnabled ? this.sdkAuthToken : undefined;
|
|
589
|
+
const sessionSub = jwtSubject(session);
|
|
590
|
+
if (session && sessionSub === user) return session;
|
|
591
|
+
// A token whose `sub` cannot be read: the current user's, if it was set for them.
|
|
592
|
+
if (
|
|
593
|
+
session &&
|
|
594
|
+
!sessionSub &&
|
|
595
|
+
user === this.currentUserId &&
|
|
596
|
+
(this.sdkAuthTokenUser === null || this.sdkAuthTokenUser === user)
|
|
597
|
+
) {
|
|
598
|
+
return session;
|
|
599
|
+
}
|
|
600
|
+
// Attached even when this page view did not enable SDK authentication
|
|
601
|
+
// (rule 61): the token was minted for these leftovers, and the server
|
|
602
|
+
// refuses them without it once the workspace requires it.
|
|
603
|
+
return this.retiredSdkAuthTokens.get(user)?.token;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** The user the network client treats as signed in. */
|
|
607
|
+
getCurrentUserId(): string | null {
|
|
608
|
+
return this.currentUserId;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Register the host-app failure callback. */
|
|
612
|
+
setSdkAuthenticationFailureCallback(cb: SdkAuthFailureCallback | null): void {
|
|
613
|
+
this.sdkAuthFailureCallback = cb;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Keep the current user id in sync so failure info can report it. */
|
|
617
|
+
setCurrentUserId(userId: string | null): void {
|
|
618
|
+
this.currentUserId = userId;
|
|
619
|
+
// At init (a restart) too: the signed-in user's kept token is theirs (rule 89).
|
|
620
|
+
this.adoptStandbyToken(userId);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** True once the API answered 402 SDK_INGESTION_SUSPENDED this session. */
|
|
624
|
+
isSuspended(): boolean {
|
|
625
|
+
return this.suspended;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Record a 402 suspension (also called by the in-app manager's own fetches). */
|
|
629
|
+
noteSuspended(status: number, data: any): boolean {
|
|
630
|
+
if (status !== 402) return false;
|
|
631
|
+
if (!this.suspended) {
|
|
632
|
+
this.suspended = true;
|
|
633
|
+
// console, not the logger: the logger is silent by default and this is
|
|
634
|
+
// the one message a site owner must see - their data is not arriving.
|
|
635
|
+
// eslint-disable-next-line no-console
|
|
636
|
+
console.warn(
|
|
637
|
+
`[Joryio SDK] The API refused this request with 402 ${data?.code || ''} - event ingestion is ` +
|
|
638
|
+
'paused for this workspace. Nothing more will be sent this page view; queued events are kept.',
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Auth headers to merge into a raw fetch made OUTSIDE this client (in-app
|
|
646
|
+
* messaging issues its own fetch). Returns {} when auth is off or no token.
|
|
647
|
+
* `subjectUserId`: the user the request is about - a logged-out user's
|
|
648
|
+
* request carries THAT user's retired token, never the next user's.
|
|
649
|
+
*/
|
|
650
|
+
getSdkAuthHeaders(subjectUserId?: string | null): Record<string, string> {
|
|
651
|
+
const token = this.sdkAuthTokenFor(subjectUserId);
|
|
652
|
+
return token ? { [SDK_AUTH_HEADER]: token } : {};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Room left in the browser's keepalive budget for this page, in bytes. */
|
|
656
|
+
keepaliveBudget(): number {
|
|
657
|
+
return Math.max(0, KEEPALIVE_MAX_BYTES - this.keepaliveBytesInFlight);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** True if a parsed response body is a `sdk_authentication_error`. */
|
|
661
|
+
isSdkAuthError(status: number, data: any): boolean {
|
|
662
|
+
return (
|
|
663
|
+
status === 401 &&
|
|
664
|
+
!!data &&
|
|
665
|
+
typeof data === 'object' &&
|
|
666
|
+
data.error === 'sdk_authentication_error'
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Fire the host-app failure callback and, if it yields a fresh token
|
|
672
|
+
* (either by calling setSdkAuthenticationToken synchronously or by
|
|
673
|
+
* returning/resolving a token string), install it. Returns the fresh
|
|
674
|
+
* token when it differs from the one that just failed, else undefined.
|
|
675
|
+
*
|
|
676
|
+
* Used both by request() (for its one-shot retry) and by the in-app
|
|
677
|
+
* fetch path (fire-and-forget, so the next sync carries a valid token).
|
|
678
|
+
*/
|
|
679
|
+
async refreshSdkAuthToken(info: SdkAuthFailureInfo): Promise<string | undefined> {
|
|
680
|
+
if (!this.sdkAuthEnabled || !this.sdkAuthFailureCallback) {
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
const previous = this.sdkAuthToken;
|
|
684
|
+
let returned: void | string;
|
|
685
|
+
try {
|
|
686
|
+
returned = await Promise.resolve(this.sdkAuthFailureCallback(info));
|
|
687
|
+
} catch (err) {
|
|
688
|
+
this.logger.error('SDK authentication failure callback threw:', err);
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
if (typeof returned === 'string' && returned) {
|
|
692
|
+
// Through the setter: a token for another user retires the old one (rule 61).
|
|
693
|
+
this.setSdkAuthenticationToken(returned);
|
|
694
|
+
}
|
|
695
|
+
const fresh = this.sdkAuthToken;
|
|
696
|
+
if (fresh && fresh !== previous) {
|
|
697
|
+
return fresh;
|
|
698
|
+
}
|
|
699
|
+
return undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Make an HTTP request with retry logic and circuit breaker protection.
|
|
704
|
+
*
|
|
705
|
+
* Circuit breaker states:
|
|
706
|
+
* closed - Normal operation. Failures are counted.
|
|
707
|
+
* open - Fail fast without making network requests until cooldown expires.
|
|
708
|
+
* half-open - Allow one probe request. Success closes the circuit; failure re-opens it.
|
|
709
|
+
*/
|
|
710
|
+
async request(request: NetworkRequest): Promise<NetworkResponse> {
|
|
711
|
+
// A withdrawal still goes while ingestion is paused: the server accepts it.
|
|
712
|
+
if (this.suspended && !isWithdrawalRequest(request.url, request.body)) {
|
|
713
|
+
return { ok: false, status: 402, error: 'Ingestion suspended', data: { code: SDK_INGESTION_SUSPENDED_CODE } };
|
|
714
|
+
}
|
|
715
|
+
// --- Circuit breaker check ---
|
|
716
|
+
if (this.circuitState === 'open') {
|
|
717
|
+
const elapsed = Date.now() - this.circuitOpenedAt;
|
|
718
|
+
if (elapsed < this.circuitBreakerConfig.cooldownMs) {
|
|
719
|
+
// Still in cooldown – fail fast
|
|
720
|
+
this.logger.warn(`Circuit breaker OPEN – failing fast (${Math.round((this.circuitBreakerConfig.cooldownMs - elapsed) / 1000)}s remaining)`);
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
status: 0,
|
|
724
|
+
error: 'Circuit breaker is open – API requests are temporarily blocked',
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// Cooldown elapsed – transition to half-open and allow a probe
|
|
728
|
+
this.circuitState = 'half-open';
|
|
729
|
+
this.logger.info('Circuit breaker transitioning to HALF-OPEN – allowing probe request');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const { url, method = 'POST', headers = {} } = request;
|
|
733
|
+
// Serialized ONCE, before any attempt: a body that cannot be serialized
|
|
734
|
+
// (a circular object, a BigInt) used to throw inside the fetch attempt,
|
|
735
|
+
// look like "offline" (status 0) and be retried forever. It is refused
|
|
736
|
+
// for good instead (rule 49).
|
|
737
|
+
let body: string | undefined;
|
|
738
|
+
try {
|
|
739
|
+
body = request.body ? JSON.stringify(request.body) : undefined;
|
|
740
|
+
} catch (error) {
|
|
741
|
+
this.logger.error(`Request to ${url} not sent: its body cannot be serialized`, error);
|
|
742
|
+
return { ok: false, status: 400, error: 'The request body cannot be serialized' };
|
|
743
|
+
}
|
|
744
|
+
const forCurrentUser =
|
|
745
|
+
request.subjectUserId === undefined || (request.subjectUserId || null) === this.currentUserId;
|
|
746
|
+
|
|
747
|
+
let lastError: Error | null = null;
|
|
748
|
+
let lastStatus = 0;
|
|
749
|
+
let lastData: any;
|
|
750
|
+
let retryAfterMs: number | undefined;
|
|
751
|
+
let attempt = 0;
|
|
752
|
+
// SDK-auth refresh is a distinct ONE-SHOT retry, kept out of the
|
|
753
|
+
// backoff loop below so it can never loop or consume a backoff attempt.
|
|
754
|
+
let sdkAuthRefreshAttempted = false;
|
|
755
|
+
|
|
756
|
+
while (attempt <= this.retryConfig.maxRetries) {
|
|
757
|
+
try {
|
|
758
|
+
this.logger.debug(`Request attempt ${attempt + 1}/${this.retryConfig.maxRetries + 1}: ${method} ${url}`);
|
|
759
|
+
|
|
760
|
+
let { response, responseData } = await this.performFetch(url, method, headers, body, request.subjectUserId);
|
|
761
|
+
|
|
762
|
+
// --- SDK authentication: one-shot refresh + retry (out-of-band) ---
|
|
763
|
+
// On a 401 sdk_authentication_error, fire the host callback to obtain a
|
|
764
|
+
// fresh token and retry the SAME request exactly once with it. This does
|
|
765
|
+
// NOT increment `attempt` and does NOT wait for backoff.
|
|
766
|
+
if (
|
|
767
|
+
!sdkAuthRefreshAttempted &&
|
|
768
|
+
this.sdkAuthEnabled &&
|
|
769
|
+
forCurrentUser &&
|
|
770
|
+
this.isSdkAuthError(response.status, responseData)
|
|
771
|
+
) {
|
|
772
|
+
sdkAuthRefreshAttempted = true;
|
|
773
|
+
this.logger.warn(`SDK authentication rejected (${responseData?.reason || 'invalid'}) on ${url} – refreshing token`);
|
|
774
|
+
const fresh = await this.refreshSdkAuthToken({
|
|
775
|
+
reason: (responseData?.reason as SdkAuthFailureReason) || (this.sdkAuthToken ? 'invalid' : 'missing'),
|
|
776
|
+
endpoint: url,
|
|
777
|
+
userId: request.subjectUserId === undefined ? this.currentUserId : request.subjectUserId || null,
|
|
778
|
+
});
|
|
779
|
+
if (fresh) {
|
|
780
|
+
this.logger.debug('SDK authentication token refreshed – retrying request once');
|
|
781
|
+
({ response, responseData } = await this.performFetch(url, method, headers, body, request.subjectUserId));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (response.ok) {
|
|
786
|
+
this.onRequestSuccess();
|
|
787
|
+
// 2xx with `success: false` is a FAILURE (SDK contract, addendum 15).
|
|
788
|
+
// It used to be returned as ok, so attributes, aliases, subscription
|
|
789
|
+
// changes and push registrations the server had refused - or lost to
|
|
790
|
+
// a database error - were acknowledged and never retried.
|
|
791
|
+
if (isSoftFailure(responseData)) {
|
|
792
|
+
this.logger.warn(`Request to ${url} answered ${response.status} with success: false`);
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
status: response.status,
|
|
796
|
+
data: responseData,
|
|
797
|
+
error: responseData?.error || responseData?.message || 'The server reported success: false',
|
|
798
|
+
softFailure: true,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
this.logger.debug(`Request successful: ${response.status}`);
|
|
802
|
+
return {
|
|
803
|
+
ok: true,
|
|
804
|
+
status: response.status,
|
|
805
|
+
data: responseData,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Check if we should retry based on status code
|
|
810
|
+
if (!this.shouldRetry(response.status)) {
|
|
811
|
+
this.logger.warn(`Request failed with non-retryable status: ${response.status}`);
|
|
812
|
+
this.noteSuspended(response.status, responseData);
|
|
813
|
+
// Non-retryable client errors (4xx except 429) don't count as circuit breaker failures
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
status: response.status,
|
|
817
|
+
error: responseData?.message || response.statusText,
|
|
818
|
+
data: responseData,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Retry on server errors (5xx) or rate limiting (429)
|
|
823
|
+
lastStatus = response.status;
|
|
824
|
+
lastData = responseData;
|
|
825
|
+
retryAfterMs = parseRetryAfter(response.headers?.get?.('retry-after'));
|
|
826
|
+
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
827
|
+
this.logger.warn(`Request failed (attempt ${attempt + 1}): ${lastError.message}`);
|
|
828
|
+
|
|
829
|
+
} catch (error) {
|
|
830
|
+
lastError = error as Error;
|
|
831
|
+
lastStatus = 0;
|
|
832
|
+
lastData = undefined;
|
|
833
|
+
retryAfterMs = undefined;
|
|
834
|
+
this.logger.error(`Request error (attempt ${attempt + 1}):`, error);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Don't wait after the last attempt
|
|
838
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
839
|
+
// A server that said when to come back is believed (bounded, so a
|
|
840
|
+
// hostile or broken header cannot park the page for an hour).
|
|
841
|
+
const delay = Math.max(this.calculateBackoff(attempt), Math.min(retryAfterMs ?? 0, 30_000));
|
|
842
|
+
this.logger.debug(`Retrying in ${delay}ms...`);
|
|
843
|
+
await this.sleep(delay);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
attempt++;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// All retries exhausted – record failure for circuit breaker
|
|
850
|
+
this.onRequestFailure();
|
|
851
|
+
|
|
852
|
+
this.logger.error(`Request failed after ${this.retryConfig.maxRetries + 1} attempts`);
|
|
853
|
+
// The LAST status, not 0: a caller deciding whether to keep or drop data
|
|
854
|
+
// must be able to tell a 503 from "offline".
|
|
855
|
+
return {
|
|
856
|
+
ok: false,
|
|
857
|
+
status: lastStatus,
|
|
858
|
+
data: lastData,
|
|
859
|
+
error: lastError?.message || 'Network request failed',
|
|
860
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Record a successful request – reset circuit breaker to closed state.
|
|
866
|
+
*/
|
|
867
|
+
private onRequestSuccess(): void {
|
|
868
|
+
if (this.consecutiveFailures > 0 || this.circuitState !== 'closed') {
|
|
869
|
+
this.logger.info(`Circuit breaker CLOSED – request succeeded after ${this.consecutiveFailures} consecutive failure(s)`);
|
|
870
|
+
}
|
|
871
|
+
this.consecutiveFailures = 0;
|
|
872
|
+
this.circuitState = 'closed';
|
|
873
|
+
this.circuitOpenedAt = 0;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Record a failed request – increment consecutive failure count
|
|
878
|
+
* and open the circuit if the threshold is reached.
|
|
879
|
+
*/
|
|
880
|
+
private onRequestFailure(): void {
|
|
881
|
+
this.consecutiveFailures++;
|
|
882
|
+
|
|
883
|
+
if (this.circuitState === 'half-open') {
|
|
884
|
+
// Probe failed – reopen the circuit
|
|
885
|
+
this.circuitState = 'open';
|
|
886
|
+
this.circuitOpenedAt = Date.now();
|
|
887
|
+
this.logger.warn(`Circuit breaker re-OPENED – probe request failed (${this.consecutiveFailures} consecutive failures)`);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (this.consecutiveFailures >= this.circuitBreakerConfig.failureThreshold) {
|
|
892
|
+
this.circuitState = 'open';
|
|
893
|
+
this.circuitOpenedAt = Date.now();
|
|
894
|
+
this.logger.warn(
|
|
895
|
+
`Circuit breaker OPENED – ${this.consecutiveFailures} consecutive failures reached threshold of ${this.circuitBreakerConfig.failureThreshold}`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Get the current circuit breaker state (useful for diagnostics / testing).
|
|
902
|
+
*/
|
|
903
|
+
getCircuitBreakerState(): { state: CircuitState; consecutiveFailures: number; openedAt: number } {
|
|
904
|
+
return {
|
|
905
|
+
state: this.circuitState,
|
|
906
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
907
|
+
openedAt: this.circuitOpenedAt,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Perform a single fetch (with the configured per-attempt timeout) and parse the body.
|
|
913
|
+
* Reads the CURRENT sdk-auth token each call, so a refresh-then-retry
|
|
914
|
+
* automatically carries the fresh token.
|
|
915
|
+
*/
|
|
916
|
+
private async performFetch(
|
|
917
|
+
url: string,
|
|
918
|
+
method: string,
|
|
919
|
+
callerHeaders: Record<string, string>,
|
|
920
|
+
body: string | undefined,
|
|
921
|
+
subjectUserId?: string | null,
|
|
922
|
+
): Promise<{ response: Response; responseData: any }> {
|
|
923
|
+
const controller = new AbortController();
|
|
924
|
+
const timeoutId = setTimeout(() => controller.abort(), this.retryConfig.timeoutMs || 10_000);
|
|
925
|
+
|
|
926
|
+
const headers: Record<string, string> = {
|
|
927
|
+
'Content-Type': 'application/json',
|
|
928
|
+
'X-SDK-Key': this.sdkKey,
|
|
929
|
+
...callerHeaders,
|
|
930
|
+
};
|
|
931
|
+
// Attach the SDK-auth JWT last so the managed token is authoritative.
|
|
932
|
+
const authToken = this.sdkAuthTokenFor(subjectUserId);
|
|
933
|
+
if (authToken) {
|
|
934
|
+
headers[SDK_AUTH_HEADER] = authToken;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let response: Response;
|
|
938
|
+
try {
|
|
939
|
+
response = await fetch(url, {
|
|
940
|
+
method,
|
|
941
|
+
headers,
|
|
942
|
+
body,
|
|
943
|
+
signal: controller.signal,
|
|
944
|
+
// The SDK authenticates with headers, never cookies, and the public SDK
|
|
945
|
+
// routes answer `Access-Control-Allow-Origin: *`, which a credentialed
|
|
946
|
+
// request may not use.
|
|
947
|
+
credentials: 'omit',
|
|
948
|
+
});
|
|
949
|
+
} finally {
|
|
950
|
+
clearTimeout(timeoutId);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const responseData = await this.parseResponse(response);
|
|
954
|
+
return { response, responseData };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Parse response body
|
|
959
|
+
*/
|
|
960
|
+
private async parseResponse(response: Response): Promise<any> {
|
|
961
|
+
const contentType = response.headers?.get?.('content-type');
|
|
962
|
+
|
|
963
|
+
if (contentType && contentType.includes('application/json')) {
|
|
964
|
+
try {
|
|
965
|
+
return await response.json();
|
|
966
|
+
} catch (e) {
|
|
967
|
+
this.logger.warn('Failed to parse JSON response');
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return await response.text();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Determine if we should retry based on status code
|
|
977
|
+
*/
|
|
978
|
+
private shouldRetry(status: number): boolean {
|
|
979
|
+
// Retry on:
|
|
980
|
+
// - 408 (Request Timeout)
|
|
981
|
+
// - 429 (Too Many Requests)
|
|
982
|
+
// - 5xx (Server Errors)
|
|
983
|
+
return status === 408 || status === 429 || status >= 500;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Calculate exponential backoff with jitter
|
|
988
|
+
*/
|
|
989
|
+
private calculateBackoff(attempt: number): number {
|
|
990
|
+
const { backoffMs, maxBackoffMs = 30000 } = this.retryConfig;
|
|
991
|
+
|
|
992
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
993
|
+
const exponentialDelay = backoffMs * Math.pow(2, attempt);
|
|
994
|
+
|
|
995
|
+
// Cap at max backoff
|
|
996
|
+
const cappedDelay = Math.min(exponentialDelay, maxBackoffMs);
|
|
997
|
+
|
|
998
|
+
// Add jitter (±25%)
|
|
999
|
+
const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
|
|
1000
|
+
const delayWithJitter = cappedDelay + jitter;
|
|
1001
|
+
|
|
1002
|
+
return Math.max(0, Math.floor(delayWithJitter));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Sleep utility
|
|
1007
|
+
*/
|
|
1008
|
+
private sleep(ms: number): Promise<void> {
|
|
1009
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Send events batch, optionally with an atomic identify payload
|
|
1014
|
+
*/
|
|
1015
|
+
async sendEvents(apiEndpoint: string, events: any[], identify?: { userId: string; anonymousId: string; attributes?: Record<string, any> }): Promise<NetworkResponse> {
|
|
1016
|
+
const body: any = { events };
|
|
1017
|
+
if (identify) {
|
|
1018
|
+
body.identify = wireIdentify(identify);
|
|
1019
|
+
}
|
|
1020
|
+
return this.request({
|
|
1021
|
+
url: `${apiEndpoint}/v1/track/batch`,
|
|
1022
|
+
method: 'POST',
|
|
1023
|
+
body,
|
|
1024
|
+
// A batch is of one person (the queue never mixes them).
|
|
1025
|
+
subjectUserId: identify?.userId || events.find((e) => e && e.userId)?.userId || null,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* True when this browser honours `fetch(..., { keepalive: true })`, the only
|
|
1031
|
+
* way to send at unload a request that carries our headers. (Firefox before
|
|
1032
|
+
* 133 silently ignores the flag, so its request would be cancelled with the
|
|
1033
|
+
* page - and the caller must then keep the events rather than drop them.)
|
|
1034
|
+
*/
|
|
1035
|
+
supportsKeepalive(): boolean {
|
|
1036
|
+
try {
|
|
1037
|
+
return (
|
|
1038
|
+
typeof fetch === 'function' &&
|
|
1039
|
+
typeof Request !== 'undefined' &&
|
|
1040
|
+
'keepalive' in Request.prototype
|
|
1041
|
+
);
|
|
1042
|
+
} catch {
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Hand a batch to the browser at page unload with `fetch` keepalive.
|
|
1049
|
+
*
|
|
1050
|
+
* Replaces navigator.sendBeacon, which could not work here: a beacon cannot
|
|
1051
|
+
* set headers (so the SDK key rode in the URL and X-Joryio-Auth was lost),
|
|
1052
|
+
* and an `application/json` beacon is a CREDENTIALED CORS request that needs
|
|
1053
|
+
* a preflight the API's `Access-Control-Allow-Origin: *` must refuse. The
|
|
1054
|
+
* beacon still returned `true` - "queued", not "delivered" - so the caller
|
|
1055
|
+
* cleared every event it had just failed to send.
|
|
1056
|
+
*
|
|
1057
|
+
* Returns null when the request could not be handed off at all; otherwise
|
|
1058
|
+
* the eventual response (useful only if the page survives, e.g. bfcache).
|
|
1059
|
+
*/
|
|
1060
|
+
sendEventsKeepalive(
|
|
1061
|
+
apiEndpoint: string,
|
|
1062
|
+
events: any[],
|
|
1063
|
+
identify?: { userId: string; anonymousId: string; attributes?: Record<string, any> },
|
|
1064
|
+
): Promise<NetworkResponse> | null {
|
|
1065
|
+
const body: any = { events };
|
|
1066
|
+
if (identify) body.identify = wireIdentify(identify);
|
|
1067
|
+
return this.postKeepalive(`${apiEndpoint}/v1/track/batch`, body);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* POST one JSON body with fetch keepalive (page unload): the SDK key and
|
|
1072
|
+
* the SDK-auth token as headers, no cookies. Null when it could not be
|
|
1073
|
+
* handed off at all.
|
|
1074
|
+
*/
|
|
1075
|
+
postKeepalive(url: string, body: any): Promise<NetworkResponse> | null {
|
|
1076
|
+
if ((this.suspended && !isWithdrawalRequest(url, body)) || !this.supportsKeepalive()) return null;
|
|
1077
|
+
let json: string;
|
|
1078
|
+
try {
|
|
1079
|
+
json = JSON.stringify(body);
|
|
1080
|
+
} catch {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
// One budget for the whole page (beforeunload and pagehide both run the
|
|
1084
|
+
// hand-off): what the browser would refuse is not handed off at all, so
|
|
1085
|
+
// the caller keeps it rather than counting it as sent.
|
|
1086
|
+
const bytes = utf8Length(json) + 256;
|
|
1087
|
+
if (this.keepaliveBytesInFlight + bytes > KEEPALIVE_MAX_BYTES) return null;
|
|
1088
|
+
const headers: Record<string, string> = {
|
|
1089
|
+
'Content-Type': 'application/json',
|
|
1090
|
+
'X-SDK-Key': this.sdkKey,
|
|
1091
|
+
};
|
|
1092
|
+
const authToken = this.sdkAuthTokenFor(identifiedUserOf(body));
|
|
1093
|
+
if (authToken) {
|
|
1094
|
+
headers[SDK_AUTH_HEADER] = authToken;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
this.keepaliveBytesInFlight += bytes;
|
|
1098
|
+
let released = false;
|
|
1099
|
+
const release = () => {
|
|
1100
|
+
if (released) return;
|
|
1101
|
+
released = true;
|
|
1102
|
+
this.keepaliveBytesInFlight = Math.max(0, this.keepaliveBytesInFlight - bytes);
|
|
1103
|
+
};
|
|
1104
|
+
return fetch(url, {
|
|
1105
|
+
method: 'POST',
|
|
1106
|
+
headers,
|
|
1107
|
+
body: json,
|
|
1108
|
+
keepalive: true,
|
|
1109
|
+
credentials: 'omit',
|
|
1110
|
+
}).then(
|
|
1111
|
+
async (response) => {
|
|
1112
|
+
release();
|
|
1113
|
+
const data = await this.parseResponse(response).catch(() => null);
|
|
1114
|
+
const soft = response.ok && isSoftFailure(data);
|
|
1115
|
+
return { ok: response.ok && !soft, status: response.status, data, ...(soft ? { softFailure: true } : {}) };
|
|
1116
|
+
},
|
|
1117
|
+
(error) => {
|
|
1118
|
+
release();
|
|
1119
|
+
return { ok: false, status: 0, error: String(error?.message || error) };
|
|
1120
|
+
},
|
|
1121
|
+
);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
this.keepaliveBytesInFlight = Math.max(0, this.keepaliveBytesInFlight - bytes);
|
|
1124
|
+
this.logger.warn('keepalive request could not be started:', error);
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Send identify request
|
|
1131
|
+
*/
|
|
1132
|
+
async sendIdentify(
|
|
1133
|
+
apiEndpoint: string,
|
|
1134
|
+
userId: string,
|
|
1135
|
+
attributes: any,
|
|
1136
|
+
anonymousId?: string,
|
|
1137
|
+
): Promise<NetworkResponse> {
|
|
1138
|
+
return this.request({
|
|
1139
|
+
url: `${apiEndpoint}/v1/identify`,
|
|
1140
|
+
method: 'POST',
|
|
1141
|
+
body: {
|
|
1142
|
+
userId,
|
|
1143
|
+
attributes,
|
|
1144
|
+
anonymousId,
|
|
1145
|
+
},
|
|
1146
|
+
subjectUserId: userId,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Add a labelled alias (`aliasLabel` / `aliasName`) to this visitor.
|
|
1152
|
+
*
|
|
1153
|
+
* Works for anonymous visitors too: `anonymousId` is always sent, `userId`
|
|
1154
|
+
* when identified. The server attaches the alias to whichever contact those
|
|
1155
|
+
* resolve to.
|
|
1156
|
+
*/
|
|
1157
|
+
async sendAddAlias(
|
|
1158
|
+
apiEndpoint: string,
|
|
1159
|
+
payload: { anonymousId: string; userId?: string; aliasLabel: string; aliasName: string },
|
|
1160
|
+
): Promise<NetworkResponse> {
|
|
1161
|
+
return this.request({
|
|
1162
|
+
url: `${apiEndpoint}/v1/alias/add`,
|
|
1163
|
+
method: 'POST',
|
|
1164
|
+
body: payload,
|
|
1165
|
+
subjectUserId: payload.userId || null,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Track device session
|
|
1171
|
+
*/
|
|
1172
|
+
async sendDeviceSession(
|
|
1173
|
+
apiEndpoint: string,
|
|
1174
|
+
payload: {
|
|
1175
|
+
userId?: string;
|
|
1176
|
+
anonymousId?: string;
|
|
1177
|
+
deviceId: string;
|
|
1178
|
+
platform: 'ios' | 'android' | 'web';
|
|
1179
|
+
deviceInfo?: Record<string, any>;
|
|
1180
|
+
},
|
|
1181
|
+
): Promise<NetworkResponse> {
|
|
1182
|
+
return this.request({
|
|
1183
|
+
url: `${apiEndpoint}/v1/device/session`,
|
|
1184
|
+
method: 'POST',
|
|
1185
|
+
body: payload,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Send an attribute-operations request (without triggering an identify event).
|
|
1191
|
+
*
|
|
1192
|
+
* `ops` carries the atomic operation sections the backend implements on
|
|
1193
|
+
* `POST /v1/attributes`:
|
|
1194
|
+
* - attributes: absolute `$set`
|
|
1195
|
+
* - increment: `$inc` (deltas, NOT computed absolutes)
|
|
1196
|
+
* - append: `$addToSet`
|
|
1197
|
+
* - remove: `$pull`
|
|
1198
|
+
* - unset: `$unset`
|
|
1199
|
+
* Only the sections that are present are sent, so multi-device / multi-tab
|
|
1200
|
+
* writes no longer clobber each other with locally-computed absolutes.
|
|
1201
|
+
*/
|
|
1202
|
+
async sendSetAttributes(
|
|
1203
|
+
apiEndpoint: string,
|
|
1204
|
+
ops: AttributeOps,
|
|
1205
|
+
userId?: string | null,
|
|
1206
|
+
anonymousId?: string,
|
|
1207
|
+
): Promise<NetworkResponse> {
|
|
1208
|
+
return this.request({
|
|
1209
|
+
url: `${apiEndpoint}/v1/attributes`,
|
|
1210
|
+
method: 'POST',
|
|
1211
|
+
body: {
|
|
1212
|
+
userId: userId || undefined,
|
|
1213
|
+
anonymousId: userId ? undefined : anonymousId,
|
|
1214
|
+
...ops,
|
|
1215
|
+
},
|
|
1216
|
+
subjectUserId: userId || null,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Send subscription status update
|
|
1222
|
+
*/
|
|
1223
|
+
async sendSubscriptionUpdate(
|
|
1224
|
+
apiEndpoint: string,
|
|
1225
|
+
payload: {
|
|
1226
|
+
channel: SubscriptionChannel;
|
|
1227
|
+
// camelCase `optedIn` — backend rejects snake_case `opted_in`. See B7.
|
|
1228
|
+
status: 'optedIn' | 'subscribed' | 'unsubscribed';
|
|
1229
|
+
userId?: string;
|
|
1230
|
+
anonymousId?: string;
|
|
1231
|
+
},
|
|
1232
|
+
): Promise<NetworkResponse> {
|
|
1233
|
+
return this.request({
|
|
1234
|
+
url: `${apiEndpoint}/v1/subscriptions/channel`,
|
|
1235
|
+
method: 'POST',
|
|
1236
|
+
body: payload,
|
|
1237
|
+
subjectUserId: payload.userId || null,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Send subscription group (list) update
|
|
1243
|
+
*/
|
|
1244
|
+
async sendSubscriptionGroupUpdate(
|
|
1245
|
+
apiEndpoint: string,
|
|
1246
|
+
payload: {
|
|
1247
|
+
groupId: string;
|
|
1248
|
+
channel: SubscriptionChannel;
|
|
1249
|
+
action: 'subscribe' | 'unsubscribe';
|
|
1250
|
+
userId?: string;
|
|
1251
|
+
anonymousId?: string;
|
|
1252
|
+
},
|
|
1253
|
+
): Promise<NetworkResponse> {
|
|
1254
|
+
return this.request({
|
|
1255
|
+
url: `${apiEndpoint}/v1/subscriptions/group`,
|
|
1256
|
+
method: 'POST',
|
|
1257
|
+
body: payload,
|
|
1258
|
+
subjectUserId: payload.userId || null,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}
|