@flayerlabs/gamemode-client 0.3.0 → 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.
package/src/embed.ts CHANGED
@@ -1,395 +1,226 @@
1
- import type { PlayerId } from '@flayerlabs/gamemode-spec';
1
+ import type { Host, HostBuyProgress } from './live.js';
2
+ import type { BuyResult } from './index.js';
2
3
  import {
3
- EMBED_CHANNEL,
4
- EMBED_PROTOCOL_VERSION,
5
- isCanonicalSpendHookData,
6
- isPlayerIdentities,
7
- isSessionChallenge,
8
- parseGameToHostMessage,
9
- parseHostToGameMessage,
10
- type EmbedFailure,
11
- type EmbedRequest,
12
- type EmbeddedContext,
13
- type GameToHostMessage,
14
- type HostToGameMessage,
15
- } from '@flayerlabs/gamemode-spec/embed';
16
- import type { MarketState, WireMarketState } from '@flayerlabs/gamemode-spec/live';
17
- import type { ClientPlatform, IdentityResolver, Readable } from './index.js';
18
- import { immutableLaunch, marketFrom, type Authorisation, type Host, type HostBuyProgress } from './live.js';
19
- import { Signal } from './signal.js';
20
-
21
- const DEFAULT_TIMEOUT_MS = 120_000;
22
- /** A room uses far fewer; refusing after the cap keeps lifetime replay memory strictly bounded. */
23
- const MAX_ACCEPTED_REQUEST_IDS = 1_024;
24
-
25
- export interface EmbedEventWindow {
26
- addEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
27
- removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
4
+ FRAME_PROTOCOL_VERSION,
5
+ hostFrameFrom,
6
+ type BuyFailure,
7
+ type EmbedContext,
8
+ type GameToHostFrame,
9
+ type HostErrorCode,
10
+ } from '@flayerlabs/gamemode-spec/frame';
11
+
12
+ /**
13
+ * The game's end of the frame protocol: a {@link Host} reconstructed over `postMessage`.
14
+ *
15
+ * A game on flaunch.gg runs in a cross-origin iframe, so the page cannot hand it a Host object —
16
+ * the three Host calls travel as `gm:` frames instead (see `@flayerlabs/gamemode-spec/frame` for the
17
+ * shapes and the trust story). This file exists so a game never touches `postMessage` itself: it
18
+ * calls {@link connectHost} once and receives either a working Host plus the round to join, or
19
+ * `null` which is an answer, not an error. Null means "not embedded", and the caller's right
20
+ * move is a mock room, so `pnpm dev` and the real page run the same entry file.
21
+ *
22
+ * Trust is decided once, before any message is read: the parent's origin comes from the one query
23
+ * parameter the page stamps on the iframe URL, and every frame is checked against both that origin
24
+ * and the parent window itself. A frame from anywhere else is silently ignored — this listener
25
+ * shares the window with whatever else the page runs.
26
+ */
27
+
28
+ /** How often the game announces itself until the page answers. */
29
+ const HELLO_INTERVAL_MS = 250;
30
+ /** How long to wait for the page before concluding this is not an embed. */
31
+ const DEFAULT_TIMEOUT_MS = 3_000;
32
+
33
+ /**
34
+ * Where frames come from and go to. The default is the real window; tests inject a pair of these
35
+ * to run both ends of the protocol in one process with no DOM.
36
+ */
37
+ export interface FrameEndpoint {
38
+ /** Deliver every incoming message with its origin and source; returns an unlisten. */
39
+ listen(handler: (data: unknown, origin: string, source: unknown) => void): () => void;
40
+ post(message: unknown, targetOrigin: string): void;
41
+ /** What a trusted message's source must equal. Null when there is no parent to talk to. */
42
+ parentSource(): unknown;
28
43
  }
29
44
 
