@seatlayer/js 0.39.0 → 0.41.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 +847 -732
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +226 -166
- package/dist/index.d.ts +226 -166
- package/dist/index.js +847 -732
- 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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
case "buyer_access_invalid":
|
|
634
|
-
return "invalid";
|
|
635
|
-
case "buyer_access_revoked":
|
|
636
|
-
return "revoked";
|
|
637
|
-
case "buyer_access_origin_mismatch":
|
|
638
|
-
return "origin_mismatch";
|
|
639
|
-
case "buyer_access_event_mismatch":
|
|
640
|
-
return "event_mismatch";
|
|
641
|
-
case "buyer_access_mode_mismatch":
|
|
642
|
-
return "mode_mismatch";
|
|
643
|
-
case "channel_access_denied":
|
|
644
|
-
return "channel_denied";
|
|
645
|
-
case "channel_paused":
|
|
646
|
-
return "paused";
|
|
647
|
-
case "invalid_channel_scope":
|
|
648
|
-
return "invalid_scope";
|
|
649
|
-
default:
|
|
650
|
-
break;
|
|
651
|
-
}
|
|
652
|
-
if (status === 401) return "invalid";
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
function isAccessExpiry(status, code) {
|
|
656
|
-
return status === 401 && !!code && EXPIRED_CODES.has(code);
|
|
657
|
-
}
|
|
658
|
-
var DEFAULT_SKEW_MS = 3e4;
|
|
659
|
-
var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
|
|
660
|
-
var BuyerAccessContext = class {
|
|
661
|
-
constructor(options) {
|
|
662
|
-
__privateAdd(this, _BuyerAccessContext_instances);
|
|
663
|
-
/** Private field: not enumerable, not spreadable, not serializable. */
|
|
664
|
-
__privateAdd(this, _token, null);
|
|
665
|
-
__privateAdd(this, _expiresAt, 0);
|
|
666
|
-
__privateAdd(this, _provider);
|
|
667
|
-
__privateAdd(this, _skewMs);
|
|
668
|
-
__privateAdd(this, _inflight, null);
|
|
669
|
-
__privateAdd(this, _terminal, null);
|
|
670
|
-
/** The most recent failure, terminal or not — so one cause reports once. */
|
|
671
|
-
__privateAdd(this, _lastFailure, null);
|
|
672
|
-
__privateAdd(this, _onExpired);
|
|
673
|
-
__privateAdd(this, _onUnavailable);
|
|
674
|
-
/** Decided once, at construction. See the `configured` getter. */
|
|
675
|
-
__privateAdd(this, _configured, false);
|
|
676
|
-
__privateSet(this, _provider, options.provider);
|
|
677
|
-
__privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
|
|
678
|
-
__privateSet(this, _onExpired, options.onExpired);
|
|
679
|
-
__privateSet(this, _onUnavailable, options.onUnavailable);
|
|
680
|
-
if (options.token) {
|
|
681
|
-
const seed = typeof options.token === "string" ? { token: options.token } : options.token;
|
|
682
|
-
__privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
|
|
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
|
+
}
|
|
683
564
|
}
|
|
684
|
-
|
|
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
|
+
};
|
|
685
625
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
+
}
|
|
701
672
|
}
|
|
702
|
-
/**
|
|
703
|
-
|
|
704
|
-
|
|
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;
|
|
705
678
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
+
});
|
|
709
689
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
713
|
}
|
|
714
714
|
/**
|
|
715
|
-
*
|
|
715
|
+
* FULL jitter, not plain exponential backoff.
|
|
716
716
|
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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)}`;
|
|
739
|
-
}
|
|
740
|
-
/**
|
|
741
|
-
* Handle a 401/403 from a scoped call. Returns true when the caller should
|
|
742
|
-
* retry the same request once with the refreshed bearer.
|
|
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.
|
|
743
726
|
*/
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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);
|
|
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);
|
|
774
736
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
737
|
+
clearPongTimer() {
|
|
738
|
+
if (!this.pongTimer) return;
|
|
739
|
+
clearTimeout(this.pongTimer);
|
|
740
|
+
this.pongTimer = null;
|
|
778
741
|
}
|
|
779
|
-
|
|
780
|
-
|
|
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;
|
|
781
750
|
}
|
|
782
751
|
};
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
return null;
|
|
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";
|
|
756
|
+
}
|
|
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
|
+
}
|
|
820
788
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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);
|
|
827
819
|
}
|
|
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
820
|
};
|
|
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);
|
|
848
|
-
}
|
|
849
|
-
(_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
|
|
850
|
-
return event;
|
|
851
|
-
};
|
|
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
821
|
}
|
|
860
822
|
|
|
861
|
-
// src/
|
|
862
|
-
var
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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 });
|
|
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;
|
|
884
833
|
}
|
|
885
|
-
|
|
886
|
-
|
|
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);
|
|
887
847
|
}
|
|
888
|
-
return
|
|
848
|
+
return void 0;
|
|
889
849
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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;
|
|
894
862
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
863
|
+
/** True when this client is bound to a buyer access session. */
|
|
864
|
+
get accessScoped() {
|
|
865
|
+
return !!this.access?.configured;
|
|
899
866
|
}
|
|
900
|
-
|
|
901
|
-
|
|
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);
|
|
874
|
+
}
|
|
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;
|
|
902
910
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
constructor(options) {
|
|
906
|
-
this.ws = null;
|
|
907
|
-
this.stopped = true;
|
|
908
|
-
this.attempt = 0;
|
|
909
|
-
this.reconnectTimer = null;
|
|
910
|
-
this.pingTimer = null;
|
|
911
|
-
this.pongTimer = null;
|
|
912
|
-
this.resumeTimer = null;
|
|
913
|
-
/** Our model of this scope's projection. Null until the first snapshot. */
|
|
914
|
-
this.projection = null;
|
|
915
|
-
/** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
|
|
916
|
-
this.version = null;
|
|
917
|
-
/** True once the 101 echoed `seatlayer.v1`. */
|
|
918
|
-
this.v1 = false;
|
|
919
|
-
/** Set when we offered v1 and the handshake came back without it — a proxy
|
|
920
|
-
* most likely stripped the header, so the next attempt selects the v1 frame
|
|
921
|
-
* format with the `?pv=1` marker instead (protocol doc §1). The marker
|
|
922
|
-
* selects a format and can never carry a credential or widen a scope. */
|
|
923
|
-
this.useQueryMarker = false;
|
|
924
|
-
this.hidden = null;
|
|
925
|
-
this.closedSections = null;
|
|
926
|
-
this.opts = options;
|
|
927
|
-
assertCredentialFreeUrl(options.url);
|
|
911
|
+
chart(key) {
|
|
912
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
928
913
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
return this.ws ? this.v1 ? "v1" : "legacy" : null;
|
|
914
|
+
objects(key) {
|
|
915
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
|
|
932
916
|
}
|
|
933
|
-
|
|
934
|
-
return this.
|
|
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
|
+
});
|
|
935
923
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
+
});
|
|
940
932
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
}
|
|
933
|
+
resume(key, holdId) {
|
|
934
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
|
|
935
|
+
method: "POST",
|
|
936
|
+
body: { holdId }
|
|
937
|
+
});
|
|
957
938
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
this.attempt = 0;
|
|
964
|
-
this.start();
|
|
939
|
+
release(key, labels, holdId) {
|
|
940
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
|
|
941
|
+
method: "POST",
|
|
942
|
+
body: { labels, holdId }
|
|
943
|
+
});
|
|
965
944
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
minted = await this.opts.mintTicket();
|
|
974
|
-
} catch (err) {
|
|
975
|
-
this.reportIfAccessError(err);
|
|
976
|
-
this.scheduleReconnect();
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
if (this.stopped) return;
|
|
980
|
-
if (minted?.protocols?.length) {
|
|
981
|
-
protocols = [...minted.protocols];
|
|
982
|
-
if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
|
|
983
|
-
} else if (minted?.ticket) {
|
|
984
|
-
protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
const offeredResume = this.version !== null;
|
|
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
|
-
};
|
|
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
|
+
});
|
|
1047
952
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
const closed = Array.isArray(frame.closed) ? frame.closed : [];
|
|
1059
|
-
const hKey = hidden.join(SEP);
|
|
1060
|
-
const cKey = closed.join(SEP);
|
|
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);
|
|
1093
|
-
}
|
|
953
|
+
/**
|
|
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.
|
|
957
|
+
*
|
|
958
|
+
* Anonymous, and it discloses no account, key, mode or currency for a gateway
|
|
959
|
+
* that did not match.
|
|
960
|
+
*/
|
|
961
|
+
paymentOptions(key) {
|
|
962
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
|
|
1094
963
|
}
|
|
1095
|
-
/**
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
+
});
|
|
1100
978
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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`);
|
|
987
|
+
}
|
|
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: {}
|
|
1110
998
|
});
|
|
1111
999
|
}
|
|
1112
|
-
// ---- keepalive & backoff --------------------------------------------------
|
|
1113
1000
|
/**
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
|
|
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);
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
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.
|
|
1026
|
+
*
|
|
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.
|
|
1037
|
+
*/
|
|
1038
|
+
socketProtocols(key) {
|
|
1039
|
+
void key;
|
|
1040
|
+
return this.accessScoped ? [] : [SEATLAYER_V1];
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
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.
|
|
1057
|
+
*/
|
|
1058
|
+
createRealtime(key, sink) {
|
|
1059
|
+
if (this.accessScoped) return null;
|
|
1060
|
+
return new BuyerRealtimeClient({ url: this.subscribeUrl(key), sink });
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
|
|
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;
|
|
1072
|
+
}
|
|
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;
|
|
1100
|
+
}
|
|
1101
|
+
if (status === 401) return "invalid";
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
function isAccessExpiry(status, code) {
|
|
1105
|
+
return status === 401 && !!code && EXPIRED_CODES.has(code);
|
|
1106
|
+
}
|
|
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 {
|
|
1110
|
+
constructor(options) {
|
|
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));
|
|
1134
|
+
}
|
|
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);
|
|
1150
|
+
}
|
|
1151
|
+
/** Set once a state arrives that refreshing cannot clear. */
|
|
1152
|
+
get unavailable() {
|
|
1153
|
+
return __privateGet(this, _terminal);
|
|
1154
|
+
}
|
|
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());
|
|
1158
|
+
}
|
|
1159
|
+
/** Epoch ms the current token expires, or 0 when the host didn't say. */
|
|
1160
|
+
get expiresAt() {
|
|
1161
|
+
return __privateGet(this, _expiresAt);
|
|
1162
|
+
}
|
|
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.
|
|
1117
1170
|
*/
|
|
1118
|
-
|
|
1119
|
-
this.
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
+
);
|
|
1125
1184
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
try {
|
|
1130
|
-
ws.close();
|
|
1131
|
-
} catch {
|
|
1132
|
-
}
|
|
1133
|
-
}, PONG_GRACE_MS);
|
|
1134
|
-
}, PING_INTERVAL_MS);
|
|
1185
|
+
return `Bearer ${token}`;
|
|
1186
|
+
}
|
|
1187
|
+
return `Bearer ${__privateGet(this, _token)}`;
|
|
1135
1188
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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;
|
|
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;
|
|
1144
1209
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
this
|
|
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);
|
|
1149
1217
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
this
|
|
1153
|
-
this
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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);
|
|
1223
|
+
}
|
|
1224
|
+
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
1225
|
+
toJSON() {
|
|
1226
|
+
return { configured: this.configured, hasToken: this.hasToken };
|
|
1227
|
+
}
|
|
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
|
|
@@ -1250,6 +1329,7 @@ var SeatingChart = class {
|
|
|
1250
1329
|
this.mount = null;
|
|
1251
1330
|
this.hostEl = null;
|
|
1252
1331
|
this.rendered = false;
|
|
1332
|
+
this.retryBtn = null;
|
|
1253
1333
|
this.mode_ = null;
|
|
1254
1334
|
this.tipEl = null;
|
|
1255
1335
|
this.tipPos = { x: 0, y: 0 };
|
|
@@ -1311,6 +1391,7 @@ var SeatingChart = class {
|
|
|
1311
1391
|
const info = await this.controller.render(host);
|
|
1312
1392
|
if (!info) {
|
|
1313
1393
|
this.rendered = false;
|
|
1394
|
+
if (this.opts.errorDisplay !== "none") this.showLoadFailure(host);
|
|
1314
1395
|
return this;
|
|
1315
1396
|
}
|
|
1316
1397
|
this.controller.setViewMode(this.opts.initialView ?? "flat");
|
|
@@ -1601,10 +1682,36 @@ var SeatingChart = class {
|
|
|
1601
1682
|
}
|
|
1602
1683
|
return ok;
|
|
1603
1684
|
}
|
|
1685
|
+
/**
|
|
1686
|
+
* The visible failure state. Deliberately inline-styled and dependency-free:
|
|
1687
|
+
* this renders on a stranger's website, where our stylesheet may not have
|
|
1688
|
+
* loaded (the chart fetch just failed) and where inheriting the host's own
|
|
1689
|
+
* styles is likelier to produce something unreadable than something on-brand.
|
|
1690
|
+
*/
|
|
1691
|
+
showLoadFailure(host) {
|
|
1692
|
+
const box = document.createElement("div");
|
|
1693
|
+
box.setAttribute("role", "status");
|
|
1694
|
+
box.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;width:100%;height:100%;min-height:180px;box-sizing:border-box;padding:24px;text-align:center;font:500 14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#3b4256;';
|
|
1695
|
+
const text = document.createElement("div");
|
|
1696
|
+
text.textContent = "The seat map didn\u2019t load.";
|
|
1697
|
+
box.appendChild(text);
|
|
1698
|
+
const btn = document.createElement("button");
|
|
1699
|
+
btn.type = "button";
|
|
1700
|
+
btn.textContent = "Try again";
|
|
1701
|
+
btn.style.cssText = "appearance:none;border:1px solid #c9cede;background:#fff;color:#10162a;border-radius:8px;padding:8px 16px;font:600 13px/1 inherit;cursor:pointer;";
|
|
1702
|
+
btn.addEventListener("click", () => {
|
|
1703
|
+
this.destroy();
|
|
1704
|
+
void this.render().catch((err) => this.opts.onError?.(err));
|
|
1705
|
+
});
|
|
1706
|
+
box.appendChild(btn);
|
|
1707
|
+
this.retryBtn = btn;
|
|
1708
|
+
host.appendChild(box);
|
|
1709
|
+
}
|
|
1604
1710
|
destroy() {
|
|
1605
1711
|
this.realtime?.stop();
|
|
1606
1712
|
this.realtime = null;
|
|
1607
1713
|
this.access?.clear();
|
|
1714
|
+
this.retryBtn = null;
|
|
1608
1715
|
if (this.hostEl && this.onTipMove) this.hostEl.removeEventListener("mousemove", this.onTipMove);
|
|
1609
1716
|
this.tipEl = null;
|
|
1610
1717
|
this.onTipMove = null;
|
|
@@ -5783,7 +5890,15 @@ var SeatPicker = class _SeatPicker {
|
|
|
5783
5890
|
this.checkoutPanel = mountCheckout2({
|
|
5784
5891
|
root: this.root,
|
|
5785
5892
|
state,
|
|
5786
|
-
|
|
5893
|
+
// `returnUrl` rides along so a redirecting gateway can come back to the
|
|
5894
|
+
// HOST's page. The server validates its origin against the organizer's
|
|
5895
|
+
// declared embed domains and silently falls back to our own buyer page
|
|
5896
|
+
// when it does not match, so passing one can never redirect a paid buyer
|
|
5897
|
+
// somewhere the organizer did not sanction.
|
|
5898
|
+
startSession: (input) => this.pubApi.startCheckout(this.opts.event, {
|
|
5899
|
+
...input,
|
|
5900
|
+
...this.opts.returnUrl ? { returnUrl: this.opts.returnUrl } : {}
|
|
5901
|
+
}),
|
|
5787
5902
|
orderStatus: (orderId) => this.pubApi.orderStatus(orderId),
|
|
5788
5903
|
onCancel: () => {
|
|
5789
5904
|
this.checkoutPanel = null;
|