@thg-agentic/hyper-personalisation-web 3.22.2 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Onsite forms (ADR 0064): sign-up popups, bars, teasers, inline forms and spin-to-win, shown by
3
+ * the settings' `hp.onsite` index and decided by the server.
4
+ *
5
+ * import { startOnsite } from '@thg-agentic/hyper-personalisation-web/onsite';
6
+ * startOnsite(hp); // the client bootstrap resolved to, or pass `onsite: true` to bootstrap
7
+ *
8
+ * The browser judges the trigger; the server decides the rest. Each live form's trigger is watched
9
+ * here, with its pages, devices, dates and caps. When one fires, `POST /v1/decisions` is asked for
10
+ * that one slot: a held-out visitor, or no decision, shows nothing. One overlay per page view, the
11
+ * highest priority among those that fire within 50 ms; one that cannot show passes to the next, but
12
+ * a held-out visitor sees none, and once one has shown, closing it lets no other in; with the
13
+ * property's `overlay_gap_minutes`, none until that long after the last one this session. A form
14
+ * the site opens on the shopper's click (`manual`) is past the caps, the gap and the allowance. `?hp_debug=onsite` logs why each form did
15
+ * or did not show; `hp.onsite.debug()` returns it. The renderer (./render.js, its own chunk) is
16
+ * fetched only once a trigger fires, alongside the decision request; a page whose forms never fire
17
+ * never downloads it.
18
+ */
19
+
20
+ import { loadAnswers } from './answers-loader.js';
21
+ import { createApi } from './api.js';
22
+ import { createCounters } from './counters.js';
23
+ import { loadRender } from './render-loader.js';
24
+ import {
25
+ ASKED,
26
+ blockers,
27
+ byPriority,
28
+ deviceOf,
29
+ INTERRUPTS,
30
+ OVERLAY,
31
+ readCreative,
32
+ readForm,
33
+ } from './rules.js';
34
+ import { watch } from './triggers.js';
35
+ import { loadWheel } from './wheel-loader.js';
36
+
37
+ const ACCESS = Symbol.for(
38
+ '@thg-agentic/hyper-personalisation-web.onsite-access',
39
+ );
40
+ const CONTROLLER = Symbol.for('@thg-agentic/hyper-personalisation-web.onsite');
41
+ const PENDING = Symbol.for(
42
+ '@thg-agentic/hyper-personalisation-web.onsite.pending',
43
+ );
44
+ const LISTENERS = Symbol.for(
45
+ '@thg-agentic/hyper-personalisation-web.onsite.listeners',
46
+ );
47
+ const SUPPRESSED = Symbol.for(
48
+ '@thg-agentic/hyper-personalisation-web.onsite.suppressed',
49
+ );
50
+
51
+ /** A preview link's counters: a trader's visit counts nothing and is capped by nothing. */
52
+ const INERT = {
53
+ seen: () => ({ session: 0, lifetime: 0, closedAt: null, submitted: false }),
54
+ subscribed: () => false,
55
+ shown() {},
56
+ closed() {},
57
+ submitted() {},
58
+ tab() {},
59
+ tabOut: () => false,
60
+ pin: () => null,
61
+ coveredAt: () => null,
62
+ covered() {},
63
+ code: () => null,
64
+ };
65
+
66
+ /** How long a fired trigger waits for others, so two forms entering together show the higher priority. */
67
+ const FIRE_WINDOW_MS = 50;
68
+ /** The longest a form waits for its first step's pictures before it opens without them. */
69
+ const WARM_MS = 300;
70
+
71
+ const debugParam = () => {
72
+ try {
73
+ return new URLSearchParams(location.search).get('hp_debug') === 'onsite';
74
+ } catch {
75
+ return false;
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Start onsite forms for `client`, once per page. Returns the controller, or null without a
81
+ * browser or a client from this SDK.
82
+ *
83
+ * @param {object} client The SDK client (bootstrap or createAnalytics).
84
+ * @param {import('../../types/onsite').OnsiteOptions} [options]
85
+ */
86
+ export function startOnsite(client, options = {}) {
87
+ if (typeof window === 'undefined' || typeof document === 'undefined')
88
+ return null;
89
+ if (window[CONTROLLER]) return window[CONTROLLER];
90
+ const access =
91
+ typeof client?.[ACCESS] === 'function' ? client[ACCESS]() : null;
92
+ if (!access) {
93
+ console.warn(
94
+ '[hp] onsite: startOnsite needs the client bootstrap or createAnalytics made',
95
+ );
96
+ return null;
97
+ }
98
+ const settings = options.settings ?? access.settings ?? {};
99
+ const api = createApi(access, {
100
+ ...options,
101
+ challenge: settings.capture?.challenge,
102
+ });
103
+ const counters = access.preview
104
+ ? INERT
105
+ : createCounters(access.storageKey, access.personalisation);
106
+ const mounted = new Map();
107
+ const suppressed = () => window[SUPPRESSED]?.size > 0;
108
+
109
+ /** Tell the page's `hp.onsite.on` listeners; a listener that throws never reaches the form. */
110
+ const emit = (name, detail) => {
111
+ for (const fn of window[LISTENERS]?.[name] ?? []) {
112
+ try {
113
+ fn(detail);
114
+ } catch (err) {
115
+ console.warn(`[hp] onsite ${name} listener failed:`, err);
116
+ }
117
+ }
118
+ };
119
+ const device = deviceOf(window);
120
+ const logging = access.debug || options.debug === true || debugParam();
121
+ const trace = new Map();
122
+ const stops = new Map();
123
+ const forms = [];
124
+ for (const raw of Array.isArray(settings.forms) ? settings.forms : []) {
125
+ const form = readForm(raw);
126
+ if (form) forms.push(form);
127
+ else
128
+ trace.set(String(raw?.slot ?? `form ${trace.size + 1}`), {
129
+ slot: String(raw?.slot ?? ''),
130
+ state: 'invalid',
131
+ reasons: ['not a form this SDK can show'],
132
+ });
133
+ }
134
+ /**
135
+ * The covering forms on screen or being opened, by slot (a bar and a popup the shopper asked for
136
+ * can both be up), the slots being opened now, and the form that spent this page view's allowance.
137
+ */
138
+ const covering = new Map();
139
+ const opening = new Map();
140
+ let spent = null;
141
+ /** The pictures warmed on this page view, by every form, so none is fetched twice. */
142
+ const warmed = new Set();
143
+ /**
144
+ * The covering form that keeps `asked` (a form the shopper asked for, or not) off the screen. Any
145
+ * covering form keeps one the page triggered away; one the shopper asked for waits only for a
146
+ * form still being opened, or for a modal actually on screen (an expanded teaser is one), so it
147
+ * opens over a bar or a teaser's tab.
148
+ */
149
+ const blocker = (asked) => {
150
+ for (const slot of covering.keys()) {
151
+ const handle = mounted.get(slot);
152
+ if (!asked || !handle || handle.modal?.()) return slot;
153
+ }
154
+ return null;
155
+ };
156
+ let fired = [];
157
+ const gap = Number.isInteger(settings.overlay_gap_minutes)
158
+ ? settings.overlay_gap_minutes
159
+ : 0;
160
+ /** Why the property's gap between covering forms still holds this session, or null. */
161
+ const gapHolds = () => {
162
+ const at = counters.coveredAt();
163
+ if (!(gap > 0) || typeof at !== 'number') return null;
164
+ const ago = Math.max(0, Math.floor((Date.now() - at) / 60_000));
165
+ return ago < gap
166
+ ? `a popup showed ${ago} min ago; ${gap} min between them`
167
+ : null;
168
+ };
169
+ /** Forms whose trigger fired on an empty basket: they try again when the basket changes. */
170
+ const awaitingBasket = new Set();
171
+ /** Covering forms whose trigger fired before the consent banner was answered. */
172
+ const awaitingConsent = new Map();
173
+
174
+ const note = (form, state, reasons = []) => {
175
+ trace.set(form.slot, {
176
+ slot: form.slot,
177
+ format: form.format,
178
+ trigger: form.trigger.type,
179
+ priority: form.priority,
180
+ state,
181
+ reasons,
182
+ });
183
+ if (logging)
184
+ console.info(
185
+ `[hp] onsite ${form.slot}: ${state}${reasons.length ? ` (${reasons.join('; ')})` : ''}`,
186
+ );
187
+ return state;
188
+ };
189
+
190
+ const sessionAudiences = () => {
191
+ try {
192
+ const given = options.audiences?.();
193
+ return Array.isArray(given) ? given : null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ };
198
+
199
+ const env = (form) => ({
200
+ path: location.pathname,
201
+ device,
202
+ now: Date.now(),
203
+ seen: counters.seen(form.slot),
204
+ subscribed: counters.subscribed() || options.subscribed?.() === true,
205
+ personalisation: access.personalisation(),
206
+ preview: Boolean(access.preview),
207
+ audiences: sessionAudiences(),
208
+ });
209
+
210
+ const unwatch = (form) => {
211
+ stops.get(form.slot)?.();
212
+ stops.delete(form.slot);
213
+ };
214
+
215
+ /**
216
+ * Show `form` if its rules allow it; resolves to the state its trace records. A form on screen is
217
+ * not mounted again, and a second call while it is being opened (a double click during the
218
+ * decision) shares the first one's answer.
219
+ */
220
+ function show(form, reason, kept = false, asked = ASKED(form)) {
221
+ if (mounted.has(form.slot)) return Promise.resolve('shown');
222
+ if (!opening.has(form.slot))
223
+ opening.set(
224
+ form.slot,
225
+ attempt(form, reason, kept, asked).finally(() =>
226
+ opening.delete(form.slot),
227
+ ),
228
+ );
229
+ return opening.get(form.slot);
230
+ }
231
+
232
+ /**
233
+ * `kept`: a teaser's tab carried from an earlier page, shown again whatever its caps. `asked`: the
234
+ * shopper asked for it (a `manual` form, or one opened by an alias), so its counters do not apply.
235
+ */
236
+ async function attempt(form, reason, kept, asked) {
237
+ if (suppressed()) {
238
+ return note(form, 'not shown', [
239
+ `suppressed: ${[...window[SUPPRESSED]].join(', ')}`,
240
+ ]);
241
+ }
242
+ const reasons = blockers(form, {
243
+ ...env(form),
244
+ ...(kept ? { seen: INERT.seen() } : {}),
245
+ asked,
246
+ });
247
+ if (reasons.length) {
248
+ return note(form, 'not eligible', reasons);
249
+ }
250
+ // The gap first: it holds for the whole session, so it is the reason worth reading.
251
+ const interrupts = INTERRUPTS(form.format) && !kept;
252
+ const wait = interrupts && !asked && gapHolds();
253
+ if (wait) {
254
+ return note(form, 'not shown', [wait]);
255
+ }
256
+ // Never over an unanswered consent banner: the shopper answers it first, whatever the answer.
257
+ if (interrupts && !access.preview && access.consentAnswered?.() === false) {
258
+ awaitingConsent.set(form, asked);
259
+ return note(form, 'not shown', ['waiting for the consent banner']);
260
+ }
261
+ const covers = OVERLAY(form.format);
262
+ const shown = covers && blocker(asked);
263
+ if (shown) return note(form, 'not shown', [`${shown} is showing`]);
264
+ if (covers && spent && !asked) {
265
+ return note(form, 'not shown', [
266
+ `${spent} already showed on this page view`,
267
+ ]);
268
+ }
269
+ let container = null;
270
+ if (!covers) {
271
+ try {
272
+ container = document.querySelector(form.trigger.selector ?? '');
273
+ } catch {
274
+ container = null;
275
+ }
276
+ if (!container) {
277
+ return note(form, 'not shown', ['no element matches its selector']);
278
+ }
279
+ }
280
+ if (covers) covering.set(form.slot, form);
281
+ unwatch(form);
282
+ note(form, 'asking for a decision', [reason]);
283
+ // The renderer is fetched while the decision is asked, so neither waits on the other.
284
+ const renderer = loadRender().catch((err) => {
285
+ console.warn('[hp] onsite renderer not loaded:', err?.message ?? err);
286
+ return null;
287
+ });
288
+ const res = await api.decide(form.slot, counters.pin(form.slot));
289
+ const decision =
290
+ res.status === 200
291
+ ? res.body?.decisions?.find((d) => d?.slot === form.slot)
292
+ : null;
293
+ // The version this visit was shown in the form's A/B test, kept for the rest of it (ADR 0065).
294
+ if (decision?.experiment && decision.arm && decision.split)
295
+ counters.pin(form.slot, [
296
+ decision.experiment,
297
+ decision.arm,
298
+ decision.split,
299
+ ]);
300
+ const creative =
301
+ decision && decision.holdout !== true
302
+ ? readCreative(decision.payload)
303
+ : null;
304
+ // A reminder needs a code to remind of, and a basket bar a basket: without one, nothing shows.
305
+ const first = creative?.steps[0].blocks ?? [];
306
+ const coupon =
307
+ decision?.offer?.code ?? (access.preview ? 'PREVIEW' : counters.code());
308
+ const basket = access.basketValue?.() ?? null;
309
+ const missing =
310
+ (first.some((b) => b?.t === 'code') &&
311
+ !coupon &&
312
+ 'no code to remind of') ||
313
+ (first.some((b) => b?.t === 'progress') &&
314
+ !(basket > 0) &&
315
+ 'the basket is empty');
316
+ if (missing) {
317
+ covering.delete(form.slot);
318
+ if (missing === 'the basket is empty') awaitingBasket.add(form);
319
+ return note(form, 'not shown', [missing]);
320
+ }
321
+ awaitingBasket.delete(form);
322
+ if (!creative) {
323
+ // A held-out visitor whose trigger fired is exposed to the default, as a treated one is,
324
+ // and its page stays one with no popup: the allowance is spent as a shown one spends it.
325
+ if (decision?.holdout === true && access.analytics())
326
+ client.recordExposure(res.body);
327
+ covering.delete(form.slot);
328
+ if (covers && decision?.holdout === true) spent ??= form.slot;
329
+ // The site shows a held-out visitor what it shows without HP, at the same moment; a form
330
+ // the site opened hears so from `open`, which resolves false.
331
+ if (decision?.holdout === true && !asked)
332
+ emit('held', {
333
+ slot: form.slot,
334
+ arm: decision.arm ?? null,
335
+ format: form.format,
336
+ });
337
+ return note(
338
+ form,
339
+ decision?.holdout === true ? 'held out' : 'no decision',
340
+ [
341
+ res.status === 200
342
+ ? 'no creative for this visitor'
343
+ : `decisions answered ${res.status}`,
344
+ ],
345
+ );
346
+ }
347
+ const render = await renderer;
348
+ if (!render) {
349
+ covering.delete(form.slot);
350
+ return note(form, 'not shown', ['the renderer did not load']);
351
+ }
352
+ // Its first step's pictures fetched and decoded first, so it never opens on an empty one, and
353
+ // never waits for them longer than WARM_MS.
354
+ await Promise.race([
355
+ render.warm?.(form, creative, creative.steps[0].id, warmed),
356
+ new Promise((resolve) => setTimeout(resolve, WARM_MS)),
357
+ ]);
358
+ if (suppressed()) {
359
+ covering.delete(form.slot);
360
+ return note(form, 'not shown', ['suppressed']);
361
+ }
362
+ if (!kept) counters.shown(form.slot);
363
+ if (interrupts) counters.covered();
364
+ if (covers) spent ??= form.slot;
365
+ const tell = (name, detail = {}) =>
366
+ emit(name, {
367
+ slot: form.slot,
368
+ arm: decision.arm ?? null,
369
+ format: creative.format ?? form.format,
370
+ ...detail,
371
+ });
372
+ const handle = render.mountForm({
373
+ form,
374
+ creative,
375
+ decision,
376
+ access,
377
+ client,
378
+ api,
379
+ counters,
380
+ loadWheel,
381
+ loadAnswers,
382
+ container,
383
+ warmed,
384
+ coupon,
385
+ basket: () => access.basketValue?.() ?? null,
386
+ emit: tell,
387
+ onClose(why) {
388
+ mounted.delete(form.slot);
389
+ covering.delete(form.slot);
390
+ note(form, 'closed', [why]);
391
+ tell('closed', { reason: why });
392
+ },
393
+ });
394
+ mounted.set(form.slot, handle);
395
+ if (access.analytics()) client.recordExposure(res.body);
396
+ note(form, 'shown', [reason]);
397
+ tell('shown');
398
+ return 'shown';
399
+ }
400
+
401
+ /**
402
+ * Triggers that fire within FIRE_WINDOW_MS of each other compete: only the highest priority shows.
403
+ * One that cannot show passes to the next; a held-out visitor sees none, so the holdout stays clean.
404
+ */
405
+ function fire(form, reason, asked = ASKED(form)) {
406
+ if (!OVERLAY(form.format)) return show(form, reason, false, asked);
407
+ fired.push([form, reason, asked]);
408
+ if (fired.length > 1) return;
409
+ setTimeout(async () => {
410
+ const queue = fired.sort(([a], [b]) => byPriority(a, b));
411
+ fired = [];
412
+ for (const [i, [next, why, ask]] of queue.entries()) {
413
+ const state = await show(next, why, false, ask);
414
+ if (state === 'shown' || state === 'held out') {
415
+ for (const [f] of queue.slice(i + 1))
416
+ note(f, 'not shown', [`${next.slot} has a higher priority`]);
417
+ return;
418
+ }
419
+ }
420
+ }, FIRE_WINDOW_MS);
421
+ }
422
+
423
+ for (const form of forms) {
424
+ // A teaser's tab still out from an earlier page comes back whatever its caps and trigger.
425
+ if (form.format === 'teaser' && counters.tabOut(form.slot)) {
426
+ show(form, 'tab kept from an earlier page', true);
427
+ continue;
428
+ }
429
+ const reasons = blockers(form, env(form));
430
+ if (reasons.length) {
431
+ note(form, 'not eligible', reasons);
432
+ continue;
433
+ }
434
+ const type = form.trigger.type;
435
+ if (type === 'manual') note(form, 'waiting for hp.onsite.open');
436
+ else if (type === 'inline' || form.format === 'inline')
437
+ show(form, 'inline');
438
+ else arm(form);
439
+ }
440
+
441
+ /** Watch a form's trigger; when it fires, the form competes to show. */
442
+ function arm(form) {
443
+ // A form armed again (after the consent banner) stops its first watcher, so it fires once.
444
+ unwatch(form);
445
+ stops.set(
446
+ form.slot,
447
+ watch(
448
+ form.trigger,
449
+ (reason) => {
450
+ unwatch(form);
451
+ fire(form, reason);
452
+ },
453
+ { device },
454
+ ),
455
+ );
456
+ note(form, 'armed', [`${form.trigger.type} on ${device}`]);
457
+ }
458
+
459
+ // Once the banner is answered, a form that waited is armed again, so it opens at its trigger's
460
+ // next moment (an exit form at the next exit), not at once; a form the site opened is shown.
461
+ const stopConsent = access.onConsentAnswered?.(() => {
462
+ const waiting = [...awaitingConsent];
463
+ awaitingConsent.clear();
464
+ for (const [form, asked] of waiting)
465
+ if (!mounted.has(form.slot))
466
+ asked ? fire(form, 'manual', asked) : arm(form);
467
+ });
468
+
469
+ const stopBasket = access.onBasket?.(() => {
470
+ if (!((access.basketValue?.() ?? 0) > 0)) return;
471
+ // Out of the set before the decision is asked, so a second update cannot show them twice;
472
+ // covering ones compete on priority as any trigger does.
473
+ const waiting = [...awaitingBasket];
474
+ awaitingBasket.clear();
475
+ for (const form of waiting)
476
+ if (!mounted.has(form.slot)) fire(form, 'the basket changed');
477
+ });
478
+
479
+ const controller = Object.freeze({
480
+ /**
481
+ * Show the form keyed `name`, else the first whose `aliases` has it (the site's own sign-up
482
+ * button names one), now, whatever its trigger. From there it is that form, by its slot.
483
+ */
484
+ open(name) {
485
+ const keyed = forms.find((f) => f.slot === name);
486
+ const form = keyed ?? forms.find((f) => f.aliases.includes(name));
487
+ if (!form) return Promise.resolve(false);
488
+ // One already on screen (an inline form) is shown, not mounted twice. A form opened by an
489
+ // alias is the shopper asking, as a `manual` one is.
490
+ return mounted.has(form.slot)
491
+ ? Promise.resolve(true)
492
+ : show(form, 'manual', false, ASKED(form) || !keyed).then(
493
+ (state) => state === 'shown',
494
+ );
495
+ },
496
+ /** `hp.onsite.suppress`: take every form off the screen, quietly; none shows until unsuppressed. */
497
+ suppress() {
498
+ for (const [slot, handle] of mounted) {
499
+ handle.destroy();
500
+ mounted.delete(slot);
501
+ covering.delete(slot);
502
+ const form = forms.find((f) => f.slot === slot);
503
+ if (form) note(form, 'closed', ['suppressed']);
504
+ }
505
+ },
506
+ /** Each form's state on this page and why, in settings order. */
507
+ debug: () =>
508
+ [...trace.values()].map((t) => ({ ...t, reasons: [...t.reasons] })),
509
+ /** Stop watching every trigger. A form on screen stays until it is closed. */
510
+ stop() {
511
+ for (const stop of stops.values()) stop();
512
+ stops.clear();
513
+ stopBasket?.();
514
+ stopConsent?.();
515
+ awaitingBasket.clear();
516
+ awaitingConsent.clear();
517
+ if (window[CONTROLLER] === controller) delete window[CONTROLLER];
518
+ },
519
+ });
520
+ window[CONTROLLER] = controller;
521
+ for (const { slot, resolve } of window[PENDING]?.splice(0) ?? [])
522
+ resolve(controller.open(slot));
523
+ if (logging) console.table?.(controller.debug());
524
+ return controller;
525
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ALTCHA's proof of work (the v1 SHA-256 challenge, MIT), solved headless: find `number` in
3
+ * [0, maxnumber] with `sha256(salt + number) === challenge`. The answer is the base64 payload
4
+ * `/v1/capture` takes as `altcha`. Off the main thread in a Worker made from a Blob; where a CSP or
5
+ * the browser refuses one, on the main thread, yielding to the page between batches.
6
+ */
7
+
8
+ const TIMEOUT_MS = 20_000;
9
+
10
+ /**
11
+ * Self-contained, so it can be the Worker's body: SubtleCrypto, 256 hashes at a time. `pause`
12
+ * yields between batches on the main thread; an aborted `signal` stops it there, with null.
13
+ *
14
+ * @param {{ challenge: string, salt: string, maxnumber?: number }} c
15
+ * @param {() => Promise<void>} [pause]
16
+ * @param {AbortSignal} [signal]
17
+ */
18
+ export async function solveChallenge(c, pause, signal) {
19
+ const hex = (buf) =>
20
+ Array.from(new Uint8Array(buf), (b) =>
21
+ b.toString(16).padStart(2, '0'),
22
+ ).join('');
23
+ const encoder = new TextEncoder();
24
+ const max = c.maxnumber ?? 1e6;
25
+ for (let start = 0; start <= max; start += 256) {
26
+ const batch = [];
27
+ for (let n = start; n < start + 256 && n <= max; n++)
28
+ batch.push(crypto.subtle.digest('SHA-256', encoder.encode(c.salt + n)));
29
+ const found = (await Promise.all(batch)).findIndex(
30
+ (d) => hex(d) === c.challenge,
31
+ );
32
+ if (found >= 0) return start + found;
33
+ if (pause) await pause();
34
+ if (signal?.aborted) return null;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * A turn for the page between batches: a `user-visible` task, queued behind the page's own, with no
41
+ * timer's 4 ms clamp (the proof ends 1.4 to 2x sooner than with setTimeout). Not `background`,
42
+ * which a busy page starves until the proof times out, nor `scheduler.yield`, whose continuation
43
+ * runs ahead of the page's tasks and held off its timers for the whole proof.
44
+ */
45
+ const pause = () =>
46
+ globalThis.scheduler?.postTask?.(() => {}, { priority: 'user-visible' }) ??
47
+ new Promise((resolve) => setTimeout(resolve, 0));
48
+
49
+ /** On the main thread, giving the page a turn between batches, until `signal` aborts. */
50
+ export const solveOnMainThread = (c, signal) =>
51
+ solveChallenge(c, pause, signal);
52
+
53
+ function solveWithWorker(c, signal) {
54
+ const source = `onmessage=async(e)=>postMessage(await(${solveChallenge})(e.data))`;
55
+ const url = URL.createObjectURL(
56
+ new Blob([source], { type: 'text/javascript' }),
57
+ );
58
+ return new Promise((resolve, reject) => {
59
+ let worker;
60
+ try {
61
+ worker = new Worker(url);
62
+ } catch (err) {
63
+ URL.revokeObjectURL(url);
64
+ reject(err);
65
+ return;
66
+ }
67
+ const done = (fn, value) => {
68
+ worker.terminate();
69
+ URL.revokeObjectURL(url);
70
+ fn(value);
71
+ };
72
+ signal.addEventListener('abort', () => done(resolve, null), {
73
+ once: true,
74
+ });
75
+ worker.onmessage = (e) => done(resolve, e.data);
76
+ worker.onerror = (e) => {
77
+ e.preventDefault?.();
78
+ done(reject, e);
79
+ };
80
+ worker.postMessage({
81
+ challenge: c.challenge,
82
+ salt: c.salt,
83
+ maxnumber: c.maxnumber,
84
+ });
85
+ });
86
+ }
87
+
88
+ const isChallenge = (c) =>
89
+ c &&
90
+ c.algorithm === 'SHA-256' &&
91
+ typeof c.challenge === 'string' &&
92
+ typeof c.salt === 'string' &&
93
+ typeof c.signature === 'string';
94
+
95
+ /** The `altcha` payload for challenge `c`, or null when it cannot be solved in time. */
96
+ export async function solve(c) {
97
+ // Without SubtleCrypto (not a secure context) there is no proof: the gateway decides.
98
+ if (!isChallenge(c) || typeof crypto?.subtle?.digest !== 'function')
99
+ return null;
100
+ const started = Date.now();
101
+ // Out of time, the proof stops: the worker is ended, the main thread's batches cease.
102
+ const stop = new AbortController();
103
+ let timer;
104
+ const timeout = new Promise((resolve) => {
105
+ timer = setTimeout(() => resolve(null), TIMEOUT_MS);
106
+ });
107
+ const work =
108
+ typeof Worker === 'function' && typeof Blob === 'function'
109
+ ? solveWithWorker(c, stop.signal).catch(() =>
110
+ solveOnMainThread(c, stop.signal),
111
+ )
112
+ : solveOnMainThread(c, stop.signal);
113
+ const number = await Promise.race([work, timeout]);
114
+ clearTimeout(timer);
115
+ stop.abort();
116
+ if (typeof number !== 'number') return null;
117
+ return btoa(
118
+ JSON.stringify({
119
+ algorithm: c.algorithm,
120
+ challenge: c.challenge,
121
+ number,
122
+ salt: c.salt,
123
+ signature: c.signature,
124
+ took: Date.now() - started,
125
+ }),
126
+ );
127
+ }