30
- export interface EmbedTargetWindow {
31
- postMessage(message: unknown, targetOrigin: string): void;
45
+ export interface ConnectHostOptions {
46
+ /** Default: the `parentOrigin` query parameter of the page's own URL. */
47
+ parentOrigin?: string;
48
+ timeoutMs?: number;
49
+ endpoint?: FrameEndpoint;
32
50
  }
33
51
 
34
- export interface EmbeddedGameConnection {
35
- context: EmbeddedContext;
52
+ export interface EmbeddedHost {
36
53
  host: Host;
37
- platform: ClientPlatform;
54
+ context: EmbedContext;
38
55
  dispose(): void;
39
56
  }
40
57
 
41
- interface Pending {
42
- method: EmbedRequest['method'];
43
- resolve(value: unknown): void;
44
- reject(error: Error): void;
45
- progress?: (event: HostBuyProgress) => void;
46
- timer: ReturnType<typeof setTimeout>;
58
+ function windowEndpoint(): FrameEndpoint | null {
59
+ if (typeof window === 'undefined') return null;
60
+ return {
61
+ listen(handler) {
62
+ const onMessage = (event: MessageEvent) => handler(event.data, event.origin, event.source);
63
+ window.addEventListener('message', onMessage);
64
+ return () => window.removeEventListener('message', onMessage);
65
+ },
66
+ post(message, targetOrigin) {
67
+ window.parent.postMessage(message, targetOrigin);
68
+ },
69
+ parentSource() {
70
+ return window.parent === window ? null : window.parent;
71
+ },
72
+ };
47
73
  }
48
74
 
49
- export interface ConnectEmbeddedGameOptions {
50
- /** Fixed in trusted game code, never taken from a query string supplied by the parent. */
51
- parentOrigin: string;
52
- timeoutMs?: number;
53
- currentWindow?: EmbedEventWindow;
54
- parentWindow?: EmbedTargetWindow;
75
+ function parentOriginFromLocation(): string | null {
76
+ if (typeof window === 'undefined') return null;
77
+ const origin = new URLSearchParams(window.location.search).get('parentOrigin');
78
+ return origin && origin.length > 0 ? origin : null;
55
79
  }
56
80
 
57
- /** Connect an untrusted game frame to the semantic services of its trusted parent. */
58
- export function connectEmbeddedGame(options: ConnectEmbeddedGameOptions): Promise<EmbeddedGameConnection> {
59
- const current = options.currentWindow ?? window;
60
- const parent = options.parentWindow ?? window.parent;
61
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
62
- const market = new Signal<MarketState>({ status: 'unavailable', prices: [], trades: [], marketCapUsd: null });
63
- const pending = new Map<string, Pending>();
64
- let disposed = false;
65
- let context: EmbeddedContext | null = null;
66
- let resolveContext!: (value: EmbeddedContext) => void;
67
- let rejectContext!: (error: Error) => void;
68
- const ready = new Promise<EmbeddedContext>((resolve, reject) => {
69
- resolveContext = resolve;
70
- rejectContext = reject;
71
- });
72
-
73
- const post = (message: GameToHostMessage): void => parent.postMessage(message, options.parentOrigin);
74
- const request = (value: EmbedRequest, progress?: (event: HostBuyProgress) => void): Promise<unknown> => {
75
- if (disposed) return Promise.reject(new Error('the embedded connection is closed'));
76
- const id = globalThis.crypto.randomUUID();
77
- return new Promise((resolve, reject) => {
78
- const timer = setTimeout(() => {
79
- pending.delete(id);
80
- reject(new Error('the trusted parent did not answer in time'));
81
- }, timeoutMs);
82
- pending.set(id, { method: value.method, resolve, reject, timer, ...(progress ? { progress } : {}) });
83
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'request', id, request: value });
84
- });
85
- };
86
-
87
- const onMessage: EventListener = (rawEvent): void => {
88
- const event = rawEvent as MessageEvent;
89
- if (event.origin !== options.parentOrigin || event.source !== parent) return;
90
- const message = parseHostToGameMessage(event.data);
91
- if (!message) return;
92
- if (message.type === 'context') {
93
- if (context === null) {
94
- context = Object.freeze({ ...message.context, launch: immutableLaunch(message.context.launch) });
95
- resolveContext(context);
96
- }
97
- return;
98
- }
99
- if (message.type === 'market') {
100
- const parsed = marketFrom(message.market);
101
- if (parsed) market.set(parsed);
102
- return;
103
- }
104
- const waiting = pending.get(message.id);
105
- if (!waiting) return;
106
- if (message.type === 'buy-progress') {
107
- waiting.progress?.(
108
- message.transactionHash
109
- ? { state: 'pending', transactionHash: message.transactionHash }
110
- : { state: 'pending' },
111
- );
112
- return;
113
- }
114
- pending.delete(message.id);
115
- clearTimeout(waiting.timer);
116
- if (!message.ok) {
117
- if (waiting.method === 'buy') {
118
- const reason = message.error === 'no-wallet' ? 'try-again' : message.error;
119
- waiting.resolve({ failed: { bought: false, reason } });
120
- } else {
121
- waiting.reject(new Error(message.error));
122
- }
123
- return;
124
- }
125
- waiting.resolve(message.value);
126
- };
127
- current.addEventListener('message', onMessage);
128
-
129
- const contextTimer = setTimeout(() => {
130
- disposed = true;
131
- current.removeEventListener('message', onMessage);
132
- rejectContext(new Error('the trusted parent did not provide game context'));
133
- }, timeoutMs);
134
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'ready' });
81
+ /** What the game is told when the page refuses a call outright rather than failing a buy. */
82
+ const failureFor = (code: HostErrorCode): BuyFailure => (code === 'declined' ? 'declined' : 'try-again');
135
83
 
136
- return ready.then((trustedContext) => {
137
- clearTimeout(contextTimer);
138
- const host: Host = {
139
- address: async () => {
140
- const value = await request({ method: 'address' });
141
- return typeof value === 'string' || value === null ? value : null;
142
- },
143
- signIn: async (message) => {
144
- const value = await request({ method: 'sign-in', message });
145
- if (typeof value !== 'string' || !/^0x[0-9a-fA-F]+$/.test(value)) throw new Error('invalid signature response');
146
- return value as `0x${string}`;
147
- },
148
- sessionEvidence: () => request({ method: 'session-evidence' }),
149
- buy: async (authorisation, progress) => {
150
- const value = await request({ method: 'buy', authorisation }, progress);
151
- if (typeof value !== 'object' || value === null) return { failed: { bought: false, reason: 'try-again' } };
152
- const spent = (value as { spentWei?: unknown }).spentWei;
153
- try {
154
- const spentWei = BigInt(String(spent));
155
- return spentWei >= 0n ? { spentWei } : { failed: { bought: false, reason: 'try-again' } };
156
- } catch {
157
- return { failed: { bought: false, reason: 'try-again' } };
158
- }
159
- },
160
- };
161
- const identity: IdentityResolver = {
162
- resolve: async (players) => {
163
- const value = await request({ method: 'identity', players });
164
- return isPlayerIdentities(value) ? value : [];
165
- },
84
+ type Pending =
85
+ | { kind: 'address'; resolve: (address: string | null) => void; reject: (error: Error) => void }
86
+ | { kind: 'signIn'; resolve: (signature: `0x${string}`) => void; reject: (error: Error) => void }
87
+ | {
88
+ kind: 'buy';
89
+ resolve: (outcome: { spentWei: bigint } | { failed: BuyResult }) => void;
90
+ progress: ((event: HostBuyProgress) => void) | undefined;
166
91
  };
167
- const marketReadable: Readable<MarketState> = {
168
- current: () => market.current(),
169
- subscribe: (listener) => market.subscribe(listener),
170
- };
171
- return {
172
- context: trustedContext,
173
- host,
174
- platform: { identity, market: marketReadable },
175
- dispose: () => {
176
- if (disposed) return;
177
- disposed = true;
178
- current.removeEventListener('message', onMessage);
179
- for (const waiting of pending.values()) {
180
- clearTimeout(waiting.timer);
181
- waiting.reject(new Error('the embedded connection is closed'));
182
- }
183
- pending.clear();
184
- market.clear();
185
- },
186
- };
187
- });
188
- }
189
92
 
190
- export interface ServeEmbeddedGameOptions {
191
- currentWindow?: EmbedEventWindow;
192
- frameWindow: EmbedTargetWindow;
193
- gameOrigin: string;
194
- context: EmbeddedContext;
195
- host: Host;
196
- /**
197
- * Verify the gate signature and signer for the canonical spend fields.
198
- *
199
- * The bridge itself checks the complete ABI encoding, including the required zero referrer,
200
- * before calling this seam. A false result fails closed before any wallet UI can open.
201
- */
202
- validateAuthorisation(
203
- authorisation: Authorisation,
204
- context: EmbeddedContext,
205
- signal?: AbortSignal,
206
- ): boolean | Promise<boolean>;
207
- platform?: ClientPlatform;
208
- /** Display domain used by the gate's canonical wallet challenge. */
209
- signInDomain?: string;
210
- }
93
+ /**
94
+ * Connect to the embedding page, or learn that there is none.
95
+ *
96
+ * Resolves null when the game is not in an iframe, when no `parentOrigin` was provided, or when
97
+ * the page does not answer within the timeout — a page that answers late was never going to
98
+ * service a buy either. After it resolves, individual Host calls have no timeout of their own:
99
+ * sign-in and buy legitimately wait on a human, and the page always answers, refusals included.
100
+ */
101
+ export async function connectHost(options: ConnectHostOptions = {}): Promise<EmbeddedHost | null> {
102
+ const endpoint = options.endpoint ?? windowEndpoint();
103
+ if (!endpoint) return null;
104
+ const parentOrigin = options.parentOrigin ?? parentOriginFromLocation();
105
+ if (!parentOrigin) return null;
106
+ const parent = endpoint.parentSource();
107
+ if (parent === null) return null;
211
108
 
212
- function wireMarket(state: MarketState): WireMarketState {
213
- return { ...state, trades: state.trades.map((trade) => ({ ...trade, spendWei: trade.spendWei.toString() })) };
214
- }
109
+ const pending = new Map<string, Pending>();
110
+ let unlisten: () => void = () => {};
111
+ let disposed = false;
215
112
 
216
- function failure(reason: string): EmbedFailure {
217
- return ['nothing-to-spend', 'declined', 'not-enough-for-fees', 'window-closed'].includes(reason)
218
- ? (reason as EmbedFailure)
219
- : 'try-again';
220
- }
113
+ const send = (frame: GameToHostFrame): void => endpoint.post(frame, parentOrigin);
114
+
115
+ const context = await new Promise<EmbedContext | null>((resolveContext) => {
116
+ let settled = false;
117
+ const hello = setInterval(() => send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:hello' }), HELLO_INTERVAL_MS);
118
+ const deadline = setTimeout(() => {
119
+ if (settled) return;
120
+ settled = true;
121
+ clearInterval(hello);
122
+ unlisten();
123
+ resolveContext(null);
124
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
125
+
126
+ unlisten = endpoint.listen((data, origin, source) => {
127
+ // Both checks, always: the origin says who wrote the frame, the source says which window
128
+ // sent it, and either alone can be satisfied by a page we never agreed to trust.
129
+ if (origin !== parentOrigin || source !== parent) return;
130
+ const frame = hostFrameFrom(data);
131
+ if (!frame) return;
132
+
133
+ if (frame.type === 'gm:context') {
134
+ // First context wins; the page re-sends it for every hello and nothing may move the game
135
+ // to a different round mid-flight.
136
+ if (settled) return;
137
+ settled = true;
138
+ clearInterval(hello);
139
+ clearTimeout(deadline);
140
+ resolveContext(frame.context);
141
+ return;
142
+ }
221
143
 
222
- /** Serve one known iframe. Both its origin and WindowProxy are required for every request. */
223
- export function serveEmbeddedGame(options: ServeEmbeddedGameOptions): () => void {
224
- const current = options.currentWindow ?? window;
225
- const active = new Set<string>();
226
- const acceptedRequestIds = new Set<string>();
227
- const usedAuthorisations = new Set<string>();
228
- const requestTimes: number[] = [];
229
- let buyActive = false;
230
- let signInActive = false;
231
- let evidenceActive = false;
232
- let childReady = false;
233
- let stopped = false;
234
- const lifetime = new AbortController();
235
- let latestMarket: MarketState | null = options.platform?.market?.current() ?? null;
236
- const post = (message: HostToGameMessage): void => {
237
- if (!stopped) options.frameWindow.postMessage(message, options.gameOrigin);
238
- };
239
- const answer = (id: string, value: unknown): void =>
240
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'response', id, ok: true, value });
241
- const refuse = (id: string, error: EmbedFailure): void =>
242
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'response', id, ok: false, error });
144
+ if (frame.type === 'gm:progress') {
145
+ const request = pending.get(frame.id);
146
+ if (request?.kind === 'buy') {
147
+ request.progress?.(
148
+ frame.progress.transactionHash !== undefined
149
+ ? { state: 'pending', transactionHash: frame.progress.transactionHash }
150
+ : { state: 'pending' },
151
+ );
152
+ }
153
+ return;
154
+ }
243
155
 
244
- const run = async (id: string, request: EmbedRequest): Promise<void> => {
245
- if (stopped || acceptedRequestIds.has(id) || acceptedRequestIds.size >= MAX_ACCEPTED_REQUEST_IDS) return;
246
- const now = Date.now();
247
- while (requestTimes[0] !== undefined && requestTimes[0] <= now - 1_000) requestTimes.shift();
248
- if (active.size >= 16 || requestTimes.length >= 64) return;
249
- requestTimes.push(now);
250
- acceptedRequestIds.add(id);
251
- active.add(id);
252
- try {
253
- switch (request.method) {
254
- case 'address':
255
- answer(id, await options.host.address(lifetime.signal));
256
- break;
257
- case 'sign-in':
258
- if (signInActive) {
259
- refuse(id, 'try-again');
260
- break;
261
- }
262
- signInActive = true;
263
- try {
264
- const address = await options.host.address(lifetime.signal);
265
- if (stopped) break;
266
- if (
267
- !address ||
268
- !isSessionChallenge(request.message, address, options.context.gateUrl, options.signInDomain)
269
- ) {
270
- refuse(id, address ? 'try-again' : 'no-wallet');
271
- break;
272
- }
273
- const signature = await options.host.signIn(request.message, lifetime.signal);
274
- if (stopped) break;
275
- answer(id, signature);
276
- } finally {
277
- signInActive = false;
278
- }
279
- break;
280
- case 'session-evidence':
281
- if (evidenceActive) {
282
- refuse(id, 'try-again');
283
- break;
284
- }
285
- evidenceActive = true;
286
- try {
287
- const evidence = await options.host.sessionEvidence?.(lifetime.signal);
288
- if (!stopped) answer(id, evidence);
289
- } finally {
290
- evidenceActive = false;
291
- }
292
- break;
293
- case 'identity':
294
- answer(id, (await options.platform?.identity?.resolve(request.players)) ?? []);
295
- break;
296
- case 'buy': {
297
- if (buyActive) {
298
- refuse(id, 'try-again');
299
- break;
300
- }
301
- buyActive = true;
302
- try {
303
- const address = await options.host.address(lifetime.signal);
304
- if (stopped) break;
305
- if (
306
- !address ||
307
- address.toLowerCase() !== request.authorisation.buyer.toLowerCase() ||
308
- request.authorisation.poolId.toLowerCase() !== options.context.launch.poolId.toLowerCase()
309
- ) {
310
- refuse(id, address ? 'try-again' : 'no-wallet');
311
- break;
312
- }
313
- if (
314
- !isCanonicalSpendHookData(request.authorisation) ||
315
- !(await options.validateAuthorisation(request.authorisation, options.context, lifetime.signal))
316
- ) {
317
- refuse(id, 'try-again');
318
- break;
319
- }
320
- if (stopped) break;
321
- const authorisationId = [
322
- request.authorisation.buyer.toLowerCase(),
323
- request.authorisation.poolId.toLowerCase(),
324
- request.authorisation.nonce,
325
- ].join(':');
326
- if (usedAuthorisations.has(authorisationId) || usedAuthorisations.size >= 256) {
327
- refuse(id, 'try-again');
328
- break;
329
- }
330
- usedAuthorisations.add(authorisationId);
331
- const result = await options.host.buy(
332
- request.authorisation,
333
- (event) => {
334
- post({
335
- channel: EMBED_CHANNEL,
336
- v: EMBED_PROTOCOL_VERSION,
337
- type: 'buy-progress',
338
- id,
339
- ...(event.transactionHash ? { transactionHash: event.transactionHash } : {}),
340
- });
341
- },
342
- lifetime.signal,
343
- );
344
- if (stopped) break;
345
- if ('failed' in result) {
346
- refuse(id, result.failed.bought ? 'try-again' : failure(result.failed.reason));
347
- } else {
348
- answer(id, { spentWei: result.spentWei.toString() });
349
- }
350
- } finally {
351
- buyActive = false;
352
- }
353
- break;
156
+ // gm:res terminal, exactly once per id. A late or repeated answer finds nothing here.
157
+ const request = pending.get(frame.id);
158
+ if (!request) return;
159
+ pending.delete(frame.id);
160
+
161
+ if (request.kind === 'buy') {
162
+ if (!frame.ok) {
163
+ request.resolve({ failed: { bought: false, reason: failureFor(frame.error.code) } });
164
+ } else if (frame.result.method !== 'buy') {
165
+ request.resolve({ failed: { bought: false, reason: 'try-again' } });
166
+ } else if ('spentWei' in frame.result.outcome) {
167
+ request.resolve({ spentWei: BigInt(frame.result.outcome.spentWei) });
168
+ } else {
169
+ request.resolve({ failed: frame.result.outcome.failed });
354
170
  }
171
+ return;
355
172
  }
356
- } catch {
357
- refuse(id, 'try-again');
358
- } finally {
359
- active.delete(id);
360
- }
361
- };
362
173
 
363
- const onMessage: EventListener = (rawEvent): void => {
364
- const event = rawEvent as MessageEvent;
365
- if (event.origin !== options.gameOrigin || event.source !== options.frameWindow) return;
366
- const message = parseGameToHostMessage(event.data);
367
- if (!message) return;
368
- if (message.type === 'ready') {
369
- if (childReady) return;
370
- childReady = true;
371
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'context', context: options.context });
372
- if (latestMarket) {
373
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'market', market: wireMarket(latestMarket) });
174
+ if (request.kind === 'address') {
175
+ if (frame.ok && frame.result.method === 'address') request.resolve(frame.result.address);
176
+ else request.reject(new Error(frame.ok ? 'the page answered the wrong call' : frame.error.message));
177
+ return;
374
178
  }
375
- return;
376
- }
377
- void run(message.id, message.request);
179
+
180
+ if (frame.ok && frame.result.method === 'signIn') request.resolve(frame.result.signature);
181
+ else request.reject(new Error(frame.ok ? 'the page answered the wrong call' : frame.error.message));
182
+ });
183
+ });
184
+
185
+ if (!context) return null;
186
+
187
+ const requestId = (): string => globalThis.crypto.randomUUID();
188
+
189
+ const host: Host = {
190
+ address: () =>
191
+ new Promise<string | null>((resolve, reject) => {
192
+ if (disposed) return resolve(null);
193
+ const id = requestId();
194
+ pending.set(id, { kind: 'address', resolve, reject });
195
+ send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:req', id, call: { method: 'address' } });
196
+ }),
197
+
198
+ signIn: (message: string) =>
199
+ new Promise<`0x${string}`>((resolve, reject) => {
200
+ if (disposed) return reject(new Error('this embed has been disposed'));
201
+ const id = requestId();
202
+ pending.set(id, { kind: 'signIn', resolve, reject });
203
+ send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:req', id, call: { method: 'signIn', message } });
204
+ }),
205
+
206
+ buy: (authorisation, progress) =>
207
+ new Promise<{ spentWei: bigint } | { failed: BuyResult }>((resolve) => {
208
+ if (disposed) return resolve({ failed: { bought: false, reason: 'try-again' } });
209
+ const id = requestId();
210
+ pending.set(id, { kind: 'buy', resolve, progress });
211
+ // The client's Authorisation is already all strings plus optional hookData, which is
212
+ // exactly the wire shape — nothing to convert, nothing to get wrong.
213
+ send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:req', id, call: { method: 'buy', authorisation } });
214
+ }),
378
215
  };
379
- current.addEventListener('message', onMessage);
380
- const stopMarket =
381
- options.platform?.market?.subscribe((state) => {
382
- latestMarket = state;
383
- if (childReady) {
384
- post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'market', market: wireMarket(state) });
385
- }
386
- }) ?? (() => {});
387
216
 
388
- return () => {
389
- if (stopped) return;
390
- stopped = true;
391
- lifetime.abort();
392
- current.removeEventListener('message', onMessage);
393
- stopMarket();
217
+ return {
218
+ host,
219
+ context,
220
+ dispose() {
221
+ disposed = true;
222
+ unlisten();
223
+ pending.clear();
224
+ },
394
225
  };
395
226
  }