@thg-agentic/hyper-personalisation-web 0.0.0-stage → 3.17.1
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/LICENSE +21 -0
- package/README.md +825 -2
- package/dist/hp.min.js +3 -0
- package/dist/snippet.min.js +1 -0
- package/package.json +83 -3
- package/src/assignment.js +75 -0
- package/src/auto/attributes.js +16 -0
- package/src/auto/exposures.js +45 -0
- package/src/auto/lists.js +136 -0
- package/src/auto/promotions.js +104 -0
- package/src/auto/rails.js +217 -0
- package/src/auto/viewability.js +150 -0
- package/src/auto.js +13 -0
- package/src/bootstrap.js +476 -0
- package/src/carry.js +74 -0
- package/src/conformance.js +530 -0
- package/src/consent-gate.js +324 -0
- package/src/consent.js +604 -0
- package/src/context.js +439 -0
- package/src/datalayer.js +411 -0
- package/src/decisions.js +90 -0
- package/src/device-storage.js +201 -0
- package/src/experiments.js +220 -0
- package/src/ga4.js +124 -0
- package/src/hash.js +138 -0
- package/src/hp.js +135 -0
- package/src/index.js +818 -0
- package/src/preview.js +44 -0
- package/src/recommendation-context.js +101 -0
- package/src/segment.js +375 -0
- package/src/session/category.js +40 -0
- package/src/session/record.js +163 -0
- package/src/session/rules.js +195 -0
- package/src/session/signals.js +146 -0
- package/src/session-id.js +62 -0
- package/src/session.js +28 -0
- package/src/settings.js +211 -0
- package/src/shape.js +111 -0
- package/src/social-proof.js +139 -0
- package/src/spec-version.js +4 -0
- package/src/storage-scope.js +159 -0
- package/src/transport.js +436 -0
- package/src/version.js +2 -0
- package/types/auto.d.ts +51 -0
- package/types/bootstrap.d.ts +154 -0
- package/types/consent.d.ts +126 -0
- package/types/datalayer.d.ts +76 -0
- package/types/events.d.ts +680 -0
- package/types/hp.d.ts +64 -0
- package/types/index.d.ts +610 -0
- package/types/segment.d.ts +68 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @thg-agentic/hyper-personalisation-web — browser SDK for the THG Hyper-Personalisation event specification.
|
|
3
|
+
*
|
|
4
|
+
* It emits the wire format defined in @thg-agentic/hyper-personalisation-spec, as do the
|
|
5
|
+
* @segment/analytics-next plugins in ./segment.js. Most sites start it with
|
|
6
|
+
* `bootstrap({ writeKey, apiHost }, options)` from ./bootstrap.js, which fetches the tenant's
|
|
7
|
+
* settings and calls this. createAnalytics is the lower-level API:
|
|
8
|
+
*
|
|
9
|
+
* import { createAnalytics } from '@thg-agentic/hyper-personalisation-web';
|
|
10
|
+
*
|
|
11
|
+
* const analytics = createAnalytics({
|
|
12
|
+
* apiHost: 'https://api.example.com',
|
|
13
|
+
* writeKey: 'hp_pk_live_…',
|
|
14
|
+
* propertyId: 'acme-gb',
|
|
15
|
+
* consent: oneTrustConsent(),
|
|
16
|
+
* // Where device ids come from, as the source's settings say (`hp.anonymousId`). Without it,
|
|
17
|
+
* // getAnonymousId is the device id; with `hp_cookie` the SDK mints and keeps its own.
|
|
18
|
+
* anonymousIdSource: settings.anonymousId,
|
|
19
|
+
* getAnonymousId: () => getCookie('device_id'),
|
|
20
|
+
* getSessionId: () => getCookie('session_id'),
|
|
21
|
+
* // The rails on the page, stamped onto every event as context.recommendations.
|
|
22
|
+
* getRecommendations: () => railsOnThisPage,
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* analytics.page({ type: 'product' });
|
|
26
|
+
* analytics.productViewed({ products: [item] });
|
|
27
|
+
*
|
|
28
|
+
* Recommendation rails are instrumented from markup, against a viewability standard:
|
|
29
|
+
*
|
|
30
|
+
* import { trackRecommendationRails } from '@thg-agentic/hyper-personalisation-web/auto';
|
|
31
|
+
* import { assignRecommendationVariant } from '@thg-agentic/hyper-personalisation-web';
|
|
32
|
+
*
|
|
33
|
+
* // Before requesting the rail: a control arm must not call the recommender.
|
|
34
|
+
* const arm = assignRecommendationVariant({
|
|
35
|
+
* unit: getCookie('device_id'),
|
|
36
|
+
* experiment: 'reco_ranker_v2',
|
|
37
|
+
* variants: [{ name: 'blend', share: 0.5 }, { name: 'ranker', share: 0.5 }],
|
|
38
|
+
* holdoutShare: 0.05,
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* trackRecommendationRails(analytics);
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { assignmentSession, surfaceExposureToken } from './assignment.js';
|
|
45
|
+
import { hostConsent, servingHeaders } from './consent.js';
|
|
46
|
+
import { createConsentGate } from './consent-gate.js';
|
|
47
|
+
import { buildContext, getCookie, perRequest } from './context.js';
|
|
48
|
+
import { createDecisionMemory, servedDecisions } from './decisions.js';
|
|
49
|
+
import { createHeldStore, deviceIdFrom } from './device-storage.js';
|
|
50
|
+
import {
|
|
51
|
+
banditSplit,
|
|
52
|
+
holdoutAssignment,
|
|
53
|
+
holdoutShares,
|
|
54
|
+
} from './experiments.js';
|
|
55
|
+
import { emailSha256, sha256Hex } from './hash.js';
|
|
56
|
+
import { applyPreview } from './preview.js';
|
|
57
|
+
import {
|
|
58
|
+
forgetVisit,
|
|
59
|
+
keepVisit,
|
|
60
|
+
recommendationContextFrom,
|
|
61
|
+
} from './recommendation-context.js';
|
|
62
|
+
import { recordSessionEvent } from './session/record.js';
|
|
63
|
+
import {
|
|
64
|
+
clearSessionSignals,
|
|
65
|
+
getSessionSignals,
|
|
66
|
+
setSessionScope,
|
|
67
|
+
} from './session/signals.js';
|
|
68
|
+
import { createSessionId } from './session-id.js';
|
|
69
|
+
import { planAllows, resolveApiHost } from './settings.js';
|
|
70
|
+
import {
|
|
71
|
+
detectNavigationTrigger,
|
|
72
|
+
PAGE_TYPES,
|
|
73
|
+
pruneProperties,
|
|
74
|
+
realId,
|
|
75
|
+
uuid,
|
|
76
|
+
} from './shape.js';
|
|
77
|
+
import {
|
|
78
|
+
configureStorageNames,
|
|
79
|
+
migrateLegacyStorage,
|
|
80
|
+
storageKey,
|
|
81
|
+
storageName,
|
|
82
|
+
} from './storage-scope.js';
|
|
83
|
+
import { Transport } from './transport.js';
|
|
84
|
+
import { LIB, VERSION } from './version.js';
|
|
85
|
+
|
|
86
|
+
/** The surfaces a `Personalisation Exposed` names, as the public API's exposures do. */
|
|
87
|
+
const EXPOSURE_SURFACES = new Set([
|
|
88
|
+
'audiences',
|
|
89
|
+
'decisions',
|
|
90
|
+
'recommendations',
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
export {
|
|
94
|
+
ASSIGNMENT_DECISION_NAMESPACE,
|
|
95
|
+
assignmentDecisionId,
|
|
96
|
+
} from './assignment.js';
|
|
97
|
+
export { defaultProductId, fromGa4Item, toProducts } from './ga4.js';
|
|
98
|
+
export { DEFAULT_API_HOST, resolveApiHost } from './settings.js';
|
|
99
|
+
export { PAGE_TYPES, pruneProperties } from './shape.js';
|
|
100
|
+
export { emailSha256, getCookie };
|
|
101
|
+
|
|
102
|
+
/** How a call made before the SDK started is replayed with the messageId and time it was made at. */
|
|
103
|
+
const REPLAY = Symbol.for('@thg-agentic/hyper-personalisation-web.replay');
|
|
104
|
+
|
|
105
|
+
/** SHA-256 of a string as lowercase hex. Synchronous since 3.9 (sha256Hex); kept async for callers. */
|
|
106
|
+
export async function sha256(value) {
|
|
107
|
+
return sha256Hex(String(value));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Cart Updated's properties from the platform's confirmed basket: the lines as given, and
|
|
112
|
+
* `item_count` summed from their quantities when the basket does not carry one. Prices are left
|
|
113
|
+
* alone: a line price includes tax where the market displays it so, and `value` does not, so the
|
|
114
|
+
* SDK never derives `value` from the lines.
|
|
115
|
+
*/
|
|
116
|
+
export function cartSnapshot(basket) {
|
|
117
|
+
const props = { ...(basket ?? {}) };
|
|
118
|
+
const lines = Array.isArray(props.products) ? props.products : null;
|
|
119
|
+
if (props.item_count == null && lines) {
|
|
120
|
+
const quantities = lines.map((line) => line?.quantity);
|
|
121
|
+
if (quantities.every(Number.isInteger))
|
|
122
|
+
props.item_count = quantities.reduce((sum, q) => sum + q, 0);
|
|
123
|
+
}
|
|
124
|
+
return props;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** As emailSha256, kept async for the callers that await it. */
|
|
128
|
+
export async function hashEmail(email) {
|
|
129
|
+
return emailSha256(email);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `phone_sha256` for identify: the E.164 number (`+` then 8 to 15 digits, spaces, dots, dashes and
|
|
134
|
+
* brackets removed), hashed. Null for a national number such as `07700 900123`: its country cannot
|
|
135
|
+
* be known here, and hashing it as typed would never match the same number sent in E.164.
|
|
136
|
+
*/
|
|
137
|
+
export async function hashPhone(phone) {
|
|
138
|
+
if (typeof phone !== 'string') return null;
|
|
139
|
+
const normalised = phone.replace(/[\s.()-]/g, '');
|
|
140
|
+
if (!/^\+[1-9]\d{7,14}$/.test(normalised)) return null;
|
|
141
|
+
return sha256Hex(normalised);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The digest of the empty string: what a platform that hashes a blank email field sends for "no
|
|
146
|
+
* email". Every customer without an address would share it.
|
|
147
|
+
*/
|
|
148
|
+
const EMPTY_SHA256 =
|
|
149
|
+
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
|
150
|
+
|
|
151
|
+
function hexDigest(text) {
|
|
152
|
+
if (/^[0-9a-fA-F]{64}$/.test(text)) return text.toLowerCase();
|
|
153
|
+
if (!/^[A-Za-z0-9+/_-]{43}=?$/.test(text)) return null;
|
|
154
|
+
try {
|
|
155
|
+
const binary = atob(
|
|
156
|
+
text.replace(/-/g, '+').replace(/_/g, '/').padEnd(44, '='),
|
|
157
|
+
);
|
|
158
|
+
if (binary.length !== 32) return null;
|
|
159
|
+
return Array.from(binary, (c) =>
|
|
160
|
+
c.charCodeAt(0).toString(16).padStart(2, '0'),
|
|
161
|
+
).join('');
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A SHA-256 digest as the lowercase hex `email_sha256` wants, from hex or base64 (standard or
|
|
169
|
+
* URL-safe). Re-encodes, never re-hashes. Null for anything that is not a 32-byte digest, and for
|
|
170
|
+
* the digest of the empty string.
|
|
171
|
+
*/
|
|
172
|
+
export function normaliseSha256(value) {
|
|
173
|
+
if (typeof value !== 'string') return null;
|
|
174
|
+
const hex = hexDigest(value.trim());
|
|
175
|
+
return hex === EMPTY_SHA256 ? null : hex;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// On the main entry so integrators find them: without an assigned variant a rail is unmeasurable.
|
|
179
|
+
export {
|
|
180
|
+
assignRecommendationVariant,
|
|
181
|
+
banditSplit,
|
|
182
|
+
candidateSetFingerprint,
|
|
183
|
+
hash32,
|
|
184
|
+
hashBucket,
|
|
185
|
+
holdoutAssignment,
|
|
186
|
+
} from './experiments.js';
|
|
187
|
+
/** Social proof: the one part of this package that reads rather than writes. */
|
|
188
|
+
export { createSocialProof } from './social-proof.js';
|
|
189
|
+
|
|
190
|
+
export { VERSION };
|
|
191
|
+
|
|
192
|
+
let warnedMintAnonymousId = false;
|
|
193
|
+
|
|
194
|
+
/** 3.5's `mintAnonymousId` does nothing since 3.6: where device ids come from is the source's. */
|
|
195
|
+
function warnMintAnonymousId(debug) {
|
|
196
|
+
if (!debug || warnedMintAnonymousId) return;
|
|
197
|
+
warnedMintAnonymousId = true;
|
|
198
|
+
console.warn(
|
|
199
|
+
'[hp] mintAnonymousId is ignored since 3.6: where device ids come from is set per source in the console (the site’s own cookie, or Hyper-Personalisation’s).',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Per-call context overrides, given directly or analytics-next style as `{ context }`. */
|
|
204
|
+
function contextOverrides(options) {
|
|
205
|
+
if (
|
|
206
|
+
options &&
|
|
207
|
+
typeof options.context === 'object' &&
|
|
208
|
+
options.context !== null
|
|
209
|
+
)
|
|
210
|
+
return options.context;
|
|
211
|
+
return options;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createAnalytics(options) {
|
|
215
|
+
const apiHost = resolveApiHost(options?.apiHost);
|
|
216
|
+
if (!apiHost)
|
|
217
|
+
throw new Error(
|
|
218
|
+
'createAnalytics: apiHost, when given, must be a host or an http(s) origin',
|
|
219
|
+
);
|
|
220
|
+
if (!options?.propertyId)
|
|
221
|
+
throw new Error('createAnalytics: propertyId is required');
|
|
222
|
+
|
|
223
|
+
const cfg = {
|
|
224
|
+
libraryName: LIB,
|
|
225
|
+
libraryVersion: VERSION,
|
|
226
|
+
writeKey: 'web',
|
|
227
|
+
queryParamBlocklist: [],
|
|
228
|
+
debug: false,
|
|
229
|
+
validate: null, // inject @thg-agentic/hyper-personalisation-spec's validateEvent in dev
|
|
230
|
+
consentTimeoutMs: 5000,
|
|
231
|
+
...options,
|
|
232
|
+
apiHost,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
configureStorageNames(cfg.storageNames);
|
|
236
|
+
migrateLegacyStorage();
|
|
237
|
+
|
|
238
|
+
// A consent adapter makes the SDK strict: nothing is sent or stored until analytics is granted.
|
|
239
|
+
// Without one the host has decided the lawful basis itself, and every event says so (spec 1.5).
|
|
240
|
+
const strict = Boolean(cfg.consent);
|
|
241
|
+
let gate = null;
|
|
242
|
+
cfg.getConsent = () => (gate ? gate.stamp() : hostConsent());
|
|
243
|
+
const granted = () => !strict || gate?.status === 'granted';
|
|
244
|
+
|
|
245
|
+
// Opt-in (3.5): events held for an unanswered CMP survive a navigation, in sessionStorage.
|
|
246
|
+
const heldStore =
|
|
247
|
+
strict && cfg.holdAcrossPages
|
|
248
|
+
? createHeldStore(storageKey(storageName('held'), cfg.propertyId))
|
|
249
|
+
: null;
|
|
250
|
+
const carried = heldStore?.read() ?? { items: [], anonymousId: null };
|
|
251
|
+
|
|
252
|
+
if (cfg.mintAnonymousId !== undefined) warnMintAnonymousId(cfg.debug);
|
|
253
|
+
const device = deviceIdFrom(cfg, {
|
|
254
|
+
carried: carried.anonymousId,
|
|
255
|
+
mayStore: granted,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// No session from the host: the SDK keeps one (3.15). Never a second beside the host's.
|
|
259
|
+
const session =
|
|
260
|
+
cfg.getSessionId == null
|
|
261
|
+
? createSessionId({
|
|
262
|
+
name: storageName('session'),
|
|
263
|
+
domain: cfg.consentCookieDomain ?? null,
|
|
264
|
+
mayStore: granted,
|
|
265
|
+
})
|
|
266
|
+
: null;
|
|
267
|
+
if (session) cfg.getSessionId = () => session.get();
|
|
268
|
+
|
|
269
|
+
const previewing = applyPreview(
|
|
270
|
+
storageName('preview'),
|
|
271
|
+
cfg.consentCookieDomain ?? null,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const transport =
|
|
275
|
+
cfg.transport ??
|
|
276
|
+
new Transport({
|
|
277
|
+
apiHost: cfg.apiHost,
|
|
278
|
+
writeKey: cfg.writeKey,
|
|
279
|
+
// Per tenant: a persisted batch is replayed through whichever instance starts next, with its
|
|
280
|
+
// writeKey, and one origin can serve several tenants.
|
|
281
|
+
storageKey: storageKey(storageName('retry'), cfg.propertyId),
|
|
282
|
+
// Under strict consent, persisting is a device write and replaying is a send: both wait for a
|
|
283
|
+
// grant (see onStatus below, which replays on a grant and clears on a refusal).
|
|
284
|
+
mayPersist: strict ? () => gate?.status === 'granted' : undefined,
|
|
285
|
+
deferReplay: strict,
|
|
286
|
+
onError: (m, e) => cfg.debug && console.warn('[hp]', m, e ?? ''),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// The previous page view's messageId, carried across navigations in sessionStorage, per tenant.
|
|
290
|
+
const PREV_PAGE_KEY = storageKey(storageName('previousPage'), cfg.propertyId);
|
|
291
|
+
|
|
292
|
+
// Memory is authoritative within a document; storage only carries the link across a navigation.
|
|
293
|
+
let lastPageMessageId = null;
|
|
294
|
+
|
|
295
|
+
const readLastPageMessageId = () => {
|
|
296
|
+
if (lastPageMessageId) return lastPageMessageId;
|
|
297
|
+
try {
|
|
298
|
+
return sessionStorage.getItem(PREV_PAGE_KEY) || null;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const writeLastPageMessageId = (id) => {
|
|
305
|
+
lastPageMessageId = id || null;
|
|
306
|
+
if (strict && gate.status !== 'granted') return;
|
|
307
|
+
try {
|
|
308
|
+
sessionStorage.setItem(PREV_PAGE_KEY, id);
|
|
309
|
+
} catch {
|
|
310
|
+
/* private browsing: the chain still holds within this document */
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
// What decisioned slots served, stamped on every later event unless the host supplies its own.
|
|
314
|
+
const decisions = createDecisionMemory({
|
|
315
|
+
key: storageKey(storageName('decisions'), cfg.propertyId),
|
|
316
|
+
mayStore: granted,
|
|
317
|
+
});
|
|
318
|
+
cfg.getDecisions ??= decisions.current;
|
|
319
|
+
|
|
320
|
+
let userId = realId(perRequest(cfg.getUserId, null));
|
|
321
|
+
|
|
322
|
+
/** The call being replayed: its first event keeps the messageId and time it was made with. */
|
|
323
|
+
let replaying = null;
|
|
324
|
+
|
|
325
|
+
function emit(partial, ctxOverrides, force = false) {
|
|
326
|
+
const made = replaying;
|
|
327
|
+
replaying = null;
|
|
328
|
+
let event = {
|
|
329
|
+
messageId: made?.messageId ?? uuid(),
|
|
330
|
+
timestamp: made?.timestamp ?? new Date().toISOString(),
|
|
331
|
+
anonymousId: realId(perRequest(cfg.getAnonymousId, null)),
|
|
332
|
+
userId: userId ?? null,
|
|
333
|
+
...partial,
|
|
334
|
+
context: buildContext(cfg, contextOverrides(ctxOverrides)),
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (event.properties) event.properties = pruneProperties(event.properties);
|
|
338
|
+
|
|
339
|
+
// A redaction or sampling hook may change what an event says or drop it, never what it is.
|
|
340
|
+
// One that fails drops the event rather than sending what it was meant to redact.
|
|
341
|
+
if (cfg.beforeSend) {
|
|
342
|
+
let shaped;
|
|
343
|
+
try {
|
|
344
|
+
shaped = cfg.beforeSend(event);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.warn('[hp] beforeSend failed; the event was not sent', err);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
if (!shaped) return null;
|
|
350
|
+
const { type, event: name, messageId, timestamp } = event;
|
|
351
|
+
event = {
|
|
352
|
+
...shaped,
|
|
353
|
+
type,
|
|
354
|
+
messageId,
|
|
355
|
+
timestamp,
|
|
356
|
+
...(name ? { event: name } : {}),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Injected rather than imported, so the validator and ajv never reach a production bundle.
|
|
361
|
+
if (cfg.validate) {
|
|
362
|
+
const res = cfg.validate(event, { producer: 'web' });
|
|
363
|
+
if (!res.valid) {
|
|
364
|
+
const msg = `[hp] invalid ${event.event ?? event.type}:\n ${res.errors.join('\n ')}`;
|
|
365
|
+
if (cfg.throwOnInvalid) throw new Error(msg);
|
|
366
|
+
console.error(msg);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (cfg.debug) console.debug('[hp]', event.event ?? event.type, event);
|
|
371
|
+
|
|
372
|
+
deliver(event, force);
|
|
373
|
+
return event;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function observe(event) {
|
|
377
|
+
if (event.type === 'track') recordVisit(event);
|
|
378
|
+
// Lets a caller fold the event the warehouse will receive into session state. An observer
|
|
379
|
+
// that throws must not cost the event.
|
|
380
|
+
if (cfg.onEvent) {
|
|
381
|
+
try {
|
|
382
|
+
cfg.onEvent(event);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
console.warn('[hp] onEvent observer failed', err);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The visit is kept on the device only for a visitor who allowed personalisation. */
|
|
390
|
+
const personalisationGranted = () =>
|
|
391
|
+
strict && gate?.state?.personalisation === true;
|
|
392
|
+
const scopeVisit = () =>
|
|
393
|
+
setSessionScope(perRequest(cfg.getSessionId, null), cfg.propertyId);
|
|
394
|
+
|
|
395
|
+
let visitKept = false;
|
|
396
|
+
|
|
397
|
+
/** What `recommendationContext()` reads: the products and searches of the events sent. */
|
|
398
|
+
function recordVisit(event) {
|
|
399
|
+
try {
|
|
400
|
+
if (personalisationGranted()) {
|
|
401
|
+
scopeVisit();
|
|
402
|
+
keepVisit(
|
|
403
|
+
recommendationContextFrom(
|
|
404
|
+
recordSessionEvent(event.event, event.properties),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
visitKept = true;
|
|
408
|
+
} else {
|
|
409
|
+
// Not allowed, or withdrawn: what was kept under the permission goes with it.
|
|
410
|
+
forgetVisit();
|
|
411
|
+
if (visitKept) clearSessionSignals();
|
|
412
|
+
visitKept = false;
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
if (cfg.debug) console.warn('[hp] the visit was not recorded', err);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function deliver(event, force) {
|
|
420
|
+
// The observer only sees what will be sent, never an event the visitor has not allowed.
|
|
421
|
+
if (!strict || force || gate.admits()) {
|
|
422
|
+
observe(event);
|
|
423
|
+
transport.enqueue(event);
|
|
424
|
+
} else {
|
|
425
|
+
gate.hold(event);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const track = (name, properties, ctx) =>
|
|
430
|
+
planAllows(cfg.plan, name)
|
|
431
|
+
? emit({ type: 'track', event: name, properties: properties ?? {} }, ctx)
|
|
432
|
+
: null;
|
|
433
|
+
|
|
434
|
+
// One exposure event per serving response, however often the page reuses it.
|
|
435
|
+
const exposedTokens = new Set();
|
|
436
|
+
|
|
437
|
+
/** What the response personalised, by surface. Nothing in the holdout: nothing was applied. */
|
|
438
|
+
const exposed = (surface, holdout, body) => {
|
|
439
|
+
if (surface === 'audiences')
|
|
440
|
+
return { audiences: holdout ? null : (body.audiences ?? []) };
|
|
441
|
+
if (surface === 'decisions')
|
|
442
|
+
return {
|
|
443
|
+
decisions: (body.decisions ?? []).map((d) => ({
|
|
444
|
+
slot: d.slot,
|
|
445
|
+
decision_id: d.decision_id ?? null,
|
|
446
|
+
arm: d.arm ?? null,
|
|
447
|
+
...(d.variant && { variant: d.variant }),
|
|
448
|
+
})),
|
|
449
|
+
};
|
|
450
|
+
return {
|
|
451
|
+
recommendation_request_id: body.recommendation_request_id ?? null,
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
if (strict) {
|
|
456
|
+
gate = createConsentGate({
|
|
457
|
+
adapter: cfg.consent,
|
|
458
|
+
timeoutMs: cfg.consentTimeoutMs,
|
|
459
|
+
cookieDomain: cfg.consentCookieDomain,
|
|
460
|
+
debug: cfg.debug,
|
|
461
|
+
store: heldStore && {
|
|
462
|
+
read: () => carried.items,
|
|
463
|
+
write: (items) => heldStore.write(items, device?.peek() ?? null),
|
|
464
|
+
},
|
|
465
|
+
release(event) {
|
|
466
|
+
// Only the consent block is rewritten; everything else was true when it was collected.
|
|
467
|
+
if (event.context) event.context.consent = gate.stamp();
|
|
468
|
+
observe(event);
|
|
469
|
+
transport.enqueue(event);
|
|
470
|
+
},
|
|
471
|
+
onStatus(status) {
|
|
472
|
+
if (status === 'granted') {
|
|
473
|
+
device?.persist();
|
|
474
|
+
session?.persist();
|
|
475
|
+
if (lastPageMessageId) writeLastPageMessageId(lastPageMessageId);
|
|
476
|
+
decisions.persist();
|
|
477
|
+
transport.replayPersisted?.();
|
|
478
|
+
}
|
|
479
|
+
if (status === 'denied') {
|
|
480
|
+
// Nothing kept on the device may outlive the permission it was kept under.
|
|
481
|
+
transport.clearPersisted?.();
|
|
482
|
+
device?.forget();
|
|
483
|
+
session?.forget();
|
|
484
|
+
try {
|
|
485
|
+
sessionStorage.removeItem(PREV_PAGE_KEY);
|
|
486
|
+
} catch {
|
|
487
|
+
/* nothing was stored */
|
|
488
|
+
}
|
|
489
|
+
decisions.forget();
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
cfg.onConsentStatus?.(status);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.warn('[hp] onConsentStatus observer failed', err);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
announce({ action, state, withdrawal }) {
|
|
498
|
+
emit(
|
|
499
|
+
{
|
|
500
|
+
type: 'track',
|
|
501
|
+
event: 'Consent Updated',
|
|
502
|
+
properties: { action, state },
|
|
503
|
+
},
|
|
504
|
+
undefined,
|
|
505
|
+
withdrawal,
|
|
506
|
+
);
|
|
507
|
+
if (withdrawal) transport.flush();
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
gate.start();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* This shopper's group, as the edge and the public API decide it: `holdout` and `excluded` (outside
|
|
515
|
+
* the property's ramp) both get the default everywhere, so personalise in the page only for
|
|
516
|
+
* `personalised`.
|
|
517
|
+
*/
|
|
518
|
+
/** The property's holdout split from the settings, as a serving response would state it. */
|
|
519
|
+
const configuredShares = () =>
|
|
520
|
+
cfg.holdout ? holdoutShares(cfg.holdout) : null;
|
|
521
|
+
|
|
522
|
+
const personalisationGroup = () =>
|
|
523
|
+
holdoutAssignment(cfg.holdout, realId(perRequest(cfg.getAnonymousId, null)))
|
|
524
|
+
.group;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The response with each readout slot's side of its bandit-vs-default 50/50 (`variant`): the
|
|
528
|
+
* response's own, else this visitor's from the settings, the hash serving used. A page cached per
|
|
529
|
+
* arm has no response, so its side is computed here. The default side is no decision: its id is
|
|
530
|
+
* dropped, so it is never in force and never names a decision. A held-out visitor is in no split.
|
|
531
|
+
*/
|
|
532
|
+
function withSplit(body, holdout) {
|
|
533
|
+
if (!Array.isArray(body?.decisions)) return body;
|
|
534
|
+
const readout = cfg.holdout?.readoutSlots ?? [];
|
|
535
|
+
const visitor = realId(perRequest(cfg.getAnonymousId, null));
|
|
536
|
+
return {
|
|
537
|
+
...body,
|
|
538
|
+
decisions: body.decisions.map((d) => {
|
|
539
|
+
const variant =
|
|
540
|
+
d?.variant === 'bandit' || d?.variant === 'default'
|
|
541
|
+
? d.variant
|
|
542
|
+
: !holdout && d?.holdout !== true && readout.includes(d?.slot)
|
|
543
|
+
? banditSplit(d.slot, visitor)
|
|
544
|
+
: null;
|
|
545
|
+
if (!variant) return d;
|
|
546
|
+
return variant === 'default'
|
|
547
|
+
? { ...d, variant, decision_id: null }
|
|
548
|
+
: { ...d, variant };
|
|
549
|
+
}),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Log the exposure a serving response carries (membership, decisions or recommendations):
|
|
555
|
+
* pass the parsed body after rendering it. Null when the response personalised nothing, or its
|
|
556
|
+
* token was already recorded. A decisions response's decisions are then in force: every later
|
|
557
|
+
* event this session carries them as context.decisions, unless the host passes getDecisions.
|
|
558
|
+
*
|
|
559
|
+
* A page rendered server-side from `/v1/assignments` and cached has no serving response, so it
|
|
560
|
+
* passes the exposure itself (`token` the assignment's derived id, `holdout` from the page's
|
|
561
|
+
* hash) and omits the shares: they are then the property's, from these settings, so the
|
|
562
|
+
* sample-ratio check can read the split as it does for any response.
|
|
563
|
+
*/
|
|
564
|
+
function recordExposure(response, c) {
|
|
565
|
+
// A trader previewing an arm is not a shopper: nothing they see is exposure or in force.
|
|
566
|
+
if (previewing) return null;
|
|
567
|
+
const exposure = response?.exposure;
|
|
568
|
+
const holdout = exposure?.holdout === true;
|
|
569
|
+
const body = withSplit(response, holdout);
|
|
570
|
+
// In force from the render, so a basket later in the session is credited to the slot.
|
|
571
|
+
decisions.remember(servedDecisions(body));
|
|
572
|
+
if (!exposure?.token || exposedTokens.has(exposure.token)) return null;
|
|
573
|
+
exposedTokens.add(exposure.token);
|
|
574
|
+
return track(
|
|
575
|
+
'Personalisation Exposed',
|
|
576
|
+
{
|
|
577
|
+
exposure_token: exposure.token,
|
|
578
|
+
surface: exposure.surface,
|
|
579
|
+
holdout,
|
|
580
|
+
holdout_share:
|
|
581
|
+
exposure.holdout_share ?? configuredShares()?.holdout_share ?? null,
|
|
582
|
+
personalised_share:
|
|
583
|
+
exposure.personalised_share ??
|
|
584
|
+
configuredShares()?.personalised_share ??
|
|
585
|
+
null,
|
|
586
|
+
...exposed(exposure.surface, holdout, body),
|
|
587
|
+
},
|
|
588
|
+
c,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
/** Escape hatch for events not yet in the core spec. Prefer a named method. */
|
|
594
|
+
track,
|
|
595
|
+
|
|
596
|
+
page(props = {}, ctx) {
|
|
597
|
+
// page.type is inherited by every later event on the page, so an unknown value would get
|
|
598
|
+
// them all rejected. Keep the last good type instead: stale beats invalid.
|
|
599
|
+
let pageType = cfg.pageType ?? null;
|
|
600
|
+
if (props.type) {
|
|
601
|
+
if (PAGE_TYPES.has(props.type)) {
|
|
602
|
+
pageType = props.type;
|
|
603
|
+
cfg.pageType = props.type;
|
|
604
|
+
} else if (cfg.debug) {
|
|
605
|
+
console.warn(
|
|
606
|
+
`[hp] ignoring unknown page.type "${props.type}". Allowed: ${[...PAGE_TYPES].join(', ')}`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const ev = emit(
|
|
612
|
+
{
|
|
613
|
+
type: 'page',
|
|
614
|
+
properties: {
|
|
615
|
+
name: props.name ?? null,
|
|
616
|
+
navigation_trigger:
|
|
617
|
+
props.navigation_trigger ?? detectNavigationTrigger(),
|
|
618
|
+
previous_message_id: readLastPageMessageId(),
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
// page.type is a context field, inherited by every later event on this page.
|
|
622
|
+
{ page: { type: pageType }, ...(contextOverrides(ctx) ?? {}) },
|
|
623
|
+
);
|
|
624
|
+
if (ev) writeLastPageMessageId(ev.messageId);
|
|
625
|
+
return ev;
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
identify(traits, ctx) {
|
|
629
|
+
const { userId: id, ...rest } =
|
|
630
|
+
traits && typeof traits === 'object' ? traits : {};
|
|
631
|
+
if (id !== undefined) userId = realId(id);
|
|
632
|
+
// A digest in hex or base64 is sent as hex; anything that is not one is not sent.
|
|
633
|
+
if (typeof rest.email_sha256 === 'string') {
|
|
634
|
+
const email = normaliseSha256(rest.email_sha256);
|
|
635
|
+
if (email) rest.email_sha256 = email;
|
|
636
|
+
else delete rest.email_sha256;
|
|
637
|
+
}
|
|
638
|
+
return emit({ type: 'identify', traits: rest }, ctx);
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
/** Clear identity on sign-out so the next events are not attributed to the old user. */
|
|
642
|
+
reset() {
|
|
643
|
+
userId = null;
|
|
644
|
+
writeLastPageMessageId('');
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
productViewed: (p, c) => track('Product Viewed', p, c),
|
|
648
|
+
productListViewed: (p, c) => track('Product List Viewed', p, c),
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* A recommendation rail was on screen.
|
|
652
|
+
*
|
|
653
|
+
* Prefer `trackRecommendationRails` from ./auto, which fires on the MRC viewability standard
|
|
654
|
+
* and fills in `viewable`. Call this directly only if the host measures viewability to the same
|
|
655
|
+
* standard itself; omit `viewable` rather than guess it.
|
|
656
|
+
*/
|
|
657
|
+
recommendationsViewed: (p, c) => track('Recommendations Viewed', p, c),
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* A serving response personalised the page, or would have outside the holdout. Prefer
|
|
661
|
+
* `recordExposure`, which reads the response's `exposure` block.
|
|
662
|
+
*/
|
|
663
|
+
personalisationExposed: (p, c) => track('Personalisation Exposed', p, c),
|
|
664
|
+
|
|
665
|
+
recordExposure,
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* A surface rendered server-side from a serving answer the page never sees (a product rail
|
|
669
|
+
* the site's server fetched from HP) came into view: record the property holdout's exposure for it, for
|
|
670
|
+
* both groups on the same trigger, so the readout counts like with like. The group is this
|
|
671
|
+
* visitor's as serving decides it, from the same holdout settings, salt and visitor id
|
|
672
|
+
* (spec/assignment/holdout.json), so it is the one serving answered with. Nothing is recorded
|
|
673
|
+
* for a visitor outside the experiment: without the property's holdout in the settings, outside
|
|
674
|
+
* the ramp, with no visitor or session id, or where serving personalises the surface only with
|
|
675
|
+
* consent this visitor has not given (rails and audiences need personalisation; decisions run
|
|
676
|
+
* on analytics, which every event already needs). One exposure per session per surface.
|
|
677
|
+
*/
|
|
678
|
+
surfaceViewed({ surface } = {}, c) {
|
|
679
|
+
if (!EXPOSURE_SURFACES.has(surface) || !cfg.holdout) return null;
|
|
680
|
+
if (surface !== 'decisions' && !personalisationGranted()) return null;
|
|
681
|
+
const visitor = realId(perRequest(cfg.getAnonymousId, null));
|
|
682
|
+
const group = holdoutAssignment(cfg.holdout, visitor).group;
|
|
683
|
+
if (group === 'excluded') return null;
|
|
684
|
+
const token = surfaceExposureToken({
|
|
685
|
+
sessionId: assignmentSession(
|
|
686
|
+
perRequest(cfg.getSessionId, null),
|
|
687
|
+
visitor,
|
|
688
|
+
),
|
|
689
|
+
surface,
|
|
690
|
+
});
|
|
691
|
+
if (!token) return null;
|
|
692
|
+
return recordExposure(
|
|
693
|
+
{ exposure: { token, surface, holdout: group === 'holdout' } },
|
|
694
|
+
c,
|
|
695
|
+
);
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Whether this shopper must see the default: held out, or outside the property's ramp
|
|
700
|
+
* (excluded), as the edge and the public API decide it. Skip anything personalised in the page
|
|
701
|
+
* itself (session-evaluated audiences, client re-ranking) when true. `personalisationGroup()`
|
|
702
|
+
* gives the exact group. An exposure's `holdout` flag comes from the serving response, never
|
|
703
|
+
* from this, except in `surfaceViewed`, for a surface whose response the page never sees.
|
|
704
|
+
*/
|
|
705
|
+
inHoldout: () => personalisationGroup() !== 'personalised',
|
|
706
|
+
personalisationGroup,
|
|
707
|
+
productClicked: (p, c) => track('Product Clicked', p, c),
|
|
708
|
+
productListFiltered: (p, c) => track('Product List Filtered', p, c),
|
|
709
|
+
productsSearched: (p, c) => track('Products Searched', p, c),
|
|
710
|
+
productAdded: (p, c) => track('Product Added', p, c),
|
|
711
|
+
productRemoved: (p, c) => track('Product Removed', p, c),
|
|
712
|
+
/**
|
|
713
|
+
* The basket as the platform confirmed it after a change: pass the basket your cart API
|
|
714
|
+
* returned, every line with its total quantity. Send it alongside productAdded and
|
|
715
|
+
* productRemoved, never instead of them. `item_count` is summed from the lines when omitted.
|
|
716
|
+
*/
|
|
717
|
+
cartUpdated: (basket, c) => track('Cart Updated', cartSnapshot(basket), c),
|
|
718
|
+
/**
|
|
719
|
+
* An identity edge an identify cannot imply: a guest checkout, a newsletter sign-up, an email
|
|
720
|
+
* click. Send the event-named trigger (`signed_in`, not `user_login`).
|
|
721
|
+
*/
|
|
722
|
+
identityLinked: (p, c) => track('Identity Linked', p, c),
|
|
723
|
+
/** A visitor saw, clicked or dismissed a decisioned slot: the bandit's reward signal. */
|
|
724
|
+
slotEngaged: (p, c) => (previewing ? null : track('Slot Engaged', p, c)),
|
|
725
|
+
/** A size, colour or flavour the shopper picked: product_id the master, sku the new variant. */
|
|
726
|
+
productVariantSelected: (p, c) => track('Product Variant Selected', p, c),
|
|
727
|
+
cartViewed: (p, c) => track('Cart Viewed', p, c),
|
|
728
|
+
productAddedToWishlist: (p, c) => track('Product Added to Wishlist', p, c),
|
|
729
|
+
productRemovedFromWishlist: (p, c) =>
|
|
730
|
+
track('Product Removed from Wishlist', p, c),
|
|
731
|
+
/** A wishlist conversion. Send productAdded for the cart add as well: they are two facts. */
|
|
732
|
+
wishlistProductAddedToCart: (p, c) =>
|
|
733
|
+
track('Wishlist Product Added to Cart', p, c),
|
|
734
|
+
productShared: (p, c) => track('Product Shared', p, c),
|
|
735
|
+
cartShared: (p, c) => track('Cart Shared', p, c),
|
|
736
|
+
|
|
737
|
+
// Segment fires the coupon events from wherever the code is handled. Report each coupon fact
|
|
738
|
+
// from ONE producer per property, browser or server, or it is counted twice.
|
|
739
|
+
couponEntered: (p, c) => track('Coupon Entered', p, c),
|
|
740
|
+
couponApplied: (p, c) => track('Coupon Applied', p, c),
|
|
741
|
+
couponDenied: (p, c) => track('Coupon Denied', p, c),
|
|
742
|
+
couponRemoved: (p, c) => track('Coupon Removed', p, c),
|
|
743
|
+
|
|
744
|
+
// Order outcomes are backend-only: the server SDK sends them, and the ingest refuses them from
|
|
745
|
+
// a web key.
|
|
746
|
+
checkoutStarted: (p, c) => track('Checkout Started', p, c),
|
|
747
|
+
promotionViewed: (p, c) => track('Promotion Viewed', p, c),
|
|
748
|
+
promotionClicked: (p, c) => track('Promotion Clicked', p, c),
|
|
749
|
+
checkoutStepViewed: (p, c) => track('Checkout Step Viewed', p, c),
|
|
750
|
+
checkoutStepCompleted: (p, c) => track('Checkout Step Completed', p, c),
|
|
751
|
+
/** Send INSTEAD of checkoutStepCompleted for the payment step, never as well as it. */
|
|
752
|
+
paymentInfoEntered: (p, c) => track('Payment Info Entered', p, c),
|
|
753
|
+
/** Where checkout failed and why. The kind of payment instrument only, never card data. */
|
|
754
|
+
checkoutFailed: (p, c) => track('Checkout Failed', p, c),
|
|
755
|
+
|
|
756
|
+
signedIn: (p, c) => track('Signed In', p, c),
|
|
757
|
+
signedUp: (p, c) => track('Signed Up', p, c),
|
|
758
|
+
/** Sent under the signed-in identity, which is then cleared for whoever uses the device next. */
|
|
759
|
+
signedOut(c) {
|
|
760
|
+
const event = track('Signed Out', {}, c);
|
|
761
|
+
userId = null;
|
|
762
|
+
return event;
|
|
763
|
+
},
|
|
764
|
+
newsletterSubscribed: (p, c) => track('Newsletter Subscribed', p, c),
|
|
765
|
+
/** An on-site opt-out. The ESP reports its own as Email Unsubscribed. */
|
|
766
|
+
newsletterUnsubscribed: (p, c) => track('Newsletter Unsubscribed', p, c),
|
|
767
|
+
/** An alert request for one product: back in stock, a price drop or a launch. */
|
|
768
|
+
backInStockSubscribed: (p, c) => track('Back In Stock Subscribed', p, c),
|
|
769
|
+
productReviewed: (p, c) => track('Product Reviewed', p, c),
|
|
770
|
+
consentUpdated: (p, c) => track('Consent Updated', p, c),
|
|
771
|
+
|
|
772
|
+
flush: (o) => transport.flush(o),
|
|
773
|
+
/**
|
|
774
|
+
* This visit as the recommendations API's session context: `viewed`, `basket`, `q` and
|
|
775
|
+
* `impressed`, newest first, in its wire form. `{}` unless the visitor allowed personalisation.
|
|
776
|
+
* Synchronous and never throws: `new URLSearchParams(context)` is what to append. Since 3.11.
|
|
777
|
+
*/
|
|
778
|
+
recommendationContext() {
|
|
779
|
+
if (!personalisationGranted()) return {};
|
|
780
|
+
try {
|
|
781
|
+
scopeVisit();
|
|
782
|
+
return recommendationContextFrom(getSessionSignals());
|
|
783
|
+
} catch {
|
|
784
|
+
return {};
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
/** The consent state in force (strict mode), or null while the CMP has not answered. */
|
|
788
|
+
get consent() {
|
|
789
|
+
return gate ? gate.state : null;
|
|
790
|
+
},
|
|
791
|
+
/**
|
|
792
|
+
* Headers every request to the serving API must carry: `hp-consent`, so serving personalises
|
|
793
|
+
* only what the visitor allowed. Spread them into the recommendations or decisions fetch.
|
|
794
|
+
*/
|
|
795
|
+
servingHeaders: () => servingHeaders(gate?.state ?? null),
|
|
796
|
+
get lastPageMessageId() {
|
|
797
|
+
return readLastPageMessageId();
|
|
798
|
+
},
|
|
799
|
+
/** The device id events carry: the site's cookie's, or under `hp_cookie` the SDK's own. */
|
|
800
|
+
get anonymousId() {
|
|
801
|
+
return realId(perRequest(cfg.getAnonymousId, null));
|
|
802
|
+
},
|
|
803
|
+
/** `hp` and bootstrap replay a call made before the start through this. Not public API. */
|
|
804
|
+
[REPLAY](method, args, made) {
|
|
805
|
+
if (typeof this[method] !== 'function') return;
|
|
806
|
+
replaying =
|
|
807
|
+
typeof made?.messageId === 'string' &&
|
|
808
|
+
typeof made?.timestamp === 'string'
|
|
809
|
+
? { messageId: made.messageId, timestamp: made.timestamp }
|
|
810
|
+
: null;
|
|
811
|
+
try {
|
|
812
|
+
this[method](...(Array.isArray(args) ? args : []));
|
|
813
|
+
} finally {
|
|
814
|
+
replaying = null;
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
}
|