@seatlayer/js 0.38.0 → 0.40.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/dist/index.cjs +1027 -784
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +268 -160
- package/dist/index.d.ts +268 -160
- package/dist/index.js +1029 -784
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -436,796 +436,875 @@ module.exports = __toCommonJS(index_exports);
|
|
|
436
436
|
// src/SeatingChart.ts
|
|
437
437
|
var import_core = require("@seatlayer/core");
|
|
438
438
|
|
|
439
|
-
// src/
|
|
440
|
-
var
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
channel_assignment_conflict: "ineligible",
|
|
454
|
-
allocation_exhausted: "exhausted"
|
|
455
|
-
};
|
|
456
|
-
var PubApi = class {
|
|
457
|
-
constructor(base, options = {}) {
|
|
458
|
-
this.base = base;
|
|
459
|
-
this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
460
|
-
this.access = options.access;
|
|
461
|
-
this.onObjectUnavailable = options.onObjectUnavailable;
|
|
462
|
-
}
|
|
463
|
-
/** True when this client is bound to a buyer access session. */
|
|
464
|
-
get accessScoped() {
|
|
465
|
-
return !!this.access?.configured;
|
|
466
|
-
}
|
|
467
|
-
async request(path, init = {}, retried = false) {
|
|
468
|
-
const method = init.method ?? "GET";
|
|
469
|
-
const headers = {};
|
|
470
|
-
let body;
|
|
471
|
-
if (init.body !== void 0) {
|
|
472
|
-
headers["Content-Type"] = "application/json";
|
|
473
|
-
body = JSON.stringify(init.body);
|
|
474
|
-
}
|
|
475
|
-
const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
|
|
476
|
-
if (authorization) headers.Authorization = authorization;
|
|
477
|
-
const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
|
|
478
|
-
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
479
|
-
const data = isJson ? await res.json().catch(() => null) : null;
|
|
480
|
-
if (!res.ok) {
|
|
481
|
-
const err = data;
|
|
482
|
-
const code = err?.code ?? err?.error;
|
|
483
|
-
if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
|
|
484
|
-
const refreshed = await this.access.handleFailure(res.status, code);
|
|
485
|
-
if (refreshed && !retried) return this.request(path, init, true);
|
|
486
|
-
}
|
|
487
|
-
if (res.status === 409) {
|
|
488
|
-
const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
|
|
489
|
-
const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
|
|
490
|
-
if (reason) this.onObjectUnavailable?.({ labels, reason, code });
|
|
491
|
-
}
|
|
492
|
-
throw new ApiError(
|
|
493
|
-
res.status,
|
|
494
|
-
err?.error ?? `request_failed_${res.status}`,
|
|
495
|
-
code,
|
|
496
|
-
err?.conflicts,
|
|
497
|
-
err?.reason
|
|
498
|
-
);
|
|
439
|
+
// src/buyerRealtime.ts
|
|
440
|
+
var SEATLAYER_V1 = "seatlayer.v1";
|
|
441
|
+
var SEP = "\0";
|
|
442
|
+
var CLOSE_ACCESS_REVOKED = 4401;
|
|
443
|
+
var MAX_BACKOFF_MS = 15e3;
|
|
444
|
+
var PING_INTERVAL_MS = 25e3;
|
|
445
|
+
var PONG_GRACE_MS = 1e4;
|
|
446
|
+
var RESUME_ANSWER_GRACE_MS = 5e3;
|
|
447
|
+
function projectionFromSnapshot(frame) {
|
|
448
|
+
const fallback = typeof frame.default === "string" ? frame.default : "free";
|
|
449
|
+
const exceptions = {};
|
|
450
|
+
if (frame.seats && typeof frame.seats === "object") {
|
|
451
|
+
for (const [label, status] of Object.entries(frame.seats)) {
|
|
452
|
+
if (typeof status === "string" && status !== fallback) exceptions[label] = status;
|
|
499
453
|
}
|
|
500
|
-
return data;
|
|
501
|
-
}
|
|
502
|
-
chart(key) {
|
|
503
|
-
return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
504
|
-
}
|
|
505
|
-
objects(key) {
|
|
506
|
-
return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
|
|
507
454
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
455
|
+
return { default: fallback, exceptions };
|
|
456
|
+
}
|
|
457
|
+
function diffProjections(prev, next) {
|
|
458
|
+
if (!prev || prev.default !== next.default) return null;
|
|
459
|
+
const changes = [];
|
|
460
|
+
for (const [label, status] of Object.entries(next.exceptions)) {
|
|
461
|
+
if (prev.exceptions[label] !== status) changes.push({ label, status });
|
|
514
462
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
// the SDK quietly pick venue-wide and hold for the server default instead.
|
|
518
|
-
bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
|
|
519
|
-
return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
|
|
520
|
-
method: "POST",
|
|
521
|
-
body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
|
|
522
|
-
});
|
|
463
|
+
for (const label of Object.keys(prev.exceptions)) {
|
|
464
|
+
if (!(label in next.exceptions)) changes.push({ label, status: next.default });
|
|
523
465
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
466
|
+
return changes;
|
|
467
|
+
}
|
|
468
|
+
function applyChanges(projection, changes) {
|
|
469
|
+
for (const change of changes) {
|
|
470
|
+
if (change.status === projection.default) delete projection.exceptions[change.label];
|
|
471
|
+
else projection.exceptions[change.label] = change.status;
|
|
529
472
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
473
|
+
}
|
|
474
|
+
function assertCredentialFreeUrl(url) {
|
|
475
|
+
if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
|
|
476
|
+
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
535
477
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
extend(key, holdId, ttlMs) {
|
|
539
|
-
return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
|
|
540
|
-
method: "POST",
|
|
541
|
-
body: { holdId, ...ttlMs ? { ttlMs } : {} }
|
|
542
|
-
});
|
|
478
|
+
if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
|
|
479
|
+
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
543
480
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
481
|
+
}
|
|
482
|
+
var BuyerRealtimeClient = class {
|
|
483
|
+
constructor(options) {
|
|
484
|
+
this.ws = null;
|
|
485
|
+
this.stopped = true;
|
|
486
|
+
this.attempt = 0;
|
|
487
|
+
this.reconnectTimer = null;
|
|
488
|
+
this.pingTimer = null;
|
|
489
|
+
this.pongTimer = null;
|
|
490
|
+
this.resumeTimer = null;
|
|
491
|
+
/** Our model of this scope's projection. Null until the first snapshot. */
|
|
492
|
+
this.projection = null;
|
|
493
|
+
/** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
|
|
494
|
+
this.version = null;
|
|
495
|
+
/** True once the 101 echoed `seatlayer.v1`. */
|
|
496
|
+
this.v1 = false;
|
|
497
|
+
/** Set when we offered v1 and the handshake came back without it — a proxy
|
|
498
|
+
* most likely stripped the header, so the next attempt selects the v1 frame
|
|
499
|
+
* format with the `?pv=1` marker instead (protocol doc §1). The marker
|
|
500
|
+
* selects a format and can never carry a credential or widen a scope. */
|
|
501
|
+
this.useQueryMarker = false;
|
|
502
|
+
this.hidden = null;
|
|
503
|
+
this.closedSections = null;
|
|
504
|
+
this.opts = options;
|
|
505
|
+
assertCredentialFreeUrl(options.url);
|
|
554
506
|
}
|
|
555
|
-
/**
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
* The amount is NOT sent: the server recomputes it from the hold's own items,
|
|
559
|
-
* which is the only reason a browser cannot alter what it pays. Nor is the
|
|
560
|
-
* PROVIDER — the event row decides which gateway charges, and a `provider` in
|
|
561
|
-
* the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
|
|
562
|
-
* it is the shape that cannot disagree.
|
|
563
|
-
*/
|
|
564
|
-
startCheckout(key, input) {
|
|
565
|
-
return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
|
|
566
|
-
method: "POST",
|
|
567
|
-
body: input
|
|
568
|
-
});
|
|
507
|
+
/** Negotiated protocol, for tests and diagnostics. */
|
|
508
|
+
get protocol() {
|
|
509
|
+
return this.ws ? this.v1 ? "v1" : "legacy" : null;
|
|
569
510
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
* unguessable token the buyer already holds, so it acts as the capability —
|
|
573
|
-
* which is also why a buyer returning from a gateway page can be told what
|
|
574
|
-
* happened with nothing but the id in the return URL.
|
|
575
|
-
*/
|
|
576
|
-
orderStatus(orderId) {
|
|
577
|
-
return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
|
|
511
|
+
get snapshotVersion() {
|
|
512
|
+
return this.version;
|
|
578
513
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
* subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
|
|
584
|
-
*/
|
|
585
|
-
subscribeTicket(key) {
|
|
586
|
-
return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
|
|
587
|
-
method: "POST",
|
|
588
|
-
body: {}
|
|
589
|
-
});
|
|
514
|
+
start() {
|
|
515
|
+
if (!this.stopped) return;
|
|
516
|
+
this.stopped = false;
|
|
517
|
+
void this.connect();
|
|
590
518
|
}
|
|
591
|
-
/**
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
519
|
+
/** Stop for good (destroy, or a revocation). Safe to call twice. */
|
|
520
|
+
stop() {
|
|
521
|
+
this.stopped = true;
|
|
522
|
+
this.clearTimers();
|
|
523
|
+
const ws = this.ws;
|
|
524
|
+
this.ws = null;
|
|
525
|
+
if (ws) {
|
|
526
|
+
ws.onopen = null;
|
|
527
|
+
ws.onmessage = null;
|
|
528
|
+
ws.onclose = null;
|
|
529
|
+
ws.onerror = null;
|
|
530
|
+
try {
|
|
531
|
+
ws.close();
|
|
532
|
+
} catch {
|
|
533
|
+
}
|
|
534
|
+
}
|
|
599
535
|
}
|
|
600
|
-
/**
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
* public client returns exactly the URL it always has, so nothing about the
|
|
608
|
-
* public picker's realtime path changes.
|
|
609
|
-
*/
|
|
610
|
-
socketUrl(key) {
|
|
611
|
-
return this.accessScoped ? "" : this.subscribeUrl(key);
|
|
536
|
+
/** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
|
|
537
|
+
restart() {
|
|
538
|
+
this.stop();
|
|
539
|
+
this.projection = null;
|
|
540
|
+
this.version = null;
|
|
541
|
+
this.attempt = 0;
|
|
542
|
+
this.start();
|
|
612
543
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
544
|
+
// ---- connection -----------------------------------------------------------
|
|
545
|
+
async connect() {
|
|
546
|
+
if (this.stopped) return;
|
|
547
|
+
let protocols = [SEATLAYER_V1];
|
|
548
|
+
if (this.opts.mintTicket) {
|
|
549
|
+
let minted;
|
|
550
|
+
try {
|
|
551
|
+
minted = await this.opts.mintTicket();
|
|
552
|
+
} catch (err) {
|
|
553
|
+
this.reportIfAccessError(err);
|
|
554
|
+
this.scheduleReconnect();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (this.stopped) return;
|
|
558
|
+
if (minted?.protocols?.length) {
|
|
559
|
+
protocols = [...minted.protocols];
|
|
560
|
+
if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
|
|
561
|
+
} else if (minted?.ticket) {
|
|
562
|
+
protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const offeredResume = this.version !== null;
|
|
566
|
+
if (offeredResume) protocols.push(`sv.${this.version}`);
|
|
567
|
+
const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
|
|
568
|
+
assertCredentialFreeUrl(url);
|
|
569
|
+
let ws;
|
|
570
|
+
try {
|
|
571
|
+
const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
|
|
572
|
+
ws = make(url, protocols);
|
|
573
|
+
} catch {
|
|
574
|
+
this.scheduleReconnect();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
this.ws = ws;
|
|
578
|
+
ws.onopen = () => {
|
|
579
|
+
if (this.ws !== ws) return;
|
|
580
|
+
this.attempt = 0;
|
|
581
|
+
this.v1 = ws.protocol === SEATLAYER_V1;
|
|
582
|
+
if (!this.v1) this.useQueryMarker = true;
|
|
583
|
+
this.startKeepalive(ws);
|
|
584
|
+
if (offeredResume) {
|
|
585
|
+
this.resumeTimer = setTimeout(() => {
|
|
586
|
+
this.resumeTimer = null;
|
|
587
|
+
void this.opts.sink.resync();
|
|
588
|
+
}, RESUME_ANSWER_GRACE_MS);
|
|
589
|
+
} else {
|
|
590
|
+
void this.opts.sink.resync();
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
ws.onmessage = (event) => {
|
|
594
|
+
if (this.ws !== ws) return;
|
|
595
|
+
let parsed;
|
|
596
|
+
try {
|
|
597
|
+
parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
598
|
+
} catch {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
602
|
+
this.handleFrame(parsed);
|
|
603
|
+
};
|
|
604
|
+
ws.onclose = (event) => {
|
|
605
|
+
if (this.ws !== ws) return;
|
|
606
|
+
this.ws = null;
|
|
607
|
+
this.clearTimers();
|
|
608
|
+
if (event?.code === CLOSE_ACCESS_REVOKED) {
|
|
609
|
+
this.stopped = true;
|
|
610
|
+
this.opts.onAccessUnavailable?.({
|
|
611
|
+
reason: "revoked",
|
|
612
|
+
code: "access_revoked",
|
|
613
|
+
retryable: false
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this.scheduleReconnect();
|
|
618
|
+
};
|
|
619
|
+
ws.onerror = () => {
|
|
620
|
+
try {
|
|
621
|
+
ws.close();
|
|
622
|
+
} catch {
|
|
623
|
+
}
|
|
624
|
+
};
|
|
623
625
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
626
|
+
handleFrame(frame) {
|
|
627
|
+
const type = typeof frame.type === "string" ? frame.type : "";
|
|
628
|
+
if (frame.protocol === 1) this.v1 = true;
|
|
629
|
+
if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
|
|
630
|
+
if (type === "pong") {
|
|
631
|
+
this.clearPongTimer();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
|
|
635
|
+
const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
|
|
636
|
+
const closed = Array.isArray(frame.closed) ? frame.closed : [];
|
|
637
|
+
const hKey = hidden.join(SEP);
|
|
638
|
+
const cKey = closed.join(SEP);
|
|
639
|
+
if (hKey !== this.hidden || cKey !== this.closedSections) {
|
|
640
|
+
this.hidden = hKey;
|
|
641
|
+
this.closedSections = cKey;
|
|
642
|
+
this.opts.sink.onSections?.(hidden, closed);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (type === "hidden") return;
|
|
646
|
+
if (type === "presence") {
|
|
647
|
+
this.opts.sink.onPresence?.({
|
|
648
|
+
shoppingSessions: Number(frame.shoppingSessions) || 0,
|
|
649
|
+
activeHolds: Number(frame.activeHolds) || 0
|
|
650
|
+
});
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (type === "allocation") {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (type === "snapshot" || !type && frame.seats) {
|
|
657
|
+
this.answered();
|
|
658
|
+
const next = projectionFromSnapshot(frame);
|
|
659
|
+
const changes = diffProjections(this.projection, next);
|
|
660
|
+
this.projection = next;
|
|
661
|
+
if (changes === null) void this.opts.sink.resync();
|
|
662
|
+
else if (changes.length) this.opts.sink.applyStatuses(changes);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (type === "delta" && Array.isArray(frame.changes)) {
|
|
666
|
+
this.answered();
|
|
667
|
+
const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
|
|
668
|
+
if (!changes.length) return;
|
|
669
|
+
if (this.projection) applyChanges(this.projection, changes);
|
|
670
|
+
this.opts.sink.applyStatuses(changes);
|
|
671
|
+
}
|
|
651
672
|
}
|
|
652
|
-
|
|
653
|
-
|
|
673
|
+
/** The server answered our resume; cancel the fallback resync. */
|
|
674
|
+
answered() {
|
|
675
|
+
if (!this.resumeTimer) return;
|
|
676
|
+
clearTimeout(this.resumeTimer);
|
|
677
|
+
this.resumeTimer = null;
|
|
678
|
+
}
|
|
679
|
+
reportIfAccessError(err) {
|
|
680
|
+
const reason = err?.reason;
|
|
681
|
+
if (err?.name !== "BuyerAccessUnavailableError") return;
|
|
682
|
+
this.stopped = true;
|
|
683
|
+
this.opts.onAccessUnavailable?.({
|
|
684
|
+
reason: reason ?? "invalid",
|
|
685
|
+
code: err.code,
|
|
686
|
+
status: err.status,
|
|
687
|
+
retryable: reason === "paused"
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
// ---- keepalive & backoff --------------------------------------------------
|
|
691
|
+
/**
|
|
692
|
+
* Liveness is ping/pong, and only ping/pong. A socket that receives nothing
|
|
693
|
+
* for minutes is the normal, correct state for a narrowly-scoped buyer on a
|
|
694
|
+
* busy event (protocol doc §5), so quiet time never triggers a reconnect.
|
|
695
|
+
*/
|
|
696
|
+
startKeepalive(ws) {
|
|
697
|
+
this.pingTimer = setInterval(() => {
|
|
698
|
+
if (this.ws !== ws) return;
|
|
699
|
+
try {
|
|
700
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
701
|
+
} catch {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
this.clearPongTimer();
|
|
705
|
+
this.pongTimer = setTimeout(() => {
|
|
706
|
+
this.pongTimer = null;
|
|
707
|
+
try {
|
|
708
|
+
ws.close();
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
}, PONG_GRACE_MS);
|
|
712
|
+
}, PING_INTERVAL_MS);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* FULL jitter, not plain exponential backoff.
|
|
716
|
+
*
|
|
717
|
+
* A deterministic `2**attempt` schedule makes every browser that lost the same
|
|
718
|
+
* socket — a worker redeploy, a DO eviction, a flaky edge PoP — come back in
|
|
719
|
+
* the same millisecond, and an on-sale crowd reconnecting in lockstep is the
|
|
720
|
+
* thing that turns one blip into a self-sustaining thundering herd. Full
|
|
721
|
+
* jitter (`random() * ceiling`) spreads the same crowd across the whole
|
|
722
|
+
* window; the ceiling still doubles, so a persistent outage still backs off.
|
|
723
|
+
*
|
|
724
|
+
* `Math.random` is correct here: this is client code choosing a delay, not a
|
|
725
|
+
* Workflow step that has to replay deterministically.
|
|
726
|
+
*/
|
|
727
|
+
scheduleReconnect() {
|
|
728
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
729
|
+
const attempt = Math.min(this.attempt++, 5);
|
|
730
|
+
const ceiling = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
|
|
731
|
+
const delay = Math.random() * ceiling;
|
|
732
|
+
this.reconnectTimer = setTimeout(() => {
|
|
733
|
+
this.reconnectTimer = null;
|
|
734
|
+
void this.connect();
|
|
735
|
+
}, delay);
|
|
736
|
+
}
|
|
737
|
+
clearPongTimer() {
|
|
738
|
+
if (!this.pongTimer) return;
|
|
739
|
+
clearTimeout(this.pongTimer);
|
|
740
|
+
this.pongTimer = null;
|
|
741
|
+
}
|
|
742
|
+
clearTimers() {
|
|
743
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
744
|
+
this.pingTimer = null;
|
|
745
|
+
this.clearPongTimer();
|
|
746
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
747
|
+
this.reconnectTimer = null;
|
|
748
|
+
if (this.resumeTimer) clearTimeout(this.resumeTimer);
|
|
749
|
+
this.resumeTimer = null;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
function rendererStatus(wire) {
|
|
753
|
+
if (wire === "blocked") return "not_for_sale";
|
|
754
|
+
if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
|
|
755
|
+
return "free";
|
|
654
756
|
}
|
|
655
|
-
function
|
|
656
|
-
|
|
757
|
+
function createControllerSink(controller, options = {}) {
|
|
758
|
+
const idsForLabel = (label) => {
|
|
759
|
+
const table = controller.tableSelection(label);
|
|
760
|
+
if (table) return table.physicalSeatIds;
|
|
761
|
+
const id = controller.idForLabel(label);
|
|
762
|
+
return id ? [id] : [];
|
|
763
|
+
};
|
|
764
|
+
return {
|
|
765
|
+
applyStatuses(changes) {
|
|
766
|
+
const held = controller.currentHold()?.labels ?? [];
|
|
767
|
+
const buckets = {
|
|
768
|
+
free: [],
|
|
769
|
+
held: [],
|
|
770
|
+
booked: [],
|
|
771
|
+
not_for_sale: []
|
|
772
|
+
};
|
|
773
|
+
const flashes = [];
|
|
774
|
+
const lost = [];
|
|
775
|
+
const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
|
|
776
|
+
for (const change of changes) {
|
|
777
|
+
const ids = idsForLabel(change.label);
|
|
778
|
+
if (!ids.length) continue;
|
|
779
|
+
const next = rendererStatus(change.status);
|
|
780
|
+
buckets[next].push(...ids);
|
|
781
|
+
if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
|
|
782
|
+
const color = next === "held" ? "#f4b740" : "#f43f5e";
|
|
783
|
+
for (const id of ids) flashes.push({ id, color });
|
|
784
|
+
}
|
|
785
|
+
if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
|
|
786
|
+
lost.push(change.label);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
for (const status of ["free", "held", "booked", "not_for_sale"]) {
|
|
790
|
+
if (buckets[status].length) controller.setStatus(buckets[status], status);
|
|
791
|
+
}
|
|
792
|
+
for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
|
|
793
|
+
if (lost.length) {
|
|
794
|
+
const ids = lost.flatMap((label) => idsForLabel(label));
|
|
795
|
+
if (ids.length) controller.deselect(ids);
|
|
796
|
+
const ineligible = changes.some(
|
|
797
|
+
(c) => c.status === "blocked" && lost.includes(c.label)
|
|
798
|
+
);
|
|
799
|
+
options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
|
|
800
|
+
}
|
|
801
|
+
options.onStatusChange?.();
|
|
802
|
+
},
|
|
803
|
+
async resync() {
|
|
804
|
+
await controller.refresh();
|
|
805
|
+
},
|
|
806
|
+
/**
|
|
807
|
+
* Section availability moved. Statuses are re-pulled so the map repaints.
|
|
808
|
+
*
|
|
809
|
+
* Known limit: rebuilding the chart when a section is newly HIDDEN (its
|
|
810
|
+
* seats are stripped, not greyed) lives inside PickerController's own
|
|
811
|
+
* socket handler and has no public entry point, so an access-scoped picker
|
|
812
|
+
* repaints statuses but does not restructure the chart until its next
|
|
813
|
+
* mount. Closing/opening a section — the common mid-sale move — is a
|
|
814
|
+
* status-level change and is handled here in full.
|
|
815
|
+
*/
|
|
816
|
+
onSections(hidden, closed) {
|
|
817
|
+
void controller.refresh();
|
|
818
|
+
options.onSections?.(hidden, closed);
|
|
819
|
+
}
|
|
820
|
+
};
|
|
657
821
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
var
|
|
661
|
-
constructor(
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
822
|
+
|
|
823
|
+
// src/api.ts
|
|
824
|
+
var ApiError = class extends Error {
|
|
825
|
+
constructor(status, message, code, conflicts, reason, retryAfterS) {
|
|
826
|
+
super(message);
|
|
827
|
+
this.name = "ApiError";
|
|
828
|
+
this.status = status;
|
|
829
|
+
this.code = code;
|
|
830
|
+
this.conflicts = conflicts;
|
|
831
|
+
this.reason = reason;
|
|
832
|
+
this.retryAfterS = retryAfterS;
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
var MAX_RATE_LIMIT_WAIT_S = 10;
|
|
836
|
+
var DEFAULT_RATE_LIMIT_WAIT_S = 1;
|
|
837
|
+
function parseRetryAfter(header, bodyValue) {
|
|
838
|
+
const raw = (header ?? "").trim();
|
|
839
|
+
if (raw) {
|
|
840
|
+
const seconds = Number(raw);
|
|
841
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
|
|
842
|
+
const at = Date.parse(raw);
|
|
843
|
+
if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1e3));
|
|
844
|
+
}
|
|
845
|
+
if (typeof bodyValue === "number" && Number.isFinite(bodyValue) && bodyValue >= 0) {
|
|
846
|
+
return Math.ceil(bodyValue);
|
|
847
|
+
}
|
|
848
|
+
return void 0;
|
|
849
|
+
}
|
|
850
|
+
var OBJECT_UNAVAILABLE_CODES = {
|
|
851
|
+
seat_conflict: "taken",
|
|
852
|
+
conflict: "taken",
|
|
853
|
+
channel_assignment_conflict: "ineligible",
|
|
854
|
+
allocation_exhausted: "exhausted"
|
|
855
|
+
};
|
|
856
|
+
var PubApi = class {
|
|
857
|
+
constructor(base, options = {}) {
|
|
858
|
+
this.base = base;
|
|
859
|
+
this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
860
|
+
this.access = options.access;
|
|
861
|
+
this.onObjectUnavailable = options.onObjectUnavailable;
|
|
862
|
+
}
|
|
863
|
+
/** True when this client is bound to a buyer access session. */
|
|
864
|
+
get accessScoped() {
|
|
865
|
+
return !!this.access?.configured;
|
|
866
|
+
}
|
|
867
|
+
async request(path, init = {}, retried = {}) {
|
|
868
|
+
const method = init.method ?? "GET";
|
|
869
|
+
const headers = {};
|
|
870
|
+
let body;
|
|
871
|
+
if (init.body !== void 0) {
|
|
872
|
+
headers["Content-Type"] = "application/json";
|
|
873
|
+
body = JSON.stringify(init.body);
|
|
683
874
|
}
|
|
684
|
-
|
|
875
|
+
const authorization = await this.access?.authorization(retried.auth ? "unauthorized" : "initial");
|
|
876
|
+
if (authorization) headers.Authorization = authorization;
|
|
877
|
+
const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
|
|
878
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
879
|
+
const data = isJson ? await res.json().catch(() => null) : null;
|
|
880
|
+
if (!res.ok) {
|
|
881
|
+
const err = data;
|
|
882
|
+
const code = err?.code ?? err?.error;
|
|
883
|
+
if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
|
|
884
|
+
const refreshed = await this.access.handleFailure(res.status, code);
|
|
885
|
+
if (refreshed && !retried.auth) return this.request(path, init, { ...retried, auth: true });
|
|
886
|
+
}
|
|
887
|
+
if (res.status === 409) {
|
|
888
|
+
const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
|
|
889
|
+
const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
|
|
890
|
+
if (reason) this.onObjectUnavailable?.({ labels, reason, code });
|
|
891
|
+
}
|
|
892
|
+
let retryAfterS;
|
|
893
|
+
if (res.status === 429) {
|
|
894
|
+
retryAfterS = parseRetryAfter(res.headers.get("Retry-After"), err?.retryAfterSeconds) ?? DEFAULT_RATE_LIMIT_WAIT_S;
|
|
895
|
+
if (method === "GET" && !retried.rateLimit && retryAfterS <= MAX_RATE_LIMIT_WAIT_S) {
|
|
896
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfterS * 1e3));
|
|
897
|
+
return this.request(path, init, { ...retried, rateLimit: true });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
throw new ApiError(
|
|
901
|
+
res.status,
|
|
902
|
+
err?.error ?? `request_failed_${res.status}`,
|
|
903
|
+
code,
|
|
904
|
+
err?.conflicts,
|
|
905
|
+
err?.reason,
|
|
906
|
+
retryAfterS
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
return data;
|
|
910
|
+
}
|
|
911
|
+
chart(key) {
|
|
912
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
913
|
+
}
|
|
914
|
+
objects(key) {
|
|
915
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
|
|
916
|
+
}
|
|
917
|
+
hold(key, selections, ttlMs, replaceHoldId) {
|
|
918
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
|
|
919
|
+
method: "POST",
|
|
920
|
+
body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
|
|
921
|
+
labels: selections.map((s) => s.label)
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
// `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
|
|
925
|
+
// window — both are part of the route contract, and dropping either here made
|
|
926
|
+
// the SDK quietly pick venue-wide and hold for the server default instead.
|
|
927
|
+
bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
|
|
928
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
|
|
929
|
+
method: "POST",
|
|
930
|
+
body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
resume(key, holdId) {
|
|
934
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
|
|
935
|
+
method: "POST",
|
|
936
|
+
body: { holdId }
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
release(key, labels, holdId) {
|
|
940
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
|
|
941
|
+
method: "POST",
|
|
942
|
+
body: { labels, holdId }
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
|
|
946
|
+
* (reason: expired | extend_limit | not_found | not_active) if it can't. */
|
|
947
|
+
extend(key, holdId, ttlMs) {
|
|
948
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
|
|
949
|
+
method: "POST",
|
|
950
|
+
body: { holdId, ...ttlMs ? { ttlMs } : {} }
|
|
951
|
+
});
|
|
685
952
|
}
|
|
686
953
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
954
|
+
* Which gateways this event can actually take money through — the question
|
|
955
|
+
* `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
|
|
956
|
+
* the answer is never discovered by failing a payment.
|
|
689
957
|
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
* rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
|
|
693
|
-
* the first refusal turned a configured context into an "unconfigured" one,
|
|
694
|
-
* `authorization()` then returned null instead of throwing, and the very next
|
|
695
|
-
* call went out with no bearer — the anonymous Public sale fallback this
|
|
696
|
-
* module exists to prevent. A provider host never saw it, because `#provider`
|
|
697
|
-
* held `configured` true. Found against a live worker in the M9 pass.
|
|
958
|
+
* Anonymous, and it discloses no account, key, mode or currency for a gateway
|
|
959
|
+
* that did not match.
|
|
698
960
|
*/
|
|
699
|
-
|
|
700
|
-
return
|
|
961
|
+
paymentOptions(key) {
|
|
962
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
|
|
701
963
|
}
|
|
702
|
-
/**
|
|
703
|
-
|
|
704
|
-
|
|
964
|
+
/**
|
|
965
|
+
* Turn a live hold into an order and start a payment.
|
|
966
|
+
*
|
|
967
|
+
* The amount is NOT sent: the server recomputes it from the hold's own items,
|
|
968
|
+
* which is the only reason a browser cannot alter what it pays. Nor is the
|
|
969
|
+
* PROVIDER — the event row decides which gateway charges, and a `provider` in
|
|
970
|
+
* the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
|
|
971
|
+
* it is the shape that cannot disagree.
|
|
972
|
+
*/
|
|
973
|
+
startCheckout(key, input) {
|
|
974
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
|
|
975
|
+
method: "POST",
|
|
976
|
+
body: input
|
|
977
|
+
});
|
|
705
978
|
}
|
|
706
|
-
/**
|
|
707
|
-
|
|
708
|
-
|
|
979
|
+
/**
|
|
980
|
+
* Poll an order while its gateway webhook lands. The order id is an
|
|
981
|
+
* unguessable token the buyer already holds, so it acts as the capability —
|
|
982
|
+
* which is also why a buyer returning from a gateway page can be told what
|
|
983
|
+
* happened with nothing but the id in the return URL.
|
|
984
|
+
*/
|
|
985
|
+
orderStatus(orderId) {
|
|
986
|
+
return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
|
|
709
987
|
}
|
|
710
|
-
/**
|
|
711
|
-
|
|
712
|
-
|
|
988
|
+
/**
|
|
989
|
+
* Mint a one-use subscribe ticket for the next socket attempt (protocol doc
|
|
990
|
+
* §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
|
|
991
|
+
* already apply; the socket then carries only the short-lived ticket, in its
|
|
992
|
+
* subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
|
|
993
|
+
*/
|
|
994
|
+
subscribeTicket(key) {
|
|
995
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
|
|
996
|
+
method: "POST",
|
|
997
|
+
body: {}
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* The subscribe URL. Never carries a credential — not the bearer, not the
|
|
1002
|
+
* ticket. Query parameters are diagnostics only.
|
|
1003
|
+
*/
|
|
1004
|
+
subscribeUrl(key) {
|
|
1005
|
+
const wsBase = this.base.replace(/^http/, "ws");
|
|
1006
|
+
const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
|
|
1007
|
+
return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* What PickerController opens its own socket with.
|
|
1011
|
+
*
|
|
1012
|
+
* Empty for an access-scoped client: a private scope authenticates with a
|
|
1013
|
+
* subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
|
|
1014
|
+
* BuyerRealtimeClient owns that socket instead and the controller skips its
|
|
1015
|
+
* own (an empty URL is its documented "no live feed" contract). A tokenless
|
|
1016
|
+
* public client returns exactly the URL it always has, so nothing about the
|
|
1017
|
+
* public picker's realtime path changes.
|
|
1018
|
+
*/
|
|
1019
|
+
socketUrl(key) {
|
|
1020
|
+
return this.accessScoped ? "" : this.subscribeUrl(key);
|
|
713
1021
|
}
|
|
714
1022
|
/**
|
|
715
|
-
* The `
|
|
1023
|
+
* The subprotocol list a PLAIN `new WebSocket(url, protocols)` must offer for
|
|
1024
|
+
* this transport — `PickerTransport.socketProtocols`, which PickerController
|
|
1025
|
+
* calls optionally and which nothing implemented until now.
|
|
716
1026
|
*
|
|
717
|
-
*
|
|
718
|
-
* anonymous
|
|
719
|
-
*
|
|
720
|
-
*
|
|
1027
|
+
* Offering `seatlayer.v1` is the whole point: without it the DO answers an
|
|
1028
|
+
* anonymous socket with the LEGACY verbose frame — every unit of a 10k-seat
|
|
1029
|
+
* event, on connect and on every reconnect — instead of the compact
|
|
1030
|
+
* `{default, exceptions}` form. Empty for an access-scoped client, which
|
|
1031
|
+
* authenticates with a one-use ticket a URL-only constructor cannot carry and
|
|
1032
|
+
* whose socket BuyerRealtimeClient owns instead (see `socketUrl`).
|
|
1033
|
+
*
|
|
1034
|
+
* `createRealtime` below is the preferred path and supersedes this for any
|
|
1035
|
+
* host that can use it; this stays the correct answer for a host that builds
|
|
1036
|
+
* the socket itself from the transport contract.
|
|
721
1037
|
*/
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const now = Date.now();
|
|
726
|
-
const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
|
|
727
|
-
if (stale) {
|
|
728
|
-
const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
|
|
729
|
-
const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
|
|
730
|
-
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
|
|
731
|
-
if (!token) {
|
|
732
|
-
throw new BuyerAccessUnavailableError(
|
|
733
|
-
__privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
return `Bearer ${token}`;
|
|
737
|
-
}
|
|
738
|
-
return `Bearer ${__privateGet(this, _token)}`;
|
|
1038
|
+
socketProtocols(key) {
|
|
1039
|
+
void key;
|
|
1040
|
+
return this.accessScoped ? [] : [SEATLAYER_V1];
|
|
739
1041
|
}
|
|
740
1042
|
/**
|
|
741
|
-
*
|
|
742
|
-
*
|
|
1043
|
+
* Hand PickerController the v1 realtime client instead of letting it open a
|
|
1044
|
+
* bare socket — `PickerTransport.createRealtime`.
|
|
1045
|
+
*
|
|
1046
|
+
* This is what puts an ANONYMOUS buyer (the on-sale case) on the same wire as
|
|
1047
|
+
* a private-channel one: compact snapshots, `sv.<n>` resume so a reconnect
|
|
1048
|
+
* inside the ring costs a delta rather than a full re-snapshot, ping/pong
|
|
1049
|
+
* liveness, and one jittered backoff implementation shared by both. The
|
|
1050
|
+
* anonymous case simply passes no `mintTicket` — the `/pub/events/:key/
|
|
1051
|
+
* subscribe` upgrade requires no ticket, and the DO resolves a ticketless
|
|
1052
|
+
* socket to the public scope.
|
|
1053
|
+
*
|
|
1054
|
+
* Null when access-scoped: that socket is owned by the widget's own
|
|
1055
|
+
* BuyerRealtimeClient (with the ticket exchange), and `socketUrl()` already
|
|
1056
|
+
* returns '' so the controller opens nothing.
|
|
743
1057
|
*/
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (isAccessExpiry(status, code)) {
|
|
748
|
-
__privateSet(this, _token, null);
|
|
749
|
-
__privateSet(this, _expiresAt, 0);
|
|
750
|
-
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
|
|
751
|
-
(_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
|
|
752
|
-
return !!token;
|
|
753
|
-
}
|
|
754
|
-
const reason = classifyAccessFailure(status, code);
|
|
755
|
-
if (reason) {
|
|
756
|
-
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
|
|
757
|
-
return false;
|
|
758
|
-
}
|
|
759
|
-
return false;
|
|
760
|
-
}
|
|
761
|
-
/** Host-driven re-acquisition (after the buyer signs in again, say). */
|
|
762
|
-
async refresh(reason = "manual") {
|
|
763
|
-
__privateSet(this, _terminal, null);
|
|
764
|
-
__privateSet(this, _lastFailure, null);
|
|
765
|
-
__privateSet(this, _token, null);
|
|
766
|
-
__privateSet(this, _expiresAt, 0);
|
|
767
|
-
return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
|
|
768
|
-
}
|
|
769
|
-
/** Drop the bearer. Called on destroy so nothing outlives the widget. */
|
|
770
|
-
clear() {
|
|
771
|
-
__privateSet(this, _token, null);
|
|
772
|
-
__privateSet(this, _expiresAt, 0);
|
|
773
|
-
__privateSet(this, _inflight, null);
|
|
774
|
-
}
|
|
775
|
-
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
776
|
-
toJSON() {
|
|
777
|
-
return { configured: this.configured, hasToken: this.hasToken };
|
|
778
|
-
}
|
|
779
|
-
toString() {
|
|
780
|
-
return "[BuyerAccessContext redacted]";
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
_token = new WeakMap();
|
|
784
|
-
_expiresAt = new WeakMap();
|
|
785
|
-
_provider = new WeakMap();
|
|
786
|
-
_skewMs = new WeakMap();
|
|
787
|
-
_inflight = new WeakMap();
|
|
788
|
-
_terminal = new WeakMap();
|
|
789
|
-
_lastFailure = new WeakMap();
|
|
790
|
-
_onExpired = new WeakMap();
|
|
791
|
-
_onUnavailable = new WeakMap();
|
|
792
|
-
_configured = new WeakMap();
|
|
793
|
-
_BuyerAccessContext_instances = new WeakSet();
|
|
794
|
-
// ---- internals ------------------------------------------------------------
|
|
795
|
-
accept_fn = function(next) {
|
|
796
|
-
if (!next || typeof next.token !== "string" || !next.token) return null;
|
|
797
|
-
__privateSet(this, _token, next.token);
|
|
798
|
-
__privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
|
|
799
|
-
return __privateGet(this, _token);
|
|
800
|
-
};
|
|
801
|
-
/**
|
|
802
|
-
* One provider call at a time. Several operations racing an expiry (chart +
|
|
803
|
-
* objects + a socket ticket) must not mint several sessions — the guide's
|
|
804
|
-
* rotate-on-retry rule would revoke the ones they didn't observe.
|
|
805
|
-
*/
|
|
806
|
-
renew_fn = function(reason, code) {
|
|
807
|
-
if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
|
|
808
|
-
const provider = __privateGet(this, _provider);
|
|
809
|
-
if (!provider) {
|
|
810
|
-
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
|
|
811
|
-
return Promise.resolve(null);
|
|
812
|
-
}
|
|
813
|
-
const run = (async () => {
|
|
814
|
-
try {
|
|
815
|
-
const next = await provider({ reason });
|
|
816
|
-
const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
|
|
817
|
-
if (!token) {
|
|
818
|
-
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
819
|
-
return null;
|
|
820
|
-
}
|
|
821
|
-
return token;
|
|
822
|
-
} catch {
|
|
823
|
-
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
824
|
-
return null;
|
|
825
|
-
} finally {
|
|
826
|
-
__privateSet(this, _inflight, null);
|
|
827
|
-
}
|
|
828
|
-
})();
|
|
829
|
-
__privateSet(this, _inflight, run);
|
|
830
|
-
return run;
|
|
831
|
-
};
|
|
832
|
-
fail_fn = function(reason, code, status) {
|
|
833
|
-
var _a;
|
|
834
|
-
const event = {
|
|
835
|
-
reason,
|
|
836
|
-
code,
|
|
837
|
-
status,
|
|
838
|
-
// Unchanged: `retryable` means "the SAME request may succeed later".
|
|
839
|
-
// `provider_failed` is recoverable but not retryable — the host must fix
|
|
840
|
-
// its mint endpoint first — so the two sets are deliberately different.
|
|
841
|
-
retryable: reason === "paused" || reason === "channel_denied"
|
|
842
|
-
};
|
|
843
|
-
__privateSet(this, _lastFailure, event);
|
|
844
|
-
if (!RECOVERABLE.has(reason)) {
|
|
845
|
-
__privateSet(this, _terminal, event);
|
|
846
|
-
__privateSet(this, _token, null);
|
|
847
|
-
__privateSet(this, _expiresAt, 0);
|
|
1058
|
+
createRealtime(key, sink) {
|
|
1059
|
+
if (this.accessScoped) return null;
|
|
1060
|
+
return new BuyerRealtimeClient({ url: this.subscribeUrl(key), sink });
|
|
848
1061
|
}
|
|
849
|
-
(_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
|
|
850
|
-
return event;
|
|
851
1062
|
};
|
|
852
|
-
function createBuyerAccessContext(options, hooks = {}) {
|
|
853
|
-
if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
|
|
854
|
-
return new BuyerAccessContext({
|
|
855
|
-
provider: options.buyerAccessTokenProvider,
|
|
856
|
-
token: options.buyerAccessToken,
|
|
857
|
-
...hooks
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
1063
|
|
|
861
|
-
// src/
|
|
862
|
-
var
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
function projectionFromSnapshot(frame) {
|
|
870
|
-
const fallback = typeof frame.default === "string" ? frame.default : "free";
|
|
871
|
-
const exceptions = {};
|
|
872
|
-
if (frame.seats && typeof frame.seats === "object") {
|
|
873
|
-
for (const [label, status] of Object.entries(frame.seats)) {
|
|
874
|
-
if (typeof status === "string" && status !== fallback) exceptions[label] = status;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
return { default: fallback, exceptions };
|
|
878
|
-
}
|
|
879
|
-
function diffProjections(prev, next) {
|
|
880
|
-
if (!prev || prev.default !== next.default) return null;
|
|
881
|
-
const changes = [];
|
|
882
|
-
for (const [label, status] of Object.entries(next.exceptions)) {
|
|
883
|
-
if (prev.exceptions[label] !== status) changes.push({ label, status });
|
|
884
|
-
}
|
|
885
|
-
for (const label of Object.keys(prev.exceptions)) {
|
|
886
|
-
if (!(label in next.exceptions)) changes.push({ label, status: next.default });
|
|
1064
|
+
// src/buyerAccess.ts
|
|
1065
|
+
var BuyerAccessUnavailableError = class extends Error {
|
|
1066
|
+
constructor(event) {
|
|
1067
|
+
super(`buyer_access_unavailable:${event.reason}`);
|
|
1068
|
+
this.name = "BuyerAccessUnavailableError";
|
|
1069
|
+
this.reason = event.reason;
|
|
1070
|
+
this.code = event.code;
|
|
1071
|
+
this.status = event.status;
|
|
887
1072
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1073
|
+
};
|
|
1074
|
+
var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
|
|
1075
|
+
var RECOVERABLE = /* @__PURE__ */ new Set([
|
|
1076
|
+
"paused",
|
|
1077
|
+
"provider_failed",
|
|
1078
|
+
"channel_denied"
|
|
1079
|
+
]);
|
|
1080
|
+
function classifyAccessFailure(status, code) {
|
|
1081
|
+
switch (code) {
|
|
1082
|
+
case "buyer_access_invalid":
|
|
1083
|
+
return "invalid";
|
|
1084
|
+
case "buyer_access_revoked":
|
|
1085
|
+
return "revoked";
|
|
1086
|
+
case "buyer_access_origin_mismatch":
|
|
1087
|
+
return "origin_mismatch";
|
|
1088
|
+
case "buyer_access_event_mismatch":
|
|
1089
|
+
return "event_mismatch";
|
|
1090
|
+
case "buyer_access_mode_mismatch":
|
|
1091
|
+
return "mode_mismatch";
|
|
1092
|
+
case "channel_access_denied":
|
|
1093
|
+
return "channel_denied";
|
|
1094
|
+
case "channel_paused":
|
|
1095
|
+
return "paused";
|
|
1096
|
+
case "invalid_channel_scope":
|
|
1097
|
+
return "invalid_scope";
|
|
1098
|
+
default:
|
|
1099
|
+
break;
|
|
894
1100
|
}
|
|
1101
|
+
if (status === 401) return "invalid";
|
|
1102
|
+
return null;
|
|
895
1103
|
}
|
|
896
|
-
function
|
|
897
|
-
|
|
898
|
-
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
899
|
-
}
|
|
900
|
-
if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
|
|
901
|
-
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
902
|
-
}
|
|
1104
|
+
function isAccessExpiry(status, code) {
|
|
1105
|
+
return status === 401 && !!code && EXPIRED_CODES.has(code);
|
|
903
1106
|
}
|
|
904
|
-
var
|
|
1107
|
+
var DEFAULT_SKEW_MS = 3e4;
|
|
1108
|
+
var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
|
|
1109
|
+
var BuyerAccessContext = class {
|
|
905
1110
|
constructor(options) {
|
|
906
|
-
this
|
|
907
|
-
|
|
908
|
-
this
|
|
909
|
-
this
|
|
910
|
-
this
|
|
911
|
-
this
|
|
912
|
-
this
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
this
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
this
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
/** Negotiated protocol, for tests and diagnostics. */
|
|
930
|
-
get protocol() {
|
|
931
|
-
return this.ws ? this.v1 ? "v1" : "legacy" : null;
|
|
1111
|
+
__privateAdd(this, _BuyerAccessContext_instances);
|
|
1112
|
+
/** Private field: not enumerable, not spreadable, not serializable. */
|
|
1113
|
+
__privateAdd(this, _token, null);
|
|
1114
|
+
__privateAdd(this, _expiresAt, 0);
|
|
1115
|
+
__privateAdd(this, _provider);
|
|
1116
|
+
__privateAdd(this, _skewMs);
|
|
1117
|
+
__privateAdd(this, _inflight, null);
|
|
1118
|
+
__privateAdd(this, _terminal, null);
|
|
1119
|
+
/** The most recent failure, terminal or not — so one cause reports once. */
|
|
1120
|
+
__privateAdd(this, _lastFailure, null);
|
|
1121
|
+
__privateAdd(this, _onExpired);
|
|
1122
|
+
__privateAdd(this, _onUnavailable);
|
|
1123
|
+
/** Decided once, at construction. See the `configured` getter. */
|
|
1124
|
+
__privateAdd(this, _configured, false);
|
|
1125
|
+
__privateSet(this, _provider, options.provider);
|
|
1126
|
+
__privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
|
|
1127
|
+
__privateSet(this, _onExpired, options.onExpired);
|
|
1128
|
+
__privateSet(this, _onUnavailable, options.onUnavailable);
|
|
1129
|
+
if (options.token) {
|
|
1130
|
+
const seed = typeof options.token === "string" ? { token: options.token } : options.token;
|
|
1131
|
+
__privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
|
|
1132
|
+
}
|
|
1133
|
+
__privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
|
|
932
1134
|
}
|
|
933
|
-
|
|
934
|
-
|
|
1135
|
+
/**
|
|
1136
|
+
* True when this picker is access-scoped at all. A false here is the
|
|
1137
|
+
* tokenless public picker, which must behave exactly as it always has.
|
|
1138
|
+
*
|
|
1139
|
+
* Answered from what the HOST asked for, never from live token state. It used
|
|
1140
|
+
* to be `!!#provider || !!#token`, which quietly inverted this file's central
|
|
1141
|
+
* rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
|
|
1142
|
+
* the first refusal turned a configured context into an "unconfigured" one,
|
|
1143
|
+
* `authorization()` then returned null instead of throwing, and the very next
|
|
1144
|
+
* call went out with no bearer — the anonymous Public sale fallback this
|
|
1145
|
+
* module exists to prevent. A provider host never saw it, because `#provider`
|
|
1146
|
+
* held `configured` true. Found against a live worker in the M9 pass.
|
|
1147
|
+
*/
|
|
1148
|
+
get configured() {
|
|
1149
|
+
return __privateGet(this, _configured);
|
|
935
1150
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
this
|
|
939
|
-
void this.connect();
|
|
1151
|
+
/** Set once a state arrives that refreshing cannot clear. */
|
|
1152
|
+
get unavailable() {
|
|
1153
|
+
return __privateGet(this, _terminal);
|
|
940
1154
|
}
|
|
941
|
-
/**
|
|
942
|
-
|
|
943
|
-
this
|
|
944
|
-
this.clearTimers();
|
|
945
|
-
const ws = this.ws;
|
|
946
|
-
this.ws = null;
|
|
947
|
-
if (ws) {
|
|
948
|
-
ws.onopen = null;
|
|
949
|
-
ws.onmessage = null;
|
|
950
|
-
ws.onclose = null;
|
|
951
|
-
ws.onerror = null;
|
|
952
|
-
try {
|
|
953
|
-
ws.close();
|
|
954
|
-
} catch {
|
|
955
|
-
}
|
|
956
|
-
}
|
|
1155
|
+
/** True while a usable bearer is held (ignores skew). */
|
|
1156
|
+
get hasToken() {
|
|
1157
|
+
return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
|
|
957
1158
|
}
|
|
958
|
-
/**
|
|
959
|
-
|
|
960
|
-
this
|
|
961
|
-
this.projection = null;
|
|
962
|
-
this.version = null;
|
|
963
|
-
this.attempt = 0;
|
|
964
|
-
this.start();
|
|
1159
|
+
/** Epoch ms the current token expires, or 0 when the host didn't say. */
|
|
1160
|
+
get expiresAt() {
|
|
1161
|
+
return __privateGet(this, _expiresAt);
|
|
965
1162
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1163
|
+
/**
|
|
1164
|
+
* The `Authorization` header value for a scoped operation.
|
|
1165
|
+
*
|
|
1166
|
+
* Returns null only when this context is not configured at all (the ordinary
|
|
1167
|
+
* anonymous public picker). A configured context either returns a bearer or
|
|
1168
|
+
* throws `BuyerAccessUnavailableError` — it never returns null, because a
|
|
1169
|
+
* null here would send the request as anonymous Public sale.
|
|
1170
|
+
*/
|
|
1171
|
+
async authorization(reason = "initial") {
|
|
1172
|
+
if (!this.configured) return null;
|
|
1173
|
+
if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
|
|
1174
|
+
const now = Date.now();
|
|
1175
|
+
const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
|
|
1176
|
+
if (stale) {
|
|
1177
|
+
const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
|
|
1178
|
+
const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
|
|
1179
|
+
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
|
|
1180
|
+
if (!token) {
|
|
1181
|
+
throw new BuyerAccessUnavailableError(
|
|
1182
|
+
__privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
|
|
1183
|
+
);
|
|
985
1184
|
}
|
|
1185
|
+
return `Bearer ${token}`;
|
|
986
1186
|
}
|
|
987
|
-
|
|
988
|
-
if (offeredResume) protocols.push(`sv.${this.version}`);
|
|
989
|
-
const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
|
|
990
|
-
assertCredentialFreeUrl(url);
|
|
991
|
-
let ws;
|
|
992
|
-
try {
|
|
993
|
-
const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
|
|
994
|
-
ws = make(url, protocols);
|
|
995
|
-
} catch {
|
|
996
|
-
this.scheduleReconnect();
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
this.ws = ws;
|
|
1000
|
-
ws.onopen = () => {
|
|
1001
|
-
if (this.ws !== ws) return;
|
|
1002
|
-
this.attempt = 0;
|
|
1003
|
-
this.v1 = ws.protocol === SEATLAYER_V1;
|
|
1004
|
-
if (!this.v1) this.useQueryMarker = true;
|
|
1005
|
-
this.startKeepalive(ws);
|
|
1006
|
-
if (offeredResume) {
|
|
1007
|
-
this.resumeTimer = setTimeout(() => {
|
|
1008
|
-
this.resumeTimer = null;
|
|
1009
|
-
void this.opts.sink.resync();
|
|
1010
|
-
}, RESUME_ANSWER_GRACE_MS);
|
|
1011
|
-
} else {
|
|
1012
|
-
void this.opts.sink.resync();
|
|
1013
|
-
}
|
|
1014
|
-
};
|
|
1015
|
-
ws.onmessage = (event) => {
|
|
1016
|
-
if (this.ws !== ws) return;
|
|
1017
|
-
let parsed;
|
|
1018
|
-
try {
|
|
1019
|
-
parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
1020
|
-
} catch {
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
if (!parsed || typeof parsed !== "object") return;
|
|
1024
|
-
this.handleFrame(parsed);
|
|
1025
|
-
};
|
|
1026
|
-
ws.onclose = (event) => {
|
|
1027
|
-
if (this.ws !== ws) return;
|
|
1028
|
-
this.ws = null;
|
|
1029
|
-
this.clearTimers();
|
|
1030
|
-
if (event?.code === CLOSE_ACCESS_REVOKED) {
|
|
1031
|
-
this.stopped = true;
|
|
1032
|
-
this.opts.onAccessUnavailable?.({
|
|
1033
|
-
reason: "revoked",
|
|
1034
|
-
code: "access_revoked",
|
|
1035
|
-
retryable: false
|
|
1036
|
-
});
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
this.scheduleReconnect();
|
|
1040
|
-
};
|
|
1041
|
-
ws.onerror = () => {
|
|
1042
|
-
try {
|
|
1043
|
-
ws.close();
|
|
1044
|
-
} catch {
|
|
1045
|
-
}
|
|
1046
|
-
};
|
|
1187
|
+
return `Bearer ${__privateGet(this, _token)}`;
|
|
1047
1188
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
if (hKey !== this.hidden || cKey !== this.closedSections) {
|
|
1062
|
-
this.hidden = hKey;
|
|
1063
|
-
this.closedSections = cKey;
|
|
1064
|
-
this.opts.sink.onSections?.(hidden, closed);
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
if (type === "hidden") return;
|
|
1068
|
-
if (type === "presence") {
|
|
1069
|
-
this.opts.sink.onPresence?.({
|
|
1070
|
-
shoppingSessions: Number(frame.shoppingSessions) || 0,
|
|
1071
|
-
activeHolds: Number(frame.activeHolds) || 0
|
|
1072
|
-
});
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
|
-
if (type === "allocation") {
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
if (type === "snapshot" || !type && frame.seats) {
|
|
1079
|
-
this.answered();
|
|
1080
|
-
const next = projectionFromSnapshot(frame);
|
|
1081
|
-
const changes = diffProjections(this.projection, next);
|
|
1082
|
-
this.projection = next;
|
|
1083
|
-
if (changes === null) void this.opts.sink.resync();
|
|
1084
|
-
else if (changes.length) this.opts.sink.applyStatuses(changes);
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
if (type === "delta" && Array.isArray(frame.changes)) {
|
|
1088
|
-
this.answered();
|
|
1089
|
-
const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
|
|
1090
|
-
if (!changes.length) return;
|
|
1091
|
-
if (this.projection) applyChanges(this.projection, changes);
|
|
1092
|
-
this.opts.sink.applyStatuses(changes);
|
|
1189
|
+
/**
|
|
1190
|
+
* Handle a 401/403 from a scoped call. Returns true when the caller should
|
|
1191
|
+
* retry the same request once with the refreshed bearer.
|
|
1192
|
+
*/
|
|
1193
|
+
async handleFailure(status, code) {
|
|
1194
|
+
var _a;
|
|
1195
|
+
if (!this.configured) return false;
|
|
1196
|
+
if (isAccessExpiry(status, code)) {
|
|
1197
|
+
__privateSet(this, _token, null);
|
|
1198
|
+
__privateSet(this, _expiresAt, 0);
|
|
1199
|
+
const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
|
|
1200
|
+
(_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
|
|
1201
|
+
return !!token;
|
|
1093
1202
|
}
|
|
1203
|
+
const reason = classifyAccessFailure(status, code);
|
|
1204
|
+
if (reason) {
|
|
1205
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
return false;
|
|
1094
1209
|
}
|
|
1095
|
-
/**
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
this
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
const reason = err?.reason;
|
|
1103
|
-
if (err?.name !== "BuyerAccessUnavailableError") return;
|
|
1104
|
-
this.stopped = true;
|
|
1105
|
-
this.opts.onAccessUnavailable?.({
|
|
1106
|
-
reason: reason ?? "invalid",
|
|
1107
|
-
code: err.code,
|
|
1108
|
-
status: err.status,
|
|
1109
|
-
retryable: reason === "paused"
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
// ---- keepalive & backoff --------------------------------------------------
|
|
1113
|
-
/**
|
|
1114
|
-
* Liveness is ping/pong, and only ping/pong. A socket that receives nothing
|
|
1115
|
-
* for minutes is the normal, correct state for a narrowly-scoped buyer on a
|
|
1116
|
-
* busy event (protocol doc §5), so quiet time never triggers a reconnect.
|
|
1117
|
-
*/
|
|
1118
|
-
startKeepalive(ws) {
|
|
1119
|
-
this.pingTimer = setInterval(() => {
|
|
1120
|
-
if (this.ws !== ws) return;
|
|
1121
|
-
try {
|
|
1122
|
-
ws.send(JSON.stringify({ type: "ping" }));
|
|
1123
|
-
} catch {
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
this.clearPongTimer();
|
|
1127
|
-
this.pongTimer = setTimeout(() => {
|
|
1128
|
-
this.pongTimer = null;
|
|
1129
|
-
try {
|
|
1130
|
-
ws.close();
|
|
1131
|
-
} catch {
|
|
1132
|
-
}
|
|
1133
|
-
}, PONG_GRACE_MS);
|
|
1134
|
-
}, PING_INTERVAL_MS);
|
|
1210
|
+
/** Host-driven re-acquisition (after the buyer signs in again, say). */
|
|
1211
|
+
async refresh(reason = "manual") {
|
|
1212
|
+
__privateSet(this, _terminal, null);
|
|
1213
|
+
__privateSet(this, _lastFailure, null);
|
|
1214
|
+
__privateSet(this, _token, null);
|
|
1215
|
+
__privateSet(this, _expiresAt, 0);
|
|
1216
|
+
return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
|
|
1135
1217
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
this
|
|
1141
|
-
this.reconnectTimer = null;
|
|
1142
|
-
void this.connect();
|
|
1143
|
-
}, delay);
|
|
1218
|
+
/** Drop the bearer. Called on destroy so nothing outlives the widget. */
|
|
1219
|
+
clear() {
|
|
1220
|
+
__privateSet(this, _token, null);
|
|
1221
|
+
__privateSet(this, _expiresAt, 0);
|
|
1222
|
+
__privateSet(this, _inflight, null);
|
|
1144
1223
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
this.pongTimer = null;
|
|
1224
|
+
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
1225
|
+
toJSON() {
|
|
1226
|
+
return { configured: this.configured, hasToken: this.hasToken };
|
|
1149
1227
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
this.pingTimer = null;
|
|
1153
|
-
this.clearPongTimer();
|
|
1154
|
-
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
1155
|
-
this.reconnectTimer = null;
|
|
1156
|
-
if (this.resumeTimer) clearTimeout(this.resumeTimer);
|
|
1157
|
-
this.resumeTimer = null;
|
|
1228
|
+
toString() {
|
|
1229
|
+
return "[BuyerAccessContext redacted]";
|
|
1158
1230
|
}
|
|
1159
1231
|
};
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
for (const status of ["free", "held", "booked", "not_for_sale"]) {
|
|
1198
|
-
if (buckets[status].length) controller.setStatus(buckets[status], status);
|
|
1199
|
-
}
|
|
1200
|
-
for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
|
|
1201
|
-
if (lost.length) {
|
|
1202
|
-
const ids = lost.flatMap((label) => idsForLabel(label));
|
|
1203
|
-
if (ids.length) controller.deselect(ids);
|
|
1204
|
-
const ineligible = changes.some(
|
|
1205
|
-
(c) => c.status === "blocked" && lost.includes(c.label)
|
|
1206
|
-
);
|
|
1207
|
-
options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
|
|
1232
|
+
_token = new WeakMap();
|
|
1233
|
+
_expiresAt = new WeakMap();
|
|
1234
|
+
_provider = new WeakMap();
|
|
1235
|
+
_skewMs = new WeakMap();
|
|
1236
|
+
_inflight = new WeakMap();
|
|
1237
|
+
_terminal = new WeakMap();
|
|
1238
|
+
_lastFailure = new WeakMap();
|
|
1239
|
+
_onExpired = new WeakMap();
|
|
1240
|
+
_onUnavailable = new WeakMap();
|
|
1241
|
+
_configured = new WeakMap();
|
|
1242
|
+
_BuyerAccessContext_instances = new WeakSet();
|
|
1243
|
+
// ---- internals ------------------------------------------------------------
|
|
1244
|
+
accept_fn = function(next) {
|
|
1245
|
+
if (!next || typeof next.token !== "string" || !next.token) return null;
|
|
1246
|
+
__privateSet(this, _token, next.token);
|
|
1247
|
+
__privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
|
|
1248
|
+
return __privateGet(this, _token);
|
|
1249
|
+
};
|
|
1250
|
+
/**
|
|
1251
|
+
* One provider call at a time. Several operations racing an expiry (chart +
|
|
1252
|
+
* objects + a socket ticket) must not mint several sessions — the guide's
|
|
1253
|
+
* rotate-on-retry rule would revoke the ones they didn't observe.
|
|
1254
|
+
*/
|
|
1255
|
+
renew_fn = function(reason, code) {
|
|
1256
|
+
if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
|
|
1257
|
+
const provider = __privateGet(this, _provider);
|
|
1258
|
+
if (!provider) {
|
|
1259
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
|
|
1260
|
+
return Promise.resolve(null);
|
|
1261
|
+
}
|
|
1262
|
+
const run = (async () => {
|
|
1263
|
+
try {
|
|
1264
|
+
const next = await provider({ reason });
|
|
1265
|
+
const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
|
|
1266
|
+
if (!token) {
|
|
1267
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
1268
|
+
return null;
|
|
1208
1269
|
}
|
|
1209
|
-
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
* Section availability moved. Statuses are re-pulled so the map repaints.
|
|
1216
|
-
*
|
|
1217
|
-
* Known limit: rebuilding the chart when a section is newly HIDDEN (its
|
|
1218
|
-
* seats are stripped, not greyed) lives inside PickerController's own
|
|
1219
|
-
* socket handler and has no public entry point, so an access-scoped picker
|
|
1220
|
-
* repaints statuses but does not restructure the chart until its next
|
|
1221
|
-
* mount. Closing/opening a section — the common mid-sale move — is a
|
|
1222
|
-
* status-level change and is handled here in full.
|
|
1223
|
-
*/
|
|
1224
|
-
onSections(hidden, closed) {
|
|
1225
|
-
void controller.refresh();
|
|
1226
|
-
options.onSections?.(hidden, closed);
|
|
1270
|
+
return token;
|
|
1271
|
+
} catch {
|
|
1272
|
+
__privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
|
|
1273
|
+
return null;
|
|
1274
|
+
} finally {
|
|
1275
|
+
__privateSet(this, _inflight, null);
|
|
1227
1276
|
}
|
|
1277
|
+
})();
|
|
1278
|
+
__privateSet(this, _inflight, run);
|
|
1279
|
+
return run;
|
|
1280
|
+
};
|
|
1281
|
+
fail_fn = function(reason, code, status) {
|
|
1282
|
+
var _a;
|
|
1283
|
+
const event = {
|
|
1284
|
+
reason,
|
|
1285
|
+
code,
|
|
1286
|
+
status,
|
|
1287
|
+
// Unchanged: `retryable` means "the SAME request may succeed later".
|
|
1288
|
+
// `provider_failed` is recoverable but not retryable — the host must fix
|
|
1289
|
+
// its mint endpoint first — so the two sets are deliberately different.
|
|
1290
|
+
retryable: reason === "paused" || reason === "channel_denied"
|
|
1228
1291
|
};
|
|
1292
|
+
__privateSet(this, _lastFailure, event);
|
|
1293
|
+
if (!RECOVERABLE.has(reason)) {
|
|
1294
|
+
__privateSet(this, _terminal, event);
|
|
1295
|
+
__privateSet(this, _token, null);
|
|
1296
|
+
__privateSet(this, _expiresAt, 0);
|
|
1297
|
+
}
|
|
1298
|
+
(_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
|
|
1299
|
+
return event;
|
|
1300
|
+
};
|
|
1301
|
+
function createBuyerAccessContext(options, hooks = {}) {
|
|
1302
|
+
if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
|
|
1303
|
+
return new BuyerAccessContext({
|
|
1304
|
+
provider: options.buyerAccessTokenProvider,
|
|
1305
|
+
token: options.buyerAccessToken,
|
|
1306
|
+
...hooks
|
|
1307
|
+
});
|
|
1229
1308
|
}
|
|
1230
1309
|
|
|
1231
1310
|
// src/seatLayerBrand.ts
|
|
@@ -9570,13 +9649,39 @@ var SeatManager = class {
|
|
|
9570
9649
|
this.labelToId = /* @__PURE__ */ new Map();
|
|
9571
9650
|
this.labelToSeat = /* @__PURE__ */ new Map();
|
|
9572
9651
|
this.allIds = [];
|
|
9652
|
+
/**
|
|
9653
|
+
* GA inventory units — real sellable labels the server counts, with NO seat
|
|
9654
|
+
* geometry and therefore no renderer binding. They live here rather than in
|
|
9655
|
+
* `labelToId`/`allIds` so every paint path keeps addressing paintable nodes
|
|
9656
|
+
* only, while the tally denominator finally covers the same universe the
|
|
9657
|
+
* numerator does. Without them a GA sale hit `booked` but not `total`:
|
|
9658
|
+
* Free under-reported by GA capacity and SOLD% could exceed 100%.
|
|
9659
|
+
*/
|
|
9660
|
+
this.gaUnitLabelSet = /* @__PURE__ */ new Set();
|
|
9573
9661
|
this.status = /* @__PURE__ */ new Map();
|
|
9662
|
+
/** Live non-free counters, moved by each delta rather than re-walked. */
|
|
9663
|
+
this.counts = { held: 0, booked: 0, blocked: 0 };
|
|
9664
|
+
/** Bumped whenever the seat model is replaced wholesale (a full snapshot). */
|
|
9665
|
+
this.modelVersion = 0;
|
|
9574
9666
|
this.currency = "USD";
|
|
9575
9667
|
this.authoritativeGrossRevenue = 0;
|
|
9576
9668
|
this.revenueStatus = "loading";
|
|
9577
9669
|
this.revenueRequest = 0;
|
|
9578
|
-
this.revenueRefreshTimer = null;
|
|
9579
9670
|
this.controlRoomSnapshot = null;
|
|
9671
|
+
/**
|
|
9672
|
+
* The server's own totals, pinned to the client model they were read against.
|
|
9673
|
+
* Display = server baseline + (client now − client then), so the authoritative
|
|
9674
|
+
* numbers land exactly on arrival and deltas still move them between reads.
|
|
9675
|
+
* A wholesale model replacement invalidates the pairing (`model`), and the
|
|
9676
|
+
* client tallies — themselves a fresh authenticated read — take over.
|
|
9677
|
+
*/
|
|
9678
|
+
this.serverBaseline = null;
|
|
9679
|
+
/** Latest presence frame, held whether or not a snapshot has landed yet. */
|
|
9680
|
+
this.livePresence = null;
|
|
9681
|
+
/** Latest cumulative booked gross pushed on a delta frame. */
|
|
9682
|
+
this.liveGross = null;
|
|
9683
|
+
/** Coalesces a burst of deltas into one KPI/rail repaint. */
|
|
9684
|
+
this.paintHandle = null;
|
|
9580
9685
|
this.trendWindowMinutes = 15;
|
|
9581
9686
|
this.heatEnabled = false;
|
|
9582
9687
|
this.lastKpiValues = /* @__PURE__ */ new Map();
|
|
@@ -9674,12 +9779,7 @@ var SeatManager = class {
|
|
|
9674
9779
|
const res = await this.api.chart(this.key);
|
|
9675
9780
|
this.doc = res.doc;
|
|
9676
9781
|
this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
|
|
9677
|
-
|
|
9678
|
-
for (const s of seats) {
|
|
9679
|
-
this.labelToId.set(s.label, s.id);
|
|
9680
|
-
this.labelToSeat.set(s.label, s);
|
|
9681
|
-
this.allIds.push(s.id);
|
|
9682
|
-
}
|
|
9782
|
+
this.buildUnitUniverse(res.doc);
|
|
9683
9783
|
this.buildRenderer();
|
|
9684
9784
|
this.buildSectionOptions();
|
|
9685
9785
|
const [, controlRoom] = await Promise.all([
|
|
@@ -10040,7 +10140,10 @@ var SeatManager = class {
|
|
|
10040
10140
|
if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
|
|
10041
10141
|
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
10042
10142
|
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
10043
|
-
if (this.
|
|
10143
|
+
if (this.paintHandle !== null && typeof cancelAnimationFrame === "function") {
|
|
10144
|
+
cancelAnimationFrame(this.paintHandle);
|
|
10145
|
+
}
|
|
10146
|
+
this.paintHandle = null;
|
|
10044
10147
|
this.channels?.destroy();
|
|
10045
10148
|
this.channels = null;
|
|
10046
10149
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
@@ -10123,6 +10226,37 @@ var SeatManager = class {
|
|
|
10123
10226
|
}
|
|
10124
10227
|
this.syncSelection();
|
|
10125
10228
|
}
|
|
10229
|
+
/**
|
|
10230
|
+
* Build the client's inventory universe from the chart.
|
|
10231
|
+
*
|
|
10232
|
+
* `expandChart` yields SEATS — it has no output for a GA area, whose capacity
|
|
10233
|
+
* is sold as N synthetic unit labels. The server's seat map keys, its deltas
|
|
10234
|
+
* and its `totals` all speak those labels, so a client that only knows seats
|
|
10235
|
+
* counts GA sales in the numerator (every key of the snapshot is written into
|
|
10236
|
+
* `status`) while leaving them out of the denominator. Registering the GA
|
|
10237
|
+
* units here — labels only, never a render binding — is what makes the two
|
|
10238
|
+
* agree.
|
|
10239
|
+
*/
|
|
10240
|
+
buildUnitUniverse(doc) {
|
|
10241
|
+
for (const seat of (0, import_core3.expandChart)(doc)) {
|
|
10242
|
+
this.labelToId.set(seat.label, seat.id);
|
|
10243
|
+
this.labelToSeat.set(seat.label, seat);
|
|
10244
|
+
this.allIds.push(seat.id);
|
|
10245
|
+
}
|
|
10246
|
+
for (const area of (0, import_core3.gaAreasOf)(doc)) {
|
|
10247
|
+
for (const label of (0, import_core3.gaUnitLabels)(area)) {
|
|
10248
|
+
if (!this.labelToId.has(label)) this.gaUnitLabelSet.add(label);
|
|
10249
|
+
}
|
|
10250
|
+
}
|
|
10251
|
+
}
|
|
10252
|
+
/** Every sellable unit the client knows: seats + GA capacity. */
|
|
10253
|
+
unitTotal() {
|
|
10254
|
+
return this.allIds.length + this.gaUnitLabelSet.size;
|
|
10255
|
+
}
|
|
10256
|
+
/** Every label the client models, whether or not it can be painted. */
|
|
10257
|
+
knownLabels() {
|
|
10258
|
+
return [...this.labelToId.keys(), ...this.gaUnitLabelSet];
|
|
10259
|
+
}
|
|
10126
10260
|
repaintAll() {
|
|
10127
10261
|
const r = this.renderer;
|
|
10128
10262
|
if (!r) return;
|
|
@@ -10172,7 +10306,7 @@ var SeatManager = class {
|
|
|
10172
10306
|
ws.onopen = () => {
|
|
10173
10307
|
this.attempt = 0;
|
|
10174
10308
|
this.setLive(true);
|
|
10175
|
-
void this.resnapshot().then(() => this.
|
|
10309
|
+
void this.resnapshot().then(() => this.refreshControlRoom()).catch((err) => this.opts.onError?.(err));
|
|
10176
10310
|
void this.refreshAvailability();
|
|
10177
10311
|
};
|
|
10178
10312
|
ws.onmessage = (e) => this.onMessage(e);
|
|
@@ -10210,15 +10344,18 @@ var SeatManager = class {
|
|
|
10210
10344
|
this.updateEffectiveAvailability(m.hidden, m.closed);
|
|
10211
10345
|
}
|
|
10212
10346
|
if (m.type === "presence") {
|
|
10213
|
-
if (
|
|
10214
|
-
this.
|
|
10215
|
-
|
|
10216
|
-
|
|
10347
|
+
if (typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
|
|
10348
|
+
this.livePresence = {
|
|
10349
|
+
at: Date.now(),
|
|
10350
|
+
value: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
|
|
10217
10351
|
};
|
|
10352
|
+
if (this.controlRoomSnapshot) {
|
|
10353
|
+
this.controlRoomSnapshot = { ...this.controlRoomSnapshot, presence: this.livePresence.value };
|
|
10354
|
+
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
10355
|
+
}
|
|
10218
10356
|
this.lastSyncedAt = Date.now();
|
|
10219
10357
|
this.recomputeTallies();
|
|
10220
10358
|
this.paintMonitorInsights();
|
|
10221
|
-
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
10222
10359
|
}
|
|
10223
10360
|
return;
|
|
10224
10361
|
}
|
|
@@ -10232,7 +10369,7 @@ var SeatManager = class {
|
|
|
10232
10369
|
const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
|
|
10233
10370
|
const prev = this.status.get(ch.label) ?? "free";
|
|
10234
10371
|
if (prev === st) continue;
|
|
10235
|
-
this.
|
|
10372
|
+
this.setStatusLabel(ch.label, st, prev);
|
|
10236
10373
|
const id = this.labelToId.get(ch.label);
|
|
10237
10374
|
if (id) {
|
|
10238
10375
|
this.renderer?.setStatus([id], toRenderStatus(st));
|
|
@@ -10252,10 +10389,38 @@ var SeatManager = class {
|
|
|
10252
10389
|
this.lastSyncedAt = Date.now();
|
|
10253
10390
|
this.afterPaint();
|
|
10254
10391
|
}
|
|
10392
|
+
if (typeof m.revenue?.gross === "number" && Number.isFinite(m.revenue.gross)) {
|
|
10393
|
+
this.applyLiveGross(m.revenue.gross);
|
|
10394
|
+
}
|
|
10255
10395
|
this.recomputeTallies();
|
|
10256
|
-
if (ids.length) this.scheduleRevenueRefresh();
|
|
10257
10396
|
}
|
|
10258
10397
|
}
|
|
10398
|
+
/**
|
|
10399
|
+
* Adopt the cumulative booked gross a delta frame carried.
|
|
10400
|
+
*
|
|
10401
|
+
* Stashed with its arrival time so an in-flight control-room read can decide
|
|
10402
|
+
* whether it is holding the newer number: a frame that landed after the
|
|
10403
|
+
* request started is newer than the response, one that landed before is not.
|
|
10404
|
+
*/
|
|
10405
|
+
applyLiveGross(gross) {
|
|
10406
|
+
this.liveGross = { at: Date.now(), value: gross };
|
|
10407
|
+
this.authoritativeGrossRevenue = gross;
|
|
10408
|
+
this.revenueStatus = "current";
|
|
10409
|
+
if (this.controlRoomSnapshot) {
|
|
10410
|
+
this.controlRoomSnapshot = {
|
|
10411
|
+
...this.controlRoomSnapshot,
|
|
10412
|
+
revenue: { ...this.controlRoomSnapshot.revenue, gross }
|
|
10413
|
+
};
|
|
10414
|
+
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
10415
|
+
}
|
|
10416
|
+
}
|
|
10417
|
+
/** The single writer for a label's status, so the counters never drift. */
|
|
10418
|
+
setStatusLabel(label, next, prev = this.status.get(label) ?? "free") {
|
|
10419
|
+
this.status.set(label, next);
|
|
10420
|
+
if (prev === next) return;
|
|
10421
|
+
if (prev !== "free") this.counts[prev] -= 1;
|
|
10422
|
+
if (next !== "free") this.counts[next] += 1;
|
|
10423
|
+
}
|
|
10259
10424
|
async resnapshot() {
|
|
10260
10425
|
try {
|
|
10261
10426
|
const objs = await this.api.objects(this.key);
|
|
@@ -10278,23 +10443,31 @@ var SeatManager = class {
|
|
|
10278
10443
|
const next = /* @__PURE__ */ new Map();
|
|
10279
10444
|
if (fallback !== void 0) {
|
|
10280
10445
|
const base = known(fallback);
|
|
10281
|
-
for (const label of this.
|
|
10446
|
+
for (const label of this.knownLabels()) next.set(label, base);
|
|
10282
10447
|
}
|
|
10283
10448
|
for (const [label, st] of Object.entries(seats)) {
|
|
10284
10449
|
next.set(label, known(st));
|
|
10285
10450
|
}
|
|
10286
10451
|
this.status = next;
|
|
10452
|
+
this.modelVersion += 1;
|
|
10453
|
+
this.recountAll();
|
|
10287
10454
|
this.lastSyncedAt = Date.now();
|
|
10288
10455
|
this.repaintAll();
|
|
10289
10456
|
this.afterPaint();
|
|
10290
10457
|
this.recomputeTallies();
|
|
10291
10458
|
}
|
|
10459
|
+
/** The one O(n) walk left: a wholesale model replacement re-bases the counters. */
|
|
10460
|
+
recountAll() {
|
|
10461
|
+
const counts = { held: 0, booked: 0, blocked: 0 };
|
|
10462
|
+
for (const st of this.status.values()) if (st !== "free") counts[st] += 1;
|
|
10463
|
+
this.counts = counts;
|
|
10464
|
+
}
|
|
10292
10465
|
/** Optimistic local write shared by organizer actions. Paint and tally once,
|
|
10293
10466
|
* even when an arena-sized operation changes hundreds of seats. */
|
|
10294
10467
|
setSeatsLocal(labels, st) {
|
|
10295
10468
|
const ids = [];
|
|
10296
10469
|
for (const label of labels) {
|
|
10297
|
-
this.
|
|
10470
|
+
this.setStatusLabel(label, st);
|
|
10298
10471
|
const id = this.labelToId.get(label);
|
|
10299
10472
|
if (id) ids.push(id);
|
|
10300
10473
|
}
|
|
@@ -10420,12 +10593,33 @@ var SeatManager = class {
|
|
|
10420
10593
|
this.revenueStatus = "current";
|
|
10421
10594
|
this.recomputeTallies();
|
|
10422
10595
|
}
|
|
10596
|
+
/**
|
|
10597
|
+
* Read the server's own control-room projection.
|
|
10598
|
+
*
|
|
10599
|
+
* Called on mount, on every socket (re)connect and after an organizer action —
|
|
10600
|
+
* never on a timer and never per delta frame. Presence and gross that arrived
|
|
10601
|
+
* on the socket AFTER this request started are newer than the response, so
|
|
10602
|
+
* they survive it; anything older defers to the read.
|
|
10603
|
+
*/
|
|
10423
10604
|
async refreshControlRoom() {
|
|
10424
10605
|
const request = ++this.revenueRequest;
|
|
10606
|
+
const requestedAt = Date.now();
|
|
10425
10607
|
try {
|
|
10426
|
-
const
|
|
10608
|
+
const fetched = await this.api.controlRoom(this.key, this.trendWindowMinutes);
|
|
10609
|
+
let snapshot = fetched;
|
|
10427
10610
|
if (request === this.revenueRequest) {
|
|
10611
|
+
if (this.livePresence && this.livePresence.at >= requestedAt) {
|
|
10612
|
+
snapshot = { ...snapshot, presence: this.livePresence.value };
|
|
10613
|
+
} else {
|
|
10614
|
+
this.livePresence = null;
|
|
10615
|
+
}
|
|
10616
|
+
if (this.liveGross && this.liveGross.at >= requestedAt) {
|
|
10617
|
+
snapshot = { ...snapshot, revenue: { ...snapshot.revenue, gross: this.liveGross.value } };
|
|
10618
|
+
} else {
|
|
10619
|
+
this.liveGross = null;
|
|
10620
|
+
}
|
|
10428
10621
|
this.controlRoomSnapshot = snapshot;
|
|
10622
|
+
this.rebaseServerTotals(snapshot);
|
|
10429
10623
|
this.lastSyncedAt = Date.now();
|
|
10430
10624
|
this.authoritativeGrossRevenue = snapshot.revenue.gross;
|
|
10431
10625
|
this.currency = snapshot.currency;
|
|
@@ -10444,37 +10638,80 @@ var SeatManager = class {
|
|
|
10444
10638
|
throw err;
|
|
10445
10639
|
}
|
|
10446
10640
|
}
|
|
10447
|
-
|
|
10448
|
-
|
|
10449
|
-
|
|
10450
|
-
if (
|
|
10451
|
-
|
|
10452
|
-
|
|
10453
|
-
|
|
10454
|
-
|
|
10641
|
+
/** Pin the server's totals to the client model they were read against. */
|
|
10642
|
+
rebaseServerTotals(snapshot) {
|
|
10643
|
+
const totals = snapshot.totals;
|
|
10644
|
+
if (!totals || ["free", "held", "booked", "blocked"].some(
|
|
10645
|
+
(key) => !Number.isFinite(totals[key])
|
|
10646
|
+
)) {
|
|
10647
|
+
this.serverBaseline = null;
|
|
10648
|
+
return;
|
|
10649
|
+
}
|
|
10650
|
+
this.serverBaseline = {
|
|
10651
|
+
model: this.modelVersion,
|
|
10652
|
+
server: { free: totals.free, held: totals.held, booked: totals.booked, blocked: totals.blocked },
|
|
10653
|
+
client: this.clientTallies()
|
|
10654
|
+
};
|
|
10455
10655
|
}
|
|
10456
|
-
|
|
10656
|
+
/** What the client's own model says — GA units included since `render()`. */
|
|
10657
|
+
clientTallies() {
|
|
10658
|
+
const { held, booked, blocked } = this.counts;
|
|
10659
|
+
return { held, booked, blocked, free: Math.max(0, this.unitTotal() - held - booked - blocked) };
|
|
10660
|
+
}
|
|
10661
|
+
/**
|
|
10662
|
+
* The numbers the KPI bar and rail render.
|
|
10663
|
+
*
|
|
10664
|
+
* The server is the authority: its totals land exactly as read, and the
|
|
10665
|
+
* delta-driven client model carries them forward until the next read. Before
|
|
10666
|
+
* the first snapshot — and after a wholesale model replacement invalidates the
|
|
10667
|
+
* pairing — the client model stands alone.
|
|
10668
|
+
*/
|
|
10669
|
+
buildTallies() {
|
|
10670
|
+
const client = this.clientTallies();
|
|
10671
|
+
const baseline = this.serverBaseline?.model === this.modelVersion ? this.serverBaseline : null;
|
|
10672
|
+
const of = (key) => baseline ? Math.max(0, baseline.server[key] + (client[key] - baseline.client[key])) : client[key];
|
|
10673
|
+
const seatTotal = this.controlRoomSnapshot?.event?.seatTotal;
|
|
10457
10674
|
const t3 = {
|
|
10458
|
-
free:
|
|
10459
|
-
held:
|
|
10460
|
-
booked:
|
|
10461
|
-
blocked:
|
|
10462
|
-
total: this.
|
|
10675
|
+
free: of("free"),
|
|
10676
|
+
held: of("held"),
|
|
10677
|
+
booked: of("booked"),
|
|
10678
|
+
blocked: of("blocked"),
|
|
10679
|
+
total: Number.isFinite(seatTotal) ? seatTotal : this.unitTotal(),
|
|
10463
10680
|
capacityPct: 0,
|
|
10464
10681
|
sellThroughPct: 0,
|
|
10465
10682
|
grossRevenue: this.authoritativeGrossRevenue,
|
|
10466
10683
|
revenueStatus: this.revenueStatus,
|
|
10467
10684
|
currency: this.currency
|
|
10468
10685
|
};
|
|
10469
|
-
let nonFree = 0;
|
|
10470
|
-
for (const st of this.status.values()) {
|
|
10471
|
-
t3[st] += 1;
|
|
10472
|
-
if (st !== "free") nonFree += 1;
|
|
10473
|
-
}
|
|
10474
|
-
t3.free = Math.max(0, t3.total - nonFree);
|
|
10475
10686
|
t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
|
|
10476
10687
|
const sellable = t3.total - t3.blocked;
|
|
10477
10688
|
t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
|
|
10689
|
+
return t3;
|
|
10690
|
+
}
|
|
10691
|
+
/**
|
|
10692
|
+
* Queue one KPI/rail repaint for this burst of changes.
|
|
10693
|
+
*
|
|
10694
|
+
* A delta frame can carry hundreds of seats and `paintKpis` rebuilds eight
|
|
10695
|
+
* nodes from scratch, so painting per change is what made an arena-sized
|
|
10696
|
+
* frame expensive. Coalescing on a frame keeps the burst to a single rebuild;
|
|
10697
|
+
* without `requestAnimationFrame` (SSR, an older test env) it paints inline
|
|
10698
|
+
* rather than dropping the update.
|
|
10699
|
+
*/
|
|
10700
|
+
recomputeTallies() {
|
|
10701
|
+
if (this.closed) return;
|
|
10702
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
10703
|
+
this.flushTallies();
|
|
10704
|
+
return;
|
|
10705
|
+
}
|
|
10706
|
+
if (this.paintHandle !== null) return;
|
|
10707
|
+
this.paintHandle = requestAnimationFrame(() => {
|
|
10708
|
+
this.paintHandle = null;
|
|
10709
|
+
this.flushTallies();
|
|
10710
|
+
});
|
|
10711
|
+
}
|
|
10712
|
+
flushTallies() {
|
|
10713
|
+
if (this.closed) return;
|
|
10714
|
+
const t3 = this.buildTallies();
|
|
10478
10715
|
this.paintKpis(t3);
|
|
10479
10716
|
if (this.mode === "view") {
|
|
10480
10717
|
this.paintLegend(t3);
|
|
@@ -10761,16 +10998,16 @@ var SeatManager = class {
|
|
|
10761
10998
|
paintKpis(t3) {
|
|
10762
10999
|
if (!this.els.kpis) return;
|
|
10763
11000
|
const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
|
|
10764
|
-
const presence = this.
|
|
11001
|
+
const presence = this.presenceCounts();
|
|
10765
11002
|
const items = [
|
|
10766
|
-
{ key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
|
|
10767
|
-
{ key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
|
|
10768
|
-
{ key: "
|
|
10769
|
-
{ key: "
|
|
10770
|
-
{ key: "
|
|
10771
|
-
{ key: "
|
|
10772
|
-
{ key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold" },
|
|
10773
|
-
{ key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales" }
|
|
11003
|
+
{ key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b", title: "Seats booked" },
|
|
11004
|
+
{ key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740", title: "Seats held in a checkout right now" },
|
|
11005
|
+
{ key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff", title: "Seats on sale and unsold" },
|
|
11006
|
+
{ key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac", title: "Seats withheld from sale" },
|
|
11007
|
+
{ key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers", title: "People on the map right now" },
|
|
11008
|
+
{ key: "carts", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Carts", title: "Checkouts holding seats right now \u2014 sessions, not seats" },
|
|
11009
|
+
{ key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold", title: "Sold seats as a share of the whole event" },
|
|
11010
|
+
{ key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales", title: "Exact booked gross" }
|
|
10774
11011
|
];
|
|
10775
11012
|
let hasChanges = false;
|
|
10776
11013
|
this.els.kpis.innerHTML = items.map((item) => {
|
|
@@ -10786,7 +11023,7 @@ var SeatManager = class {
|
|
|
10786
11023
|
}
|
|
10787
11024
|
if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
|
|
10788
11025
|
const activeDelta = this.activeKpiDeltas.get(item.key);
|
|
10789
|
-
return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}">
|
|
11026
|
+
return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}" title="${esc2(item.title)}">
|
|
10790
11027
|
<b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
|
|
10791
11028
|
${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
|
|
10792
11029
|
</div>`;
|
|
@@ -10844,15 +11081,21 @@ var SeatManager = class {
|
|
|
10844
11081
|
this.paintMomentumHelp();
|
|
10845
11082
|
this.paintFeed();
|
|
10846
11083
|
}
|
|
11084
|
+
/** Live presence wins over the snapshot's copy — it is the fresher channel,
|
|
11085
|
+
* and it exists from the first frame rather than the first fetch. */
|
|
11086
|
+
presenceCounts() {
|
|
11087
|
+
return this.livePresence?.value ?? this.controlRoomSnapshot?.presence ?? null;
|
|
11088
|
+
}
|
|
10847
11089
|
paintMonitorInsights() {
|
|
10848
11090
|
if (this.mode !== "view") return;
|
|
10849
11091
|
const snapshot = this.controlRoomSnapshot;
|
|
10850
11092
|
if (this.els.presence) {
|
|
10851
11093
|
const connected = this.root?.classList.contains("live");
|
|
10852
11094
|
const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
|
|
11095
|
+
const presence = this.presenceCounts();
|
|
10853
11096
|
this.els.presence.innerHTML = `
|
|
10854
|
-
<div class="slm-healthitem"><b>${
|
|
10855
|
-
<div class="slm-healthitem"><b>${
|
|
11097
|
+
<div class="slm-healthitem" title="People on the map right now"><b>${presence ? presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyers</span></div>
|
|
11098
|
+
<div class="slm-healthitem" title="Checkouts holding seats right now \u2014 sessions, not seats"><b>${presence ? presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Carts</span></div>
|
|
10856
11099
|
<div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
|
|
10857
11100
|
<div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
|
|
10858
11101
|
}
|
|
@@ -11463,7 +11706,7 @@ var SeatManager = class {
|
|
|
11463
11706
|
const activity = action === "block" ? this.pushActivity(labels, "blocked", "blocked") : action === "unblock" || action === "unblockAll" ? this.pushActivity(labels, "unblocked", "free") : action === "cancelBooking" ? this.pushActivity(labels, "cancelled", "free") : null;
|
|
11464
11707
|
if (activity) this.paintSpatialActivity(activity);
|
|
11465
11708
|
}
|
|
11466
|
-
if (action !== "setHoldTtl") this.
|
|
11709
|
+
if (action !== "setHoldTtl") void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
|
|
11467
11710
|
this.opts.onActionComplete?.({ action, labels, count: labels.length });
|
|
11468
11711
|
}
|
|
11469
11712
|
toastOk(msg) {
|