@seatlayer/js 0.39.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 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/api.ts
440
- var ApiError = class extends Error {
441
- constructor(status, message, code, conflicts, reason) {
442
- super(message);
443
- this.name = "ApiError";
444
- this.status = status;
445
- this.code = code;
446
- this.conflicts = conflicts;
447
- this.reason = reason;
448
- }
449
- };
450
- var OBJECT_UNAVAILABLE_CODES = {
451
- seat_conflict: "taken",
452
- conflict: "taken",
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
- hold(key, selections, ttlMs, replaceHoldId) {
509
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
510
- method: "POST",
511
- body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
512
- labels: selections.map((s) => s.label)
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
- // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
516
- // window both are part of the route contract, and dropping either here made
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
- resume(key, holdId) {
525
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
526
- method: "POST",
527
- body: { holdId }
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
- release(key, labels, holdId) {
531
- return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
532
- method: "POST",
533
- body: { labels, holdId }
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
- /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
537
- * (reason: expired | extend_limit | not_found | not_active) if it can't. */
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
- * Which gateways this event can actually take money through — the question
546
- * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
547
- * the answer is never discovered by failing a payment.
548
- *
549
- * Anonymous, and it discloses no account, key, mode or currency for a gateway
550
- * that did not match.
551
- */
552
- paymentOptions(key) {
553
- return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
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
- * Turn a live hold into an order and start a payment.
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
- * Poll an order while its gateway webhook lands. The order id is an
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
- * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
581
- * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
582
- * already apply; the socket then carries only the short-lived ticket, in its
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
- * The subscribe URL. Never carries a credential — not the bearer, not the
593
- * ticket. Query parameters are diagnostics only.
594
- */
595
- subscribeUrl(key) {
596
- const wsBase = this.base.replace(/^http/, "ws");
597
- const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
598
- return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
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
- * What PickerController opens its own socket with.
602
- *
603
- * Empty for an access-scoped client: a private scope authenticates with a
604
- * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
605
- * BuyerRealtimeClient owns that socket instead and the controller skips its
606
- * own (an empty URL is its documented "no live feed" contract). A tokenless
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
- // src/buyerAccess.ts
616
- var BuyerAccessUnavailableError = class extends Error {
617
- constructor(event) {
618
- super(`buyer_access_unavailable:${event.reason}`);
619
- this.name = "BuyerAccessUnavailableError";
620
- this.reason = event.reason;
621
- this.code = event.code;
622
- this.status = event.status;
623
- }
624
- };
625
- var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
626
- var RECOVERABLE = /* @__PURE__ */ new Set([
627
- "paused",
628
- "provider_failed",
629
- "channel_denied"
630
- ]);
631
- function classifyAccessFailure(status, code) {
632
- switch (code) {
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
- __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
685
- }
686
- /**
687
- * True when this picker is access-scoped at all. A false here is the
688
- * tokenless public picker, which must behave exactly as it always has.
689
- *
690
- * Answered from what the HOST asked for, never from live token state. It used
691
- * to be `!!#provider || !!#token`, which quietly inverted this file's central
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.
698
- */
699
- get configured() {
700
- return __privateGet(this, _configured);
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
+ };
701
625
  }
702
- /** Set once a state arrives that refreshing cannot clear. */
703
- get unavailable() {
704
- return __privateGet(this, _terminal);
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
+ }
705
672
  }
706
- /** True while a usable bearer is held (ignores skew). */
707
- get hasToken() {
708
- return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
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;
709
678
  }
710
- /** Epoch ms the current token expires, or 0 when the host didn't say. */
711
- get expiresAt() {
712
- return __privateGet(this, _expiresAt);
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
+ });
713
689
  }
690
+ // ---- keepalive & backoff --------------------------------------------------
714
691
  /**
715
- * The `Authorization` header value for a scoped operation.
716
- *
717
- * Returns null only when this context is not configured at all (the ordinary
718
- * anonymous public picker). A configured context either returns a bearer or
719
- * throws `BuyerAccessUnavailableError` — it never returns null, because a
720
- * null here would send the request as anonymous Public sale.
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.
721
695
  */
722
- async authorization(reason = "initial") {
723
- if (!this.configured) return null;
724
- if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
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
- );
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;
735
703
  }
736
- return `Bearer ${token}`;
737
- }
738
- return `Bearer ${__privateGet(this, _token)}`;
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);
739
713
  }
740
714
  /**
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.
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.
743
726
  */
744
- async handleFailure(status, code) {
745
- var _a;
746
- if (!this.configured) return false;
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);
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
- /** Redaction: the bearer must not survive a stringify or an interpolation. */
776
- toJSON() {
777
- return { configured: this.configured, hasToken: this.hasToken };
737
+ clearPongTimer() {
738
+ if (!this.pongTimer) return;
739
+ clearTimeout(this.pongTimer);
740
+ this.pongTimer = null;
778
741
  }
779
- toString() {
780
- return "[BuyerAccessContext redacted]";
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
- _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;
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
- 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);
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);
821
+ }
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;
848
833
  }
849
- (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
850
- return event;
851
834
  };
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
- });
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;
859
849
  }
860
-
861
- // src/buyerRealtime.ts
862
- var SEATLAYER_V1 = "seatlayer.v1";
863
- var SEP = "\0";
864
- var CLOSE_ACCESS_REVOKED = 4401;
865
- var MAX_BACKOFF_MS = 15e3;
866
- var PING_INTERVAL_MS = 25e3;
867
- var PONG_GRACE_MS = 1e4;
868
- var RESUME_ANSWER_GRACE_MS = 5e3;
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;
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);
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
+ );
875
908
  }
