@flayerlabs/gamemode-client 0.3.1 → 0.4.1

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/README.md CHANGED
@@ -45,23 +45,21 @@ and real spending. Call `unsubscribe()`, then `room.dispose()` when the game clo
45
45
  An embedded game first receives its context and restricted host services from the trusted parent:
46
46
 
47
47
  ```ts
48
- import { connectEmbeddedGame, joinRoom } from '@flayerlabs/gamemode-client'
48
+ import { connectHost, joinRoom } from '@flayerlabs/gamemode-client'
49
49
 
50
- const embedded = await connectEmbeddedGame({
51
- parentOrigin: 'https://flaunch.gg',
52
- })
50
+ const embedded = await connectHost()
51
+ if (!embedded) throw new Error('open this game from its coin page')
53
52
 
54
53
  const room = await joinRoom({
55
54
  gateUrl: embedded.context.gateUrl,
56
55
  roundId: embedded.context.roundId,
57
56
  launch: embedded.context.launch,
58
57
  host: embedded.host,
59
- platform: embedded.platform,
60
58
  })
61
59
  ```
62
60
 
63
61
  Creator code sends game actions and reads room state. It cannot send a wallet transaction or create
64
- Flaunch calldata. The trusted parent uses `serveEmbeddedGame` to provide the permitted wallet,
62
+ Flaunch calldata. The trusted parent uses `attachGameHost` to provide the permitted wallet,
65
63
  market and identity operations to one known iframe.
66
64
 
67
65
  Call `room.dispose()` and `embedded.dispose()` when the iframe closes.
@@ -71,18 +69,16 @@ Call `room.dispose()` and `embedded.dispose()` when the iframe closes.
71
69
  Use `joinEconomy()` when your existing server owns gameplay and scoring:
72
70
 
