@genex-ai/embed-sdk 0.3.1 → 0.4.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.
@@ -22,6 +22,9 @@ var authWaiters = [];
22
22
  var playerWaiters = [];
23
23
  var overlayEl = null;
24
24
  var popoverEl = null;
25
+ var pendingPlayerSave;
26
+ var hasPendingPlayerSave = false;
27
+ var pendingScores = /* @__PURE__ */ new Map();
25
28
  function win() {
26
29
  return globalThis.window;
27
30
  }
@@ -129,6 +132,140 @@ function on(event, cb) {
129
132
  set.delete(cb);
130
133
  };
131
134
  }
135
+ var KEEPALIVE_MAX_BYTES = 32 * 1024;
136
+ async function ensurePlayer() {
137
+ if (!config) throw new Error("genex embed: call initEmbed() first");
138
+ if (state === "pending") await waitForPlayer();
139
+ if (state === "blocked") throw new Error("genex embed auth: blocked");
140
+ }
141
+ function stateFetch(path, init = {}) {
142
+ const headers = { Authorization: `Bearer ${embedToken}` };
143
+ if (init.body !== void 0) headers["Content-Type"] = "application/json";
144
+ if (init.ifVersion !== void 0) headers["If-Match"] = String(init.ifVersion);
145
+ return doFetch(`${config.apiUrl}/api/projects/${encodeURIComponent(config.slug)}${path}`, {
146
+ method: init.method ?? "GET",
147
+ headers,
148
+ body: init.body,
149
+ keepalive: init.body !== void 0 && init.body.length <= KEEPALIVE_MAX_BYTES ? true : void 0
150
+ });
151
+ }
152
+ async function decodeSave(res) {
153
+ if (res.status === 409) {
154
+ const body2 = await res.json().catch(() => ({}));
155
+ return { saved: false, conflict: true, version: body2.version };
156
+ }
157
+ if (!res.ok) {
158
+ emit("error", { error: new Error(`genex state save failed (${res.status})`) });
159
+ return { saved: false };
160
+ }
161
+ const body = await res.json();
162
+ return { saved: true, version: body.version };
163
+ }
164
+ async function loadPlayerState() {
165
+ await ensurePlayer();
166
+ if (state === "guest") {
167
+ return { data: hasPendingPlayerSave ? pendingPlayerSave : null, version: 0, guest: true };
168
+ }
169
+ const res = await stateFetch("/state/me");
170
+ if (!res.ok) throw new Error(`genex load player state failed (${res.status})`);
171
+ const body = await res.json();
172
+ return { data: body.data, version: body.version };
173
+ }
174
+ async function savePlayerState(data, opts) {
175
+ await ensurePlayer();
176
+ if (state === "guest") {
177
+ pendingPlayerSave = data;
178
+ hasPendingPlayerSave = true;
179
+ return { saved: false, guest: true, queued: true };
180
+ }
181
+ return decodeSave(
182
+ await stateFetch("/state/me", {
183
+ method: "PUT",
184
+ body: JSON.stringify(data),
185
+ ifVersion: opts?.ifVersion
186
+ })
187
+ );
188
+ }
189
+ async function loadWorldState() {
190
+ await ensurePlayer();
191
+ if (state === "guest") return { data: null, version: 0, guest: true };
192
+ const res = await stateFetch("/state");
193
+ if (!res.ok) throw new Error(`genex load world state failed (${res.status})`);
194
+ const body = await res.json();
195
+ return { data: body.data, version: body.version };
196
+ }
197
+ async function saveWorldState(data, opts) {
198
+ await ensurePlayer();
199
+ if (state === "guest") return { saved: false, guest: true };
200
+ return decodeSave(
201
+ await stateFetch("/state", {
202
+ method: "PUT",
203
+ body: JSON.stringify(data),
204
+ ifVersion: opts?.ifVersion
205
+ })
206
+ );
207
+ }
208
+ async function submitScore(score, opts) {
209
+ const board = opts?.board ?? "main";
210
+ const mode = opts?.mode ?? "max";
211
+ await ensurePlayer();
212
+ if (state === "guest") {
213
+ const prev = pendingScores.get(board);
214
+ if (!prev || (mode === "min" ? score < prev.score : score > prev.score)) {
215
+ pendingScores.set(board, { score, mode });
216
+ }
217
+ return { submitted: false, guest: true, queued: true };
218
+ }
219
+ const res = await stateFetch("/scores", {
220
+ method: "POST",
221
+ body: JSON.stringify({ score, board, mode })
222
+ });
223
+ if (!res.ok) {
224
+ emit("error", { error: new Error(`genex score submit failed (${res.status})`) });
225
+ return { submitted: false };
226
+ }
227
+ const body = await res.json();
228
+ return { submitted: true, best: body.best, improved: body.improved };
229
+ }
230
+ async function getLeaderboard(opts) {
231
+ if (!config) throw new Error("genex embed: call initEmbed() first");
232
+ if (state === "pending") {
233
+ try {
234
+ await waitForPlayer();
235
+ } catch {
236
+ }
237
+ }
238
+ const params = new URLSearchParams();
239
+ if (opts?.board !== void 0) params.set("board", opts.board);
240
+ if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
241
+ if (opts?.order !== void 0) params.set("order", opts.order);
242
+ const qs = params.toString();
243
+ const headers = {};
244
+ if (embedToken) headers.Authorization = `Bearer ${embedToken}`;
245
+ const res = await doFetch(
246
+ `${config.apiUrl}/api/projects/${encodeURIComponent(config.slug)}/leaderboard${qs ? `?${qs}` : ""}`,
247
+ { headers }
248
+ );
249
+ if (!res.ok) throw new Error(`genex leaderboard failed (${res.status})`);
250
+ return await res.json();
251
+ }
252
+ async function flushGuestQueue() {
253
+ try {
254
+ if (hasPendingPlayerSave) {
255
+ const save = pendingPlayerSave;
256
+ pendingPlayerSave = void 0;
257
+ hasPendingPlayerSave = false;
258
+ await savePlayerState(save);
259
+ }
260
+ const queued = [...pendingScores.entries()];
261
+ pendingScores.clear();
262
+ for (const [board, { score, mode }] of queued) {
263
+ await submitScore(score, { board, mode });
264
+ }
265
+ } catch (error) {
266
+ emit("error", { error });
267
+ }
268
+ }
132
269
  function emit(event, ctx) {
133
270
  const set = listeners.get(event);
134
271
  if (!set) return;
@@ -204,9 +341,20 @@ function _stashTicketFromUrl() {
204
341
  if (!hash || !hash.includes("genex_")) return;
205
342
  stashedTicketHash = hash;
206
343
  try {
207
- w.history.replaceState(null, "", w.location.pathname + w.location.search);
344
+ w.history.replaceState(null, "", cleanUrl(w));
345
+ } catch {
346
+ }
347
+ }
348
+ function cleanUrl(w) {
349
+ let search = w.location.search;
350
+ try {
351
+ const params = new URLSearchParams(search);
352
+ params.delete("genex_r");
353
+ const rest = params.toString();
354
+ search = rest ? `?${rest}` : "";
208
355
  } catch {
209
356
  }
357
+ return w.location.pathname + search;
210
358
  }
211
359
  function readFragment(w) {
212
360
  const hash = stashedTicketHash ?? w.location.hash;
@@ -223,7 +371,7 @@ function readFragment(w) {
223
371
  }
224
372
  if (ticket || guest) {
225
373
  try {
226
- w.history.replaceState(null, "", w.location.pathname + w.location.search);
374
+ w.history.replaceState(null, "", cleanUrl(w));
227
375
  } catch {
228
376
  }
229
377
  }
@@ -231,8 +379,7 @@ function readFragment(w) {
231
379
  }
232
380
  async function redirectToAuthorize(w, opts) {
233
381
  if (!config) return;
234
- const origins = await fetchDashboardOrigins();
235
- const origin = origins[0] ?? config.dashboardOrigins[0];
382
+ const origin = opts?.guestOk ? config.dashboardOrigins[0] ?? (await fetchDashboardOrigins())[0] : (await fetchDashboardOrigins())[0] ?? config.dashboardOrigins[0];
236
383
  if (!origin) {
237
384
  enterBlocked();
238
385
  return;
@@ -283,6 +430,7 @@ async function redeemTicket(ticket) {
283
430
  if (isEmbedded() && parentOrigin) {
284
431
  postToParent({ type: "genex:embed:authenticated", v: PROTOCOL_VERSION }, parentOrigin);
285
432
  }
433
+ if (upgradingFromGuest) await flushGuestQueue();
286
434
  const ctx = { user };
287
435
  for (const waiter of authWaiters.splice(0)) waiter.resolve(ctx);
288
436
  for (const waiter of playerWaiters.splice(0)) waiter.resolve({ user, guest: false });
@@ -437,7 +585,7 @@ function removeOverlay() {
437
585
  function buildOverlay(d) {
438
586
  const root = d.createElement("div");
439
587
  root.setAttribute("data-genex-embed-overlay", "");
440
- 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";
588
+ root.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;background:#080a14;color:#fff;font-family:system-ui,-apple-system,sans-serif;text-align:center;pointer-events:auto;user-select:none";
441
589
  const title = d.createElement("div");
442
590
  title.style.cssText = "font-size:18px;font-weight:600;padding:0 24px";
443
591
  root.appendChild(title);
@@ -540,6 +688,9 @@ function __resetForTests(overrides) {
540
688
  authWaiters = [];
541
689
  playerWaiters = [];
542
690
  stashedTicketHash = null;
691
+ pendingPlayerSave = void 0;
692
+ hasPendingPlayerSave = false;
693
+ pendingScores.clear();
543
694
  handshakeTimeoutMs = overrides?.handshakeTimeoutMs ?? 1e4;
544
695
  refreshDelayMs = overrides?.refreshDelayMs ?? 10 * 6e4;
545
696
  refreshRetryMs = overrides?.refreshRetryMs ?? 6e4;
@@ -555,6 +706,12 @@ export {
555
706
  waitForAuth,
556
707
  waitForPlayer,
557
708
  on,
709
+ loadPlayerState,
710
+ savePlayerState,
711
+ loadWorldState,
712
+ saveWorldState,
713
+ submitScore,
714
+ getLeaderboard,
558
715
  _stashTicketFromUrl,
559
716
  __resetForTests
560
717
  };
package/dist/index.d.ts CHANGED
@@ -17,6 +17,56 @@ interface EmbedUser {
17
17
  }
18
18
  type AuthState = 'pending' | 'authenticated' | 'guest' | 'blocked';
19
19
  type EmbedEvent = 'authenticated' | 'guest' | 'blocked' | 'error';
20
+ interface PlayerStateResult {
21
+ /** The saved blob, or null when this player never saved. For guests: the queued pending save, if any. */
22
+ data: unknown;
23
+ /** Optimistic-concurrency version — pass to savePlayerState({ ifVersion }) to detect races. 0 = never saved. */
24
+ version: number;
25
+ /** True when the current identity is a guest (server storage untouched). */
26
+ guest?: boolean;
27
+ }
28
+ interface WorldStateResult {
29
+ data: unknown;
30
+ version: number;
31
+ guest?: boolean;
32
+ }
33
+ interface SaveStateResult {
34
+ /** True when the write landed on the server. */
35
+ saved: boolean;
36
+ /** The row's version after a successful write — or the CURRENT server version on a conflict. */
37
+ version?: number;
38
+ /** True when If-Match lost the race (reload, merge, retry with the fresh version). */
39
+ conflict?: boolean;
40
+ /** True when the caller is a guest — accounts save, guests don't. */
41
+ guest?: boolean;
42
+ /** True when the value was queued to auto-flush if this guest signs in mid-game. */
43
+ queued?: boolean;
44
+ }
45
+ interface SubmitScoreResult {
46
+ submitted: boolean;
47
+ /** The standing best after this submit (may be an older, better score). */
48
+ best?: number;
49
+ /** True when this submit improved the player's best. */
50
+ improved?: boolean;
51
+ guest?: boolean;
52
+ /** True when the score was queued to auto-submit if this guest signs in mid-game. */
53
+ queued?: boolean;
54
+ }
55
+ interface LeaderboardEntry {
56
+ userId: string;
57
+ name: string;
58
+ image?: string | null;
59
+ score: number;
60
+ updatedAt: string;
61
+ }
62
+ interface Leaderboard {
63
+ items: LeaderboardEntry[];
64
+ /** This player's rank + best — null for guests, tokenless reads, and players with no score. */
65
+ me: {
66
+ rank: number;
67
+ score: number;
68
+ } | null;
69
+ }
20
70
  interface EventContext {
21
71
  user?: EmbedUser;
22
72
  error?: unknown;
@@ -92,6 +142,65 @@ declare function waitForPlayer(): Promise<{
92
142
  }>;
93
143
  /** Subscribe to auth lifecycle events. Returns an unsubscribe function. */
94
144
  declare function on(event: EmbedEvent, cb: (ctx?: EventContext) => void): () => void;
145
+ /**
146
+ * Load THIS player's own save slot (per-player, per-game — another player can
147
+ * never read or clobber it). Resolves { data: null, version: 0 } when they
148
+ * never saved. Guests get their queued pending save (if any) so a round-trip
149
+ * works mid-session; the server is untouched. Await-safe from boot: resolves
150
+ * after identity exists, rejects only when the session is blocked.
151
+ */
152
+ declare function loadPlayerState(): Promise<PlayerStateResult>;
153
+ /**
154
+ * Save THIS player's own slot (any JSON ≤ 256KB; last-write-wins on their own
155
+ * row). Guests: the value is QUEUED in memory and auto-flushed if they sign in
156
+ * mid-game ({ saved: false, guest: true, queued: true }) — no extra game code.
157
+ * Pass { ifVersion } (from the last load/save) to detect same-account
158
+ * two-device races: a losing write resolves { conflict: true, version } —
159
+ * reload, merge, retry. Small saves survive tab close (fetch keepalive).
160
+ */
161
+ declare function savePlayerState(data: unknown, opts?: {
162
+ ifVersion?: number;
163
+ }): Promise<SaveStateResult>;
164
+ /**
165
+ * Load the game's SHARED world slot (one blob per game — every player reads
166
+ * the same world). Guests resolve { data: null, guest: true } — the server
167
+ * refuses guest reads; in multiplayer, guests receive the live world through
168
+ * the room instead.
169
+ */
170
+ declare function loadWorldState(): Promise<WorldStateResult>;
171
+ /**
172
+ * Save the game's SHARED world slot (any JSON ≤ 1MB, any signed-in player may
173
+ * write — let ONE authority do it, e.g. the multiplayer host, and debounce).
174
+ * Strongly prefer { ifVersion } here: the slot is shared, so unconditional
175
+ * writes silently lose races. Guests: { saved: false, guest: true } — world
176
+ * saves are NEVER queued (flushing a stale world later would clobber the live
177
+ * one; the eventual account host keeps saving instead).
178
+ */
179
+ declare function saveWorldState(data: unknown, opts?: {
180
+ ifVersion?: number;
181
+ }): Promise<SaveStateResult>;
182
+ /**
183
+ * Submit a score (soft-trust leaderboards: identity is verified server-side,
184
+ * one row per real player per board). Keep-best: only a strictly better score
185
+ * moves the row — `mode: "min"` for lap-time-style boards (send a consistent
186
+ * mode per board). Guests: the BEST value per board is queued and
187
+ * auto-submitted if they sign in mid-game.
188
+ */
189
+ declare function submitScore(score: number, opts?: {
190
+ board?: string;
191
+ mode?: 'max' | 'min';
192
+ }): Promise<SubmitScoreResult>;
193
+ /**
194
+ * Read a leaderboard: top entries (verified display names) + this player's
195
+ * own rank when signed in. Works for EVERYONE on published games — guests and
196
+ * even tokenless contexts read the public board (me: null). limit ≤ 100;
197
+ * order "asc" for lower-is-better boards.
198
+ */
199
+ declare function getLeaderboard(opts?: {
200
+ board?: string;
201
+ limit?: number;
202
+ order?: 'desc' | 'asc';
203
+ }): Promise<Leaderboard>;
95
204
  /**
96
205
  * @internal — for ./sentry.ts ONLY, not public API (games never call this).
97
206
  * If the URL fragment carries any genex marker (`genex_ticket` — a secret —
@@ -105,4 +214,4 @@ declare function __resetForTests(overrides?: {
105
214
  refreshRetryMs?: number;
106
215
  }): void;
107
216
 
108
- export { type AuthState, type EmbedConfig, type EmbedEvent, type EmbedUser, __resetForTests, _stashTicketFromUrl, getAuthState, getColyseusAuth, getEmbedToken, getUser, initEmbed, isEmbedded, on, waitForAuth, waitForPlayer };
217
+ export { type AuthState, type EmbedConfig, type EmbedEvent, type EmbedUser, type Leaderboard, type LeaderboardEntry, type PlayerStateResult, type SaveStateResult, type SubmitScoreResult, type WorldStateResult, __resetForTests, _stashTicketFromUrl, getAuthState, getColyseusAuth, getEmbedToken, getLeaderboard, getUser, initEmbed, isEmbedded, loadPlayerState, loadWorldState, on, savePlayerState, saveWorldState, submitScore, waitForAuth, waitForPlayer };
package/dist/index.js CHANGED
@@ -4,23 +4,35 @@ import {
4
4
  getAuthState,
5
5
  getColyseusAuth,
6
6
  getEmbedToken,
7
+ getLeaderboard,
7
8
  getUser,
8
9
  initEmbed,
9
10
  isEmbedded,
11
+ loadPlayerState,
12
+ loadWorldState,
10
13
  on,
14
+ savePlayerState,
15
+ saveWorldState,
16
+ submitScore,
11
17
  waitForAuth,
12
18
  waitForPlayer
13
- } from "./chunk-S7JLPCNE.js";
19
+ } from "./chunk-6E5DPEVG.js";
14
20
  export {
15
21
  __resetForTests,
16
22
  _stashTicketFromUrl,
17
23
  getAuthState,
18
24
  getColyseusAuth,
19
25
  getEmbedToken,
26
+ getLeaderboard,
20
27
  getUser,
21
28
  initEmbed,
22
29
  isEmbedded,
30
+ loadPlayerState,
31
+ loadWorldState,
23
32
  on,
33
+ savePlayerState,
34
+ saveWorldState,
35
+ submitScore,
24
36
  waitForAuth,
25
37
  waitForPlayer
26
38
  };
package/dist/sentry.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  _stashTicketFromUrl,
3
3
  getUser,
4
4
  on
5
- } from "./chunk-S7JLPCNE.js";
5
+ } from "./chunk-6E5DPEVG.js";
6
6
 
7
7
  // src/sentry.ts
8
8
  import * as Sentry from "@sentry/browser";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genex-ai/embed-sdk",
3
- "version": "0.3.1",
4
- "description": "Player identity for genex games — signed-in (dashboard iframe postMessage handshake or sign-in redirect) or guest (accountless play for published games, sign in to save).",
3
+ "version": "0.4.0",
4
+ "description": "Player identity + durable game state for genex games — signed-in or guest play, per-player save slots, shared world state, and soft-trust leaderboards.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -20,6 +20,13 @@
20
20
  "files": [
21
21
  "dist"
22
22
  ],
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "node --test test/*.test.ts",
27
+ "check": "pnpm build && publint && attw --pack . --profile esm-only",
28
+ "prepack": "pnpm build && publint"
29
+ },
23
30
  "publishConfig": {
24
31
  "access": "public"
25
32
  },
@@ -42,11 +49,5 @@
42
49
  "url": "git+https://github.com/me-ai-org/genex-demo.git",
43
50
  "directory": "packages/embed-sdk"
44
51
  },
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
- }
52
+ "license": "MIT"
53
+ }