909
+ return data;
876
910
  }
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 });
911
+ chart(key) {
912
+ return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
884
913
  }
885
- for (const label of Object.keys(prev.exceptions)) {
886
- if (!(label in next.exceptions)) changes.push({ label, status: next.default });
914
+ objects(key) {
915
+ return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
887
916
  }
888
- return changes;
889
- }
890
- function applyChanges(projection, changes) {
891
- for (const change of changes) {
892
- if (change.status === projection.default) delete projection.exceptions[change.label];
893
- else projection.exceptions[change.label] = change.status;
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
+ });
894
923
  }
895
- }
896
- function assertCredentialFreeUrl(url) {
897
- if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
898
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
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
+ });
899
932
  }
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");
933
+ resume(key, holdId) {
934
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
935
+ method: "POST",
936
+ body: { holdId }
937
+ });
902
938
  }
903
- }
904
- var BuyerRealtimeClient = class {
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);
939
+ release(key, labels, holdId) {
940
+ return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
941
+ method: "POST",
942
+ body: { labels, holdId }
943
+ });
928
944
  }
929
- /** Negotiated protocol, for tests and diagnostics. */
930
- get protocol() {
931
- return this.ws ? this.v1 ? "v1" : "legacy" : null;
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
+ });
952
+ }
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`);
963
+ }
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
+ });
978
+ }
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: {}
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);
932
1021
  }
933
- get snapshotVersion() {
934
- return this.version;
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];
935
1041
  }
936
- start() {
937
- if (!this.stopped) return;
938
- this.stopped = false;
939
- void this.connect();
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 });
940
1061
  }
941
- /** Stop for good (destroy, or a revocation). Safe to call twice. */
942
- stop() {
943
- this.stopped = true;
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
- }
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;
957
1072
  }
958
- /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
959
- restart() {
960
- this.stop();
961
- this.projection = null;
962
- this.version = null;
963
- this.attempt = 0;
964
- this.start();
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;
965
1100
  }
966
- // ---- connection -----------------------------------------------------------
967
- async connect() {
968
- if (this.stopped) return;
969
- let protocols = [SEATLAYER_V1];
970
- if (this.opts.mintTicket) {
971
- let minted;
972
- try {
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;
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);
998
1132
  }
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
- };
1133
+ __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
1047
1134
  }
1048
- handleFrame(frame) {
1049
- const type = typeof frame.type === "string" ? frame.type : "";
1050
- if (frame.protocol === 1) this.v1 = true;
1051
- if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
1052
- if (type === "pong") {
1053
- this.clearPongTimer();
1054
- return;
1055
- }
1056
- if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
1057
- const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
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
- }
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);
1094
1150
  }
1095
- /** The server answered our resume; cancel the fallback resync. */
1096
- answered() {
1097
- if (!this.resumeTimer) return;
1098
- clearTimeout(this.resumeTimer);
1099
- this.resumeTimer = null;
1151
+ /** Set once a state arrives that refreshing cannot clear. */
1152
+ get unavailable() {
1153
+ return __privateGet(this, _terminal);
1100
1154
  }
1101
- reportIfAccessError(err) {
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
- });
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);
1111
1162
  }
1112
- // ---- keepalive & backoff --------------------------------------------------
1113
1163
  /**
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.
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
- 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;
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
- 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);
1185
+ return `Bearer ${token}`;
1186
+ }
1187
+ return `Bearer ${__privateGet(this, _token)}`;
1135
1188
  }
1136
- scheduleReconnect() {
1137
- if (this.stopped || this.reconnectTimer) return;
1138
- const attempt = Math.min(this.attempt++, 5);
1139
- const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
1140
- this.reconnectTimer = setTimeout(() => {
1141
- this.reconnectTimer = null;
1142
- void this.connect();
1143
- }, delay);
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
- clearPongTimer() {
1146
- if (!this.pongTimer) return;
1147
- clearTimeout(this.pongTimer);
1148
- this.pongTimer = null;
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
- clearTimers() {
1151
- if (this.pingTimer) clearInterval(this.pingTimer);
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;
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
- function rendererStatus(wire) {
1161
- if (wire === "blocked") return "not_for_sale";
1162
- if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
1163
- return "free";
1164
- }
1165
- function createControllerSink(controller, options = {}) {
1166
- const idsForLabel = (label) => {
1167
- const table = controller.tableSelection(label);
1168
- if (table) return table.physicalSeatIds;
1169
- const id = controller.idForLabel(label);
1170
- return id ? [id] : [];
1171
- };
1172
- return {
1173
- applyStatuses(changes) {
1174
- const held = controller.currentHold()?.labels ?? [];
1175
- const buckets = {
1176
- free: [],
1177
- held: [],
1178
- booked: [],
1179
- not_for_sale: []
1180
- };
1181
- const flashes = [];
1182
- const lost = [];
1183
- const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
1184
- for (const change of changes) {
1185
- const ids = idsForLabel(change.label);
1186
- if (!ids.length) continue;
1187
- const next = rendererStatus(change.status);
1188
- buckets[next].push(...ids);
1189
- if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
1190
- const color = next === "held" ? "#f4b740" : "#f43f5e";
1191
- for (const id of ids) flashes.push({ id, color });
1192
- }
1193
- if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
1194
- lost.push(change.label);
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
- options.onStatusChange?.();
1210
- },
1211
- async resync() {
1212
- await controller.refresh();
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