@genex-ai/embed-sdk 0.2.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/chunk-KXUDNW6P.js +397 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +24 -0
- package/dist/sentry.d.ts +34 -0
- package/dist/sentry.js +113 -0
- package/package.json +52 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var PROTOCOL_VERSION = 1;
|
|
3
|
+
var RETRY_FLAG = "genex:embed:retry";
|
|
4
|
+
var config = null;
|
|
5
|
+
var state = "pending";
|
|
6
|
+
var user = null;
|
|
7
|
+
var embedToken;
|
|
8
|
+
var colyseusUrl;
|
|
9
|
+
var parentOrigin = null;
|
|
10
|
+
var initialized = false;
|
|
11
|
+
var redeeming = false;
|
|
12
|
+
var handshakeTimeoutMs = 1e4;
|
|
13
|
+
var refreshDelayMs = 10 * 6e4;
|
|
14
|
+
var refreshRetryMs = 6e4;
|
|
15
|
+
var handshakeTimer;
|
|
16
|
+
var refreshTimer;
|
|
17
|
+
var messageHandler;
|
|
18
|
+
var listeners = /* @__PURE__ */ new Map();
|
|
19
|
+
var authWaiters = [];
|
|
20
|
+
var overlayEl = null;
|
|
21
|
+
function win() {
|
|
22
|
+
return globalThis.window;
|
|
23
|
+
}
|
|
24
|
+
function doc() {
|
|
25
|
+
return globalThis.document;
|
|
26
|
+
}
|
|
27
|
+
function doFetch(input, init) {
|
|
28
|
+
return globalThis.fetch(input, init);
|
|
29
|
+
}
|
|
30
|
+
function readRetryFlag() {
|
|
31
|
+
try {
|
|
32
|
+
return win()?.sessionStorage?.getItem(RETRY_FLAG) === "1";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeRetryFlag() {
|
|
38
|
+
try {
|
|
39
|
+
win()?.sessionStorage?.setItem(RETRY_FLAG, "1");
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function clearRetryFlag() {
|
|
44
|
+
try {
|
|
45
|
+
win()?.sessionStorage?.removeItem(RETRY_FLAG);
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function initEmbed(cfg) {
|
|
50
|
+
const w = win();
|
|
51
|
+
if (!w) return;
|
|
52
|
+
if (initialized) return;
|
|
53
|
+
initialized = true;
|
|
54
|
+
config = {
|
|
55
|
+
slug: cfg.slug,
|
|
56
|
+
apiUrl: cfg.apiUrl.replace(/\/$/, ""),
|
|
57
|
+
dashboardOrigins: [...cfg.dashboardOrigins]
|
|
58
|
+
};
|
|
59
|
+
state = "pending";
|
|
60
|
+
showOverlay("connecting");
|
|
61
|
+
if (isEmbedded()) {
|
|
62
|
+
startEmbeddedHandshake(w);
|
|
63
|
+
} else {
|
|
64
|
+
void startStandaloneFlow(w);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function isEmbedded() {
|
|
68
|
+
const w = win();
|
|
69
|
+
if (!w) return false;
|
|
70
|
+
try {
|
|
71
|
+
return w.self !== w.top;
|
|
72
|
+
} catch {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function getAuthState() {
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
function getUser() {
|
|
80
|
+
return user;
|
|
81
|
+
}
|
|
82
|
+
function getEmbedToken() {
|
|
83
|
+
return embedToken;
|
|
84
|
+
}
|
|
85
|
+
function getColyseusAuth() {
|
|
86
|
+
return embedToken ? { embedToken } : void 0;
|
|
87
|
+
}
|
|
88
|
+
function waitForAuth() {
|
|
89
|
+
if (state === "authenticated" && user) return Promise.resolve({ user });
|
|
90
|
+
if (state === "blocked") return Promise.reject(new Error("genex embed auth: blocked"));
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
authWaiters.push({ resolve, reject });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function on(event, cb) {
|
|
96
|
+
let set = listeners.get(event);
|
|
97
|
+
if (!set) {
|
|
98
|
+
set = /* @__PURE__ */ new Set();
|
|
99
|
+
listeners.set(event, set);
|
|
100
|
+
}
|
|
101
|
+
set.add(cb);
|
|
102
|
+
return () => {
|
|
103
|
+
set.delete(cb);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function emit(event, ctx) {
|
|
107
|
+
const set = listeners.get(event);
|
|
108
|
+
if (!set) return;
|
|
109
|
+
for (const cb of [...set]) {
|
|
110
|
+
try {
|
|
111
|
+
cb(ctx);
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function startEmbeddedHandshake(w) {
|
|
117
|
+
messageHandler = (event) => {
|
|
118
|
+
void handleParentMessage(event);
|
|
119
|
+
};
|
|
120
|
+
w.addEventListener("message", messageHandler);
|
|
121
|
+
w.parent.postMessage({ type: "genex:embed:ready", v: PROTOCOL_VERSION }, "*");
|
|
122
|
+
handshakeTimer = setTimeout(() => {
|
|
123
|
+
enterBlocked();
|
|
124
|
+
}, handshakeTimeoutMs);
|
|
125
|
+
}
|
|
126
|
+
async function handleParentMessage(event) {
|
|
127
|
+
if (!config || state !== "pending" || redeeming) return;
|
|
128
|
+
const data = event.data;
|
|
129
|
+
if (!data || data.type !== "genex:embed:ticket" || data.v !== PROTOCOL_VERSION) return;
|
|
130
|
+
if (typeof data.ticket !== "string" || !data.ticket) return;
|
|
131
|
+
if (!config.dashboardOrigins.includes(event.origin)) {
|
|
132
|
+
const fresh = await fetchDashboardOrigins();
|
|
133
|
+
if (state !== "pending" || redeeming) return;
|
|
134
|
+
if (!fresh.includes(event.origin)) return;
|
|
135
|
+
}
|
|
136
|
+
if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
|
|
137
|
+
parentOrigin = event.origin;
|
|
138
|
+
await redeemTicket(data.ticket);
|
|
139
|
+
}
|
|
140
|
+
function postToParent(message, targetOrigin) {
|
|
141
|
+
const w = win();
|
|
142
|
+
if (!w || !isEmbedded()) return;
|
|
143
|
+
try {
|
|
144
|
+
w.parent.postMessage(message, targetOrigin);
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function startStandaloneFlow(w) {
|
|
149
|
+
const ticket = readTicketFromFragment(w);
|
|
150
|
+
if (ticket) {
|
|
151
|
+
try {
|
|
152
|
+
w.history.replaceState(null, "", w.location.pathname + w.location.search);
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
await redeemTicket(ticket);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
showOverlay("redirecting");
|
|
159
|
+
await redirectToAuthorize(w);
|
|
160
|
+
}
|
|
161
|
+
var stashedTicketHash = null;
|
|
162
|
+
function _stashTicketFromUrl() {
|
|
163
|
+
const w = win();
|
|
164
|
+
if (!w) return;
|
|
165
|
+
const hash = w.location.hash;
|
|
166
|
+
if (!hash || !hash.includes("genex_ticket")) return;
|
|
167
|
+
stashedTicketHash = hash;
|
|
168
|
+
try {
|
|
169
|
+
w.history.replaceState(null, "", w.location.pathname + w.location.search);
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function readTicketFromFragment(w) {
|
|
174
|
+
const hash = stashedTicketHash ?? w.location.hash;
|
|
175
|
+
stashedTicketHash = null;
|
|
176
|
+
if (!hash || hash.length < 2) return null;
|
|
177
|
+
try {
|
|
178
|
+
return new URLSearchParams(hash.slice(1)).get("genex_ticket");
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function redirectToAuthorize(w) {
|
|
184
|
+
if (!config) return;
|
|
185
|
+
const origins = await fetchDashboardOrigins();
|
|
186
|
+
const origin = origins[0] ?? config.dashboardOrigins[0];
|
|
187
|
+
if (!origin) {
|
|
188
|
+
enterBlocked();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const returnTo = w.location.origin + w.location.pathname;
|
|
192
|
+
const url = `${origin}/play/authorize?returnTo=${encodeURIComponent(returnTo)}&slug=${encodeURIComponent(config.slug)}`;
|
|
193
|
+
w.location.replace(url);
|
|
194
|
+
}
|
|
195
|
+
async function fetchDashboardOrigins() {
|
|
196
|
+
if (!config) return [];
|
|
197
|
+
try {
|
|
198
|
+
const res = await doFetch(`${config.apiUrl}/api/embed/dashboard-origins`);
|
|
199
|
+
if (!res.ok) return config.dashboardOrigins;
|
|
200
|
+
const body = await res.json();
|
|
201
|
+
if (Array.isArray(body.origins) && body.origins.every((o) => typeof o === "string")) {
|
|
202
|
+
return body.origins;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
return config.dashboardOrigins;
|
|
207
|
+
}
|
|
208
|
+
async function redeemTicket(ticket) {
|
|
209
|
+
if (!config || redeeming || state !== "pending") return;
|
|
210
|
+
redeeming = true;
|
|
211
|
+
try {
|
|
212
|
+
const res = await doFetch(`${config.apiUrl}/api/embed/session`, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/json" },
|
|
215
|
+
body: JSON.stringify({ ticket })
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
emit("error", { error: new Error(`ticket redemption failed (${res.status})`) });
|
|
219
|
+
await handleRedeemFailure();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const body = await res.json();
|
|
223
|
+
embedToken = body.embedToken;
|
|
224
|
+
user = body.user;
|
|
225
|
+
colyseusUrl = body.colyseus?.url;
|
|
226
|
+
void colyseusUrl;
|
|
227
|
+
state = "authenticated";
|
|
228
|
+
clearRetryFlag();
|
|
229
|
+
removeOverlay();
|
|
230
|
+
scheduleRefresh();
|
|
231
|
+
if (isEmbedded() && parentOrigin) {
|
|
232
|
+
postToParent({ type: "genex:embed:authenticated", v: PROTOCOL_VERSION }, parentOrigin);
|
|
233
|
+
}
|
|
234
|
+
const ctx = { user };
|
|
235
|
+
for (const waiter of authWaiters.splice(0)) waiter.resolve(ctx);
|
|
236
|
+
emit("authenticated", ctx);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
emit("error", { error });
|
|
239
|
+
await handleRedeemFailure();
|
|
240
|
+
} finally {
|
|
241
|
+
redeeming = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function handleRedeemFailure() {
|
|
245
|
+
const w = win();
|
|
246
|
+
if (w && !isEmbedded() && !readRetryFlag()) {
|
|
247
|
+
writeRetryFlag();
|
|
248
|
+
showOverlay("redirecting");
|
|
249
|
+
await redirectToAuthorize(w);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
enterBlocked();
|
|
253
|
+
}
|
|
254
|
+
function scheduleRefresh() {
|
|
255
|
+
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
256
|
+
refreshTimer = setTimeout(() => {
|
|
257
|
+
void refreshToken();
|
|
258
|
+
}, refreshDelayMs);
|
|
259
|
+
}
|
|
260
|
+
async function refreshToken() {
|
|
261
|
+
if (!config || state !== "authenticated" || !embedToken) return;
|
|
262
|
+
let status;
|
|
263
|
+
try {
|
|
264
|
+
const res = await doFetch(`${config.apiUrl}/api/embed/session/refresh`, {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: { Authorization: `Bearer ${embedToken}` },
|
|
267
|
+
// Best-effort completion if the tab starts unloading mid-refresh; zero
|
|
268
|
+
// security consequence either way (worst case the token just expires).
|
|
269
|
+
keepalive: true
|
|
270
|
+
});
|
|
271
|
+
status = res.status;
|
|
272
|
+
if (res.ok) {
|
|
273
|
+
const body = await res.json();
|
|
274
|
+
embedToken = body.embedToken;
|
|
275
|
+
scheduleRefresh();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
status = void 0;
|
|
280
|
+
}
|
|
281
|
+
if (status === 401) {
|
|
282
|
+
emit("error", { error: new Error("embed session expired") });
|
|
283
|
+
enterBlocked();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
287
|
+
refreshTimer = setTimeout(() => {
|
|
288
|
+
void refreshToken();
|
|
289
|
+
}, refreshRetryMs);
|
|
290
|
+
}
|
|
291
|
+
function enterBlocked() {
|
|
292
|
+
if (state === "blocked") return;
|
|
293
|
+
state = "blocked";
|
|
294
|
+
user = null;
|
|
295
|
+
embedToken = void 0;
|
|
296
|
+
if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
|
|
297
|
+
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
298
|
+
showOverlay(isEmbedded() ? "blocked-embedded" : "blocked-standalone");
|
|
299
|
+
if (isEmbedded()) {
|
|
300
|
+
postToParent({ type: "genex:embed:blocked", v: PROTOCOL_VERSION }, parentOrigin ?? "*");
|
|
301
|
+
}
|
|
302
|
+
const err = new Error("genex embed auth: blocked");
|
|
303
|
+
for (const waiter of authWaiters.splice(0)) waiter.reject(err);
|
|
304
|
+
emit("blocked");
|
|
305
|
+
}
|
|
306
|
+
var OVERLAY_TEXT = {
|
|
307
|
+
connecting: { title: "Connecting to Genex\u2026" },
|
|
308
|
+
redirecting: { title: "Signing you in\u2026" },
|
|
309
|
+
"blocked-standalone": { title: "Sign in to play", button: "Sign in" },
|
|
310
|
+
"blocked-embedded": { title: "Sign in on the Genex dashboard to continue" }
|
|
311
|
+
};
|
|
312
|
+
function showOverlay(kind) {
|
|
313
|
+
const d = doc();
|
|
314
|
+
if (!d?.createElement) return;
|
|
315
|
+
try {
|
|
316
|
+
if (!overlayEl) overlayEl = buildOverlay(d);
|
|
317
|
+
overlayEl.setContent(kind);
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function removeOverlay() {
|
|
322
|
+
try {
|
|
323
|
+
const root = overlayEl?.root;
|
|
324
|
+
root?.remove?.();
|
|
325
|
+
} catch {
|
|
326
|
+
}
|
|
327
|
+
overlayEl = null;
|
|
328
|
+
}
|
|
329
|
+
function buildOverlay(d) {
|
|
330
|
+
const root = d.createElement("div");
|
|
331
|
+
root.setAttribute("data-genex-embed-overlay", "");
|
|
332
|
+
root.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;background:rgba(8,10,20,0.92);color:#fff;font-family:system-ui,-apple-system,sans-serif;text-align:center;pointer-events:auto;user-select:none";
|
|
333
|
+
const title = d.createElement("div");
|
|
334
|
+
title.style.cssText = "font-size:18px;font-weight:600;padding:0 24px";
|
|
335
|
+
root.appendChild(title);
|
|
336
|
+
const button = d.createElement("button");
|
|
337
|
+
button.style.cssText = "font-size:15px;font-weight:600;padding:10px 28px;border-radius:8px;border:none;cursor:pointer;background:#fff;color:#111;display:none";
|
|
338
|
+
button.addEventListener("click", () => {
|
|
339
|
+
const w = win();
|
|
340
|
+
if (!w) return;
|
|
341
|
+
button.disabled = true;
|
|
342
|
+
clearRetryFlag();
|
|
343
|
+
void redirectToAuthorize(w);
|
|
344
|
+
});
|
|
345
|
+
root.appendChild(button);
|
|
346
|
+
const mount = () => {
|
|
347
|
+
if (d.body && !root.isConnected) d.body.appendChild(root);
|
|
348
|
+
};
|
|
349
|
+
if (d.body) mount();
|
|
350
|
+
else d.addEventListener?.("DOMContentLoaded", mount);
|
|
351
|
+
return {
|
|
352
|
+
root,
|
|
353
|
+
setContent(kind) {
|
|
354
|
+
const text = OVERLAY_TEXT[kind];
|
|
355
|
+
title.textContent = text.title;
|
|
356
|
+
button.style.display = text.button ? "inline-block" : "none";
|
|
357
|
+
if (text.button) button.textContent = text.button;
|
|
358
|
+
mount();
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function __resetForTests(overrides) {
|
|
363
|
+
const w = win();
|
|
364
|
+
if (w && messageHandler) w.removeEventListener("message", messageHandler);
|
|
365
|
+
messageHandler = void 0;
|
|
366
|
+
if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
|
|
367
|
+
if (refreshTimer !== void 0) clearTimeout(refreshTimer);
|
|
368
|
+
handshakeTimer = void 0;
|
|
369
|
+
refreshTimer = void 0;
|
|
370
|
+
removeOverlay();
|
|
371
|
+
config = null;
|
|
372
|
+
state = "pending";
|
|
373
|
+
user = null;
|
|
374
|
+
embedToken = void 0;
|
|
375
|
+
colyseusUrl = void 0;
|
|
376
|
+
parentOrigin = null;
|
|
377
|
+
initialized = false;
|
|
378
|
+
redeeming = false;
|
|
379
|
+
listeners.clear();
|
|
380
|
+
authWaiters = [];
|
|
381
|
+
handshakeTimeoutMs = overrides?.handshakeTimeoutMs ?? 1e4;
|
|
382
|
+
refreshDelayMs = overrides?.refreshDelayMs ?? 10 * 6e4;
|
|
383
|
+
refreshRetryMs = overrides?.refreshRetryMs ?? 6e4;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export {
|
|
387
|
+
initEmbed,
|
|
388
|
+
isEmbedded,
|
|
389
|
+
getAuthState,
|
|
390
|
+
getUser,
|
|
391
|
+
getEmbedToken,
|
|
392
|
+
getColyseusAuth,
|
|
393
|
+
waitForAuth,
|
|
394
|
+
on,
|
|
395
|
+
_stashTicketFromUrl,
|
|
396
|
+
__resetForTests
|
|
397
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
interface EmbedConfig {
|
|
2
|
+
/** This game's own slug (GENEX.slug) — identifies the project to /play/authorize. */
|
|
3
|
+
slug: string;
|
|
4
|
+
/** The genex API base URL (GENEX.apiUrl) — for redeem/refresh/origin fetches. */
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
/**
|
|
7
|
+
* EXACT-match allowlist of dashboard origins this game accepts a ticket
|
|
8
|
+
* postMessage from (GENEX.dashboardOrigins). Never matched by substring/
|
|
9
|
+
* suffix/regex. Also the fallback standalone redirect target.
|
|
10
|
+
*/
|
|
11
|
+
dashboardOrigins: string[];
|
|
12
|
+
}
|
|
13
|
+
interface EmbedUser {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
image?: string;
|
|
17
|
+
}
|
|
18
|
+
type AuthState = 'pending' | 'authenticated' | 'blocked';
|
|
19
|
+
type EmbedEvent = 'authenticated' | 'blocked' | 'error';
|
|
20
|
+
interface EventContext {
|
|
21
|
+
user?: EmbedUser;
|
|
22
|
+
error?: unknown;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Initialize embed auth. Call ONCE, first thing in the boot sequence — before
|
|
26
|
+
* connect() and before any /state call. Scene/asset boot may proceed
|
|
27
|
+
* immediately (the gate overlay blocks interaction, not execution); await
|
|
28
|
+
* waitForAuth() before anything identity-dependent.
|
|
29
|
+
*
|
|
30
|
+
* Standalone (not in an iframe) with no return-trip ticket present, this
|
|
31
|
+
* navigates away to the dashboard sign-in — by design, there is no anonymous
|
|
32
|
+
* play.
|
|
33
|
+
*/
|
|
34
|
+
declare function initEmbed(cfg: EmbedConfig): void;
|
|
35
|
+
/**
|
|
36
|
+
* Structural "is in a frame" check — deliberately a different question from
|
|
37
|
+
* "is authenticated". True even before the handshake resolves.
|
|
38
|
+
*/
|
|
39
|
+
declare function isEmbedded(): boolean;
|
|
40
|
+
/** Current auth state. Synchronous; starts 'pending', ends 'authenticated' or 'blocked'. */
|
|
41
|
+
declare function getAuthState(): AuthState;
|
|
42
|
+
/** The signed-in user — non-null only once getAuthState() === 'authenticated'. */
|
|
43
|
+
declare function getUser(): EmbedUser | null;
|
|
44
|
+
/**
|
|
45
|
+
* The raw scoped embedToken, for Authorization: Bearer calls the game makes
|
|
46
|
+
* itself (GET/PUT /api/projects/:slug/state — which REQUIRES it; there is no
|
|
47
|
+
* anonymous save path). undefined unless authenticated.
|
|
48
|
+
*
|
|
49
|
+
* NEVER log this value or pass it to crash-reporting/analytics breadcrumb
|
|
50
|
+
* capture. It is bounded (15 minutes, one project, one scope), but third-party
|
|
51
|
+
* logging-service retention can outlive that.
|
|
52
|
+
*/
|
|
53
|
+
declare function getEmbedToken(): string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* The credential for multiplayer: pass as `connect({ ..., auth: getColyseusAuth() })`
|
|
56
|
+
* AFTER `await waitForAuth()` — the relay rejects tokenless joins. Read it
|
|
57
|
+
* fresh at every connect() call (tokens rotate ~every 10 minutes).
|
|
58
|
+
* NEVER log this value.
|
|
59
|
+
*/
|
|
60
|
+
declare function getColyseusAuth(): {
|
|
61
|
+
embedToken: string;
|
|
62
|
+
} | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Resolves with the signed-in user once authenticated; rejects if the session
|
|
65
|
+
* ends up blocked. THE gate for connect() and /state calls.
|
|
66
|
+
*/
|
|
67
|
+
declare function waitForAuth(): Promise<{
|
|
68
|
+
user: EmbedUser;
|
|
69
|
+
}>;
|
|
70
|
+
/** Subscribe to auth lifecycle events. Returns an unsubscribe function. */
|
|
71
|
+
declare function on(event: EmbedEvent, cb: (ctx?: EventContext) => void): () => void;
|
|
72
|
+
/**
|
|
73
|
+
* @internal — for ./sentry.ts ONLY, not public API (games never call this).
|
|
74
|
+
* If the URL fragment carries a genex ticket, move it out of the URL (so no
|
|
75
|
+
* recorder/logger ever sees it) into a stash the standalone flow reads.
|
|
76
|
+
*/
|
|
77
|
+
declare function _stashTicketFromUrl(): void;
|
|
78
|
+
declare function __resetForTests(overrides?: {
|
|
79
|
+
handshakeTimeoutMs?: number;
|
|
80
|
+
refreshDelayMs?: number;
|
|
81
|
+
refreshRetryMs?: number;
|
|
82
|
+
}): void;
|
|
83
|
+
|
|
84
|
+
export { type AuthState, type EmbedConfig, type EmbedEvent, type EmbedUser, __resetForTests, _stashTicketFromUrl, getAuthState, getColyseusAuth, getEmbedToken, getUser, initEmbed, isEmbedded, on, waitForAuth };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__resetForTests,
|
|
3
|
+
_stashTicketFromUrl,
|
|
4
|
+
getAuthState,
|
|
5
|
+
getColyseusAuth,
|
|
6
|
+
getEmbedToken,
|
|
7
|
+
getUser,
|
|
8
|
+
initEmbed,
|
|
9
|
+
isEmbedded,
|
|
10
|
+
on,
|
|
11
|
+
waitForAuth
|
|
12
|
+
} from "./chunk-KXUDNW6P.js";
|
|
13
|
+
export {
|
|
14
|
+
__resetForTests,
|
|
15
|
+
_stashTicketFromUrl,
|
|
16
|
+
getAuthState,
|
|
17
|
+
getColyseusAuth,
|
|
18
|
+
getEmbedToken,
|
|
19
|
+
getUser,
|
|
20
|
+
initEmbed,
|
|
21
|
+
isEmbedded,
|
|
22
|
+
on,
|
|
23
|
+
waitForAuth
|
|
24
|
+
};
|
package/dist/sentry.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface GameSentryOptions {
|
|
2
|
+
/** The game's slug — use GENEX.slug. Becomes the game_slug tag. */
|
|
3
|
+
slug: string;
|
|
4
|
+
/** Override the shared genex-games project DSN. */
|
|
5
|
+
dsn?: string;
|
|
6
|
+
/** Sentry environment. Published bundles are 'production' (the default). */
|
|
7
|
+
environment?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Initialize crash reporting + session replay for a game. Call ONCE, BEFORE
|
|
11
|
+
* initEmbed() — so even auth-boot failures are reported. Safe in any context
|
|
12
|
+
* the game runs in: dashboard iframe, standalone tab, local dev preview.
|
|
13
|
+
*
|
|
14
|
+
* Replay records the DOM; the game's WebGL/WebGPU canvas needs explicit
|
|
15
|
+
* snapshots — call sentryCanvasSnapshot(renderer.domElement) in the render
|
|
16
|
+
* loop (see below).
|
|
17
|
+
*/
|
|
18
|
+
declare function initGameSentry(opts: GameSentryOptions): void;
|
|
19
|
+
/**
|
|
20
|
+
* Renderer-agnostic canvas capture for session replay: call once per frame at
|
|
21
|
+
* the END of the render loop, right after renderer.render(scene, camera),
|
|
22
|
+
* passing renderer.domElement. Throttled internally (default: one capture per
|
|
23
|
+
* 500ms), so calling at 60fps is fine.
|
|
24
|
+
*
|
|
25
|
+
* skipRequestAnimationFrame captures synchronously in the SAME task as the
|
|
26
|
+
* draw — REQUIRED for WebGPU (canvas textures expire when the task completes)
|
|
27
|
+
* and safe for WebGL (the buffer is still valid within the task, so no
|
|
28
|
+
* preserveDrawingBuffer needed). One code path for both renderers.
|
|
29
|
+
*/
|
|
30
|
+
declare function sentryCanvasSnapshot(canvas: HTMLCanvasElement, opts?: {
|
|
31
|
+
intervalMs?: number;
|
|
32
|
+
}): void;
|
|
33
|
+
|
|
34
|
+
export { type GameSentryOptions, initGameSentry, sentryCanvasSnapshot };
|
package/dist/sentry.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
_stashTicketFromUrl,
|
|
3
|
+
getUser,
|
|
4
|
+
on
|
|
5
|
+
} from "./chunk-KXUDNW6P.js";
|
|
6
|
+
|
|
7
|
+
// src/sentry.ts
|
|
8
|
+
import * as Sentry from "@sentry/browser";
|
|
9
|
+
|
|
10
|
+
// src/sentry-scrub.ts
|
|
11
|
+
var TOKENISH = /((?:genex_ticket|ticket|embedToken|token)=)[^&#\s"']+/gi;
|
|
12
|
+
function scrubTokens(s) {
|
|
13
|
+
return s.replace(TOKENISH, "$1[redacted]");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/sentry.ts
|
|
17
|
+
var DEFAULT_GAMES_DSN = "https://d1414cfcdc09bf1c1d124e86f8087251@o4511115493900288.ingest.us.sentry.io/4511662569160704";
|
|
18
|
+
var initialized = false;
|
|
19
|
+
function initGameSentry(opts) {
|
|
20
|
+
if (typeof window === "undefined") return;
|
|
21
|
+
if (initialized) return;
|
|
22
|
+
initialized = true;
|
|
23
|
+
_stashTicketFromUrl();
|
|
24
|
+
Sentry.init({
|
|
25
|
+
dsn: opts.dsn ?? DEFAULT_GAMES_DSN,
|
|
26
|
+
environment: opts.environment ?? "production",
|
|
27
|
+
tracesSampleRate: 1,
|
|
28
|
+
// low-traffic demo; first knobs to lower: replays, then this
|
|
29
|
+
replaysSessionSampleRate: 1,
|
|
30
|
+
// record EVERY session (demo decision 2026-07-02)
|
|
31
|
+
replaysOnErrorSampleRate: 1,
|
|
32
|
+
dataCollection: { userInfo: true },
|
|
33
|
+
initialScope: { tags: { game_slug: opts.slug } },
|
|
34
|
+
integrations: [
|
|
35
|
+
Sentry.browserTracingIntegration(),
|
|
36
|
+
// Unmasked (demo decision 2026-07-02): game UI is the product. Replay
|
|
37
|
+
// network capture stays at its default (URLs only, never bodies or
|
|
38
|
+
// headers) — do NOT add networkDetailAllowUrls for the genex API, or
|
|
39
|
+
// ticket/embedToken response bodies would be recorded.
|
|
40
|
+
Sentry.replayIntegration({
|
|
41
|
+
maskAllText: false,
|
|
42
|
+
blockAllMedia: false,
|
|
43
|
+
// Scrub token-ish URLs from replay CUSTOM recording events (navigation
|
|
44
|
+
// breadcrumbs, resource spans). rrweb's own DOM events can't be
|
|
45
|
+
// modified here — that's what _stashTicketFromUrl above is for.
|
|
46
|
+
beforeAddRecordingEvent: (event) => {
|
|
47
|
+
try {
|
|
48
|
+
const payload = event.data?.payload;
|
|
49
|
+
if (payload) {
|
|
50
|
+
if (typeof payload.description === "string") {
|
|
51
|
+
payload.description = scrubTokens(payload.description);
|
|
52
|
+
}
|
|
53
|
+
const d = payload.data;
|
|
54
|
+
if (d) {
|
|
55
|
+
for (const k of ["url", "to", "from"]) {
|
|
56
|
+
if (typeof d[k] === "string") d[k] = scrubTokens(d[k]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
return event;
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
// Canvas frames are captured manually from the render loop — automatic
|
|
66
|
+
// capture would need preserveDrawingBuffer (WebGL perf hit) and misses
|
|
67
|
+
// WebGPU entirely. See sentryCanvasSnapshot.
|
|
68
|
+
Sentry.replayCanvasIntegration({ enableManualSnapshot: true })
|
|
69
|
+
],
|
|
70
|
+
// The auth ticket travels in the URL fragment on the standalone flow —
|
|
71
|
+
// scrub token-ish values from everything URL- or message-shaped.
|
|
72
|
+
beforeSend(event) {
|
|
73
|
+
if (event.request?.url) event.request.url = scrubTokens(event.request.url);
|
|
74
|
+
if (event.message) event.message = scrubTokens(event.message);
|
|
75
|
+
return event;
|
|
76
|
+
},
|
|
77
|
+
beforeBreadcrumb(crumb) {
|
|
78
|
+
if (typeof crumb.data?.url === "string") crumb.data.url = scrubTokens(crumb.data.url);
|
|
79
|
+
if (typeof crumb.data?.to === "string") crumb.data.to = scrubTokens(crumb.data.to);
|
|
80
|
+
if (typeof crumb.data?.from === "string") crumb.data.from = scrubTokens(crumb.data.from);
|
|
81
|
+
if (typeof crumb.message === "string") crumb.message = scrubTokens(crumb.message);
|
|
82
|
+
return crumb;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
Sentry.addEventProcessor((event) => {
|
|
86
|
+
const e = event;
|
|
87
|
+
if (Array.isArray(e.urls)) {
|
|
88
|
+
e.urls = e.urls.map((u) => typeof u === "string" ? scrubTokens(u) : u);
|
|
89
|
+
}
|
|
90
|
+
if (e.request?.url) e.request.url = scrubTokens(e.request.url);
|
|
91
|
+
return event;
|
|
92
|
+
});
|
|
93
|
+
on("authenticated", (ctx) => {
|
|
94
|
+
const u = ctx?.user ?? getUser();
|
|
95
|
+
if (u) Sentry.setUser({ id: u.id, username: u.name });
|
|
96
|
+
});
|
|
97
|
+
on("blocked", () => Sentry.setUser(null));
|
|
98
|
+
on("error", (ctx) => {
|
|
99
|
+
if (ctx?.error) Sentry.captureException(ctx.error);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
var lastSnapshot = 0;
|
|
103
|
+
function sentryCanvasSnapshot(canvas, opts) {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (now - lastSnapshot < (opts?.intervalMs ?? 500)) return;
|
|
106
|
+
lastSnapshot = now;
|
|
107
|
+
const replayCanvas = Sentry.getClient()?.getIntegrationByName("ReplayCanvas");
|
|
108
|
+
void replayCanvas?.snapshot(canvas, { skipRequestAnimationFrame: true });
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
initGameSentry,
|
|
112
|
+
sentryCanvasSnapshot
|
|
113
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@genex-ai/embed-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Signed-in identity for genex games — embedded (dashboard iframe postMessage handshake) or standalone (sign-in redirect). No anonymous play.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"@genex-ai/source": "./src/index.ts",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./sentry": {
|
|
15
|
+
"@genex-ai/source": "./src/sentry.ts",
|
|
16
|
+
"types": "./dist/sentry.d.ts",
|
|
17
|
+
"import": "./dist/sentry.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@sentry/browser": "^10.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@sentry/browser": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@arethetypeswrong/cli": "^0.18.0",
|
|
36
|
+
"@sentry/browser": "^10.63.0",
|
|
37
|
+
"publint": "^0.3.0",
|
|
38
|
+
"tsup": "^8.0.0"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/me-ai-org/genex-demo.git",
|
|
43
|
+
"directory": "packages/embed-sdk"
|
|
44
|
+
},
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "node --test test/*.test.ts",
|
|
50
|
+
"check": "pnpm build && publint && attw --pack . --profile esm-only"
|
|
51
|
+
}
|
|
52
|
+
}
|