@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.js CHANGED
@@ -13,796 +13,875 @@ import {
13
13
  t
14
14
  } from "@seatlayer/core";
15
15
 
16
- // src/api.ts
17
- var ApiError = class extends Error {
18
- constructor(status, message, code, conflicts, reason) {
19
- super(message);
20
- this.name = "ApiError";
21
- this.status = status;
22
- this.code = code;
23
- this.conflicts = conflicts;
24
- this.reason = reason;
25
- }
26
- };
27
- var OBJECT_UNAVAILABLE_CODES = {
28
- seat_conflict: "taken",
29
- conflict: "taken",
30
- channel_assignment_conflict: "ineligible",
31
- allocation_exhausted: "exhausted"
32
- };
33
- var PubApi = class {
34
- constructor(base, options = {}) {
35
- this.base = base;
36
- this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
37
- this.access = options.access;
38
- this.onObjectUnavailable = options.onObjectUnavailable;
39
- }
40
- /** True when this client is bound to a buyer access session. */
41
- get accessScoped() {
42
- return !!this.access?.configured;
43
- }
44
- async request(path, init = {}, retried = false) {
45
- const method = init.method ?? "GET";
46
- const headers = {};
47
- let body;
48
- if (init.body !== void 0) {
49
- headers["Content-Type"] = "application/json";
50
- body = JSON.stringify(init.body);
51
- }
52
- const authorization = await this.access?.authorization(retried ? "unauthorized" : "initial");
53
- if (authorization) headers.Authorization = authorization;
54
- const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
55
- const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
56
- const data = isJson ? await res.json().catch(() => null) : null;
57
- if (!res.ok) {
58
- const err = data;
59
- const code = err?.code ?? err?.error;
60
- if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
61
- const refreshed = await this.access.handleFailure(res.status, code);
62
- if (refreshed && !retried) return this.request(path, init, true);
63
- }
64
- if (res.status === 409) {
65
- const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
66
- const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
67
- if (reason) this.onObjectUnavailable?.({ labels, reason, code });
68
- }
69
- throw new ApiError(
70
- res.status,
71
- err?.error ?? `request_failed_${res.status}`,
72
- code,
73
- err?.conflicts,
74
- err?.reason
75
- );
16
+ // src/buyerRealtime.ts
17
+ var SEATLAYER_V1 = "seatlayer.v1";
18
+ var SEP = "\0";
19
+ var CLOSE_ACCESS_REVOKED = 4401;
20
+ var MAX_BACKOFF_MS = 15e3;
21
+ var PING_INTERVAL_MS = 25e3;
22
+ var PONG_GRACE_MS = 1e4;
23
+ var RESUME_ANSWER_GRACE_MS = 5e3;
24
+ function projectionFromSnapshot(frame) {
25
+ const fallback = typeof frame.default === "string" ? frame.default : "free";
26
+ const exceptions = {};
27
+ if (frame.seats && typeof frame.seats === "object") {
28
+ for (const [label, status] of Object.entries(frame.seats)) {
29
+ if (typeof status === "string" && status !== fallback) exceptions[label] = status;
76
30
  }
77
- return data;
78
- }
79
- chart(key) {
80
- return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
81
- }
82
- objects(key) {
83
- return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
84
31
  }
85
- hold(key, selections, ttlMs, replaceHoldId) {
86
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
87
- method: "POST",
88
- body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
89
- labels: selections.map((s) => s.label)
90
- });
32
+ return { default: fallback, exceptions };
33
+ }
34
+ function diffProjections(prev, next) {
35
+ if (!prev || prev.default !== next.default) return null;
36
+ const changes = [];
37
+ for (const [label, status] of Object.entries(next.exceptions)) {
38
+ if (prev.exceptions[label] !== status) changes.push({ label, status });
91
39
  }
92
- // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
93
- // window both are part of the route contract, and dropping either here made
94
- // the SDK quietly pick venue-wide and hold for the server default instead.
95
- bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
96
- return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
97
- method: "POST",
98
- body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
99
- });
40
+ for (const label of Object.keys(prev.exceptions)) {
41
+ if (!(label in next.exceptions)) changes.push({ label, status: next.default });
100
42
  }
101
- resume(key, holdId) {
102
- return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
103
- method: "POST",
104
- body: { holdId }
105
- });
43
+ return changes;
44
+ }
45
+ function applyChanges(projection, changes) {
46
+ for (const change of changes) {
47
+ if (change.status === projection.default) delete projection.exceptions[change.label];
48
+ else projection.exceptions[change.label] = change.status;
106
49
  }
107
- release(key, labels, holdId) {
108
- return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
109
- method: "POST",
110
- body: { labels, holdId }
111
- });
50
+ }
51
+ function assertCredentialFreeUrl(url) {
52
+ if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
53
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
112
54
  }
113
- /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
114
- * (reason: expired | extend_limit | not_found | not_active) if it can't. */
115
- extend(key, holdId, ttlMs) {
116
- return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
117
- method: "POST",
118
- body: { holdId, ...ttlMs ? { ttlMs } : {} }
119
- });
55
+ if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
56
+ throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
120
57
  }
121
- /**
122
- * Which gateways this event can actually take money through — the question
123
- * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
124
- * the answer is never discovered by failing a payment.
125
- *
126
- * Anonymous, and it discloses no account, key, mode or currency for a gateway
127
- * that did not match.
128
- */
129
- paymentOptions(key) {
130
- return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
58
+ }
59
+ var BuyerRealtimeClient = class {
60
+ constructor(options) {
61
+ this.ws = null;
62
+ this.stopped = true;
63
+ this.attempt = 0;
64
+ this.reconnectTimer = null;
65
+ this.pingTimer = null;
66
+ this.pongTimer = null;
67
+ this.resumeTimer = null;
68
+ /** Our model of this scope's projection. Null until the first snapshot. */
69
+ this.projection = null;
70
+ /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
71
+ this.version = null;
72
+ /** True once the 101 echoed `seatlayer.v1`. */
73
+ this.v1 = false;
74
+ /** Set when we offered v1 and the handshake came back without it — a proxy
75
+ * most likely stripped the header, so the next attempt selects the v1 frame
76
+ * format with the `?pv=1` marker instead (protocol doc §1). The marker
77
+ * selects a format and can never carry a credential or widen a scope. */
78
+ this.useQueryMarker = false;
79
+ this.hidden = null;
80
+ this.closedSections = null;
81
+ this.opts = options;
82
+ assertCredentialFreeUrl(options.url);
131
83
  }
132
- /**
133
- * Turn a live hold into an order and start a payment.
134
- *
135
- * The amount is NOT sent: the server recomputes it from the hold's own items,
136
- * which is the only reason a browser cannot alter what it pays. Nor is the
137
- * PROVIDER — the event row decides which gateway charges, and a `provider` in
138
- * the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
139
- * it is the shape that cannot disagree.
140
- */
141
- startCheckout(key, input) {
142
- return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
143
- method: "POST",
144
- body: input
145
- });
84
+ /** Negotiated protocol, for tests and diagnostics. */
85
+ get protocol() {
86
+ return this.ws ? this.v1 ? "v1" : "legacy" : null;
146
87
  }
147
- /**
148
- * Poll an order while its gateway webhook lands. The order id is an
149
- * unguessable token the buyer already holds, so it acts as the capability —
150
- * which is also why a buyer returning from a gateway page can be told what
151
- * happened with nothing but the id in the return URL.
152
- */
153
- orderStatus(orderId) {
154
- return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
88
+ get snapshotVersion() {
89
+ return this.version;
155
90
  }
156
- /**
157
- * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
158
- * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
159
- * already apply; the socket then carries only the short-lived ticket, in its
160
- * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
161
- */
162
- subscribeTicket(key) {
163
- return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
164
- method: "POST",
165
- body: {}
166
- });
91
+ start() {
92
+ if (!this.stopped) return;
93
+ this.stopped = false;
94
+ void this.connect();
167
95
  }