73
71
  ```ts
74
- import { connectEmbeddedGame, joinEconomy } from '@flayerlabs/gamemode-client'
72
+ import { connectHost, joinEconomy } from '@flayerlabs/gamemode-client'
75
73
 
76
- const embedded = await connectEmbeddedGame({
77
- parentOrigin: 'https://flaunch.gg',
78
- })
74
+ const embedded = await connectHost()
75
+ if (!embedded) throw new Error('open this game from its coin page')
79
76
 
80
77
  const gameMode = await joinEconomy({
81
78
  gateUrl: embedded.context.gateUrl,
82
79
  roundId: embedded.context.roundId,
83
80
  launch: embedded.context.launch,
84
81
  host: embedded.host,
85
- platform: embedded.platform,
86
82
  })
87
83
 
88
84
  const stop = gameMode.economy.subscribe(renderAllowance)
@@ -129,7 +125,7 @@ to build market presentation without a live chain.
129
125
  ## Integrate the trusted parent
130
126
 
131
127
  Read the [trusted platform integration guide](https://github.com/flayerlabs/gamemode-sdk/blob/main/docs/guides/integrate-the-platform.md)
132
- before implementing `serveEmbeddedGame`, `Host` or a production `joinRoom` call.
128
+ before implementing `attachGameHost`, `Host` or a production `joinRoom` call.
133
129
 
134
130
  The [quiz example](https://github.com/flayerlabs/gamemode-sdk/tree/main/examples/quiz) shows a live
135
131
  rules room. The [external-server example](https://github.com/flayerlabs/gamemode-sdk/tree/main/examples/external-server)
package/dist/embed.d.ts CHANGED
@@ -1,45 +1,34 @@
1
- import { type EmbeddedContext } from '@flayerlabs/gamemode-spec/embed';
2
- import type { ClientPlatform } from './index.js';
3
- import { type Authorisation, type Host } from './live.js';
4
- export interface EmbedEventWindow {
5
- addEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
6
- removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
1
+ import type { Host } from './live.js';
2
+ import { type EmbedContext } from '@flayerlabs/gamemode-spec/frame';
3
+ /**
4
+ * Where frames come from and go to. The default is the real window; tests inject a pair of these
5
+ * to run both ends of the protocol in one process with no DOM.
6
+ */
7
+ export interface FrameEndpoint {
8
+ /** Deliver every incoming message with its origin and source; returns an unlisten. */
9
+ listen(handler: (data: unknown, origin: string, source: unknown) => void): () => void;
10
+ post(message: unknown, targetOrigin: string): void;
11
+ /** What a trusted message's source must equal. Null when there is no parent to talk to. */
12
+ parentSource(): unknown;
7
13
  }
8
- export interface EmbedTargetWindow {
9
- postMessage(message: unknown, targetOrigin: string): void;
10
- }
11
- export interface EmbeddedGameConnection {
12
- context: EmbeddedContext;
13
- host: Host;
14
- platform: ClientPlatform;
15
- dispose(): void;
16
- }
17
- export interface ConnectEmbeddedGameOptions {
18
- /** Fixed in trusted game code, never taken from a query string supplied by the parent. */
19
- parentOrigin: string;
14
+ export interface ConnectHostOptions {
15
+ /** Default: the `parentOrigin` query parameter of the page's own URL. */
16
+ parentOrigin?: string;
20
17
  timeoutMs?: number;
21
- currentWindow?: EmbedEventWindow;
22
- parentWindow?: EmbedTargetWindow;
18
+ endpoint?: FrameEndpoint;
23
19
  }
24
- /** Connect an untrusted game frame to the semantic services of its trusted parent. */
25
- export declare function connectEmbeddedGame(options: ConnectEmbeddedGameOptions): Promise<EmbeddedGameConnection>;
26
- export interface ServeEmbeddedGameOptions {
27
- currentWindow?: EmbedEventWindow;
28
- frameWindow: EmbedTargetWindow;
29
- gameOrigin: string;
30
- context: EmbeddedContext;
20
+ export interface EmbeddedHost {
31
21
  host: Host;
32
- /**
33
- * Verify the gate signature and signer for the canonical spend fields.
34
- *
35
- * The bridge itself checks the complete ABI encoding, including the required zero referrer,
36
- * before calling this seam. A false result fails closed before any wallet UI can open.
37
- */
38
- validateAuthorisation(authorisation: Authorisation, context: EmbeddedContext, signal?: AbortSignal): boolean | Promise<boolean>;
39
- platform?: ClientPlatform;
40
- /** Display domain used by the gate's canonical wallet challenge. */
41
- signInDomain?: string;
22
+ context: EmbedContext;
23
+ dispose(): void;
42
24
  }
43
- /** Serve one known iframe. Both its origin and WindowProxy are required for every request. */
44
- export declare function serveEmbeddedGame(options: ServeEmbeddedGameOptions): () => void;
25
+ /**
26
+ * Connect to the embedding page, or learn that there is none.
27
+ *
28
+ * Resolves null when the game is not in an iframe, when no `parentOrigin` was provided, or when
29
+ * the page does not answer within the timeout — a page that answers late was never going to
30
+ * service a buy either. After it resolves, individual Host calls have no timeout of their own:
31
+ * sign-in and buy legitimately wait on a human, and the page always answers, refusals included.
32
+ */
33
+ export declare function connectHost(options?: ConnectHostOptions): Promise<EmbeddedHost | null>;
45
34
  //# sourceMappingURL=embed.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"embed.d.ts","sourceRoot":"","sources":["../src/embed.ts"],"names":[],"mappings":"AACA,OAAO,EAUL,KAAK,eAAe,EAGrB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,cAAc,EAA8B,MAAM,YAAY,CAAC;AAC7E,OAAO,EAA+B,KAAK,aAAa,EAAE,KAAK,IAAI,EAAwB,MAAM,WAAW,CAAC;AAO7G,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,kCAAkC,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1F,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,kCAAkC,GAAG,IAAI,GAAG,IAAI,CAAC;CAC9F;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3D;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,eAAe,CAAC;IACzB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,cAAc,CAAC;IACzB,OAAO,IAAI,IAAI,CAAC;CACjB;AAUD,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,gBAAgB,CAAC;IACjC,YAAY,CAAC,EAAE,iBAAiB,CAAC;CAClC;AAED,sFAAsF;AACtF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAkIxG;AAED,MAAM,WAAW,wBAAwB;IACvC,aAAa,CAAC,EAAE,gBAAgB,CAAC;IACjC,WAAW,EAAE,iBAAiB,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,eAAe,CAAC;IACzB,IAAI,EAAE,IAAI,CAAC;IACX;;;;;OAKG;IACH,qBAAqB,CACnB,aAAa,EAAE,aAAa,EAC5B,OAAO,EAAE,eAAe,EACxB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAYD,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,IAAI,CA4K/E"}
1
+ {"version":3,"file":"embed.d.ts","sourceRoot":"","sources":["../src/embed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAmB,MAAM,WAAW,CAAC;AAEvD,OAAO,EAIL,KAAK,YAAY,EAGlB,MAAM,iCAAiC,CAAC;AAuBzC;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,sFAAsF;IACtF,MAAM,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACtF,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACnD,2FAA2F;IAC3F,YAAY,IAAI,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,YAAY,CAAC;IACtB,OAAO,IAAI,IAAI,CAAC;CACjB;AAqCD;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CA6HhG"}
package/dist/embed.js CHANGED
@@ -1,329 +1,178 @@
1
- import { EMBED_CHANNEL, EMBED_PROTOCOL_VERSION, isCanonicalSpendHookData, isPlayerIdentities, isSessionChallenge, parseGameToHostMessage, parseHostToGameMessage, } from '@flayerlabs/gamemode-spec/embed';
2
- import { immutableLaunch, marketFrom } from './live.js';
3
- import { Signal } from './signal.js';
4
- const DEFAULT_TIMEOUT_MS = 120_000;
5
- /** A room uses far fewer; refusing after the cap keeps lifetime replay memory strictly bounded. */
6
- const MAX_ACCEPTED_REQUEST_IDS = 1_024;
7
- /** Connect an untrusted game frame to the semantic services of its trusted parent. */
8
- export function connectEmbeddedGame(options) {
9
- const current = options.currentWindow ?? window;
10
- const parent = options.parentWindow ?? window.parent;
11
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12
- const market = new Signal({ status: 'unavailable', prices: [], trades: [], marketCapUsd: null });
1
+ import { FRAME_PROTOCOL_VERSION, hostFrameFrom, } from '@flayerlabs/gamemode-spec/frame';
2
+ /**
3
+ * The game's end of the frame protocol: a {@link Host} reconstructed over `postMessage`.
4
+ *
5
+ * A game on flaunch.gg runs in a cross-origin iframe, so the page cannot hand it a Host object
6
+ * the three Host calls travel as `gm:` frames instead (see `@flayerlabs/gamemode-spec/frame` for the
7
+ * shapes and the trust story). This file exists so a game never touches `postMessage` itself: it
8
+ * calls {@link connectHost} once and receives either a working Host plus the round to join, or
9
+ * `null` — which is an answer, not an error. Null means "not embedded", and the caller's right
10
+ * move is a mock room, so `pnpm dev` and the real page run the same entry file.
11
+ *
12
+ * Trust is decided once, before any message is read: the parent's origin comes from the one query
13
+ * parameter the page stamps on the iframe URL, and every frame is checked against both that origin
14
+ * and the parent window itself. A frame from anywhere else is silently ignored — this listener
15
+ * shares the window with whatever else the page runs.
16
+ */
17
+ /** How often the game announces itself until the page answers. */
18
+ const HELLO_INTERVAL_MS = 250;
19
+ /** How long to wait for the page before concluding this is not an embed. */
20
+ const DEFAULT_TIMEOUT_MS = 3_000;
21
+ function windowEndpoint() {
22
+ if (typeof window === 'undefined')
23
+ return null;
24
+ return {
25
+ listen(handler) {
26
+ const onMessage = (event) => handler(event.data, event.origin, event.source);
27
+ window.addEventListener('message', onMessage);
28
+ return () => window.removeEventListener('message', onMessage);
29
+ },
30
+ post(message, targetOrigin) {
31
+ window.parent.postMessage(message, targetOrigin);
32
+ },
33
+ parentSource() {
34
+ return window.parent === window ? null : window.parent;
35
+ },
36
+ };
37
+ }
38
+ function parentOriginFromLocation() {
39
+ if (typeof window === 'undefined')
40
+ return null;
41
+ const origin = new URLSearchParams(window.location.search).get('parentOrigin');
42
+ return origin && origin.length > 0 ? origin : null;
43
+ }
44
+ /** What the game is told when the page refuses a call outright rather than failing a buy. */
45
+ const failureFor = (code) => (code === 'declined' ? 'declined' : 'try-again');
46
+ /**
47
+ * Connect to the embedding page, or learn that there is none.
48
+ *
49
+ * Resolves null when the game is not in an iframe, when no `parentOrigin` was provided, or when
50
+ * the page does not answer within the timeout — a page that answers late was never going to
51
+ * service a buy either. After it resolves, individual Host calls have no timeout of their own:
52
+ * sign-in and buy legitimately wait on a human, and the page always answers, refusals included.
53
+ */
54
+ export async function connectHost(options = {}) {
55
+ const endpoint = options.endpoint ?? windowEndpoint();
56
+ if (!endpoint)
57
+ return null;
58
+ const parentOrigin = options.parentOrigin ?? parentOriginFromLocation();
59
+ if (!parentOrigin)
60
+ return null;
61
+ const parent = endpoint.parentSource();
62
+ if (parent === null)
63
+ return null;
13
64
  const pending = new Map();
65
+ let unlisten = () => { };
14
66
  let disposed = false;
15
- let context = null;
16
- let resolveContext;
17
- let rejectContext;
18
- const ready = new Promise((resolve, reject) => {
19
- resolveContext = resolve;
20
- rejectContext = reject;
21
- });
22
- const post = (message) => parent.postMessage(message, options.parentOrigin);
23
- const request = (value, progress) => {
24
- if (disposed)
25
- return Promise.reject(new Error('the embedded connection is closed'));
26
- const id = globalThis.crypto.randomUUID();
27
- return new Promise((resolve, reject) => {
28
- const timer = setTimeout(() => {
29
- pending.delete(id);
30
- reject(new Error('the trusted parent did not answer in time'));
31
- }, timeoutMs);
32
- pending.set(id, { method: value.method, resolve, reject, timer, ...(progress ? { progress } : {}) });
33
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'request', id, request: value });
34
- });
35
- };
36
- const onMessage = (rawEvent) => {
37
- const event = rawEvent;
38
- if (event.origin !== options.parentOrigin || event.source !== parent)
39
- return;
40
- const message = parseHostToGameMessage(event.data);
41
- if (!message)
42
- return;
43
- if (message.type === 'context') {
44
- if (context === null) {
45
- context = Object.freeze({ ...message.context, launch: immutableLaunch(message.context.launch) });
46
- resolveContext(context);
47
- }
48
- return;
49
- }
50
- if (message.type === 'market') {
51
- const parsed = marketFrom(message.market);
52
- if (parsed)
53
- market.set(parsed);
54
- return;
55
- }
56
- const waiting = pending.get(message.id);
57
- if (!waiting)
58
- return;
59
- if (message.type === 'buy-progress') {
60
- waiting.progress?.(message.transactionHash
61
- ? { state: 'pending', transactionHash: message.transactionHash }
62
- : { state: 'pending' });
63
- return;
64
- }
65
- pending.delete(message.id);
66
- clearTimeout(waiting.timer);
67
- if (!message.ok) {
68
- if (waiting.method === 'buy') {
69
- const reason = message.error === 'no-wallet' ? 'try-again' : message.error;
70
- waiting.resolve({ failed: { bought: false, reason } });
67
+ const send = (frame) => endpoint.post(frame, parentOrigin);
68
+ const context = await new Promise((resolveContext) => {
69
+ let settled = false;
70
+ const hello = setInterval(() => send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:hello' }), HELLO_INTERVAL_MS);
71
+ const deadline = setTimeout(() => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ clearInterval(hello);
76
+ unlisten();
77
+ resolveContext(null);
78
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
79
+ unlisten = endpoint.listen((data, origin, source) => {
80
+ // Both checks, always: the origin says who wrote the frame, the source says which window
81
+ // sent it, and either alone can be satisfied by a page we never agreed to trust.
82
+ if (origin !== parentOrigin || source !== parent)
83
+ return;
84
+ const frame = hostFrameFrom(data);
85
+ if (!frame)
86
+ return;
87
+ if (frame.type === 'gm:context') {
88
+ // First context wins; the page re-sends it for every hello and nothing may move the game
89
+ // to a different round mid-flight.
90
+ if (settled)
91
+ return;
92
+ settled = true;
93
+ clearInterval(hello);
94
+ clearTimeout(deadline);
95
+ resolveContext(frame.context);
96
+ return;
71
97
  }
72
- else {
73
- waiting.reject(new Error(message.error));
98
+ if (frame.type === 'gm:progress') {
99
+ const request = pending.get(frame.id);
100
+ if (request?.kind === 'buy') {
101
+ request.progress?.(frame.progress.transactionHash !== undefined
102
+ ? { state: 'pending', transactionHash: frame.progress.transactionHash }
103
+ : { state: 'pending' });
104
+ }
105
+ return;
74
106
  }
75
- return;
76
- }
77
- waiting.resolve(message.value);
78
- };
79
- current.addEventListener('message', onMessage);
80
- const contextTimer = setTimeout(() => {
81
- disposed = true;
82
- current.removeEventListener('message', onMessage);
83
- rejectContext(new Error('the trusted parent did not provide game context'));
84
- }, timeoutMs);
85
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'ready' });
86
- return ready.then((trustedContext) => {
87
- clearTimeout(contextTimer);
88
- const host = {
89
- address: async () => {
90
- const value = await request({ method: 'address' });
91
- return typeof value === 'string' || value === null ? value : null;
92
- },
93
- signIn: async (message) => {
94
- const value = await request({ method: 'sign-in', message });
95
- if (typeof value !== 'string' || !/^0x[0-9a-fA-F]+$/.test(value))
96
- throw new Error('invalid signature response');
97
- return value;
98
- },
99
- sessionEvidence: () => request({ method: 'session-evidence' }),
100
- buy: async (authorisation, progress) => {
101
- const value = await request({ method: 'buy', authorisation }, progress);
102
- if (typeof value !== 'object' || value === null)
103
- return { failed: { bought: false, reason: 'try-again' } };
104
- const spent = value.spentWei;
105
- try {
106
- const spentWei = BigInt(String(spent));
107
- return spentWei >= 0n ? { spentWei } : { failed: { bought: false, reason: 'try-again' } };
107
+ // gm:res — terminal, exactly once per id. A late or repeated answer finds nothing here.
108
+ const request = pending.get(frame.id);
109
+ if (!request)
110
+ return;
111
+ pending.delete(frame.id);
112
+ if (request.kind === 'buy') {
113
+ if (!frame.ok) {
114
+ request.resolve({ failed: { bought: false, reason: failureFor(frame.error.code) } });
108
115
  }
109
- catch {
110
- return { failed: { bought: false, reason: 'try-again' } };
116
+ else if (frame.result.method !== 'buy') {
117
+ request.resolve({ failed: { bought: false, reason: 'try-again' } });
111
118
  }
112
- },
113
- };
114
- const identity = {
115
- resolve: async (players) => {
116
- const value = await request({ method: 'identity', players });
117
- return isPlayerIdentities(value) ? value : [];
118
- },
119
- };
120
- const marketReadable = {
121
- current: () => market.current(),
122
- subscribe: (listener) => market.subscribe(listener),
123
- };
124
- return {
125
- context: trustedContext,
126
- host,
127
- platform: { identity, market: marketReadable },
128
- dispose: () => {
129
- if (disposed)
130
- return;
131
- disposed = true;
132
- current.removeEventListener('message', onMessage);
133
- for (const waiting of pending.values()) {
134
- clearTimeout(waiting.timer);
135
- waiting.reject(new Error('the embedded connection is closed'));
119
+ else if ('spentWei' in frame.result.outcome) {
120
+ request.resolve({ spentWei: BigInt(frame.result.outcome.spentWei) });
136
121
  }
137
- pending.clear();
138
- market.clear();
139
- },
140
- };
141
- });
142
- }
143
- function wireMarket(state) {
144
- return { ...state, trades: state.trades.map((trade) => ({ ...trade, spendWei: trade.spendWei.toString() })) };
145
- }
146
- function failure(reason) {
147
- return ['nothing-to-spend', 'declined', 'not-enough-for-fees', 'window-closed'].includes(reason)
148
- ? reason
149
- : 'try-again';
150
- }
151
- /** Serve one known iframe. Both its origin and WindowProxy are required for every request. */
152
- export function serveEmbeddedGame(options) {
153
- const current = options.currentWindow ?? window;
154
- const active = new Set();
155
- const acceptedRequestIds = new Set();
156
- const usedAuthorisations = new Set();
157
- const requestTimes = [];
158
- let buyActive = false;
159
- let signInActive = false;
160
- let evidenceActive = false;
161
- let childReady = false;
162
- let stopped = false;
163
- const lifetime = new AbortController();
164
- let latestMarket = options.platform?.market?.current() ?? null;
165
- const post = (message) => {
166
- if (!stopped)
167
- options.frameWindow.postMessage(message, options.gameOrigin);
168
- };
169
- const answer = (id, value) => post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'response', id, ok: true, value });
170
- const refuse = (id, error) => post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'response', id, ok: false, error });
171
- const run = async (id, request) => {
172
- if (stopped || acceptedRequestIds.has(id) || acceptedRequestIds.size >= MAX_ACCEPTED_REQUEST_IDS)
173
- return;
174
- const now = Date.now();
175
- while (requestTimes[0] !== undefined && requestTimes[0] <= now - 1_000)
176
- requestTimes.shift();
177
- if (active.size >= 16 || requestTimes.length >= 64)
178
- return;
179
- requestTimes.push(now);
180
- acceptedRequestIds.add(id);
181
- active.add(id);
182
- try {
183
- switch (request.method) {
184
- case 'address':
185
- answer(id, await options.host.address(lifetime.signal));
186
- break;
187
- case 'sign-in':
188
- if (signInActive) {
189
- refuse(id, 'try-again');
190
- break;
191
- }
192
- signInActive = true;
193
- try {
194
- const address = await options.host.address(lifetime.signal);
195
- if (stopped)
196
- break;
197
- if (!address ||
198
- !isSessionChallenge(request.message, address, options.context.gateUrl, options.signInDomain)) {
199
- refuse(id, address ? 'try-again' : 'no-wallet');
200
- break;
201
- }
202
- const signature = await options.host.signIn(request.message, lifetime.signal);
203
- if (stopped)
204
- break;
205
- answer(id, signature);
206
- }
207
- finally {
208
- signInActive = false;
209
- }
210
- break;
211
- case 'session-evidence':
212
- if (evidenceActive) {
213
- refuse(id, 'try-again');
214
- break;
215
- }
216
- evidenceActive = true;
217
- try {
218
- const evidence = await options.host.sessionEvidence?.(lifetime.signal);
219
- if (!stopped)
220
- answer(id, evidence);
221
- }
222
- finally {
223
- evidenceActive = false;
224
- }
225
- break;
226
- case 'identity':
227
- answer(id, (await options.platform?.identity?.resolve(request.players)) ?? []);
228
- break;
229
- case 'buy': {
230
- if (buyActive) {
231
- refuse(id, 'try-again');
232
- break;
233
- }
234
- buyActive = true;
235
- try {
236
- const address = await options.host.address(lifetime.signal);
237
- if (stopped)
238
- break;
239
- if (!address ||
240
- address.toLowerCase() !== request.authorisation.buyer.toLowerCase() ||
241
- request.authorisation.poolId.toLowerCase() !== options.context.launch.poolId.toLowerCase()) {
242
- refuse(id, address ? 'try-again' : 'no-wallet');
243
- break;
244
- }
245
- if (!isCanonicalSpendHookData(request.authorisation) ||
246
- !(await options.validateAuthorisation(request.authorisation, options.context, lifetime.signal))) {
247
- refuse(id, 'try-again');
248
- break;
249
- }
250
- if (stopped)
251
- break;
252
- const authorisationId = [
253
- request.authorisation.buyer.toLowerCase(),
254
- request.authorisation.poolId.toLowerCase(),
255
- request.authorisation.nonce,
256
- ].join(':');
257
- if (usedAuthorisations.has(authorisationId) || usedAuthorisations.size >= 256) {
258
- refuse(id, 'try-again');
259
- break;
260
- }
261
- usedAuthorisations.add(authorisationId);
262
- const result = await options.host.buy(request.authorisation, (event) => {
263
- post({
264
- channel: EMBED_CHANNEL,
265
- v: EMBED_PROTOCOL_VERSION,
266
- type: 'buy-progress',
267
- id,
268
- ...(event.transactionHash ? { transactionHash: event.transactionHash } : {}),
269
- });
270
- }, lifetime.signal);
271
- if (stopped)
272
- break;
273
- if ('failed' in result) {
274
- refuse(id, result.failed.bought ? 'try-again' : failure(result.failed.reason));
275
- }
276
- else {
277
- answer(id, { spentWei: result.spentWei.toString() });
278
- }
279
- }
280
- finally {
281
- buyActive = false;
282
- }
283
- break;
122
+ else {
123
+ request.resolve({ failed: frame.result.outcome.failed });
284
124
  }
125
+ return;
285
126
  }
286
- }
287
- catch {
288
- refuse(id, 'try-again');
289
- }
290
- finally {
291
- active.delete(id);
292
- }
293
- };
294
- const onMessage = (rawEvent) => {
295
- const event = rawEvent;
296
- if (event.origin !== options.gameOrigin || event.source !== options.frameWindow)
297
- return;
298
- const message = parseGameToHostMessage(event.data);
299
- if (!message)
300
- return;
301
- if (message.type === 'ready') {
302
- if (childReady)
127
+ if (request.kind === 'address') {
128
+ if (frame.ok && frame.result.method === 'address')
129
+ request.resolve(frame.result.address);
130
+ else
131
+ request.reject(new Error(frame.ok ? 'the page answered the wrong call' : frame.error.message));
303
132
  return;
304
- childReady = true;
305
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'context', context: options.context });
306
- if (latestMarket) {
307
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'market', market: wireMarket(latestMarket) });
308
133
  }
309
- return;
310
- }
311
- void run(message.id, message.request);
134
+ if (frame.ok && frame.result.method === 'signIn')
135
+ request.resolve(frame.result.signature);
136
+ else
137
+ request.reject(new Error(frame.ok ? 'the page answered the wrong call' : frame.error.message));
138
+ });
139
+ });
140
+ if (!context)
141
+ return null;
142
+ const requestId = () => globalThis.crypto.randomUUID();
143
+ const host = {
144
+ address: () => new Promise((resolve, reject) => {
145
+ if (disposed)
146
+ return resolve(null);
147
+ const id = requestId();
148
+ pending.set(id, { kind: 'address', resolve, reject });
149
+ send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:req', id, call: { method: 'address' } });
150
+ }),
151
+ signIn: (message) => new Promise((resolve, reject) => {
152
+ if (disposed)
153
+ return reject(new Error('this embed has been disposed'));
154
+ const id = requestId();
155
+ pending.set(id, { kind: 'signIn', resolve, reject });
156
+ send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:req', id, call: { method: 'signIn', message } });
157
+ }),
158
+ buy: (authorisation, progress) => new Promise((resolve) => {
159
+ if (disposed)
160
+ return resolve({ failed: { bought: false, reason: 'try-again' } });
161
+ const id = requestId();
162
+ pending.set(id, { kind: 'buy', resolve, progress });
163
+ // The client's Authorisation is already all strings plus optional hookData, which is
164
+ // exactly the wire shape — nothing to convert, nothing to get wrong.
165
+ send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:req', id, call: { method: 'buy', authorisation } });
166
+ }),
312
167
  };
313
- current.addEventListener('message', onMessage);
314
- const stopMarket = options.platform?.market?.subscribe((state) => {
315
- latestMarket = state;
316
- if (childReady) {
317
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'market', market: wireMarket(state) });
318
- }
319
- }) ?? (() => { });
320
- return () => {
321
- if (stopped)
322
- return;
323
- stopped = true;
324
- lifetime.abort();
325
- current.removeEventListener('message', onMessage);
326
- stopMarket();
168
+ return {
169
+ host,
170
+ context,
171
+ dispose() {
172
+ disposed = true;
173
+ unlisten();
174
+ pending.clear();
175
+ },
327
176
  };
328
177
  }
329
178
  //# sourceMappingURL=embed.js.map