@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,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web SDK's persisted, ordered outbox for profile writes (SDK 1.3.0
|
|
3
|
+
* contract items 28, 36, 37, 38, 40): subscription and subscription-group
|
|
4
|
+
* changes, aliases and the `tracking_opted_out` marker.
|
|
5
|
+
*
|
|
6
|
+
* Why it exists. These writes used to be fire-and-forget closures, each with
|
|
7
|
+
* its own retry loop:
|
|
8
|
+
* - two changes to the same channel were not ordered, so a retried older
|
|
9
|
+
* `subscribed` could land after a newer `unsubscribed` and invert it;
|
|
10
|
+
* - a retry loop kept sending after optOut(), a denied consent or wipeData();
|
|
11
|
+
* - once consent was granted they lived only in memory, so a navigation lost
|
|
12
|
+
* them (a newsletter sign-up followed by a redirect never arrived).
|
|
13
|
+
*
|
|
14
|
+
* Now every write is a DATA record in one list, kept in localStorage, sent one
|
|
15
|
+
* request at a time:
|
|
16
|
+
* - a newer write to the same subject (channel, group, the marker) of the same
|
|
17
|
+
* person supersedes an older one still waiting, and an older one already on
|
|
18
|
+
* the wire is never retried once a newer one is queued behind it - so no
|
|
19
|
+
* older write can land after a newer one;
|
|
20
|
+
* - a write is sent only while the SDK may send, re-checked before EVERY
|
|
21
|
+
* attempt; opt-out, denied consent and wipeData() discard every write that
|
|
22
|
+
* is not a WITHDRAWAL, and withdrawals (unsubscribes, the opt-out marker)
|
|
23
|
+
* are always delivered;
|
|
24
|
+
* - ids are resolved at SEND time: a write made while anonymous goes out with
|
|
25
|
+
* the user id once that anonymous id has been identified (otherwise SDK
|
|
26
|
+
* authentication refuses it with 403 SDK_ANONYMOUS_ID_IDENTIFIED);
|
|
27
|
+
* - retries follow the one rule of write-retry.ts (offline, 429 and 5xx never
|
|
28
|
+
* count toward the cap);
|
|
29
|
+
* - at page unload the ready writes are handed to the browser with fetch
|
|
30
|
+
* keepalive, and stay stored until acknowledged (a resend is harmless).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Logger } from '../utils/logger';
|
|
34
|
+
import type { StorageManager } from './storage';
|
|
35
|
+
import type { NetworkResponse } from './network';
|
|
36
|
+
import { generateUUID } from '../utils/uuid';
|
|
37
|
+
import {
|
|
38
|
+
WriteRecord,
|
|
39
|
+
WriteOwner,
|
|
40
|
+
isWithdrawal,
|
|
41
|
+
classifyWriteResponse,
|
|
42
|
+
writeAttemptsExhausted,
|
|
43
|
+
writeRetryDelay,
|
|
44
|
+
} from './write-retry';
|
|
45
|
+
|
|
46
|
+
/** Where the outbox lives. */
|
|
47
|
+
export const OUTBOX_KEY = 'write_outbox';
|
|
48
|
+
/** Where an earlier build kept writes held while consent was pending. */
|
|
49
|
+
export const LEGACY_DEFERRED_WRITES_KEY = 'deferred_writes';
|
|
50
|
+
/**
|
|
51
|
+
* Ids of writes that left the shared list - settled, replaced by a newer write
|
|
52
|
+
* of the same subject, or discarded - and when (rule 77). Another tab still
|
|
53
|
+
* holding one of them in memory drops it instead of sending it: an older
|
|
54
|
+
* subscribe waiting out a backoff in tab A must not land after the newer
|
|
55
|
+
* unsubscribe tab B already delivered.
|
|
56
|
+
*/
|
|
57
|
+
export const OUTBOX_REMOVED_KEY = 'write_outbox_removed';
|
|
58
|
+
/** Bound on stored writes; the oldest non-withdrawal goes first. */
|
|
59
|
+
export const MAX_OUTBOX_ENTRIES = 200;
|
|
60
|
+
/** Bound on remembered removals (newest kept), and how long one is remembered. */
|
|
61
|
+
const MAX_REMOVED_IDS = 500;
|
|
62
|
+
const REMOVED_ID_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
|
+
/** A write handed to keepalive is not handed off again within this window (one unload). */
|
|
64
|
+
const UNLOAD_HANDOFF_WINDOW_MS = 10_000;
|
|
65
|
+
|
|
66
|
+
export interface OutboxEntry {
|
|
67
|
+
id: string;
|
|
68
|
+
record: WriteRecord;
|
|
69
|
+
withdrawal: boolean;
|
|
70
|
+
/** Counted attempts (contract item 40), apart from 404s. */
|
|
71
|
+
attempts: number;
|
|
72
|
+
/** 404 "no profile yet" attempts. */
|
|
73
|
+
notFoundAttempts: number;
|
|
74
|
+
/** Every failed attempt, for the backoff. */
|
|
75
|
+
failures: number;
|
|
76
|
+
/** Not before this time (backoff, Retry-After). */
|
|
77
|
+
notBefore: number;
|
|
78
|
+
/** When the write was made (epoch ms): orders writes of several tabs in the shared store. */
|
|
79
|
+
createdAt: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type OutboxGate = 'send' | 'hold' | 'drop';
|
|
83
|
+
|
|
84
|
+
export interface OutboxDeps {
|
|
85
|
+
storage: StorageManager;
|
|
86
|
+
logger: Logger;
|
|
87
|
+
/** May this write go now ('hold': later; 'drop': never - e.g. opted out). */
|
|
88
|
+
gate(entry: OutboxEntry): OutboxGate;
|
|
89
|
+
/** The ids this write is sent with (contract item 38). */
|
|
90
|
+
resolveOwner(owner: WriteOwner): WriteOwner;
|
|
91
|
+
/** Is this the signed-in user's write (for an SDK-authentication 401)? */
|
|
92
|
+
ownerIsCurrentUser(owner: WriteOwner): boolean;
|
|
93
|
+
/** The current identity, to tell whether two writes are about the same person. */
|
|
94
|
+
currentIdentity(): { anonymousId: string; userId: string | null };
|
|
95
|
+
/** Send one write. Must never reject (a NetworkResponse with ok:false instead). */
|
|
96
|
+
send(entry: OutboxEntry, owner: WriteOwner): Promise<NetworkResponse>;
|
|
97
|
+
/** Before an attempt: get queued events and any identify to the server. */
|
|
98
|
+
beforeSend(entry: OutboxEntry): Promise<void>;
|
|
99
|
+
isSdkAuthError(status: number, data: any): boolean;
|
|
100
|
+
/** A write left the outbox: stored ('done') or given up ('drop'). */
|
|
101
|
+
onSettled?(entry: OutboxEntry, outcome: 'done' | 'drop'): void;
|
|
102
|
+
/** Hand one write to the browser at unload; returns the bytes used, or null. */
|
|
103
|
+
keepalive?(entry: OutboxEntry, owner: WriteOwner): number | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ownerOf(record: WriteRecord): WriteOwner {
|
|
107
|
+
const p: any = record.payload;
|
|
108
|
+
return { anonymousId: p.anonymousId, ...(p.userId ? { userId: p.userId } : {}) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The subject a newer write replaces an older one on; null = never superseded. */
|
|
112
|
+
export function subjectOf(record: WriteRecord): string | null {
|
|
113
|
+
switch (record.kind) {
|
|
114
|
+
case 'subscription':
|
|
115
|
+
return `subscription:${record.payload.channel}`;
|
|
116
|
+
case 'group':
|
|
117
|
+
return `group:${record.payload.groupId}:${record.payload.channel}`;
|
|
118
|
+
case 'marker':
|
|
119
|
+
return 'marker';
|
|
120
|
+
default:
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function labelOf(record: WriteRecord): string {
|
|
126
|
+
switch (record.kind) {
|
|
127
|
+
case 'alias':
|
|
128
|
+
return 'addAlias';
|
|
129
|
+
case 'subscription':
|
|
130
|
+
return `Update ${record.payload.channel} subscription`;
|
|
131
|
+
case 'group':
|
|
132
|
+
return `Subscription group ${record.payload.groupId} ${record.payload.action}`;
|
|
133
|
+
case 'marker':
|
|
134
|
+
return 'The tracking_opted_out marker';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isOffline(): boolean {
|
|
139
|
+
return typeof navigator !== 'undefined' && (navigator as any).onLine === false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isRecord(raw: any): raw is WriteRecord {
|
|
143
|
+
return (
|
|
144
|
+
!!raw &&
|
|
145
|
+
typeof raw === 'object' &&
|
|
146
|
+
(raw.kind === 'alias' || raw.kind === 'subscription' || raw.kind === 'group' || raw.kind === 'marker') &&
|
|
147
|
+
!!raw.payload &&
|
|
148
|
+
typeof raw.payload === 'object' &&
|
|
149
|
+
typeof raw.payload.anonymousId === 'string'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One stored write, or null when it is malformed. */
|
|
154
|
+
function entryFromStored(raw: any): OutboxEntry | null {
|
|
155
|
+
if (!raw || !isRecord(raw.record)) return null;
|
|
156
|
+
const count = (n: any) => (Number.isInteger(n) && n >= 0 ? n : 0);
|
|
157
|
+
return {
|
|
158
|
+
id: typeof raw.id === 'string' ? raw.id : generateUUID(),
|
|
159
|
+
record: raw.record,
|
|
160
|
+
withdrawal: isWithdrawal(raw.record),
|
|
161
|
+
attempts: count(raw.attempts),
|
|
162
|
+
notFoundAttempts: count(raw.notFoundAttempts),
|
|
163
|
+
failures: count(raw.failures),
|
|
164
|
+
// A new page view tries again now.
|
|
165
|
+
notBefore: 0,
|
|
166
|
+
// Stored by an earlier build: older than anything made now.
|
|
167
|
+
createdAt: typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : 0,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class WriteOutbox {
|
|
172
|
+
private entries: OutboxEntry[] = [];
|
|
173
|
+
private inFlight: OutboxEntry | null = null;
|
|
174
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
175
|
+
/**
|
|
176
|
+
* The key was refused (a 401 that is not SDK authentication): nothing more
|
|
177
|
+
* this page view (kept for the next one). NOT a 402: a paused workspace
|
|
178
|
+
* still accepts withdrawals, so a 402 only holds the writes that are not
|
|
179
|
+
* (rule 50; the SDK's gate does that).
|
|
180
|
+
*/
|
|
181
|
+
private stopped = false;
|
|
182
|
+
/**
|
|
183
|
+
* Writes handed to keepalive at this unload, and when. An unload runs
|
|
184
|
+
* `beforeunload` AND `pagehide`; the second must not hand the same writes
|
|
185
|
+
* off again (the browser's 64 KiB keepalive budget is shared).
|
|
186
|
+
*/
|
|
187
|
+
private handedOffAt = new Map<string, number>();
|
|
188
|
+
private destroyed = false;
|
|
189
|
+
/**
|
|
190
|
+
* Ids of every write this tab loaded or made. The stored list is shared by
|
|
191
|
+
* every tab of the site (rule 62): an id in it that is NOT here is another
|
|
192
|
+
* tab's write, kept when this tab stores its own list; an id that is here
|
|
193
|
+
* but no longer in `entries` was settled or discarded by this tab.
|
|
194
|
+
*/
|
|
195
|
+
private knownIds = new Set<string>();
|
|
196
|
+
|
|
197
|
+
constructor(private readonly deps: OutboxDeps) {
|
|
198
|
+
this.load();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Queue a write (after the caller checked it may be made at all). */
|
|
202
|
+
enqueue(record: WriteRecord): void {
|
|
203
|
+
if (this.destroyed) return;
|
|
204
|
+
const entry: OutboxEntry = {
|
|
205
|
+
id: generateUUID(),
|
|
206
|
+
record,
|
|
207
|
+
withdrawal: isWithdrawal(record),
|
|
208
|
+
attempts: 0,
|
|
209
|
+
notFoundAttempts: 0,
|
|
210
|
+
failures: 0,
|
|
211
|
+
notBefore: 0,
|
|
212
|
+
createdAt: Date.now(),
|
|
213
|
+
};
|
|
214
|
+
this.knownIds.add(entry.id);
|
|
215
|
+
const subject = subjectOf(record);
|
|
216
|
+
if (subject) {
|
|
217
|
+
// A newer write to the same subject of the same person: what was still
|
|
218
|
+
// waiting is moot. One already on the wire is answered first, and not
|
|
219
|
+
// retried (see run()).
|
|
220
|
+
this.entries = this.entries.filter(
|
|
221
|
+
(e) => e === this.inFlight || subjectOf(e.record) !== subject || !this.sameOwner(e.record, record),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
this.entries.push(entry);
|
|
225
|
+
this.trim();
|
|
226
|
+
this.persist();
|
|
227
|
+
this.kick();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Opt-out, consent denied, wipeData(): forget every write that is not a
|
|
232
|
+
* withdrawal. One on the wire is forgotten too - its answer is ignored.
|
|
233
|
+
*/
|
|
234
|
+
discardNonWithdrawals(): void {
|
|
235
|
+
this.entries = this.entries.filter((e) => e.withdrawal);
|
|
236
|
+
// Every tab's: the decision is the site's, and a tab that closed left its
|
|
237
|
+
// writes only in storage (contract item 21, scan 7 N6).
|
|
238
|
+
this.persist({ discardNonWithdrawals: true });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Store this tab's list again (another tab cleared localStorage). */
|
|
242
|
+
persistNow(): void {
|
|
243
|
+
this.persist();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Is a write matching this test still waiting (or on the wire)? */
|
|
247
|
+
find(test: (entry: OutboxEntry) => boolean): OutboxEntry | undefined {
|
|
248
|
+
return this.entries.find(test);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The newest waiting write matching this test. */
|
|
252
|
+
findLast(test: (entry: OutboxEntry) => boolean): OutboxEntry | undefined {
|
|
253
|
+
for (let i = this.entries.length - 1; i >= 0; i--) if (test(this.entries[i])) return this.entries[i];
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Remove waiting writes matching this test (never the one on the wire). */
|
|
258
|
+
cancel(test: (entry: OutboxEntry) => boolean): void {
|
|
259
|
+
const before = this.entries.length;
|
|
260
|
+
this.entries = this.entries.filter((e) => e === this.inFlight || !test(e));
|
|
261
|
+
if (this.entries.length !== before) this.persist();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The identity is about to change: writes made while `anonymousId` was
|
|
266
|
+
* anonymous belong to `userId` from now on (see resolveOwner).
|
|
267
|
+
*/
|
|
268
|
+
pinOwner(anonymousId: string, userId: string): void {
|
|
269
|
+
let changed = false;
|
|
270
|
+
for (const e of this.entries) {
|
|
271
|
+
const payload: any = e.record.payload;
|
|
272
|
+
if (payload.anonymousId === anonymousId && !payload.userId) {
|
|
273
|
+
e.record = { ...e.record, payload: { ...payload, userId } } as WriteRecord;
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (changed) this.persist();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
isInFlight(entry: OutboxEntry): boolean {
|
|
281
|
+
return this.inFlight === entry;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
size(): number {
|
|
285
|
+
return this.entries.length;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The waiting writes, oldest first (tests and diagnostics). */
|
|
289
|
+
records(): WriteRecord[] {
|
|
290
|
+
return this.entries.map((e) => e.record);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Send what may be sent now. Safe to call at any time. */
|
|
294
|
+
kick(): void {
|
|
295
|
+
if (this.destroyed || this.stopped || this.inFlight) return;
|
|
296
|
+
if (this.timer !== null) {
|
|
297
|
+
clearTimeout(this.timer);
|
|
298
|
+
this.timer = null;
|
|
299
|
+
}
|
|
300
|
+
// Offline: the SDK kicks again on the browser's `online` event.
|
|
301
|
+
if (isOffline()) return;
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const blocked = new Set<string>();
|
|
304
|
+
let wake = Number.POSITIVE_INFINITY;
|
|
305
|
+
let pick: OutboxEntry | null = null;
|
|
306
|
+
for (const entry of [...this.entries]) {
|
|
307
|
+
const key = this.blockKey(entry);
|
|
308
|
+
if (blocked.has(key)) continue;
|
|
309
|
+
const gate = this.safeGate(entry);
|
|
310
|
+
if (gate === 'drop') {
|
|
311
|
+
this.remove(entry); // not allowed any more (opt-out): discarded, not refused
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (gate === 'hold') {
|
|
315
|
+
blocked.add(key);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (entry.notBefore > now) {
|
|
319
|
+
blocked.add(key);
|
|
320
|
+
wake = Math.min(wake, entry.notBefore);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
pick = entry;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
if (pick) {
|
|
327
|
+
void this.run(pick);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (wake < Number.POSITIVE_INFINITY) {
|
|
331
|
+
this.timer = setTimeout(() => {
|
|
332
|
+
this.timer = null;
|
|
333
|
+
this.kick();
|
|
334
|
+
}, Math.max(0, wake - now));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Page unload: hand the writes that may go now to the browser with fetch
|
|
340
|
+
* keepalive. They STAY stored until acknowledged on a later page view (a
|
|
341
|
+
* repeated subscription or alias write is harmless); only one write per
|
|
342
|
+
* subject is handed off, so two of one subject never race.
|
|
343
|
+
*
|
|
344
|
+
* The same rule-77 check as run() first (rule 85): a write another tab
|
|
345
|
+
* settled, or replaced with a newer one, is never handed off - a tab
|
|
346
|
+
* closing with an older subscribe in its backoff must not land it after
|
|
347
|
+
* the other tab's newer unsubscribe. persist() drops what the stored
|
|
348
|
+
* removal list names and what a newer stored write replaced;
|
|
349
|
+
* removedElsewhere() catches the rest.
|
|
350
|
+
*/
|
|
351
|
+
handOffAtUnload(budgetBytes: number): number {
|
|
352
|
+
let used = 0;
|
|
353
|
+
if (!this.deps.keepalive || this.destroyed || this.stopped || isOffline()) return used;
|
|
354
|
+
this.persist();
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
const blocked = new Set<string>();
|
|
357
|
+
if (this.inFlight) blocked.add(this.blockKey(this.inFlight));
|
|
358
|
+
for (const entry of [...this.entries]) {
|
|
359
|
+
const key = this.blockKey(entry);
|
|
360
|
+
if (blocked.has(key)) continue;
|
|
361
|
+
if (entry !== this.inFlight && this.removedElsewhere(entry)) {
|
|
362
|
+
this.deps.logger.debug(`${labelOf(entry.record)} not handed off: another tab settled or replaced it`);
|
|
363
|
+
const i = this.entries.indexOf(entry);
|
|
364
|
+
if (i >= 0) this.entries.splice(i, 1);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
blocked.add(key);
|
|
368
|
+
if (entry === this.inFlight || this.safeGate(entry) !== 'send') continue;
|
|
369
|
+
const handed = this.handedOffAt.get(entry.id);
|
|
370
|
+
if (handed !== undefined && now - handed < UNLOAD_HANDOFF_WINDOW_MS) continue;
|
|
371
|
+
try {
|
|
372
|
+
const bytes = this.deps.keepalive(entry, this.deps.resolveOwner(ownerOf(entry.record)));
|
|
373
|
+
if (bytes === null) break;
|
|
374
|
+
this.handedOffAt.set(entry.id, now);
|
|
375
|
+
used += bytes;
|
|
376
|
+
if (used >= budgetBytes) break;
|
|
377
|
+
} catch {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
this.persist();
|
|
382
|
+
return used;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
destroy(): void {
|
|
386
|
+
this.destroyed = true;
|
|
387
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
388
|
+
this.timer = null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
private async run(entry: OutboxEntry): Promise<void> {
|
|
394
|
+
this.inFlight = entry;
|
|
395
|
+
const label = labelOf(entry.record);
|
|
396
|
+
try {
|
|
397
|
+
await this.deps.beforeSend(entry).catch(() => undefined);
|
|
398
|
+
// Re-checked after the await: an opt-out, a denial or a wipe meanwhile.
|
|
399
|
+
if (!this.entries.includes(entry)) return;
|
|
400
|
+
const gate = this.safeGate(entry);
|
|
401
|
+
if (gate === 'drop') {
|
|
402
|
+
this.remove(entry);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (gate === 'hold') return;
|
|
406
|
+
// Another tab settled this write, or made a newer one of the same
|
|
407
|
+
// subject for the same person (rule 77): sending it now could land an
|
|
408
|
+
// older choice after a newer one, or send it twice.
|
|
409
|
+
if (this.removedElsewhere(entry)) {
|
|
410
|
+
this.deps.logger.debug(`${label} not sent: another tab settled or replaced it`);
|
|
411
|
+
this.remove(entry);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const owner = this.deps.resolveOwner(ownerOf(entry.record));
|
|
416
|
+
const res = await this.deps.send(entry, owner);
|
|
417
|
+
// Discarded while on the wire (opt-out, wipe): its answer is moot.
|
|
418
|
+
if (!this.entries.includes(entry)) return;
|
|
419
|
+
|
|
420
|
+
const verdict = classifyWriteResponse(res, {
|
|
421
|
+
isSdkAuthError: this.deps.isSdkAuthError(res.status, res.data),
|
|
422
|
+
ownerIsCurrentUser: this.deps.ownerIsCurrentUser(owner),
|
|
423
|
+
});
|
|
424
|
+
switch (verdict.action) {
|
|
425
|
+
case 'done':
|
|
426
|
+
this.settle(entry, 'done');
|
|
427
|
+
return;
|
|
428
|
+
case 'drop':
|
|
429
|
+
this.deps.logger.error(`${label} dropped: ${verdict.reason}`);
|
|
430
|
+
this.settle(entry, 'drop');
|
|
431
|
+
return;
|
|
432
|
+
case 'hold':
|
|
433
|
+
this.deps.logger.error(`${label} not sent: ${verdict.reason}; kept for the next page view`);
|
|
434
|
+
this.stopped = true;
|
|
435
|
+
return;
|
|
436
|
+
case 'retry':
|
|
437
|
+
if (this.hasNewerOfSameSubject(entry)) {
|
|
438
|
+
// A newer write to this subject is queued: retrying this one
|
|
439
|
+
// could land it after the newer one.
|
|
440
|
+
this.remove(entry);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
entry.failures++;
|
|
444
|
+
if (verdict.counts) {
|
|
445
|
+
if (verdict.notFound) entry.notFoundAttempts++;
|
|
446
|
+
else entry.attempts++;
|
|
447
|
+
}
|
|
448
|
+
if (writeAttemptsExhausted(entry.attempts, entry.notFoundAttempts)) {
|
|
449
|
+
this.deps.logger.error(`${label} failed (HTTP ${res.status || 'no response'}) too many times; dropping it`);
|
|
450
|
+
this.settle(entry, 'drop');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
entry.notBefore = Date.now() + writeRetryDelay(entry.failures, verdict.retryAfterMs);
|
|
454
|
+
this.deps.logger.debug(`${label} failed with HTTP ${res.status || 'no response'}; retrying`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
// A bug, not an answer: keep the write, back off, never throw.
|
|
459
|
+
this.deps.logger.error(`${label} failed:`, error);
|
|
460
|
+
entry.failures++;
|
|
461
|
+
entry.notBefore = Date.now() + writeRetryDelay(entry.failures);
|
|
462
|
+
} finally {
|
|
463
|
+
if (this.inFlight === entry) this.inFlight = null;
|
|
464
|
+
this.persist();
|
|
465
|
+
this.kick();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private safeGate(entry: OutboxEntry): OutboxGate {
|
|
470
|
+
try {
|
|
471
|
+
return this.deps.gate(entry);
|
|
472
|
+
} catch {
|
|
473
|
+
return 'hold';
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private settle(entry: OutboxEntry, outcome: 'done' | 'drop'): void {
|
|
478
|
+
this.remove(entry);
|
|
479
|
+
try {
|
|
480
|
+
this.deps.onSettled?.(entry, outcome);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
this.deps.logger.error('Outbox bookkeeping failed:', error);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private remove(entry: OutboxEntry): void {
|
|
487
|
+
const i = this.entries.indexOf(entry);
|
|
488
|
+
if (i >= 0) {
|
|
489
|
+
this.entries.splice(i, 1);
|
|
490
|
+
this.persist();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private hasNewerOfSameSubject(entry: OutboxEntry): boolean {
|
|
495
|
+
const subject = subjectOf(entry.record);
|
|
496
|
+
if (!subject) return false;
|
|
497
|
+
const i = this.entries.indexOf(entry);
|
|
498
|
+
return this.entries.slice(i + 1).some((e) => subjectOf(e.record) === subject && this.sameOwner(e.record, entry.record));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The user id a write will carry: its own, or the current one when its anonymous id is current. */
|
|
502
|
+
private effectiveUser(owner: WriteOwner): string | null {
|
|
503
|
+
if (owner.userId) return owner.userId;
|
|
504
|
+
const current = this.deps.currentIdentity();
|
|
505
|
+
return owner.anonymousId === current.anonymousId ? current.userId : null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private sameOwner(a: WriteRecord, b: WriteRecord): boolean {
|
|
509
|
+
const oa = ownerOf(a);
|
|
510
|
+
const ob = ownerOf(b);
|
|
511
|
+
return oa.anonymousId === ob.anonymousId && this.effectiveUser(oa) === this.effectiveUser(ob);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Writes of one subject and person go in order; anything else may pass. */
|
|
515
|
+
private blockKey(entry: OutboxEntry): string {
|
|
516
|
+
const owner = ownerOf(entry.record);
|
|
517
|
+
return `${subjectOf(entry.record) ?? `${entry.record.kind}:${entry.id}`}|${owner.anonymousId}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Bound memory and storage: the oldest non-withdrawal waiting goes first. */
|
|
521
|
+
private trim(): void {
|
|
522
|
+
while (this.entries.length > MAX_OUTBOX_ENTRIES) {
|
|
523
|
+
let i = this.entries.findIndex((e) => !e.withdrawal && e !== this.inFlight);
|
|
524
|
+
if (i < 0) i = this.entries.findIndex((e) => e !== this.inFlight);
|
|
525
|
+
if (i < 0) break;
|
|
526
|
+
const [dropped] = this.entries.splice(i, 1);
|
|
527
|
+
this.deps.logger.warn(`Too many profile writes waiting; dropped the oldest (${labelOf(dropped.record)})`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Store this tab's list MERGED into the stored one (rule 62, scan 5 N8):
|
|
533
|
+
* every tab writes the same key, and a blind overwrite lost the writes of
|
|
534
|
+
* the others - an opt-out marker queued in tab A was gone once tab B stored
|
|
535
|
+
* its list, and with it the withdrawal if tab A closed before sending it.
|
|
536
|
+
* Another tab's writes are kept, in the order they were made; one of them
|
|
537
|
+
* replaced by a newer write of this tab for the same subject and person is
|
|
538
|
+
* dropped (and a newer one of theirs replaces a waiting one of ours).
|
|
539
|
+
*
|
|
540
|
+
* Every write that leaves the stored list is remembered under
|
|
541
|
+
* OUTBOX_REMOVED_KEY (rule 77), and a write another tab removed is dropped
|
|
542
|
+
* here too - never stored again by this tab, never sent (see run()).
|
|
543
|
+
*/
|
|
544
|
+
private persist(options: { discardNonWithdrawals?: boolean } = {}): void {
|
|
545
|
+
try {
|
|
546
|
+
const stored = this.deps.storage.get<any[]>(OUTBOX_KEY);
|
|
547
|
+
const storedIds = Array.isArray(stored)
|
|
548
|
+
? stored.filter((raw) => raw && typeof raw.id === 'string').map((raw) => raw.id as string)
|
|
549
|
+
: [];
|
|
550
|
+
const removed = this.readRemoved();
|
|
551
|
+
// Settled or replaced by another tab: moot here too. (One on the wire
|
|
552
|
+
// stays in memory until its answer; it is not stored again.)
|
|
553
|
+
this.entries = this.entries.filter((e) => e === this.inFlight || !removed.has(e.id));
|
|
554
|
+
let foreign = this.foreignStoredEntries(stored).filter((e) => !removed.has(e.id));
|
|
555
|
+
if (options.discardNonWithdrawals) foreign = foreign.filter((e) => e.withdrawal);
|
|
556
|
+
let kept: OutboxEntry[] = this.entries;
|
|
557
|
+
if (foreign.length) {
|
|
558
|
+
const merged = [...this.entries, ...foreign].sort((x, y) => x.createdAt - y.createdAt);
|
|
559
|
+
const superseded = (e: OutboxEntry, i: number) => {
|
|
560
|
+
const subject = subjectOf(e.record);
|
|
561
|
+
return (
|
|
562
|
+
!!subject &&
|
|
563
|
+
e !== this.inFlight &&
|
|
564
|
+
merged.slice(i + 1).some((n) => subjectOf(n.record) === subject && this.sameOwner(n.record, e.record))
|
|
565
|
+
);
|
|
566
|
+
};
|
|
567
|
+
kept = merged.filter((e, i) => !superseded(e, i));
|
|
568
|
+
// Bounded like one tab's list: the oldest non-withdrawal goes first,
|
|
569
|
+
// whichever tab made it - a tab that closed left its writes only
|
|
570
|
+
// here, so "theirs are still in their memory" does not hold (N3).
|
|
571
|
+
while (kept.length > MAX_OUTBOX_ENTRIES) {
|
|
572
|
+
let i = kept.findIndex((e) => !e.withdrawal && e !== this.inFlight);
|
|
573
|
+
if (i < 0) i = kept.findIndex((e) => e !== this.inFlight);
|
|
574
|
+
if (i < 0) break;
|
|
575
|
+
this.deps.logger.warn(`Too many profile writes waiting; dropped the oldest (${labelOf(kept[i].record)})`);
|
|
576
|
+
kept = kept.filter((_, j) => j !== i);
|
|
577
|
+
}
|
|
578
|
+
// A newer write of another tab replaced one of ours still waiting: ours is moot here too.
|
|
579
|
+
this.entries = this.entries.filter((e) => e === this.inFlight || kept.includes(e));
|
|
580
|
+
}
|
|
581
|
+
const list = kept.filter((e) => !removed.has(e.id));
|
|
582
|
+
this.write(list);
|
|
583
|
+
const written = new Set(list.map((e) => e.id));
|
|
584
|
+
const leaving = storedIds.filter((id) => !written.has(id) && !removed.has(id));
|
|
585
|
+
if (leaving.length) this.writeRemoved(removed, leaving);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
this.deps.logger.error('Failed to store pending profile writes:', error);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Did another tab settle this write, or store a newer write of the same
|
|
593
|
+
* subject for the same person (rule 77)?
|
|
594
|
+
*/
|
|
595
|
+
private removedElsewhere(entry: OutboxEntry): boolean {
|
|
596
|
+
try {
|
|
597
|
+
if (this.readRemoved().has(entry.id)) return true;
|
|
598
|
+
const subject = subjectOf(entry.record);
|
|
599
|
+
if (!subject) return false;
|
|
600
|
+
const stored = this.deps.storage.get<any[]>(OUTBOX_KEY);
|
|
601
|
+
if (!Array.isArray(stored)) return false;
|
|
602
|
+
return stored.some(
|
|
603
|
+
(raw) =>
|
|
604
|
+
raw &&
|
|
605
|
+
raw.id !== entry.id &&
|
|
606
|
+
isRecord(raw.record) &&
|
|
607
|
+
typeof raw.createdAt === 'number' &&
|
|
608
|
+
raw.createdAt > entry.createdAt &&
|
|
609
|
+
subjectOf(raw.record) === subject &&
|
|
610
|
+
this.sameOwner(raw.record, entry.record),
|
|
611
|
+
);
|
|
612
|
+
} catch {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The remembered removals (id -> when), expired ones left out. */
|
|
618
|
+
private readRemoved(): Map<string, number> {
|
|
619
|
+
const out = new Map<string, number>();
|
|
620
|
+
const stored = this.deps.storage.get<unknown>(OUTBOX_REMOVED_KEY);
|
|
621
|
+
if (!Array.isArray(stored)) return out;
|
|
622
|
+
const now = Date.now();
|
|
623
|
+
for (const item of stored) {
|
|
624
|
+
if (!Array.isArray(item) || typeof item[0] !== 'string' || typeof item[1] !== 'number') continue;
|
|
625
|
+
if (now - item[1] > REMOVED_ID_MAX_AGE_MS) continue;
|
|
626
|
+
out.set(item[0], item[1]);
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private writeRemoved(removed: Map<string, number>, leaving: string[]): void {
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
const all: [string, number][] = [...removed.entries(), ...leaving.map((id): [string, number] => [id, now])];
|
|
634
|
+
this.deps.storage.set(OUTBOX_REMOVED_KEY, all.slice(-MAX_REMOVED_IDS));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private write(list: OutboxEntry[]): void {
|
|
638
|
+
if (list.length) {
|
|
639
|
+
this.deps.storage.set(
|
|
640
|
+
OUTBOX_KEY,
|
|
641
|
+
list.map((e) => ({
|
|
642
|
+
id: e.id,
|
|
643
|
+
record: e.record,
|
|
644
|
+
attempts: e.attempts,
|
|
645
|
+
notFoundAttempts: e.notFoundAttempts,
|
|
646
|
+
failures: e.failures,
|
|
647
|
+
createdAt: e.createdAt,
|
|
648
|
+
})),
|
|
649
|
+
);
|
|
650
|
+
} else {
|
|
651
|
+
this.deps.storage.remove(OUTBOX_KEY);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Stored writes this tab never saw: another tab's. */
|
|
656
|
+
private foreignStoredEntries(stored: any[] | null): OutboxEntry[] {
|
|
657
|
+
if (!Array.isArray(stored)) return [];
|
|
658
|
+
const out: OutboxEntry[] = [];
|
|
659
|
+
for (const raw of stored) {
|
|
660
|
+
if (!raw || typeof raw.id !== 'string' || this.knownIds.has(raw.id) || !isRecord(raw.record)) continue;
|
|
661
|
+
const entry = entryFromStored(raw);
|
|
662
|
+
if (entry) out.push(entry);
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private load(): void {
|
|
668
|
+
const stored = this.deps.storage.get<any[]>(OUTBOX_KEY);
|
|
669
|
+
if (Array.isArray(stored)) {
|
|
670
|
+
// Every stored entry is read and THEN bounded by trim(), which drops the
|
|
671
|
+
// oldest non-withdrawal first. A plain slice(-MAX) here dropped the
|
|
672
|
+
// oldest entries whatever they were - an unsubscribe among them - when
|
|
673
|
+
// another tab had stored more than the bound (contract v5 51).
|
|
674
|
+
for (const raw of stored) {
|
|
675
|
+
const entry = entryFromStored(raw);
|
|
676
|
+
if (!entry) continue;
|
|
677
|
+
this.entries.push(entry);
|
|
678
|
+
this.knownIds.add(entry.id);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// Writes an earlier build held while consent was pending.
|
|
682
|
+
const legacy = this.deps.storage.get<any[]>(LEGACY_DEFERRED_WRITES_KEY);
|
|
683
|
+
if (Array.isArray(legacy)) {
|
|
684
|
+
for (const raw of legacy) {
|
|
685
|
+
if (!isRecord(raw)) continue;
|
|
686
|
+
const entry: OutboxEntry = {
|
|
687
|
+
id: generateUUID(),
|
|
688
|
+
record: raw,
|
|
689
|
+
withdrawal: isWithdrawal(raw),
|
|
690
|
+
attempts: 0,
|
|
691
|
+
notFoundAttempts: 0,
|
|
692
|
+
failures: 0,
|
|
693
|
+
notBefore: 0,
|
|
694
|
+
createdAt: 0,
|
|
695
|
+
};
|
|
696
|
+
this.entries.push(entry);
|
|
697
|
+
this.knownIds.add(entry.id);
|
|
698
|
+
}
|
|
699
|
+
this.deps.storage.remove(LEGACY_DEFERRED_WRITES_KEY);
|
|
700
|
+
this.trim();
|
|
701
|
+
this.persist();
|
|
702
|
+
} else if (this.entries.length > MAX_OUTBOX_ENTRIES) {
|
|
703
|
+
this.trim();
|
|
704
|
+
this.persist();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|