@seatlayer/js 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1027 -784
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +268 -160
- package/dist/index.d.ts +268 -160
- package/dist/index.js +1029 -784
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -13,796 +13,875 @@ import {
|
|
|
13
13
|
t
|
|
14
14
|
} from "@seatlayer/core";
|
|
15
15
|
|
|
16
|
-
// src/
|
|
17
|
-
var
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|
|
141
|
+
}
|
|
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
|
+
};
|
|
200
202
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
}
|
|
228
249
|
}
|
|
229
|
-
|
|
230
|
-
|
|
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;
|
|
255
|
+
}
|
|
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
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ---- keepalive & backoff --------------------------------------------------
|
|
268
|
+
/**
|
|
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.
|
|
272
|
+
*/
|
|
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;
|
|
280
|
+
}
|
|
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);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
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.
|
|
303
|
+
*/
|
|
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);
|
|
313
|
+
}
|
|
314
|
+
clearPongTimer() {
|
|
315
|
+
if (!this.pongTimer) return;
|
|
316
|
+
clearTimeout(this.pongTimer);
|
|
317
|
+
this.pongTimer = null;
|
|
318
|
+
}
|
|
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;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
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";
|
|
231
333
|
}
|
|
232
|
-
function
|
|
233
|
-
|
|
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
|
+
}
|
|
365
|
+
}
|
|
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);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
234
398
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
var
|
|
238
|
-
constructor(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
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;
|
|
426
|
+
}
|
|
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);
|
|
260
451
|
}
|
|
261
|
-
|
|
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
|
+
);
|
|
485
|
+
}
|
|
486
|
+
return data;
|
|
487
|
+
}
|
|
488
|
+
chart(key) {
|
|
489
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/chart`);
|
|
490
|
+
}
|
|
491
|
+
objects(key) {
|
|
492
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/objects`);
|
|
493
|
+
}
|
|
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
|
+
});
|
|
500
|
+
}
|
|
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
|
+
});
|
|
509
|
+
}
|
|
510
|
+
resume(key, holdId) {
|
|
511
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/hold/resume`, {
|
|
512
|
+
method: "POST",
|
|
513
|
+
body: { holdId }
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
release(key, labels, holdId) {
|
|
517
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/release`, {
|
|
518
|
+
method: "POST",
|
|
519
|
+
body: { labels, holdId }
|
|
520
|
+
});
|
|
521
|
+
}
|
|
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
|
+
});
|
|
262
529
|
}
|
|
263
530
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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.
|
|
266
534
|
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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.
|
|
535
|
+
* Anonymous, and it discloses no account, key, mode or currency for a gateway
|
|
536
|
+
* that did not match.
|
|
275
537
|
*/
|
|
276
|
-
|
|
277
|
-
return
|
|
538
|
+
paymentOptions(key) {
|
|
539
|
+
return this.request(`/pub/events/${encodeURIComponent(key)}/payment-options`);
|
|
278
540
|
}
|
|
279
|
-
/**
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
});
|
|
282
555
|
}
|
|
283
|
-
/**
|
|
284
|
-
|
|
285
|
-
|
|
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`);
|
|
286
564
|
}
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
|
|
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);
|
|
290
598
|
}
|
|
291
599
|
/**
|
|
292
|
-
* The `
|
|
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.
|
|
293
603
|
*
|
|
294
|
-
*
|
|
295
|
-
* anonymous
|
|
296
|
-
*
|
|
297
|
-
*
|
|
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.
|
|
298
614
|
*/
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
);
|
|
312
|
-
}
|
|
313
|
-
return `Bearer ${token}`;
|
|
314
|
-
}
|
|
315
|
-
return `Bearer ${__privateGet(this, _token)}`;
|
|
615
|
+
socketProtocols(key) {
|
|
616
|
+
void key;
|
|
617
|
+
return this.accessScoped ? [] : [SEATLAYER_V1];
|
|
316
618
|
}
|
|
317
619
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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.
|
|
320
634
|
*/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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);
|
|
351
|
-
}
|
|
352
|
-
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
353
|
-
toJSON() {
|
|
354
|
-
return { configured: this.configured, hasToken: this.hasToken };
|
|
355
|
-
}
|
|
356
|
-
toString() {
|
|
357
|
-
return "[BuyerAccessContext redacted]";
|
|
358
|
-
}
|
|
359
|
-
};
|
|
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;
|
|
397
|
-
}
|
|
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);
|
|
404
|
-
}
|
|
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
|
-
};
|
|
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);
|
|
635
|
+
createRealtime(key, sink) {
|
|
636
|
+
if (this.accessScoped) return null;
|
|
637
|
+
return new BuyerRealtimeClient({ url: this.subscribeUrl(key), sink });
|
|
425
638
|
}
|
|
426
|
-
(_a = __privateGet(this, _onUnavailable)) == null ? void 0 : _a.call(this, event);
|
|
427
|
-
return event;
|
|
428
639
|
};
|
|
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
|
-
});
|
|
436
|
-
}
|
|
437
640
|
|
|
438
|
-
// src/
|
|
439
|
-
var
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
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 });
|
|
461
|
-
}
|
|
462
|
-
for (const label of Object.keys(prev.exceptions)) {
|
|
463
|
-
if (!(label in next.exceptions)) changes.push({ label, status: next.default });
|
|
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;
|
|
464
649
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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;
|
|
471
677
|
}
|
|
678
|
+
if (status === 401) return "invalid";
|
|
679
|
+
return null;
|
|
472
680
|
}
|
|
473
|
-
function
|
|
474
|
-
|
|
475
|
-
throw new Error("seatlayer: refusing to open a socket with a credential in the URL");
|
|
476
|
-
}
|
|
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");
|
|
479
|
-
}
|
|
681
|
+
function isAccessExpiry(status, code) {
|
|
682
|
+
return status === 401 && !!code && EXPIRED_CODES.has(code);
|
|
480
683
|
}
|
|
481
|
-
var
|
|
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 {
|
|
482
687
|
constructor(options) {
|
|
483
|
-
this
|
|
484
|
-
|
|
485
|
-
this
|
|
486
|
-
this
|
|
487
|
-
this
|
|
488
|
-
this
|
|
489
|
-
this
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
this
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
this
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
/** Negotiated protocol, for tests and diagnostics. */
|
|
507
|
-
get protocol() {
|
|
508
|
-
return this.ws ? this.v1 ? "v1" : "legacy" : null;
|
|
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);
|
|
709
|
+
}
|
|
710
|
+
__privateSet(this, _configured, !!__privateGet(this, _provider) || !!__privateGet(this, _token));
|
|
509
711
|
}
|
|
510
|
-
|
|
511
|
-
|
|
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);
|
|
512
727
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
this
|
|
516
|
-
void this.connect();
|
|
728
|
+
/** Set once a state arrives that refreshing cannot clear. */
|
|
729
|
+
get unavailable() {
|
|
730
|
+
return __privateGet(this, _terminal);
|
|
517
731
|
}
|
|
518
|
-
/**
|
|
519
|
-
|
|
520
|
-
this
|
|
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
|
-
}
|
|
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());
|
|
534
735
|
}
|
|
535
|
-
/**
|
|
536
|
-
|
|
537
|
-
this
|
|
538
|
-
this.projection = null;
|
|
539
|
-
this.version = null;
|
|
540
|
-
this.attempt = 0;
|
|
541
|
-
this.start();
|
|
736
|
+
/** Epoch ms the current token expires, or 0 when the host didn't say. */
|
|
737
|
+
get expiresAt() {
|
|
738
|
+
return __privateGet(this, _expiresAt);
|
|
542
739
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
740
|
+
/**
|
|
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.
|
|
747
|
+
*/
|
|
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
|
+
);
|
|
562
761
|
}
|
|
762
|
+
return `Bearer ${token}`;
|
|
563
763
|
}
|
|
564
|
-
|
|
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;
|
|
575
|
-
}
|
|
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
|
-
};
|
|
764
|
+
return `Bearer ${__privateGet(this, _token)}`;
|
|
624
765
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
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);
|
|
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;
|
|
670
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;
|
|
671
786
|
}
|
|
672
|
-
/**
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
this
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
});
|
|
688
|
-
}
|
|
689
|
-
// ---- keepalive & backoff --------------------------------------------------
|
|
690
|
-
/**
|
|
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.
|
|
694
|
-
*/
|
|
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;
|
|
702
|
-
}
|
|
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);
|
|
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);
|
|
712
794
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
this
|
|
718
|
-
this.reconnectTimer = null;
|
|
719
|
-
void this.connect();
|
|
720
|
-
}, delay);
|
|
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);
|
|
721
800
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
this.pongTimer = null;
|
|
801
|
+
/** Redaction: the bearer must not survive a stringify or an interpolation. */
|
|
802
|
+
toJSON() {
|
|
803
|
+
return { configured: this.configured, hasToken: this.hasToken };
|
|
726
804
|
}
|
|
727
|
-
|
|
728
|
-
|
|
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;
|
|
805
|
+
toString() {
|
|
806
|
+
return "[BuyerAccessContext redacted]";
|
|
735
807
|
}
|
|
736
808
|
};
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
|
|
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
|
|
@@ -6165,6 +6244,8 @@ import {
|
|
|
6165
6244
|
SeatmapRenderer,
|
|
6166
6245
|
expandChart as expandChart2,
|
|
6167
6246
|
computeSections,
|
|
6247
|
+
gaAreasOf,
|
|
6248
|
+
gaUnitLabels,
|
|
6168
6249
|
UNGROUPED_ID
|
|
6169
6250
|
} from "@seatlayer/core";
|
|
6170
6251
|
|
|
@@ -9160,13 +9241,39 @@ var SeatManager = class {
|
|
|
9160
9241
|
this.labelToId = /* @__PURE__ */ new Map();
|
|
9161
9242
|
this.labelToSeat = /* @__PURE__ */ new Map();
|
|
9162
9243
|
this.allIds = [];
|
|
9244
|
+
/**
|
|
9245
|
+
* GA inventory units — real sellable labels the server counts, with NO seat
|
|
9246
|
+
* geometry and therefore no renderer binding. They live here rather than in
|
|
9247
|
+
* `labelToId`/`allIds` so every paint path keeps addressing paintable nodes
|
|
9248
|
+
* only, while the tally denominator finally covers the same universe the
|
|
9249
|
+
* numerator does. Without them a GA sale hit `booked` but not `total`:
|
|
9250
|
+
* Free under-reported by GA capacity and SOLD% could exceed 100%.
|
|
9251
|
+
*/
|
|
9252
|
+
this.gaUnitLabelSet = /* @__PURE__ */ new Set();
|
|
9163
9253
|
this.status = /* @__PURE__ */ new Map();
|
|
9254
|
+
/** Live non-free counters, moved by each delta rather than re-walked. */
|
|
9255
|
+
this.counts = { held: 0, booked: 0, blocked: 0 };
|
|
9256
|
+
/** Bumped whenever the seat model is replaced wholesale (a full snapshot). */
|
|
9257
|
+
this.modelVersion = 0;
|
|
9164
9258
|
this.currency = "USD";
|
|
9165
9259
|
this.authoritativeGrossRevenue = 0;
|
|
9166
9260
|
this.revenueStatus = "loading";
|
|
9167
9261
|
this.revenueRequest = 0;
|
|
9168
|
-
this.revenueRefreshTimer = null;
|
|
9169
9262
|
this.controlRoomSnapshot = null;
|
|
9263
|
+
/**
|
|
9264
|
+
* The server's own totals, pinned to the client model they were read against.
|
|
9265
|
+
* Display = server baseline + (client now − client then), so the authoritative
|
|
9266
|
+
* numbers land exactly on arrival and deltas still move them between reads.
|
|
9267
|
+
* A wholesale model replacement invalidates the pairing (`model`), and the
|
|
9268
|
+
* client tallies — themselves a fresh authenticated read — take over.
|
|
9269
|
+
*/
|
|
9270
|
+
this.serverBaseline = null;
|
|
9271
|
+
/** Latest presence frame, held whether or not a snapshot has landed yet. */
|
|
9272
|
+
this.livePresence = null;
|
|
9273
|
+
/** Latest cumulative booked gross pushed on a delta frame. */
|
|
9274
|
+
this.liveGross = null;
|
|
9275
|
+
/** Coalesces a burst of deltas into one KPI/rail repaint. */
|
|
9276
|
+
this.paintHandle = null;
|
|
9170
9277
|
this.trendWindowMinutes = 15;
|
|
9171
9278
|
this.heatEnabled = false;
|
|
9172
9279
|
this.lastKpiValues = /* @__PURE__ */ new Map();
|
|
@@ -9264,12 +9371,7 @@ var SeatManager = class {
|
|
|
9264
9371
|
const res = await this.api.chart(this.key);
|
|
9265
9372
|
this.doc = res.doc;
|
|
9266
9373
|
this.currency = res.event.currency ?? this.opts.currency ?? this.currency;
|
|
9267
|
-
|
|
9268
|
-
for (const s of seats) {
|
|
9269
|
-
this.labelToId.set(s.label, s.id);
|
|
9270
|
-
this.labelToSeat.set(s.label, s);
|
|
9271
|
-
this.allIds.push(s.id);
|
|
9272
|
-
}
|
|
9374
|
+
this.buildUnitUniverse(res.doc);
|
|
9273
9375
|
this.buildRenderer();
|
|
9274
9376
|
this.buildSectionOptions();
|
|
9275
9377
|
const [, controlRoom] = await Promise.all([
|
|
@@ -9630,7 +9732,10 @@ var SeatManager = class {
|
|
|
9630
9732
|
if (this.followLiveTimer) clearTimeout(this.followLiveTimer);
|
|
9631
9733
|
if (this.followSeatTimer) clearTimeout(this.followSeatTimer);
|
|
9632
9734
|
if (this.unblockAllConfirmTimer) clearTimeout(this.unblockAllConfirmTimer);
|
|
9633
|
-
if (this.
|
|
9735
|
+
if (this.paintHandle !== null && typeof cancelAnimationFrame === "function") {
|
|
9736
|
+
cancelAnimationFrame(this.paintHandle);
|
|
9737
|
+
}
|
|
9738
|
+
this.paintHandle = null;
|
|
9634
9739
|
this.channels?.destroy();
|
|
9635
9740
|
this.channels = null;
|
|
9636
9741
|
if (this.tokenRefreshTimer) clearTimeout(this.tokenRefreshTimer);
|
|
@@ -9713,6 +9818,37 @@ var SeatManager = class {
|
|
|
9713
9818
|
}
|
|
9714
9819
|
this.syncSelection();
|
|
9715
9820
|
}
|
|
9821
|
+
/**
|
|
9822
|
+
* Build the client's inventory universe from the chart.
|
|
9823
|
+
*
|
|
9824
|
+
* `expandChart` yields SEATS — it has no output for a GA area, whose capacity
|
|
9825
|
+
* is sold as N synthetic unit labels. The server's seat map keys, its deltas
|
|
9826
|
+
* and its `totals` all speak those labels, so a client that only knows seats
|
|
9827
|
+
* counts GA sales in the numerator (every key of the snapshot is written into
|
|
9828
|
+
* `status`) while leaving them out of the denominator. Registering the GA
|
|
9829
|
+
* units here — labels only, never a render binding — is what makes the two
|
|
9830
|
+
* agree.
|
|
9831
|
+
*/
|
|
9832
|
+
buildUnitUniverse(doc) {
|
|
9833
|
+
for (const seat of expandChart2(doc)) {
|
|
9834
|
+
this.labelToId.set(seat.label, seat.id);
|
|
9835
|
+
this.labelToSeat.set(seat.label, seat);
|
|
9836
|
+
this.allIds.push(seat.id);
|
|
9837
|
+
}
|
|
9838
|
+
for (const area of gaAreasOf(doc)) {
|
|
9839
|
+
for (const label of gaUnitLabels(area)) {
|
|
9840
|
+
if (!this.labelToId.has(label)) this.gaUnitLabelSet.add(label);
|
|
9841
|
+
}
|
|
9842
|
+
}
|
|
9843
|
+
}
|
|
9844
|
+
/** Every sellable unit the client knows: seats + GA capacity. */
|
|
9845
|
+
unitTotal() {
|
|
9846
|
+
return this.allIds.length + this.gaUnitLabelSet.size;
|
|
9847
|
+
}
|
|
9848
|
+
/** Every label the client models, whether or not it can be painted. */
|
|
9849
|
+
knownLabels() {
|
|
9850
|
+
return [...this.labelToId.keys(), ...this.gaUnitLabelSet];
|
|
9851
|
+
}
|
|
9716
9852
|
repaintAll() {
|
|
9717
9853
|
const r = this.renderer;
|
|
9718
9854
|
if (!r) return;
|
|
@@ -9762,7 +9898,7 @@ var SeatManager = class {
|
|
|
9762
9898
|
ws.onopen = () => {
|
|
9763
9899
|
this.attempt = 0;
|
|
9764
9900
|
this.setLive(true);
|
|
9765
|
-
void this.resnapshot().then(() => this.
|
|
9901
|
+
void this.resnapshot().then(() => this.refreshControlRoom()).catch((err) => this.opts.onError?.(err));
|
|
9766
9902
|
void this.refreshAvailability();
|
|
9767
9903
|
};
|
|
9768
9904
|
ws.onmessage = (e) => this.onMessage(e);
|
|
@@ -9800,15 +9936,18 @@ var SeatManager = class {
|
|
|
9800
9936
|
this.updateEffectiveAvailability(m.hidden, m.closed);
|
|
9801
9937
|
}
|
|
9802
9938
|
if (m.type === "presence") {
|
|
9803
|
-
if (
|
|
9804
|
-
this.
|
|
9805
|
-
|
|
9806
|
-
|
|
9939
|
+
if (typeof m.shoppingSessions === "number" && typeof m.activeHolds === "number") {
|
|
9940
|
+
this.livePresence = {
|
|
9941
|
+
at: Date.now(),
|
|
9942
|
+
value: { shoppingSessions: m.shoppingSessions, activeHolds: m.activeHolds }
|
|
9807
9943
|
};
|
|
9944
|
+
if (this.controlRoomSnapshot) {
|
|
9945
|
+
this.controlRoomSnapshot = { ...this.controlRoomSnapshot, presence: this.livePresence.value };
|
|
9946
|
+
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
9947
|
+
}
|
|
9808
9948
|
this.lastSyncedAt = Date.now();
|
|
9809
9949
|
this.recomputeTallies();
|
|
9810
9950
|
this.paintMonitorInsights();
|
|
9811
|
-
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
9812
9951
|
}
|
|
9813
9952
|
return;
|
|
9814
9953
|
}
|
|
@@ -9822,7 +9961,7 @@ var SeatManager = class {
|
|
|
9822
9961
|
const st = ["free", "held", "booked", "blocked"].includes(ch.status) ? ch.status : "free";
|
|
9823
9962
|
const prev = this.status.get(ch.label) ?? "free";
|
|
9824
9963
|
if (prev === st) continue;
|
|
9825
|
-
this.
|
|
9964
|
+
this.setStatusLabel(ch.label, st, prev);
|
|
9826
9965
|
const id = this.labelToId.get(ch.label);
|
|
9827
9966
|
if (id) {
|
|
9828
9967
|
this.renderer?.setStatus([id], toRenderStatus(st));
|
|
@@ -9842,10 +9981,38 @@ var SeatManager = class {
|
|
|
9842
9981
|
this.lastSyncedAt = Date.now();
|
|
9843
9982
|
this.afterPaint();
|
|
9844
9983
|
}
|
|
9984
|
+
if (typeof m.revenue?.gross === "number" && Number.isFinite(m.revenue.gross)) {
|
|
9985
|
+
this.applyLiveGross(m.revenue.gross);
|
|
9986
|
+
}
|
|
9845
9987
|
this.recomputeTallies();
|
|
9846
|
-
if (ids.length) this.scheduleRevenueRefresh();
|
|
9847
9988
|
}
|
|
9848
9989
|
}
|
|
9990
|
+
/**
|
|
9991
|
+
* Adopt the cumulative booked gross a delta frame carried.
|
|
9992
|
+
*
|
|
9993
|
+
* Stashed with its arrival time so an in-flight control-room read can decide
|
|
9994
|
+
* whether it is holding the newer number: a frame that landed after the
|
|
9995
|
+
* request started is newer than the response, one that landed before is not.
|
|
9996
|
+
*/
|
|
9997
|
+
applyLiveGross(gross) {
|
|
9998
|
+
this.liveGross = { at: Date.now(), value: gross };
|
|
9999
|
+
this.authoritativeGrossRevenue = gross;
|
|
10000
|
+
this.revenueStatus = "current";
|
|
10001
|
+
if (this.controlRoomSnapshot) {
|
|
10002
|
+
this.controlRoomSnapshot = {
|
|
10003
|
+
...this.controlRoomSnapshot,
|
|
10004
|
+
revenue: { ...this.controlRoomSnapshot.revenue, gross }
|
|
10005
|
+
};
|
|
10006
|
+
this.opts.onControlRoom?.(this.controlRoomSnapshot);
|
|
10007
|
+
}
|
|
10008
|
+
}
|
|
10009
|
+
/** The single writer for a label's status, so the counters never drift. */
|
|
10010
|
+
setStatusLabel(label, next, prev = this.status.get(label) ?? "free") {
|
|
10011
|
+
this.status.set(label, next);
|
|
10012
|
+
if (prev === next) return;
|
|
10013
|
+
if (prev !== "free") this.counts[prev] -= 1;
|
|
10014
|
+
if (next !== "free") this.counts[next] += 1;
|
|
10015
|
+
}
|
|
9849
10016
|
async resnapshot() {
|
|
9850
10017
|
try {
|
|
9851
10018
|
const objs = await this.api.objects(this.key);
|
|
@@ -9868,23 +10035,31 @@ var SeatManager = class {
|
|
|
9868
10035
|
const next = /* @__PURE__ */ new Map();
|
|
9869
10036
|
if (fallback !== void 0) {
|
|
9870
10037
|
const base = known(fallback);
|
|
9871
|
-
for (const label of this.
|
|
10038
|
+
for (const label of this.knownLabels()) next.set(label, base);
|
|
9872
10039
|
}
|
|
9873
10040
|
for (const [label, st] of Object.entries(seats)) {
|
|
9874
10041
|
next.set(label, known(st));
|
|
9875
10042
|
}
|
|
9876
10043
|
this.status = next;
|
|
10044
|
+
this.modelVersion += 1;
|
|
10045
|
+
this.recountAll();
|
|
9877
10046
|
this.lastSyncedAt = Date.now();
|
|
9878
10047
|
this.repaintAll();
|
|
9879
10048
|
this.afterPaint();
|
|
9880
10049
|
this.recomputeTallies();
|
|
9881
10050
|
}
|
|
10051
|
+
/** The one O(n) walk left: a wholesale model replacement re-bases the counters. */
|
|
10052
|
+
recountAll() {
|
|
10053
|
+
const counts = { held: 0, booked: 0, blocked: 0 };
|
|
10054
|
+
for (const st of this.status.values()) if (st !== "free") counts[st] += 1;
|
|
10055
|
+
this.counts = counts;
|
|
10056
|
+
}
|
|
9882
10057
|
/** Optimistic local write shared by organizer actions. Paint and tally once,
|
|
9883
10058
|
* even when an arena-sized operation changes hundreds of seats. */
|
|
9884
10059
|
setSeatsLocal(labels, st) {
|
|
9885
10060
|
const ids = [];
|
|
9886
10061
|
for (const label of labels) {
|
|
9887
|
-
this.
|
|
10062
|
+
this.setStatusLabel(label, st);
|
|
9888
10063
|
const id = this.labelToId.get(label);
|
|
9889
10064
|
if (id) ids.push(id);
|
|
9890
10065
|
}
|
|
@@ -10010,12 +10185,33 @@ var SeatManager = class {
|
|
|
10010
10185
|
this.revenueStatus = "current";
|
|
10011
10186
|
this.recomputeTallies();
|
|
10012
10187
|
}
|
|
10188
|
+
/**
|
|
10189
|
+
* Read the server's own control-room projection.
|
|
10190
|
+
*
|
|
10191
|
+
* Called on mount, on every socket (re)connect and after an organizer action —
|
|
10192
|
+
* never on a timer and never per delta frame. Presence and gross that arrived
|
|
10193
|
+
* on the socket AFTER this request started are newer than the response, so
|
|
10194
|
+
* they survive it; anything older defers to the read.
|
|
10195
|
+
*/
|
|
10013
10196
|
async refreshControlRoom() {
|
|
10014
10197
|
const request = ++this.revenueRequest;
|
|
10198
|
+
const requestedAt = Date.now();
|
|
10015
10199
|
try {
|
|
10016
|
-
const
|
|
10200
|
+
const fetched = await this.api.controlRoom(this.key, this.trendWindowMinutes);
|
|
10201
|
+
let snapshot = fetched;
|
|
10017
10202
|
if (request === this.revenueRequest) {
|
|
10203
|
+
if (this.livePresence && this.livePresence.at >= requestedAt) {
|
|
10204
|
+
snapshot = { ...snapshot, presence: this.livePresence.value };
|
|
10205
|
+
} else {
|
|
10206
|
+
this.livePresence = null;
|
|
10207
|
+
}
|
|
10208
|
+
if (this.liveGross && this.liveGross.at >= requestedAt) {
|
|
10209
|
+
snapshot = { ...snapshot, revenue: { ...snapshot.revenue, gross: this.liveGross.value } };
|
|
10210
|
+
} else {
|
|
10211
|
+
this.liveGross = null;
|
|
10212
|
+
}
|
|
10018
10213
|
this.controlRoomSnapshot = snapshot;
|
|
10214
|
+
this.rebaseServerTotals(snapshot);
|
|
10019
10215
|
this.lastSyncedAt = Date.now();
|
|
10020
10216
|
this.authoritativeGrossRevenue = snapshot.revenue.gross;
|
|
10021
10217
|
this.currency = snapshot.currency;
|
|
@@ -10034,37 +10230,80 @@ var SeatManager = class {
|
|
|
10034
10230
|
throw err;
|
|
10035
10231
|
}
|
|
10036
10232
|
}
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
if (
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
|
|
10044
|
-
|
|
10233
|
+
/** Pin the server's totals to the client model they were read against. */
|
|
10234
|
+
rebaseServerTotals(snapshot) {
|
|
10235
|
+
const totals = snapshot.totals;
|
|
10236
|
+
if (!totals || ["free", "held", "booked", "blocked"].some(
|
|
10237
|
+
(key) => !Number.isFinite(totals[key])
|
|
10238
|
+
)) {
|
|
10239
|
+
this.serverBaseline = null;
|
|
10240
|
+
return;
|
|
10241
|
+
}
|
|
10242
|
+
this.serverBaseline = {
|
|
10243
|
+
model: this.modelVersion,
|
|
10244
|
+
server: { free: totals.free, held: totals.held, booked: totals.booked, blocked: totals.blocked },
|
|
10245
|
+
client: this.clientTallies()
|
|
10246
|
+
};
|
|
10045
10247
|
}
|
|
10046
|
-
|
|
10248
|
+
/** What the client's own model says — GA units included since `render()`. */
|
|
10249
|
+
clientTallies() {
|
|
10250
|
+
const { held, booked, blocked } = this.counts;
|
|
10251
|
+
return { held, booked, blocked, free: Math.max(0, this.unitTotal() - held - booked - blocked) };
|
|
10252
|
+
}
|
|
10253
|
+
/**
|
|
10254
|
+
* The numbers the KPI bar and rail render.
|
|
10255
|
+
*
|
|
10256
|
+
* The server is the authority: its totals land exactly as read, and the
|
|
10257
|
+
* delta-driven client model carries them forward until the next read. Before
|
|
10258
|
+
* the first snapshot — and after a wholesale model replacement invalidates the
|
|
10259
|
+
* pairing — the client model stands alone.
|
|
10260
|
+
*/
|
|
10261
|
+
buildTallies() {
|
|
10262
|
+
const client = this.clientTallies();
|
|
10263
|
+
const baseline = this.serverBaseline?.model === this.modelVersion ? this.serverBaseline : null;
|
|
10264
|
+
const of = (key) => baseline ? Math.max(0, baseline.server[key] + (client[key] - baseline.client[key])) : client[key];
|
|
10265
|
+
const seatTotal = this.controlRoomSnapshot?.event?.seatTotal;
|
|
10047
10266
|
const t3 = {
|
|
10048
|
-
free:
|
|
10049
|
-
held:
|
|
10050
|
-
booked:
|
|
10051
|
-
blocked:
|
|
10052
|
-
total: this.
|
|
10267
|
+
free: of("free"),
|
|
10268
|
+
held: of("held"),
|
|
10269
|
+
booked: of("booked"),
|
|
10270
|
+
blocked: of("blocked"),
|
|
10271
|
+
total: Number.isFinite(seatTotal) ? seatTotal : this.unitTotal(),
|
|
10053
10272
|
capacityPct: 0,
|
|
10054
10273
|
sellThroughPct: 0,
|
|
10055
10274
|
grossRevenue: this.authoritativeGrossRevenue,
|
|
10056
10275
|
revenueStatus: this.revenueStatus,
|
|
10057
10276
|
currency: this.currency
|
|
10058
10277
|
};
|
|
10059
|
-
let nonFree = 0;
|
|
10060
|
-
for (const st of this.status.values()) {
|
|
10061
|
-
t3[st] += 1;
|
|
10062
|
-
if (st !== "free") nonFree += 1;
|
|
10063
|
-
}
|
|
10064
|
-
t3.free = Math.max(0, t3.total - nonFree);
|
|
10065
10278
|
t3.capacityPct = t3.total ? Math.round(t3.booked / t3.total * 100) : 0;
|
|
10066
10279
|
const sellable = t3.total - t3.blocked;
|
|
10067
10280
|
t3.sellThroughPct = sellable > 0 ? Math.round(t3.booked / sellable * 100) : 0;
|
|
10281
|
+
return t3;
|
|
10282
|
+
}
|
|
10283
|
+
/**
|
|
10284
|
+
* Queue one KPI/rail repaint for this burst of changes.
|
|
10285
|
+
*
|
|
10286
|
+
* A delta frame can carry hundreds of seats and `paintKpis` rebuilds eight
|
|
10287
|
+
* nodes from scratch, so painting per change is what made an arena-sized
|
|
10288
|
+
* frame expensive. Coalescing on a frame keeps the burst to a single rebuild;
|
|
10289
|
+
* without `requestAnimationFrame` (SSR, an older test env) it paints inline
|
|
10290
|
+
* rather than dropping the update.
|
|
10291
|
+
*/
|
|
10292
|
+
recomputeTallies() {
|
|
10293
|
+
if (this.closed) return;
|
|
10294
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
10295
|
+
this.flushTallies();
|
|
10296
|
+
return;
|
|
10297
|
+
}
|
|
10298
|
+
if (this.paintHandle !== null) return;
|
|
10299
|
+
this.paintHandle = requestAnimationFrame(() => {
|
|
10300
|
+
this.paintHandle = null;
|
|
10301
|
+
this.flushTallies();
|
|
10302
|
+
});
|
|
10303
|
+
}
|
|
10304
|
+
flushTallies() {
|
|
10305
|
+
if (this.closed) return;
|
|
10306
|
+
const t3 = this.buildTallies();
|
|
10068
10307
|
this.paintKpis(t3);
|
|
10069
10308
|
if (this.mode === "view") {
|
|
10070
10309
|
this.paintLegend(t3);
|
|
@@ -10351,16 +10590,16 @@ var SeatManager = class {
|
|
|
10351
10590
|
paintKpis(t3) {
|
|
10352
10591
|
if (!this.els.kpis) return;
|
|
10353
10592
|
const rev = t3.revenueStatus === "current" ? fmtMoney(t3.grossRevenue, t3.currency) : "\u2014";
|
|
10354
|
-
const presence = this.
|
|
10593
|
+
const presence = this.presenceCounts();
|
|
10355
10594
|
const items = [
|
|
10356
|
-
{ key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b" },
|
|
10357
|
-
{ key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740" },
|
|
10358
|
-
{ key: "
|
|
10359
|
-
{ key: "
|
|
10360
|
-
{ key: "
|
|
10361
|
-
{ key: "
|
|
10362
|
-
{ key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold" },
|
|
10363
|
-
{ key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales" }
|
|
10595
|
+
{ key: "sold-seats", raw: t3.booked, n: t3.booked.toLocaleString(), l: "Sold seats", dot: "#22a06b", title: "Seats booked" },
|
|
10596
|
+
{ key: "held-seats", raw: t3.held, n: t3.held.toLocaleString(), l: "Held seats", dot: "#f4b740", title: "Seats held in a checkout right now" },
|
|
10597
|
+
{ key: "free-seats", raw: t3.free, n: t3.free.toLocaleString(), l: "Free seats", dot: "#6e7bff", title: "Seats on sale and unsold" },
|
|
10598
|
+
{ key: "blocked", raw: t3.blocked, n: t3.blocked.toLocaleString(), l: "Blocked", dot: "#8b94ac", title: "Seats withheld from sale" },
|
|
10599
|
+
{ key: "buyers", raw: presence?.shoppingSessions ?? null, n: presence ? presence.shoppingSessions.toLocaleString() : "\u2014", l: "Buyers", title: "People on the map right now" },
|
|
10600
|
+
{ key: "carts", raw: presence?.activeHolds ?? null, n: presence ? presence.activeHolds.toLocaleString() : "\u2014", l: "Carts", title: "Checkouts holding seats right now \u2014 sessions, not seats" },
|
|
10601
|
+
{ key: "sold-pct", raw: t3.capacityPct, n: `${t3.capacityPct}%`, l: "Sold", title: "Sold seats as a share of the whole event" },
|
|
10602
|
+
{ key: "gross-sales", raw: t3.revenueStatus === "current" ? t3.grossRevenue : null, n: rev, l: "Gross sales", title: "Exact booked gross" }
|
|
10364
10603
|
];
|
|
10365
10604
|
let hasChanges = false;
|
|
10366
10605
|
this.els.kpis.innerHTML = items.map((item) => {
|
|
@@ -10376,7 +10615,7 @@ var SeatManager = class {
|
|
|
10376
10615
|
}
|
|
10377
10616
|
if (item.raw != null) this.lastKpiValues.set(item.key, item.raw);
|
|
10378
10617
|
const activeDelta = this.activeKpiDeltas.get(item.key);
|
|
10379
|
-
return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}">
|
|
10618
|
+
return `<div class="slm-kpi${activeDelta ? " changed" : ""}" data-kpi="${item.key}" title="${esc2(item.title)}">
|
|
10380
10619
|
<b>${item.dot ? `<span class="dot" style="background:${item.dot}"></span>` : ""}${item.n}</b><span>${item.l}</span>
|
|
10381
10620
|
${activeDelta ? `<span class="slm-kpidelta${activeDelta.down ? " down" : ""}">${activeDelta.text}</span>` : ""}
|
|
10382
10621
|
</div>`;
|
|
@@ -10434,15 +10673,21 @@ var SeatManager = class {
|
|
|
10434
10673
|
this.paintMomentumHelp();
|
|
10435
10674
|
this.paintFeed();
|
|
10436
10675
|
}
|
|
10676
|
+
/** Live presence wins over the snapshot's copy — it is the fresher channel,
|
|
10677
|
+
* and it exists from the first frame rather than the first fetch. */
|
|
10678
|
+
presenceCounts() {
|
|
10679
|
+
return this.livePresence?.value ?? this.controlRoomSnapshot?.presence ?? null;
|
|
10680
|
+
}
|
|
10437
10681
|
paintMonitorInsights() {
|
|
10438
10682
|
if (this.mode !== "view") return;
|
|
10439
10683
|
const snapshot = this.controlRoomSnapshot;
|
|
10440
10684
|
if (this.els.presence) {
|
|
10441
10685
|
const connected = this.root?.classList.contains("live");
|
|
10442
10686
|
const sync = this.lastSyncedAt ? relTime(this.lastSyncedAt, Date.now()) : "waiting";
|
|
10687
|
+
const presence = this.presenceCounts();
|
|
10443
10688
|
this.els.presence.innerHTML = `
|
|
10444
|
-
<div class="slm-healthitem"><b>${
|
|
10445
|
-
<div class="slm-healthitem"><b>${
|
|
10689
|
+
<div class="slm-healthitem" title="People on the map right now"><b>${presence ? presence.shoppingSessions.toLocaleString() : "\u2014"}</b><span>Buyers</span></div>
|
|
10690
|
+
<div class="slm-healthitem" title="Checkouts holding seats right now \u2014 sessions, not seats"><b>${presence ? presence.activeHolds.toLocaleString() : "\u2014"}</b><span>Carts</span></div>
|
|
10446
10691
|
<div class="slm-healthitem"><b>${connected ? "Healthy" : "Reconnecting"}</b><span>Live connection</span></div>
|
|
10447
10692
|
<div class="slm-healthitem"><b>${sync}</b><span>Last sync</span></div>`;
|
|
10448
10693
|
}
|
|
@@ -11053,7 +11298,7 @@ var SeatManager = class {
|
|
|
11053
11298
|
const activity = action === "block" ? this.pushActivity(labels, "blocked", "blocked") : action === "unblock" || action === "unblockAll" ? this.pushActivity(labels, "unblocked", "free") : action === "cancelBooking" ? this.pushActivity(labels, "cancelled", "free") : null;
|
|
11054
11299
|
if (activity) this.paintSpatialActivity(activity);
|
|
11055
11300
|
}
|
|
11056
|
-
if (action !== "setHoldTtl") this.
|
|
11301
|
+
if (action !== "setHoldTtl") void this.refreshControlRoom().catch((err) => this.opts.onError?.(err));
|
|
11057
11302
|
this.opts.onActionComplete?.({ action, labels, count: labels.length });
|
|
11058
11303
|
}
|
|
11059
11304
|
toastOk(msg) {
|