168
- /**
169
- * The subscribe URL. Never carries a credential — not the bearer, not the
170
- * ticket. Query parameters are diagnostics only.
171
- */
172
- subscribeUrl(key) {
173
- const wsBase = this.base.replace(/^http/, "ws");
174
- const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
175
- return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
96
+ /** Stop for good (destroy, or a revocation). Safe to call twice. */
97
+ stop() {
98
+ this.stopped = true;
99
+ this.clearTimers();
100
+ const ws = this.ws;
101
+ this.ws = null;
102
+ if (ws) {
103
+ ws.onopen = null;
104
+ ws.onmessage = null;
105
+ ws.onclose = null;
106
+ ws.onerror = null;
107
+ try {
108
+ ws.close();
109
+ } catch {
110
+ }
111
+ }
176
112
  }
177
- /**
178
- * What PickerController opens its own socket with.
179
- *
180
- * Empty for an access-scoped client: a private scope authenticates with a
181
- * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
182
- * BuyerRealtimeClient owns that socket instead and the controller skips its
183
- * own (an empty URL is its documented "no live feed" contract). A tokenless
184
- * public client returns exactly the URL it always has, so nothing about the
185
- * public picker's realtime path changes.
186
- */
187
- socketUrl(key) {
188
- return this.accessScoped ? "" : this.subscribeUrl(key);
113
+ /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
114
+ restart() {
115
+ this.stop();
116
+ this.projection = null;
117
+ this.version = null;
118
+ this.attempt = 0;
119
+ this.start();
189
120
  }
190
- };
191
-
192
- // src/buyerAccess.ts
193
- var BuyerAccessUnavailableError = class extends Error {
194
- constructor(event) {
195
- super(`buyer_access_unavailable:${event.reason}`);
196
- this.name = "BuyerAccessUnavailableError";
197
- this.reason = event.reason;
198
- this.code = event.code;
199
- this.status = event.status;
200
- }
201
- };
202
- var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
203
- var RECOVERABLE = /* @__PURE__ */ new Set([
204
- "paused",
205
- "provider_failed",
206
- "channel_denied"
207
- ]);
208
- function classifyAccessFailure(status, code) {
209
- switch (code) {
210
- case "buyer_access_invalid":
211
- return "invalid";
212
- case "buyer_access_revoked":
213
- return "revoked";
214
- case "buyer_access_origin_mismatch":
215
- return "origin_mismatch";
216
- case "buyer_access_event_mismatch":
217
- return "event_mismatch";
218
- case "buyer_access_mode_mismatch":
219
- return "mode_mismatch";
220
- case "channel_access_denied":
221
- return "channel_denied";
222
- case "channel_paused":
223
- return "paused";
224
- case "invalid_channel_scope":
225
- return "invalid_scope";
226
- default:
227
- break;
228
- }
229
- if (status === 401) return "invalid";
230
- return null;
231
- }
232
- function isAccessExpiry(status, code) {
233
- return status === 401 && !!code && EXPIRED_CODES.has(code);
234
- }
235
- var DEFAULT_SKEW_MS = 3e4;
236
- var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
237
- var BuyerAccessContext = class {
238
- constructor(options) {
239
- __privateAdd(this, _BuyerAccessContext_instances);
240
- /** Private field: not enumerable, not spreadable, not serializable. */
241
- __privateAdd(this, _token, null);
242
- __privateAdd(this, _expiresAt, 0);
243
- __privateAdd(this, _provider);
244
- __privateAdd(this, _skewMs);
245
- __privateAdd(this, _inflight, null);
246
- __privateAdd(this, _terminal, null);
247
- /** The most recent failure, terminal or not — so one cause reports once. */
248
- __privateAdd(this, _lastFailure, null);
249
- __privateAdd(this, _onExpired);
250
- __privateAdd(this, _onUnavailable);
251
- /** Decided once, at construction. See the `configured` getter. */
252
- __privateAdd(this, _configured, false);
253
- __privateSet(this, _provider, options.provider);
254
- __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
255
- __privateSet(this, _onExpired, options.onExpired);
256
- __privateSet(this, _onUnavailable, options.onUnavailable);
257
- if (options.token) {
258
- const seed = typeof options.token === "string" ? { token: options.token } : options.token;
259
- __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
121
+ // ---- connection -----------------------------------------------------------
122
+ async connect() {
123
+ if (this.stopped) return;
124
+ let protocols = [SEATLAYER_V1];
125
+ if (this.opts.mintTicket) {
126
+ let minted;
127
+ try {
128
+ minted = await this.opts.mintTicket();
129
+ } catch (err) {
130
+ this.reportIfAccessError(err);
131
+ this.scheduleReconnect();
132
+ return;
133
+ }
134
+ if (this.stopped) return;
135
+ if (minted?.protocols?.length) {
136
+ protocols = [...minted.protocols];
137
+ if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
138
+ } else if (minted?.ticket) {
139
+ protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
140
+ }
260
141
  }
261
- __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
262
- }
263
- /**
264
- * True when this picker is access-scoped at all. A false here is the
265
- * tokenless public picker, which must behave exactly as it always has.
266
- *
267
- * Answered from what the HOST asked for, never from live token state. It used
268
- * to be `!!#provider || !!#token`, which quietly inverted this file's central
269
- * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
270
- * the first refusal turned a configured context into an "unconfigured" one,
271
- * `authorization()` then returned null instead of throwing, and the very next
272
- * call went out with no bearer — the anonymous Public sale fallback this
273
- * module exists to prevent. A provider host never saw it, because `#provider`
274
- * held `configured` true. Found against a live worker in the M9 pass.
275
- */
276
- get configured() {
277
- return __privateGet(this, _configured);
142
+ const offeredResume = this.version !== null;
143
+ if (offeredResume) protocols.push(`sv.${this.version}`);
144
+ const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
145
+ assertCredentialFreeUrl(url);
146
+ let ws;
147
+ try {
148
+ const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
149
+ ws = make(url, protocols);
150
+ } catch {
151
+ this.scheduleReconnect();
152
+ return;
153
+ }
154
+ this.ws = ws;
155
+ ws.onopen = () => {
156
+ if (this.ws !== ws) return;
157
+ this.attempt = 0;
158
+ this.v1 = ws.protocol === SEATLAYER_V1;
159
+ if (!this.v1) this.useQueryMarker = true;
160
+ this.startKeepalive(ws);
161
+ if (offeredResume) {
162
+ this.resumeTimer = setTimeout(() => {
163
+ this.resumeTimer = null;
164
+ void this.opts.sink.resync();
165
+ }, RESUME_ANSWER_GRACE_MS);
166
+ } else {
167
+ void this.opts.sink.resync();
168
+ }
169
+ };
170
+ ws.onmessage = (event) => {
171
+ if (this.ws !== ws) return;
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
175
+ } catch {
176
+ return;
177
+ }
178
+ if (!parsed || typeof parsed !== "object") return;
179
+ this.handleFrame(parsed);
180
+ };
181
+ ws.onclose = (event) => {
182
+ if (this.ws !== ws) return;
183
+ this.ws = null;
184
+ this.clearTimers();
185
+ if (event?.code === CLOSE_ACCESS_REVOKED) {
186
+ this.stopped = true;
187
+ this.opts.onAccessUnavailable?.({
188
+ reason: "revoked",
189
+ code: "access_revoked",
190
+ retryable: false
191
+ });
192
+ return;
193
+ }
194
+ this.scheduleReconnect();
195
+ };
196
+ ws.onerror = () => {
197
+ try {
198
+ ws.close();
199
+ } catch {
200
+ }
201
+ };
278
202
  }
279
- /** Set once a state arrives that refreshing cannot clear. */
280
- get unavailable() {
281
- return __privateGet(this, _terminal);
203
+ handleFrame(frame) {
204
+ const type = typeof frame.type === "string" ? frame.type : "";
205
+ if (frame.protocol === 1) this.v1 = true;
206
+ if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
207
+ if (type === "pong") {
208
+ this.clearPongTimer();
209
+ return;
210
+ }
211
+ if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
212
+ const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
213
+ const closed = Array.isArray(frame.closed) ? frame.closed : [];
214
+ const hKey = hidden.join(SEP);
215
+ const cKey = closed.join(SEP);
216
+ if (hKey !== this.hidden || cKey !== this.closedSections) {
217
+ this.hidden = hKey;
218
+ this.closedSections = cKey;
219
+ this.opts.sink.onSections?.(hidden, closed);
220
+ }
221
+ }
222
+ if (type === "hidden") return;
223
+ if (type === "presence") {
224
+ this.opts.sink.onPresence?.({
225
+ shoppingSessions: Number(frame.shoppingSessions) || 0,
226
+ activeHolds: Number(frame.activeHolds) || 0
227
+ });
228
+ return;
229
+ }
230
+ if (type === "allocation") {
231
+ return;
232
+ }
233
+ if (type === "snapshot" || !type && frame.seats) {
234
+ this.answered();
235
+ const next = projectionFromSnapshot(frame);
236
+ const changes = diffProjections(this.projection, next);
237
+ this.projection = next;
238
+ if (changes === null) void this.opts.sink.resync();
239
+ else if (changes.length) this.opts.sink.applyStatuses(changes);
240
+ return;
241
+ }
242
+ if (type === "delta" && Array.isArray(frame.changes)) {
243
+ this.answered();
244
+ const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
245
+ if (!changes.length) return;
246
+ if (this.projection) applyChanges(this.projection, changes);
247
+ this.opts.sink.applyStatuses(changes);
248
+ }
282
249
  }
283
- /** True while a usable bearer is held (ignores skew). */
284
- get hasToken() {
285
- return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
250
+ /** The server answered our resume; cancel the fallback resync. */
251
+ answered() {
252
+ if (!this.resumeTimer) return;
253
+ clearTimeout(this.resumeTimer);
254
+ this.resumeTimer = null;
286
255
  }
287
- /** Epoch ms the current token expires, or 0 when the host didn't say. */
288
- get expiresAt() {
289
- return __privateGet(this, _expiresAt);
256
+ reportIfAccessError(err) {
257
+ const reason = err?.reason;
258
+ if (err?.name !== "BuyerAccessUnavailableError") return;
259
+ this.stopped = true;
260
+ this.opts.onAccessUnavailable?.({
261
+ reason: reason ?? "invalid",
262
+ code: err.code,
263
+ status: err.status,
264
+ retryable: reason === "paused"
265
+ });
290
266
  }
267
+ // ---- keepalive & backoff --------------------------------------------------
291
268
  /**
292
- * The `Authorization` header value for a scoped operation.
293
- *
294
- * Returns null only when this context is not configured at all (the ordinary
295
- * anonymous public picker). A configured context either returns a bearer or
296
- * throws `BuyerAccessUnavailableError` — it never returns null, because a
297
- * null here would send the request as anonymous Public sale.
269
+ * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
270
+ * for minutes is the normal, correct state for a narrowly-scoped buyer on a
271
+ * busy event (protocol doc §5), so quiet time never triggers a reconnect.
298
272
  */
299
- async authorization(reason = "initial") {
300
- if (!this.configured) return null;
301
- if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
302
- const now = Date.now();
303
- const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
304
- if (stale) {
305
- const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
306
- const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
307
- const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
308
- if (!token) {
309
- throw new BuyerAccessUnavailableError(
310
- __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
311
- );
273
+ startKeepalive(ws) {
274
+ this.pingTimer = setInterval(() => {
275
+ if (this.ws !== ws) return;
276
+ try {
277
+ ws.send(JSON.stringify({ type: "ping" }));
278
+ } catch {
279
+ return;
312
280
  }
313
- return `Bearer ${token}`;
314
- }
315
- return `Bearer ${__privateGet(this, _token)}`;
281
+ this.clearPongTimer();
282
+ this.pongTimer = setTimeout(() => {
283
+ this.pongTimer = null;
284
+ try {
285
+ ws.close();
286
+ } catch {
287
+ }
288
+ }, PONG_GRACE_MS);
289
+ }, PING_INTERVAL_MS);
316
290
  }
317
291
  /**
318
- * Handle a 401/403 from a scoped call. Returns true when the caller should
319
- * retry the same request once with the refreshed bearer.
292
+ * FULL jitter, not plain exponential backoff.
293
+ *
294
+ * A deterministic `2**attempt` schedule makes every browser that lost the same
295
+ * socket — a worker redeploy, a DO eviction, a flaky edge PoP — come back in
296
+ * the same millisecond, and an on-sale crowd reconnecting in lockstep is the
297
+ * thing that turns one blip into a self-sustaining thundering herd. Full
298
+ * jitter (`random() * ceiling`) spreads the same crowd across the whole
299
+ * window; the ceiling still doubles, so a persistent outage still backs off.
300
+ *
301
+ * `Math.random` is correct here: this is client code choosing a delay, not a
302
+ * Workflow step that has to replay deterministically.
320
303
  */
321
- async handleFailure(status, code) {
322
- var _a;
323
- if (!this.configured) return false;
324
- if (isAccessExpiry(status, code)) {
325
- __privateSet(this, _token, null);
326
- __privateSet(this, _expiresAt, 0);
327
- const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
328
- (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
329
- return !!token;
330
- }
331
- const reason = classifyAccessFailure(status, code);
332
- if (reason) {
333
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
334
- return false;
335
- }
336
- return false;
337
- }
338
- /** Host-driven re-acquisition (after the buyer signs in again, say). */
339
- async refresh(reason = "manual") {
340
- __privateSet(this, _terminal, null);
341
- __privateSet(this, _lastFailure, null);
342
- __privateSet(this, _token, null);
343
- __privateSet(this, _expiresAt, 0);
344
- return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
345
- }
346
- /** Drop the bearer. Called on destroy so nothing outlives the widget. */
347
- clear() {
348
- __privateSet(this, _token, null);
349
- __privateSet(this, _expiresAt, 0);
350
- __privateSet(this, _inflight, null);
304
+ scheduleReconnect() {
305
+ if (this.stopped || this.reconnectTimer) return;
306
+ const attempt = Math.min(this.attempt++, 5);
307
+ const ceiling = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
308
+ const delay = Math.random() * ceiling;
309
+ this.reconnectTimer = setTimeout(() => {
310
+ this.reconnectTimer = null;
311
+ void this.connect();
312
+ }, delay);
351
313
  }
352
- /** Redaction: the bearer must not survive a stringify or an interpolation. */
353
- toJSON() {
354
- return { configured: this.configured, hasToken: this.hasToken };
314
+ clearPongTimer() {
315
+ if (!this.pongTimer) return;
316
+ clearTimeout(this.pongTimer);
317
+ this.pongTimer = null;
355
318
  }
356
- toString() {
357
- return "[BuyerAccessContext redacted]";
319
+ clearTimers() {
320
+ if (this.pingTimer) clearInterval(this.pingTimer);
321
+ this.pingTimer = null;
322
+ this.clearPongTimer();
323
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
324
+ this.reconnectTimer = null;
325
+ if (this.resumeTimer) clearTimeout(this.resumeTimer);
326
+ this.resumeTimer = null;
358
327
  }
359
328
  };
360
- _token = new WeakMap();
361
- _expiresAt = new WeakMap();
362
- _provider = new WeakMap();
363
- _skewMs = new WeakMap();
364
- _inflight = new WeakMap();
365
- _terminal = new WeakMap();
366
- _lastFailure = new WeakMap();
367
- _onExpired = new WeakMap();
368
- _onUnavailable = new WeakMap();
369
- _configured = new WeakMap();
370
- _BuyerAccessContext_instances = new WeakSet();
371
- // ---- internals ------------------------------------------------------------
372
- accept_fn = function(next) {
373
- if (!next || typeof next.token !== "string" || !next.token) return null;
374
- __privateSet(this, _token, next.token);
375
- __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
376
- return __privateGet(this, _token);
377
- };
378
- /**
379
- * One provider call at a time. Several operations racing an expiry (chart +
380
- * objects + a socket ticket) must not mint several sessions — the guide's
381
- * rotate-on-retry rule would revoke the ones they didn't observe.
382
- */
383
- renew_fn = function(reason, code) {
384
- if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
385
- const provider = __privateGet(this, _provider);
386
- if (!provider) {
387
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
388
- return Promise.resolve(null);
389
- }
390
- const run = (async () => {
391
- try {
392
- const next = await provider({ reason });
393
- const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
394
- if (!token) {
395
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
396
- return null;
329
+ function rendererStatus(wire) {
330
+ if (wire === "blocked") return "not_for_sale";
331
+ if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
332
+ return "free";
333
+ }
334
+ function createControllerSink(controller, options = {}) {
335
+ const idsForLabel = (label) => {
336
+ const table = controller.tableSelection(label);
337
+ if (table) return table.physicalSeatIds;
338
+ const id = controller.idForLabel(label);
339
+ return id ? [id] : [];
340
+ };
341
+ return {
342
+ applyStatuses(changes) {
343
+ const held = controller.currentHold()?.labels ?? [];
344
+ const buckets = {
345
+ free: [],
346
+ held: [],
347
+ booked: [],
348
+ not_for_sale: []
349
+ };
350
+ const flashes = [];
351
+ const lost = [];
352
+ const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
353
+ for (const change of changes) {
354
+ const ids = idsForLabel(change.label);
355
+ if (!ids.length) continue;
356
+ const next = rendererStatus(change.status);
357
+ buckets[next].push(...ids);
358
+ if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
359
+ const color = next === "held" ? "#f4b740" : "#f43f5e";
360
+ for (const id of ids) flashes.push({ id, color });
361
+ }
362
+ if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
363
+ lost.push(change.label);
364
+ }
397
365
  }
398
- return token;
399
- } catch {
400
- __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
401
- return null;
402
- } finally {
403
- __privateSet(this, _inflight, null);
366
+ for (const status of ["free", "held", "booked", "not_for_sale"]) {
367
+ if (buckets[status].length) controller.setStatus(buckets[status], status);
368
+ }
369
+ for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
370
+ if (lost.length) {
371
+ const ids = lost.flatMap((label) => idsForLabel(label));
372
+ if (ids.length) controller.deselect(ids);
373
+ const ineligible = changes.some(
374
+ (c) => c.status === "blocked" && lost.includes(c.label)
375
+ );
376
+ options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
377
+ }
378
+ options.onStatusChange?.();
379
+ },
380
+ async resync() {
381
+ await controller.refresh();
382
+ },
383
+ /**
384
+ * Section availability moved. Statuses are re-pulled so the map repaints.
385
+ *
386
+ * Known limit: rebuilding the chart when a section is newly HIDDEN (its
387
+ * seats are stripped, not greyed) lives inside PickerController's own
388
+ * socket handler and has no public entry point, so an access-scoped picker
389
+ * repaints statuses but does not restructure the chart until its next
390
+ * mount. Closing/opening a section — the common mid-sale move — is a
391
+ * status-level change and is handled here in full.
392
+ */
393
+ onSections(hidden, closed) {
394
+ void controller.refresh();
395
+ options.onSections?.(hidden, closed);
404
396
  }
405
- })();
406
- __privateSet(this, _inflight, run);
407
- return run;
408
- };
409
- fail_fn = function(reason, code, status) {
410
- var _a;
411
- const event = {
412
- reason,
413
- code,
414
- status,
415
- // Unchanged: `retryable` means "the SAME request may succeed later".
416
- // `provider_failed` is recoverable but not retryable — the host must fix
417
- // its mint endpoint first — so the two sets are deliberately different.
418
- retryable: reason === "paused" || reason === "channel_denied"
419
397
  };
420
- __privateSet(this, _lastFailure, event);
421
- if (!RECOVERABLE.has(reason)) {
422
- __privateSet(this, _terminal, event);
423
- __privateSet(this, _token, null);
424
- __privateSet(this, _expiresAt, 0);
398
+ }
399
+
400
+ // src/api.ts
401
+ var ApiError = class extends Error {
402
+ constructor(status, message, code, conflicts, reason, retryAfterS) {
403
+ super(message);
404
+ this.name = "ApiError";
405
+ this.status = status;
406
+ this.code = code;
407
+ this.conflicts = conflicts;
408
+ this.reason = reason;
409
+ this.retryAfterS = retryAfterS;
425
410
  }
426
- (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
427
- return event;
428
411
  };
429
- function createBuyerAccessContext(options, hooks = {}) {
430
- if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
431
- return new BuyerAccessContext({
432
- provider: options.buyerAccessTokenProvider,
433
- token: options.buyerAccessToken,
434
- ...hooks
435
- });
412
+ var MAX_RATE_LIMIT_WAIT_S = 10;
413
+ var DEFAULT_RATE_LIMIT_WAIT_S = 1;
414
+ function parseRetryAfter(header, bodyValue) {
415
+ const raw = (header ?? "").trim();
416
+ if (raw) {
417
+ const seconds = Number(raw);
418
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
419
+ const at = Date.parse(raw);
420
+ if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1e3));
421
+ }
422
+ if (typeof bodyValue === "number" && Number.isFinite(bodyValue) && bodyValue >= 0) {
423
+ return Math.ceil(bodyValue);
424
+ }
425
+ return void 0;
436
426
  }
437
-
438
- // src/buyerRealtime.ts
439
- var SEATLAYER_V1 = "seatlayer.v1";
440
- var SEP = "\0";
441
- var CLOSE_ACCESS_REVOKED = 4401;
442
- var MAX_BACKOFF_MS = 15e3;
443
- var PING_INTERVAL_MS = 25e3;
444
- var PONG_GRACE_MS = 1e4;
445
- var RESUME_ANSWER_GRACE_MS = 5e3;
446
- function projectionFromSnapshot(frame) {
447
- const fallback = typeof frame.default === "string" ? frame.default : "free";
448
- const exceptions = {};
449
- if (frame.seats && typeof frame.seats === "object") {
450
- for (const [label, status] of Object.entries(frame.seats)) {
451
- if (typeof status === "string" && status !== fallback) exceptions[label] = status;
427
+ var OBJECT_UNAVAILABLE_CODES = {
428
+ seat_conflict: "taken",
429
+ conflict: "taken",
430
+ channel_assignment_conflict: "ineligible",
431
+ allocation_exhausted: "exhausted"
432
+ };
433
+ var PubApi = class {
434
+ constructor(base, options = {}) {
435
+ this.base = base;
436
+ this.viewerId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `viewer_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
437
+ this.access = options.access;
438
+ this.onObjectUnavailable = options.onObjectUnavailable;
439
+ }
440
+ /** True when this client is bound to a buyer access session. */
441
+ get accessScoped() {
442
+ return !!this.access?.configured;
443
+ }
444
+ async request(path, init = {}, retried = {}) {
445
+ const method = init.method ?? "GET";
446
+ const headers = {};
447
+ let body;
448
+ if (init.body !== void 0) {
449
+ headers["Content-Type"] = "application/json";
450
+ body = JSON.stringify(init.body);
451
+ }
452
+ const authorization = await this.access?.authorization(retried.auth ? "unauthorized" : "initial");
453
+ if (authorization) headers.Authorization = authorization;
454
+ const res = await fetch(`${this.base}${path}`, { method, headers, body, credentials: "omit" });
455
+ const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
456
+ const data = isJson ? await res.json().catch(() => null) : null;
457
+ if (!res.ok) {
458
+ const err = data;
459
+ const code = err?.code ?? err?.error;
460
+ if (this.access?.configured && (res.status === 401 || res.status === 403 || res.status === 422)) {
461
+ const refreshed = await this.access.handleFailure(res.status, code);
462
+ if (refreshed && !retried.auth) return this.request(path, init, { ...retried, auth: true });
463
+ }
464
+ if (res.status === 409) {
465
+ const reason = code ? OBJECT_UNAVAILABLE_CODES[code] : void 0;
466
+ const labels = err?.conflicts?.map((c) => c.label) ?? init.labels ?? [];
467
+ if (reason) this.onObjectUnavailable?.({ labels, reason, code });
468
+ }
469
+ let retryAfterS;
470
+ if (res.status === 429) {
471
+ retryAfterS = parseRetryAfter(res.headers.get("Retry-After"), err?.retryAfterSeconds) ?? DEFAULT_RATE_LIMIT_WAIT_S;
472
+ if (method === "GET" && !retried.rateLimit && retryAfterS <= MAX_RATE_LIMIT_WAIT_S) {
473
+ await new Promise((resolve) => setTimeout(resolve, retryAfterS * 1e3));
474
+ return this.request(path, init, { ...retried, rateLimit: true });
475
+ }
476
+ }
477
+ throw new ApiError(
478
+ res.status,
479
+ err?.error ?? `request_failed_${res.status}`,
480
+ code,
481
+ err?.conflicts,
482
+ err?.reason,
483
+ retryAfterS
484
+ );
452
485
  }
486
+ return data;
453
487
  }
454
- return { default: fallback, exceptions };
455
- }
456
- function diffProjections(prev, next) {
457
- if (!prev || prev.default !== next.default) return null;
458
- const changes = [];
459
- for (const [label, status] of Object.entries(next.exceptions)) {
460
- if (prev.exceptions[label] !== status) changes.push({ label, status });
488
+ chart(key) {
489
+ return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
461
490
  }
462
- for (const label of Object.keys(prev.exceptions)) {
463
- if (!(label in next.exceptions)) changes.push({ label, status: next.default });
491
+ objects(key) {
492
+ return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
464
493
  }
465
- return changes;
466
- }
467
- function applyChanges(projection, changes) {
468
- for (const change of changes) {
469
- if (change.status === projection.default) delete projection.exceptions[change.label];
470
- else projection.exceptions[change.label] = change.status;
494
+ hold(key, selections, ttlMs, replaceHoldId) {
495
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold`, {
496
+ method: "POST",
497
+ body: { selections, ...ttlMs ? { ttlMs } : {}, ...replaceHoldId ? { replaceHoldId } : {} },
498
+ labels: selections.map((s) => s.label)
499
+ });
471
500
  }
472
- }
473
- function assertCredentialFreeUrl(url) {
474
- if (/(?:^|[?&#])(?:token|access_token|bearer|authorization|ticket|tkt|bse)=/i.test(url)) {
475
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
501
+ // `zoneId` scopes the pick to one zone and `ttlMs` carries the host's checkout
502
+ // window — both are part of the route contract, and dropping either here made
503
+ // the SDK quietly pick venue-wide and hold for the server default instead.
504
+ bestAvailable(key, qty, categoryKey, zoneId, ttlMs) {
505
+ return this.request(`/pub/events/${encodeURIComponent(key)}/best-available`, {
506
+ method: "POST",
507
+ body: { qty, ...categoryKey ? { categoryKey } : {}, ...zoneId ? { zoneId } : {}, ...ttlMs ? { ttlMs } : {} }
508
+ });
476
509
  }
477
- if (/\bbse_[A-Za-z0-9._-]+/.test(url)) {
478
- throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
510
+ resume(key, holdId) {
511
+ return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
512
+ method: "POST",
513
+ body: { holdId }
514
+ });
479
515
  }
480
- }
481
- var BuyerRealtimeClient = class {
482
- constructor(options) {
483
- this.ws = null;
484
- this.stopped = true;
485
- this.attempt = 0;
486
- this.reconnectTimer = null;
487
- this.pingTimer = null;
488
- this.pongTimer = null;
489
- this.resumeTimer = null;
490
- /** Our model of this scope's projection. Null until the first snapshot. */
491
- this.projection = null;
492
- /** Last `snapshotVersion` seen on any frame that carried one — the resume point. */
493
- this.version = null;
494
- /** True once the 101 echoed `seatlayer.v1`. */
495
- this.v1 = false;
496
- /** Set when we offered v1 and the handshake came back without it — a proxy
497
- * most likely stripped the header, so the next attempt selects the v1 frame
498
- * format with the `?pv=1` marker instead (protocol doc §1). The marker
499
- * selects a format and can never carry a credential or widen a scope. */
500
- this.useQueryMarker = false;
501
- this.hidden = null;
502
- this.closedSections = null;
503
- this.opts = options;
504
- assertCredentialFreeUrl(options.url);
516
+ release(key, labels, holdId) {
517
+ return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
518
+ method: "POST",
519
+ body: { labels, holdId }
520
+ });
505
521
  }
506
- /** Negotiated protocol, for tests and diagnostics. */
507
- get protocol() {
508
- return this.ws ? this.v1 ? "v1" : "legacy" : null;
522
+ /** P4 "need more time?": push an active hold's expiry out. Throws ApiError 409
523
+ * (reason: expired | extend_limit | not_found | not_active) if it can't. */
524
+ extend(key, holdId, ttlMs) {
525
+ return this.request(`/pub/events/${encodeURIComponent(key)}/extend`, {
526
+ method: "POST",
527
+ body: { holdId, ...ttlMs ? { ttlMs } : {} }
528
+ });
529
+ }
530
+ /**
531
+ * Which gateways this event can actually take money through — the question
532
+ * `checkout: 'hosted'` has to answer BEFORE it shows a buyer a Pay button, so
533
+ * the answer is never discovered by failing a payment.
534
+ *
535
+ * Anonymous, and it discloses no account, key, mode or currency for a gateway
536
+ * that did not match.
537
+ */
538
+ paymentOptions(key) {
539
+ return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
540
+ }
541
+ /**
542
+ * Turn a live hold into an order and start a payment.
543
+ *
544
+ * The amount is NOT sent: the server recomputes it from the hold's own items,
545
+ * which is the only reason a browser cannot alter what it pays. Nor is the
546
+ * PROVIDER — the event row decides which gateway charges, and a `provider` in
547
+ * the body is checked rather than obeyed (409 `provider_mismatch`). Omitting
548
+ * it is the shape that cannot disagree.
549
+ */
550
+ startCheckout(key, input) {
551
+ return this.request(`/pub/events/${encodeURIComponent(key)}/checkout`, {
552
+ method: "POST",
553
+ body: input
554
+ });
555
+ }
556
+ /**
557
+ * Poll an order while its gateway webhook lands. The order id is an
558
+ * unguessable token the buyer already holds, so it acts as the capability —
559
+ * which is also why a buyer returning from a gateway page can be told what
560
+ * happened with nothing but the id in the return URL.
561
+ */
562
+ orderStatus(orderId) {
563
+ return this.request(`/pub/orders/${encodeURIComponent(orderId)}/status`);
564
+ }
565
+ /**
566
+ * Mint a one-use subscribe ticket for the next socket attempt (protocol doc
567
+ * §3). The bearer travels here, over ordinary HTTPS where CORS and Origin
568
+ * already apply; the socket then carries only the short-lived ticket, in its
569
+ * subprotocol list. TTL ≤ 30s, single redemption — mint one per attempt.
570
+ */
571
+ subscribeTicket(key) {
572
+ return this.request(`/pub/events/${encodeURIComponent(key)}/subscribe-tickets`, {
573
+ method: "POST",
574
+ body: {}
575
+ });
576
+ }
577
+ /**
578
+ * The subscribe URL. Never carries a credential — not the bearer, not the
579
+ * ticket. Query parameters are diagnostics only.
580
+ */
581
+ subscribeUrl(key) {
582
+ const wsBase = this.base.replace(/^http/, "ws");
583
+ const params = new URLSearchParams({ surface: "picker", viewerId: this.viewerId });
584
+ return `${wsBase}/pub/events/${encodeURIComponent(key)}/subscribe?${params}`;
585
+ }
586
+ /**
587
+ * What PickerController opens its own socket with.
588
+ *
589
+ * Empty for an access-scoped client: a private scope authenticates with a
590
+ * subprotocol ticket, which a URL-only constructor cannot carry, so the SDK's
591
+ * BuyerRealtimeClient owns that socket instead and the controller skips its
592
+ * own (an empty URL is its documented "no live feed" contract). A tokenless
593
+ * public client returns exactly the URL it always has, so nothing about the
594
+ * public picker's realtime path changes.
595
+ */
596
+ socketUrl(key) {
597
+ return this.accessScoped ? "" : this.subscribeUrl(key);
509
598
  }
510
- get snapshotVersion() {
511
- return this.version;
599
+ /**
600
+ * The subprotocol list a PLAIN `new WebSocket(url, protocols)` must offer for
601
+ * this transport — `PickerTransport.socketProtocols`, which PickerController
602
+ * calls optionally and which nothing implemented until now.
603
+ *
604
+ * Offering `seatlayer.v1` is the whole point: without it the DO answers an
605
+ * anonymous socket with the LEGACY verbose frame — every unit of a 10k-seat
606
+ * event, on connect and on every reconnect — instead of the compact
607
+ * `{default, exceptions}` form. Empty for an access-scoped client, which
608
+ * authenticates with a one-use ticket a URL-only constructor cannot carry and
609
+ * whose socket BuyerRealtimeClient owns instead (see `socketUrl`).
610
+ *
611
+ * `createRealtime` below is the preferred path and supersedes this for any
612
+ * host that can use it; this stays the correct answer for a host that builds
613
+ * the socket itself from the transport contract.
614
+ */
615
+ socketProtocols(key) {
616
+ void key;
617
+ return this.accessScoped ? [] : [SEATLAYER_V1];
512
618
  }
513
- start() {
514
- if (!this.stopped) return;
515
- this.stopped = false;
516
- void this.connect();
619
+ /**
620
+ * Hand PickerController the v1 realtime client instead of letting it open a
621
+ * bare socket — `PickerTransport.createRealtime`.
622
+ *
623
+ * This is what puts an ANONYMOUS buyer (the on-sale case) on the same wire as
624
+ * a private-channel one: compact snapshots, `sv.<n>` resume so a reconnect
625
+ * inside the ring costs a delta rather than a full re-snapshot, ping/pong
626
+ * liveness, and one jittered backoff implementation shared by both. The
627
+ * anonymous case simply passes no `mintTicket` — the `/pub/events/:key/
628
+ * subscribe` upgrade requires no ticket, and the DO resolves a ticketless
629
+ * socket to the public scope.
630
+ *
631
+ * Null when access-scoped: that socket is owned by the widget's own
632
+ * BuyerRealtimeClient (with the ticket exchange), and `socketUrl()` already
633
+ * returns '' so the controller opens nothing.
634
+ */
635
+ createRealtime(key, sink) {
636
+ if (this.accessScoped) return null;
637
+ return new BuyerRealtimeClient({ url: this.subscribeUrl(key), sink });
517
638
  }
518
- /** Stop for good (destroy, or a revocation). Safe to call twice. */
519
- stop() {
520
- this.stopped = true;
521
- this.clearTimers();
522
- const ws = this.ws;
523
- this.ws = null;
524
- if (ws) {
525
- ws.onopen = null;
526
- ws.onmessage = null;
527
- ws.onclose = null;
528
- ws.onerror = null;
529
- try {
530
- ws.close();
531
- } catch {
532
- }
533
- }
639
+ };
640
+
641
+ // src/buyerAccess.ts
642
+ var BuyerAccessUnavailableError = class extends Error {
643
+ constructor(event) {
644
+ super(`buyer_access_unavailable:${event.reason}`);
645
+ this.name = "BuyerAccessUnavailableError";
646
+ this.reason = event.reason;
647
+ this.code = event.code;
648
+ this.status = event.status;
534
649
  }
535
- /** Restart after the host re-authorized a revoked buyer (`refreshAccess()`). */
536
- restart() {
537
- this.stop();
538
- this.projection = null;
539
- this.version = null;
540
- this.attempt = 0;
541
- this.start();
650
+ };
651
+ var EXPIRED_CODES = /* @__PURE__ */ new Set(["buyer_access_expired"]);
652
+ var RECOVERABLE = /* @__PURE__ */ new Set([
653
+ "paused",
654
+ "provider_failed",
655
+ "channel_denied"
656
+ ]);
657
+ function classifyAccessFailure(status, code) {
658
+ switch (code) {
659
+ case "buyer_access_invalid":
660
+ return "invalid";
661
+ case "buyer_access_revoked":
662
+ return "revoked";
663
+ case "buyer_access_origin_mismatch":
664
+ return "origin_mismatch";
665
+ case "buyer_access_event_mismatch":
666
+ return "event_mismatch";
667
+ case "buyer_access_mode_mismatch":
668
+ return "mode_mismatch";
669
+ case "channel_access_denied":
670
+ return "channel_denied";
671
+ case "channel_paused":
672
+ return "paused";
673
+ case "invalid_channel_scope":
674
+ return "invalid_scope";
675
+ default:
676
+ break;
542
677
  }
543
- // ---- connection -----------------------------------------------------------
544
- async connect() {
545
- if (this.stopped) return;
546
- let protocols = [SEATLAYER_V1];
547
- if (this.opts.mintTicket) {
548
- let minted;
549
- try {
550
- minted = await this.opts.mintTicket();
551
- } catch (err) {
552
- this.reportIfAccessError(err);
553
- this.scheduleReconnect();
554
- return;
555
- }
556
- if (this.stopped) return;
557
- if (minted?.protocols?.length) {
558
- protocols = [...minted.protocols];
559
- if (!protocols.includes(SEATLAYER_V1)) protocols.unshift(SEATLAYER_V1);
560
- } else if (minted?.ticket) {
561
- protocols = [SEATLAYER_V1, `tkt.${minted.ticket}`];
562
- }
563
- }
564
- const offeredResume = this.version !== null;
565
- if (offeredResume) protocols.push(`sv.${this.version}`);
566
- const url = this.useQueryMarker ? `${this.opts.url}${this.opts.url.includes("?") ? "&" : "?"}pv=1` : this.opts.url;
567
- assertCredentialFreeUrl(url);
568
- let ws;
569
- try {
570
- const make = this.opts.socketFactory ?? ((u, p) => new WebSocket(u, p));
571
- ws = make(url, protocols);
572
- } catch {
573
- this.scheduleReconnect();
574
- return;
678
+ if (status === 401) return "invalid";
679
+ return null;
680
+ }
681
+ function isAccessExpiry(status, code) {
682
+ return status === 401 && !!code && EXPIRED_CODES.has(code);
683
+ }
684
+ var DEFAULT_SKEW_MS = 3e4;
685
+ var _token, _expiresAt, _provider, _skewMs, _inflight, _terminal, _lastFailure, _onExpired, _onUnavailable, _configured, _BuyerAccessContext_instances, accept_fn, renew_fn, fail_fn;
686
+ var BuyerAccessContext = class {
687
+ constructor(options) {
688
+ __privateAdd(this, _BuyerAccessContext_instances);
689
+ /** Private field: not enumerable, not spreadable, not serializable. */
690
+ __privateAdd(this, _token, null);
691
+ __privateAdd(this, _expiresAt, 0);
692
+ __privateAdd(this, _provider);
693
+ __privateAdd(this, _skewMs);
694
+ __privateAdd(this, _inflight, null);
695
+ __privateAdd(this, _terminal, null);
696
+ /** The most recent failure, terminal or not — so one cause reports once. */
697
+ __privateAdd(this, _lastFailure, null);
698
+ __privateAdd(this, _onExpired);
699
+ __privateAdd(this, _onUnavailable);
700
+ /** Decided once, at construction. See the `configured` getter. */
701
+ __privateAdd(this, _configured, false);
702
+ __privateSet(this, _provider, options.provider);
703
+ __privateSet(this, _skewMs, options.skewMs ?? DEFAULT_SKEW_MS);
704
+ __privateSet(this, _onExpired, options.onExpired);
705
+ __privateSet(this, _onUnavailable, options.onUnavailable);
706
+ if (options.token) {
707
+ const seed = typeof options.token === "string" ? { token: options.token } : options.token;
708
+ __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, seed);
575
709
  }
576
- this.ws = ws;
577
- ws.onopen = () => {
578
- if (this.ws !== ws) return;
579
- this.attempt = 0;
580
- this.v1 = ws.protocol === SEATLAYER_V1;
581
- if (!this.v1) this.useQueryMarker = true;
582
- this.startKeepalive(ws);
583
- if (offeredResume) {
584
- this.resumeTimer = setTimeout(() => {
585
- this.resumeTimer = null;
586
- void this.opts.sink.resync();
587
- }, RESUME_ANSWER_GRACE_MS);
588
- } else {
589
- void this.opts.sink.resync();
590
- }
591
- };
592
- ws.onmessage = (event) => {
593
- if (this.ws !== ws) return;
594
- let parsed;
595
- try {
596
- parsed = JSON.parse(typeof event.data === "string" ? event.data : "");
597
- } catch {
598
- return;
599
- }
600
- if (!parsed || typeof parsed !== "object") return;
601
- this.handleFrame(parsed);
602
- };
603
- ws.onclose = (event) => {
604
- if (this.ws !== ws) return;
605
- this.ws = null;
606
- this.clearTimers();
607
- if (event?.code === CLOSE_ACCESS_REVOKED) {
608
- this.stopped = true;
609
- this.opts.onAccessUnavailable?.({
610
- reason: "revoked",
611
- code: "access_revoked",
612
- retryable: false
613
- });
614
- return;
615
- }
616
- this.scheduleReconnect();
617
- };
618
- ws.onerror = () => {
619
- try {
620
- ws.close();
621
- } catch {
622
- }
623
- };
710
+ __privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
624
711
  }
625
- handleFrame(frame) {
626
- const type = typeof frame.type === "string" ? frame.type : "";
627
- if (frame.protocol === 1) this.v1 = true;
628
- if (typeof frame.snapshotVersion === "number") this.version = frame.snapshotVersion;
629
- if (type === "pong") {
630
- this.clearPongTimer();
631
- return;
632
- }
633
- if (Array.isArray(frame.hidden) || Array.isArray(frame.closed)) {
634
- const hidden = Array.isArray(frame.hidden) ? frame.hidden : [];
635
- const closed = Array.isArray(frame.closed) ? frame.closed : [];
636
- const hKey = hidden.join(SEP);
637
- const cKey = closed.join(SEP);
638
- if (hKey !== this.hidden || cKey !== this.closedSections) {
639
- this.hidden = hKey;
640
- this.closedSections = cKey;
641
- this.opts.sink.onSections?.(hidden, closed);
642
- }
643
- }
644
- if (type === "hidden") return;
645
- if (type === "presence") {
646
- this.opts.sink.onPresence?.({
647
- shoppingSessions: Number(frame.shoppingSessions) || 0,
648
- activeHolds: Number(frame.activeHolds) || 0
649
- });
650
- return;
651
- }
652
- if (type === "allocation") {
653
- return;
654
- }
655
- if (type === "snapshot" || !type && frame.seats) {
656
- this.answered();
657
- const next = projectionFromSnapshot(frame);
658
- const changes = diffProjections(this.projection, next);
659
- this.projection = next;
660
- if (changes === null) void this.opts.sink.resync();
661
- else if (changes.length) this.opts.sink.applyStatuses(changes);
662
- return;
663
- }
664
- if (type === "delta" && Array.isArray(frame.changes)) {
665
- this.answered();
666
- const changes = frame.changes.filter((c) => typeof c?.label === "string" && typeof c?.status === "string").map((c) => ({ label: c.label, status: c.status }));
667
- if (!changes.length) return;
668
- if (this.projection) applyChanges(this.projection, changes);
669
- this.opts.sink.applyStatuses(changes);
670
- }
712
+ /**
713
+ * True when this picker is access-scoped at all. A false here is the
714
+ * tokenless public picker, which must behave exactly as it always has.
715
+ *
716
+ * Answered from what the HOST asked for, never from live token state. It used
717
+ * to be `!!#provider || !!#token`, which quietly inverted this file's central
718
+ * rule for a one-shot `buyerAccessToken` host: `#fail()` clears `#token`, so
719
+ * the first refusal turned a configured context into an "unconfigured" one,
720
+ * `authorization()` then returned null instead of throwing, and the very next
721
+ * call went out with no bearer — the anonymous Public sale fallback this
722
+ * module exists to prevent. A provider host never saw it, because `#provider`
723
+ * held `configured` true. Found against a live worker in the M9 pass.
724
+ */
725
+ get configured() {
726
+ return __privateGet(this, _configured);
671
727
  }
672
- /** The server answered our resume; cancel the fallback resync. */
673
- answered() {
674
- if (!this.resumeTimer) return;
675
- clearTimeout(this.resumeTimer);
676
- this.resumeTimer = null;
728
+ /** Set once a state arrives that refreshing cannot clear. */
729
+ get unavailable() {
730
+ return __privateGet(this, _terminal);
677
731
  }
678
- reportIfAccessError(err) {
679
- const reason = err?.reason;
680
- if (err?.name !== "BuyerAccessUnavailableError") return;
681
- this.stopped = true;
682
- this.opts.onAccessUnavailable?.({
683
- reason: reason ?? "invalid",
684
- code: err.code,
685
- status: err.status,
686
- retryable: reason === "paused"
687
- });
732
+ /** True while a usable bearer is held (ignores skew). */
733
+ get hasToken() {
734
+ return !!__privateGet(this, _token) && (__privateGet(this, _expiresAt) === 0 || __privateGet(this, _expiresAt) > Date.now());
735
+ }
736
+ /** Epoch ms the current token expires, or 0 when the host didn't say. */
737
+ get expiresAt() {
738
+ return __privateGet(this, _expiresAt);
688
739
  }
689
- // ---- keepalive & backoff --------------------------------------------------
690
740
  /**
691
- * Liveness is ping/pong, and only ping/pong. A socket that receives nothing
692
- * for minutes is the normal, correct state for a narrowly-scoped buyer on a
693
- * busy event (protocol doc §5), so quiet time never triggers a reconnect.
741
+ * The `Authorization` header value for a scoped operation.
742
+ *
743
+ * Returns null only when this context is not configured at all (the ordinary
744
+ * anonymous public picker). A configured context either returns a bearer or
745
+ * throws `BuyerAccessUnavailableError` — it never returns null, because a
746
+ * null here would send the request as anonymous Public sale.
694
747
  */
695
- startKeepalive(ws) {
696
- this.pingTimer = setInterval(() => {
697
- if (this.ws !== ws) return;
698
- try {
699
- ws.send(JSON.stringify({ type: "ping" }));
700
- } catch {
701
- return;
748
+ async authorization(reason = "initial") {
749
+ if (!this.configured) return null;
750
+ if (__privateGet(this, _terminal)) throw new BuyerAccessUnavailableError(__privateGet(this, _terminal));
751
+ const now = Date.now();
752
+ const stale = !__privateGet(this, _token) || __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) - __privateGet(this, _skewMs) <= now;
753
+ if (stale) {
754
+ const expired = !!__privateGet(this, _token) && __privateGet(this, _expiresAt) > 0 && __privateGet(this, _expiresAt) <= now;
755
+ const why = __privateGet(this, _token) ? expired ? "expired" : "expiring" : reason;
756
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, why);
757
+ if (!token) {
758
+ throw new BuyerAccessUnavailableError(
759
+ __privateGet(this, _terminal) ?? __privateGet(this, _lastFailure) ?? __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed")
760
+ );
702
761
  }
703
- this.clearPongTimer();
704
- this.pongTimer = setTimeout(() => {
705
- this.pongTimer = null;
706
- try {
707
- ws.close();
708
- } catch {
709
- }
710
- }, PONG_GRACE_MS);
711
- }, PING_INTERVAL_MS);
762
+ return `Bearer ${token}`;
763
+ }
764
+ return `Bearer ${__privateGet(this, _token)}`;
712
765
  }
713
- scheduleReconnect() {
714
- if (this.stopped || this.reconnectTimer) return;
715
- const attempt = Math.min(this.attempt++, 5);
716
- const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
717
- this.reconnectTimer = setTimeout(() => {
718
- this.reconnectTimer = null;
719
- void this.connect();
720
- }, delay);
766
+ /**
767
+ * Handle a 401/403 from a scoped call. Returns true when the caller should
768
+ * retry the same request once with the refreshed bearer.
769
+ */
770
+ async handleFailure(status, code) {
771
+ var _a;
772
+ if (!this.configured) return false;
773
+ if (isAccessExpiry(status, code)) {
774
+ __privateSet(this, _token, null);
775
+ __privateSet(this, _expiresAt, 0);
776
+ const token = await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, "unauthorized", code);
777
+ (_a = __privateGet(this, _onExpired)) == null ? void 0 : _a.call(this, { reason: "unauthorized", code, refreshed: !!token });
778
+ return !!token;
779
+ }
780
+ const reason = classifyAccessFailure(status, code);
781
+ if (reason) {
782
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, reason, code, status);
783
+ return false;
784
+ }
785
+ return false;
721
786
  }
722
- clearPongTimer() {
723
- if (!this.pongTimer) return;
724
- clearTimeout(this.pongTimer);
725
- this.pongTimer = null;
787
+ /** Host-driven re-acquisition (after the buyer signs in again, say). */
788
+ async refresh(reason = "manual") {
789
+ __privateSet(this, _terminal, null);
790
+ __privateSet(this, _lastFailure, null);
791
+ __privateSet(this, _token, null);
792
+ __privateSet(this, _expiresAt, 0);
793
+ return !!await __privateMethod(this, _BuyerAccessContext_instances, renew_fn).call(this, reason);
726
794
  }
727
- clearTimers() {
728
- if (this.pingTimer) clearInterval(this.pingTimer);
729
- this.pingTimer = null;
730
- this.clearPongTimer();
731
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
732
- this.reconnectTimer = null;
733
- if (this.resumeTimer) clearTimeout(this.resumeTimer);
734
- this.resumeTimer = null;
795
+ /** Drop the bearer. Called on destroy so nothing outlives the widget. */
796
+ clear() {
797
+ __privateSet(this, _token, null);
798
+ __privateSet(this, _expiresAt, 0);
799
+ __privateSet(this, _inflight, null);
800
+ }
801
+ /** Redaction: the bearer must not survive a stringify or an interpolation. */
802
+ toJSON() {
803
+ return { configured: this.configured, hasToken: this.hasToken };
804
+ }
805
+ toString() {
806
+ return "[BuyerAccessContext redacted]";
735
807
  }
736
808
  };
737
- function rendererStatus(wire) {
738
- if (wire === "blocked") return "not_for_sale";
739
- if (wire === "held" || wire === "booked" || wire === "free" || wire === "not_for_sale") return wire;
740
- return "free";
741
- }
742
- function createControllerSink(controller, options = {}) {
743
- const idsForLabel = (label) => {
744
- const table = controller.tableSelection(label);
745
- if (table) return table.physicalSeatIds;
746
- const id = controller.idForLabel(label);
747
- return id ? [id] : [];
748
- };
749
- return {
750
- applyStatuses(changes) {
751
- const held = controller.currentHold()?.labels ?? [];
752
- const buckets = {
753
- free: [],
754
- held: [],
755
- booked: [],
756
- not_for_sale: []
757
- };
758
- const flashes = [];
759
- const lost = [];
760
- const selected = new Map(controller.getSelection().map((s) => [s.label, s.id]));
761
- for (const change of changes) {
762
- const ids = idsForLabel(change.label);
763
- if (!ids.length) continue;
764
- const next = rendererStatus(change.status);
765
- buckets[next].push(...ids);
766
- if (options.flashOnLiveChange && next !== "free" && !held.includes(change.label) && ids.some((id) => controller.getStatus(id) === "free")) {
767
- const color = next === "held" ? "#f4b740" : "#f43f5e";
768
- for (const id of ids) flashes.push({ id, color });
769
- }
770
- if (next !== "free" && !held.includes(change.label) && selected.has(change.label)) {
771
- lost.push(change.label);
772
- }
773
- }
774
- for (const status of ["free", "held", "booked", "not_for_sale"]) {
775
- if (buckets[status].length) controller.setStatus(buckets[status], status);
776
- }
777
- for (const flash of flashes) controller.flashSeat(flash.id, flash.color);
778
- if (lost.length) {
779
- const ids = lost.flatMap((label) => idsForLabel(label));
780
- if (ids.length) controller.deselect(ids);
781
- const ineligible = changes.some(
782
- (c) => c.status === "blocked" && lost.includes(c.label)
783
- );
784
- options.onSelectedObjectUnavailable?.(lost, ineligible ? "ineligible" : "taken");
809
+ _token = new WeakMap();
810
+ _expiresAt = new WeakMap();
811
+ _provider = new WeakMap();
812
+ _skewMs = new WeakMap();
813
+ _inflight = new WeakMap();
814
+ _terminal = new WeakMap();
815
+ _lastFailure = new WeakMap();
816
+ _onExpired = new WeakMap();
817
+ _onUnavailable = new WeakMap();
818
+ _configured = new WeakMap();
819
+ _BuyerAccessContext_instances = new WeakSet();
820
+ // ---- internals ------------------------------------------------------------
821
+ accept_fn = function(next) {
822
+ if (!next || typeof next.token !== "string" || !next.token) return null;
823
+ __privateSet(this, _token, next.token);
824
+ __privateSet(this, _expiresAt, typeof next.expiresAt === "number" ? next.expiresAt : 0);
825
+ return __privateGet(this, _token);
826
+ };
827
+ /**
828
+ * One provider call at a time. Several operations racing an expiry (chart +
829
+ * objects + a socket ticket) must not mint several sessions — the guide's
830
+ * rotate-on-retry rule would revoke the ones they didn't observe.
831
+ */
832
+ renew_fn = function(reason, code) {
833
+ if (__privateGet(this, _inflight)) return __privateGet(this, _inflight);
834
+ const provider = __privateGet(this, _provider);
835
+ if (!provider) {
836
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "no_token", code);
837
+ return Promise.resolve(null);
838
+ }
839
+ const run = (async () => {
840
+ try {
841
+ const next = await provider({ reason });
842
+ const token = __privateMethod(this, _BuyerAccessContext_instances, accept_fn).call(this, next);
843
+ if (!token) {
844
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
845
+ return null;
785
846
  }
786
- options.onStatusChange?.();
787
- },
788
- async resync() {
789
- await controller.refresh();
790
- },
791
- /**
792
- * Section availability moved. Statuses are re-pulled so the map repaints.
793
- *
794
- * Known limit: rebuilding the chart when a section is newly HIDDEN (its
795
- * seats are stripped, not greyed) lives inside PickerController's own
796
- * socket handler and has no public entry point, so an access-scoped picker
797
- * repaints statuses but does not restructure the chart until its next
798
- * mount. Closing/opening a section — the common mid-sale move — is a
799
- * status-level change and is handled here in full.
800
- */
801
- onSections(hidden, closed) {
802
- void controller.refresh();
803
- options.onSections?.(hidden, closed);
847
+ return token;
848
+ } catch {
849
+ __privateMethod(this, _BuyerAccessContext_instances, fail_fn).call(this, "provider_failed", code);
850
+ return null;
851
+ } finally {
852
+ __privateSet(this, _inflight, null);
804
853
  }
854
+ })();
855
+ __privateSet(this, _inflight, run);
856
+ return run;
857
+ };
858
+ fail_fn = function(reason, code, status) {
859
+ var _a;
860
+ const event = {
861
+ reason,
862
+ code,
863
+ status,
864
+ // Unchanged: `retryable` means "the SAME request may succeed later".
865
+ // `provider_failed` is recoverable but not retryable — the host must fix
866
+ // its mint endpoint first — so the two sets are deliberately different.
867
+ retryable: reason === "paused" || reason === "channel_denied"
805
868
  };
869
+ __privateSet(this, _lastFailure, event);
870
+ if (!RECOVERABLE.has(reason)) {
871
+ __privateSet(this, _terminal, event);
872
+ __privateSet(this, _token, null);
873
+ __privateSet(this, _expiresAt, 0);
874
+ }
875
+ (_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
876
+ return event;
877
+ };
878
+ function createBuyerAccessContext(options, hooks = {}) {
879
+ if (!options.buyerAccessTokenProvider && !options.buyerAccessToken) return null;
880
+ return new BuyerAccessContext({
881
+ provider: options.buyerAccessTokenProvider,
882
+ token: options.buyerAccessToken,
883
+ ...hooks
884
+ });
806
885
  }
807
886
 
808
887
  // src/seatLayerBrand.ts