@reservamos/browser-analytics 1.1.8-alpha.0 → 1.2.0-alpha.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 +21 -3
- package/README.md +33 -7
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +15 -2
- package/dist/browser-analytics.esm.js +590 -109
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/events/identify/identify.robustness.test.ts +112 -13
- package/src/events/identify/identify.ts +84 -34
- package/src/events/passengersCreated/passengersCreatedSchema.test.ts +142 -0
- package/src/events/passengersCreated/passengersCreatedSchema.ts +7 -1
- package/src/events/purchaseConfirmation/purchaseConfirmation.integration.test.ts +49 -0
- package/src/events/purchaseConfirmation/purchaseConfirmation.test.ts +17 -0
- package/src/events/purchaseConfirmation/purchaseConfirmationSchema.ts +2 -0
- package/src/events/sharedSchemas/tripSchema.ts +3 -8
- package/src/index.ts +1 -0
- package/src/init.botDetection.test.ts +187 -0
- package/src/init.test.ts +1 -0
- package/src/init.ts +15 -2
- package/src/services/botDetectionRelay.test.ts +3 -2
- package/src/services/deferredBotDetection.ts +60 -0
- package/src/services/fingerprint.test.ts +190 -0
- package/src/services/fingerprint.ts +141 -13
- package/src/services/fingerprint.wait.integration.test.ts +129 -0
- package/src/services/mixpanel.test.ts +119 -73
- package/src/services/mixpanel.ts +82 -23
- package/src/services/trackingLifecycle.ts +54 -0
- package/src/services/validator/validator.test.ts +79 -0
- package/src/track.readiness.test.ts +131 -0
- package/src/track.recovery.test.ts +339 -0
- package/src/track.storage.test.ts +142 -0
- package/src/track.test.ts +130 -5
- package/src/track.ts +110 -25
- package/src/util/pendingWork.test.ts +164 -0
- package/src/util/pendingWork.ts +148 -0
- package/src/util/snapshotEventData.ts +15 -0
package/src/track.ts
CHANGED
|
@@ -4,10 +4,14 @@ import type {
|
|
|
4
4
|
EventMetadata,
|
|
5
5
|
} from './types/eventData';
|
|
6
6
|
import botDetectionRelayService from '@/services/botDetectionRelay';
|
|
7
|
+
import { deferBotDetection } from '@/services/deferredBotDetection';
|
|
7
8
|
import fingerprintService from '@/services/fingerprint';
|
|
8
9
|
import geolocationService from '@/services/geolocation';
|
|
9
10
|
import mixpanelService from '@/services/mixpanel';
|
|
11
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
10
12
|
import validator from '@/services/validator';
|
|
13
|
+
import { createPendingWorkQueue } from '@/util/pendingWork';
|
|
14
|
+
import { snapshotEventData } from '@/util/snapshotEventData';
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* List of events that trigger the fingerprint to be sent with the event. other events will only fetch the cached fingerprint.
|
|
@@ -99,12 +103,14 @@ export async function tryTrackEvent(
|
|
|
99
103
|
const finish = (isReady: boolean) => {
|
|
100
104
|
clearTimeout(timer);
|
|
101
105
|
window.removeEventListener('Tracker Ready', onReady);
|
|
106
|
+
unsubscribe();
|
|
102
107
|
resolve(isReady);
|
|
103
108
|
};
|
|
104
109
|
const onReady = () => {
|
|
105
110
|
if (mixpanelService.isReady()) finish(true);
|
|
106
111
|
};
|
|
107
112
|
const timer = setTimeout(() => finish(mixpanelService.isReady()), 5000);
|
|
113
|
+
const unsubscribe = mixpanelService.onReady(onReady);
|
|
108
114
|
window.addEventListener('Tracker Ready', onReady);
|
|
109
115
|
onReady();
|
|
110
116
|
});
|
|
@@ -135,25 +141,96 @@ export async function tryTrackEvent(
|
|
|
135
141
|
* @param {string} eventName - The name of the event to track.
|
|
136
142
|
* @param {object} eventProperties - The properties of the event to track.
|
|
137
143
|
* @param {EventMetadata} meta - Additional metadata to include in the event.
|
|
138
|
-
*
|
|
139
|
-
* Optional fingerprint failure does not discard an otherwise valid event.
|
|
144
|
+
* Retains unsent events for up to sixty seconds without retrying a started submission.
|
|
140
145
|
*/
|
|
146
|
+
const pendingEvents = createPendingWorkQueue({
|
|
147
|
+
isReady: () => mixpanelService.isReady(),
|
|
148
|
+
subscribe(notify) {
|
|
149
|
+
const stopReady = mixpanelService.onReady(notify);
|
|
150
|
+
const stopIdentity = trackingLifecycle.onChange(notify);
|
|
151
|
+
window.addEventListener('Tracker Ready', notify);
|
|
152
|
+
return () => {
|
|
153
|
+
stopReady();
|
|
154
|
+
stopIdentity();
|
|
155
|
+
window.removeEventListener('Tracker Ready', notify);
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
maxEntries: 100,
|
|
159
|
+
maxBytes: 1024 * 1024,
|
|
160
|
+
maxActive: 100,
|
|
161
|
+
lifetime: 60000,
|
|
162
|
+
wait: 5000,
|
|
163
|
+
stage: 'readiness',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/** Resolves within the initial five-second wait; unsent events can remain pending for 60 seconds. */
|
|
141
167
|
export function trackEvent(
|
|
142
168
|
eventName: string,
|
|
143
169
|
eventProperties: object,
|
|
144
170
|
meta: EventMetadata = {},
|
|
145
171
|
): Promise<void> {
|
|
146
|
-
|
|
147
|
-
|
|
172
|
+
const isCurrent = trackingLifecycle.capture();
|
|
173
|
+
const trackingParams = extractTrackingParams();
|
|
174
|
+
if (mixpanelService.isReady()) {
|
|
175
|
+
return submitEvent(
|
|
176
|
+
eventName,
|
|
177
|
+
eventProperties,
|
|
178
|
+
meta,
|
|
179
|
+
isCurrent,
|
|
180
|
+
trackingParams,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
validator.parseEventProps(eventName, eventProperties);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
trackEventError(eventName, error, eventProperties);
|
|
187
|
+
return Promise.resolve();
|
|
188
|
+
}
|
|
189
|
+
let snapshot: { eventProperties: object; meta: EventMetadata };
|
|
190
|
+
let bytes: number;
|
|
191
|
+
try {
|
|
192
|
+
const serialized = JSON.stringify(
|
|
193
|
+
{ eventName, eventProperties, meta, trackingParams },
|
|
194
|
+
(_key, value: unknown) => {
|
|
195
|
+
if (typeof value === 'function' || typeof value === 'symbol')
|
|
196
|
+
throw new Error();
|
|
197
|
+
return value === undefined ? null : value;
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
bytes = new Blob([serialized]).size;
|
|
201
|
+
if (bytes > 1024 * 1024) throw new Error();
|
|
202
|
+
snapshot = snapshotEventData({ eventProperties, meta });
|
|
203
|
+
} catch {
|
|
204
|
+
console.error('Tracking recovery diagnostic', {
|
|
205
|
+
eventName,
|
|
206
|
+
stage: 'readiness',
|
|
207
|
+
reason: 'snapshot_unavailable',
|
|
208
|
+
});
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
}
|
|
211
|
+
return pendingEvents.enqueue({
|
|
148
212
|
eventName,
|
|
149
|
-
|
|
213
|
+
bytes,
|
|
214
|
+
isCurrent,
|
|
215
|
+
run: (isActive) =>
|
|
216
|
+
submitEvent(
|
|
217
|
+
eventName,
|
|
218
|
+
snapshot.eventProperties,
|
|
219
|
+
snapshot.meta,
|
|
220
|
+
isActive,
|
|
221
|
+
trackingParams,
|
|
222
|
+
),
|
|
223
|
+
});
|
|
150
224
|
}
|
|
151
225
|
|
|
152
226
|
async function submitEvent(
|
|
153
227
|
eventName: string,
|
|
154
228
|
eventProperties: object,
|
|
155
229
|
meta: EventMetadata,
|
|
230
|
+
isCurrent: () => boolean,
|
|
231
|
+
trackingParams: Record<string, string>,
|
|
156
232
|
): Promise<void> {
|
|
233
|
+
const observationContext = trackingLifecycle.capture();
|
|
157
234
|
try {
|
|
158
235
|
validator.parseEventProps(eventName, eventProperties);
|
|
159
236
|
// Call the Mixpanel service to store the distinct ID in the cache
|
|
@@ -162,16 +239,15 @@ async function submitEvent(
|
|
|
162
239
|
botDetectionRelayService.isEnabledFor(eventName);
|
|
163
240
|
let fingerprint = '';
|
|
164
241
|
|
|
165
|
-
if (botDetectionEnabled) {
|
|
242
|
+
if (botDetectionEnabled || FP_TRIGGER_EVENTS.includes(eventName)) {
|
|
166
243
|
try {
|
|
167
244
|
fingerprint = fingerprintService.getCachedFingerprint() ?? '';
|
|
168
245
|
} catch (error) {
|
|
169
246
|
console.error('Error reading cached fingerprint:', error);
|
|
170
247
|
}
|
|
171
248
|
} else {
|
|
172
|
-
const cacheOnly = !FP_TRIGGER_EVENTS.includes(eventName);
|
|
173
249
|
try {
|
|
174
|
-
fingerprint = await fingerprintService.getFingerprint(
|
|
250
|
+
fingerprint = await fingerprintService.getFingerprint(true);
|
|
175
251
|
} catch {
|
|
176
252
|
console.error('Tracking diagnostic', {
|
|
177
253
|
eventName,
|
|
@@ -193,7 +269,6 @@ async function submitEvent(
|
|
|
193
269
|
const defaultProperties = {
|
|
194
270
|
'User Fingerprint': fingerprint,
|
|
195
271
|
};
|
|
196
|
-
const trackingParams = extractTrackingParams();
|
|
197
272
|
|
|
198
273
|
const simplifiedData = flattenEventData(eventProperties);
|
|
199
274
|
const properties = {
|
|
@@ -204,10 +279,22 @@ async function submitEvent(
|
|
|
204
279
|
...meta,
|
|
205
280
|
};
|
|
206
281
|
|
|
207
|
-
|
|
282
|
+
if (!isCurrent()) return;
|
|
283
|
+
try {
|
|
284
|
+
mixpanelService.track(eventName, properties);
|
|
285
|
+
} catch {
|
|
286
|
+
console.error('Tracking diagnostic', {
|
|
287
|
+
eventName,
|
|
288
|
+
stage: 'submission',
|
|
289
|
+
reason: 'uncertain',
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
208
293
|
|
|
209
294
|
if (botDetectionEnabled) {
|
|
210
|
-
|
|
295
|
+
deferBotDetection(eventName, distinctId, observationContext);
|
|
296
|
+
} else if (FP_TRIGGER_EVENTS.includes(eventName)) {
|
|
297
|
+
void refreshFingerprint(eventName, observationContext);
|
|
211
298
|
}
|
|
212
299
|
} catch (error) {
|
|
213
300
|
console.error(`Error tracking event '${eventName}':`, error);
|
|
@@ -215,25 +302,23 @@ async function submitEvent(
|
|
|
215
302
|
}
|
|
216
303
|
}
|
|
217
304
|
|
|
218
|
-
/**
|
|
219
|
-
async function
|
|
305
|
+
/** Refreshes optional identity after submission without delaying the event. */
|
|
306
|
+
async function refreshFingerprint(
|
|
220
307
|
eventName: string,
|
|
221
|
-
|
|
308
|
+
isCurrent: () => boolean,
|
|
222
309
|
): Promise<void> {
|
|
310
|
+
const deadline = Date.now() + 10000;
|
|
223
311
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
requestId: identification.requestId,
|
|
231
|
-
visitorId: identification.visitorId,
|
|
232
|
-
...(distinctId && { distinctId }),
|
|
312
|
+
await fingerprintService.getFingerprint(false, {
|
|
313
|
+
isActive: () => isCurrent() && Date.now() < deadline,
|
|
314
|
+
report: () => undefined,
|
|
315
|
+
});
|
|
316
|
+
} catch {
|
|
317
|
+
console.error('Tracking diagnostic', {
|
|
233
318
|
eventName,
|
|
319
|
+
stage: 'fingerprint',
|
|
320
|
+
reason: 'failed',
|
|
234
321
|
});
|
|
235
|
-
} catch (error) {
|
|
236
|
-
console.error(`Error running Bot Detection for '${eventName}':`, error);
|
|
237
322
|
}
|
|
238
323
|
}
|
|
239
324
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createPendingWorkQueue } from './pendingWork';
|
|
3
|
+
|
|
4
|
+
describe('bounded pending work', () => {
|
|
5
|
+
let ready: boolean;
|
|
6
|
+
let signal: () => void;
|
|
7
|
+
let detach: ReturnType<typeof vi.fn>;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
ready = false;
|
|
11
|
+
signal = () => undefined;
|
|
12
|
+
detach = vi.fn();
|
|
13
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.clearAllTimers();
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
function queue(maxActive = 1) {
|
|
21
|
+
return createPendingWorkQueue({
|
|
22
|
+
isReady: () => ready,
|
|
23
|
+
subscribe: (notify) => {
|
|
24
|
+
signal = notify;
|
|
25
|
+
return detach;
|
|
26
|
+
},
|
|
27
|
+
maxEntries: 2,
|
|
28
|
+
maxBytes: 10,
|
|
29
|
+
maxActive,
|
|
30
|
+
lifetime: 60000,
|
|
31
|
+
wait: 5000,
|
|
32
|
+
stage: 'readiness',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const work = (run: () => void | Promise<void>, bytes = 1) => ({
|
|
36
|
+
eventName: 'Test',
|
|
37
|
+
bytes,
|
|
38
|
+
isCurrent: () => true,
|
|
39
|
+
run,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('resolves the foreground wait but retains unsent work for readiness at 5.2 seconds', async () => {
|
|
43
|
+
const run = vi.fn();
|
|
44
|
+
const pending = queue().enqueue(work(run));
|
|
45
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
46
|
+
await expect(pending).resolves.toBeUndefined();
|
|
47
|
+
expect(run).not.toHaveBeenCalled();
|
|
48
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
49
|
+
ready = true;
|
|
50
|
+
signal();
|
|
51
|
+
signal();
|
|
52
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
53
|
+
expect(run).toHaveBeenCalledOnce();
|
|
54
|
+
expect(detach).toHaveBeenCalledOnce();
|
|
55
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('never retries after a provider accepts and throws, including reentrant readiness', async () => {
|
|
59
|
+
const q = queue();
|
|
60
|
+
const run = vi.fn(() => {
|
|
61
|
+
signal();
|
|
62
|
+
throw new Error('accepted then threw');
|
|
63
|
+
});
|
|
64
|
+
const pending = q.enqueue(work(run));
|
|
65
|
+
ready = true;
|
|
66
|
+
signal();
|
|
67
|
+
signal();
|
|
68
|
+
await pending;
|
|
69
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
70
|
+
signal();
|
|
71
|
+
expect(run).toHaveBeenCalledOnce();
|
|
72
|
+
expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
|
|
73
|
+
'accepted then threw',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps separate legitimate equal-payload calls', async () => {
|
|
78
|
+
const q = queue(2);
|
|
79
|
+
const run = vi.fn();
|
|
80
|
+
const first = q.enqueue(work(run));
|
|
81
|
+
const second = q.enqueue(work(run));
|
|
82
|
+
ready = true;
|
|
83
|
+
signal();
|
|
84
|
+
await Promise.all([first, second]);
|
|
85
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('expires without submission and cleans up its subscription and timer', async () => {
|
|
89
|
+
const run = vi.fn();
|
|
90
|
+
const pending = queue().enqueue(work(run));
|
|
91
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
92
|
+
await pending;
|
|
93
|
+
ready = true;
|
|
94
|
+
signal();
|
|
95
|
+
expect(run).not.toHaveBeenCalled();
|
|
96
|
+
expect(detach).toHaveBeenCalledOnce();
|
|
97
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('evicts the oldest waiting work at capacity and rejects oversized work', async () => {
|
|
101
|
+
const q = queue();
|
|
102
|
+
const runs = [vi.fn(), vi.fn(), vi.fn(), vi.fn()];
|
|
103
|
+
const promises = runs.map((run, i) =>
|
|
104
|
+
q.enqueue(work(run, i === 3 ? 11 : 4)),
|
|
105
|
+
);
|
|
106
|
+
ready = true;
|
|
107
|
+
signal();
|
|
108
|
+
await Promise.all(promises);
|
|
109
|
+
expect(runs[0]).not.toHaveBeenCalled();
|
|
110
|
+
expect(runs[1]).toHaveBeenCalledOnce();
|
|
111
|
+
expect(runs[2]).toHaveBeenCalledOnce();
|
|
112
|
+
expect(runs[3]).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('invalidates waiting work before identity changes can submit it', async () => {
|
|
116
|
+
let current = true;
|
|
117
|
+
const run = vi.fn();
|
|
118
|
+
const pending = queue().enqueue({ ...work(run), isCurrent: () => current });
|
|
119
|
+
current = false;
|
|
120
|
+
signal();
|
|
121
|
+
await pending;
|
|
122
|
+
ready = true;
|
|
123
|
+
signal();
|
|
124
|
+
expect(run).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('enforces the byte budget before the entry-count budget is reached', async () => {
|
|
128
|
+
const q = queue();
|
|
129
|
+
const first = vi.fn();
|
|
130
|
+
const second = vi.fn();
|
|
131
|
+
const old = q.enqueue(work(first, 6));
|
|
132
|
+
const retained = q.enqueue(work(second, 6));
|
|
133
|
+
ready = true;
|
|
134
|
+
signal();
|
|
135
|
+
await Promise.all([old, retained]);
|
|
136
|
+
expect(first).not.toHaveBeenCalled();
|
|
137
|
+
expect(second).toHaveBeenCalledOnce();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('suppresses expired active effects without exceeding actual provider concurrency', async () => {
|
|
141
|
+
const q = queue();
|
|
142
|
+
ready = true;
|
|
143
|
+
let finish!: () => void;
|
|
144
|
+
let active!: () => boolean;
|
|
145
|
+
const first = q.enqueue({
|
|
146
|
+
...work(() => undefined),
|
|
147
|
+
run: (isActive) => {
|
|
148
|
+
active = isActive;
|
|
149
|
+
return new Promise<void>((resolve) => {
|
|
150
|
+
finish = resolve;
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
155
|
+
await first;
|
|
156
|
+
expect(active()).toBe(false);
|
|
157
|
+
const run = vi.fn();
|
|
158
|
+
const second = q.enqueue(work(run));
|
|
159
|
+
expect(run).not.toHaveBeenCalled();
|
|
160
|
+
finish();
|
|
161
|
+
await second;
|
|
162
|
+
expect(run).toHaveBeenCalledOnce();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
interface PendingWork {
|
|
2
|
+
eventName: string;
|
|
3
|
+
bytes: number;
|
|
4
|
+
isCurrent: () => boolean;
|
|
5
|
+
run: (isActive: () => boolean) => void | Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface QueueOptions {
|
|
9
|
+
isReady: () => boolean;
|
|
10
|
+
subscribe: (notify: () => void) => () => void;
|
|
11
|
+
maxEntries: number;
|
|
12
|
+
maxBytes: number;
|
|
13
|
+
maxActive: number;
|
|
14
|
+
lifetime: number;
|
|
15
|
+
wait: number;
|
|
16
|
+
stage: 'readiness' | 'fingerprint';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Entry extends PendingWork {
|
|
20
|
+
state: 'waiting' | 'active' | 'closed';
|
|
21
|
+
deadline: number;
|
|
22
|
+
foregroundDeadline: number;
|
|
23
|
+
resolve?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Retains bounded local work and claims each entry once, before invoking it. */
|
|
27
|
+
export function createPendingWorkQueue(options: QueueOptions) {
|
|
28
|
+
const entries = new Set<Entry>();
|
|
29
|
+
let bytes = 0;
|
|
30
|
+
let active = 0;
|
|
31
|
+
let draining = false;
|
|
32
|
+
let unsubscribe: (() => void) | undefined;
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
|
|
35
|
+
function diagnostic(entry: PendingWork, reason: string): void {
|
|
36
|
+
console.error('Tracking recovery diagnostic', {
|
|
37
|
+
eventName: entry.eventName,
|
|
38
|
+
stage: options.stage,
|
|
39
|
+
reason,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveForeground(entry: Entry): void {
|
|
44
|
+
entry.resolve?.();
|
|
45
|
+
entry.resolve = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function close(entry: Entry, reason?: string): void {
|
|
49
|
+
if (entry.state === 'closed') return;
|
|
50
|
+
entry.state = 'closed';
|
|
51
|
+
entries.delete(entry);
|
|
52
|
+
bytes -= entry.bytes;
|
|
53
|
+
resolveForeground(entry);
|
|
54
|
+
if (reason) diagnostic(entry, reason);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function schedule(): void {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
timer = undefined;
|
|
60
|
+
if (entries.size === 0) {
|
|
61
|
+
unsubscribe?.();
|
|
62
|
+
unsubscribe = undefined;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let next = Infinity;
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
next = Math.min(next, entry.deadline);
|
|
68
|
+
if (entry.resolve) next = Math.min(next, entry.foregroundDeadline);
|
|
69
|
+
}
|
|
70
|
+
timer = setTimeout(drain, Math.max(0, next - Date.now()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function drain(): void {
|
|
74
|
+
if (draining) return;
|
|
75
|
+
draining = true;
|
|
76
|
+
try {
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.isCurrent()) close(entry, 'identity_changed');
|
|
79
|
+
else if (Date.now() >= entry.deadline) close(entry, 'timeout');
|
|
80
|
+
else if (entry.resolve && Date.now() >= entry.foregroundDeadline) {
|
|
81
|
+
resolveForeground(entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (active >= options.maxActive || !options.isReady()) break;
|
|
86
|
+
if (entry.state !== 'waiting') continue;
|
|
87
|
+
// No await before this claim: reentrant or repeated signals cannot replay it.
|
|
88
|
+
entry.state = 'active';
|
|
89
|
+
active++;
|
|
90
|
+
const isActive = () =>
|
|
91
|
+
entry.state === 'active' &&
|
|
92
|
+
Date.now() < entry.deadline &&
|
|
93
|
+
entry.isCurrent();
|
|
94
|
+
const finish = (failed = false) => {
|
|
95
|
+
if (failed && isActive()) diagnostic(entry, 'failed');
|
|
96
|
+
close(entry);
|
|
97
|
+
// An expired remote call still occupies its physical concurrency slot.
|
|
98
|
+
active--;
|
|
99
|
+
drain();
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
void Promise.resolve(entry.run(isActive)).then(
|
|
103
|
+
() => finish(),
|
|
104
|
+
() => finish(true),
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
finish(true);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
draining = false;
|
|
112
|
+
schedule();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function enqueue(work: PendingWork): Promise<void> {
|
|
117
|
+
if (work.bytes > options.maxBytes) {
|
|
118
|
+
diagnostic(work, 'capacity');
|
|
119
|
+
return Promise.resolve();
|
|
120
|
+
}
|
|
121
|
+
while (
|
|
122
|
+
entries.size >= options.maxEntries ||
|
|
123
|
+
bytes + work.bytes > options.maxBytes
|
|
124
|
+
) {
|
|
125
|
+
const oldest = [...entries].find((entry) => entry.state === 'waiting');
|
|
126
|
+
if (!oldest) {
|
|
127
|
+
diagnostic(work, 'capacity');
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
}
|
|
130
|
+
close(oldest, 'capacity');
|
|
131
|
+
}
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const entry: Entry = {
|
|
134
|
+
...work,
|
|
135
|
+
state: 'waiting',
|
|
136
|
+
deadline: Date.now() + options.lifetime,
|
|
137
|
+
foregroundDeadline: Date.now() + options.wait,
|
|
138
|
+
resolve,
|
|
139
|
+
};
|
|
140
|
+
entries.add(entry);
|
|
141
|
+
bytes += entry.bytes;
|
|
142
|
+
unsubscribe ??= options.subscribe(drain);
|
|
143
|
+
drain();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { enqueue };
|
|
148
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Copies serializable event structures without replacing array undefined values with null. */
|
|
2
|
+
export function snapshotEventData<T>(value: T): T {
|
|
3
|
+
if (Array.isArray(value))
|
|
4
|
+
return value.map((item: unknown) => snapshotEventData(item)) as T;
|
|
5
|
+
if (value instanceof Date) return new Date(value.getTime()) as T;
|
|
6
|
+
if (value !== null && typeof value === 'object') {
|
|
7
|
+
return Object.fromEntries(
|
|
8
|
+
Object.entries(value).map(([key, item]) => [
|
|
9
|
+
key,
|
|
10
|
+
snapshotEventData(item),
|
|
11
|
+
]),
|
|
12
|
+
) as T;
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|