@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/README.md +8 -12
- package/dist/embed.d.ts +28 -39
- package/dist/embed.d.ts.map +1 -1
- package/dist/embed.js +163 -314
- package/dist/embed.js.map +1 -1
- package/dist/host.d.ts +48 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +142 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/src/embed.ts +198 -367
- package/src/host.ts +178 -0
- package/src/index.ts +8 -13
package/src/host.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Host } from './live.js';
|
|
2
|
+
import {
|
|
3
|
+
FRAME_PROTOCOL_VERSION,
|
|
4
|
+
gameFrameFrom,
|
|
5
|
+
type EmbedContext,
|
|
6
|
+
type HostErrorCode,
|
|
7
|
+
type HostToGameFrame,
|
|
8
|
+
} from '@flayerlabs/gamemode-spec/frame';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The page's end of the frame protocol: service a game iframe's Host calls.
|
|
12
|
+
*
|
|
13
|
+
* This file is its own entry (`@flayerlabs/gamemode-client/host`) rather than part of the main one,
|
|
14
|
+
* because the page that embeds a game needs none of the game's machinery — importing the parent
|
|
15
|
+
* side must not drag `LiveRoom` or the mock into an embedder's bundle. The only thing it shares
|
|
16
|
+
* with the game side is the wire contract in `@flayerlabs/gamemode-spec/frame`; `Host` crosses as a
|
|
17
|
+
* type alone.
|
|
18
|
+
*
|
|
19
|
+
* The page implements the three Host methods with its real wallet; this function does the
|
|
20
|
+
* listening, the pinning and the dispatch. It answers every `gm:hello` with the context (the frame
|
|
21
|
+
* may have said hello before this listener existed, so the context is also posted unprompted on
|
|
22
|
+
* attach — both mount orders work), and it answers every request, refusals included: a game left
|
|
23
|
+
* waiting cannot tell a slow human from a broken page.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Thrown by a page's Host implementation to name WHY a call was refused. Anything else thrown maps
|
|
28
|
+
* to 'failed', so a page that never throws this still behaves — it just tells the game less.
|
|
29
|
+
*/
|
|
30
|
+
export class HostRefusal extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
public readonly code: HostErrorCode,
|
|
33
|
+
message: string,
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'HostRefusal';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The one thing a frame's window must be able to do. Structural, so tests can stand one in. */
|
|
41
|
+
export interface PostTarget {
|
|
42
|
+
postMessage(message: unknown, targetOrigin: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where the page hears messages. Defaults to the real window; injectable for tests. */
|
|
46
|
+
export interface HostListenSource {
|
|
47
|
+
listen(handler: (data: unknown, origin: string, source: unknown) => void): () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AttachGameHostOptions {
|
|
51
|
+
/** The iframe element (or anything with its `contentWindow`). Read at send time, not attach time. */
|
|
52
|
+
frame: { contentWindow: PostTarget | null };
|
|
53
|
+
/** The game's origin, from the iframe URL the page itself built — never from a message. */
|
|
54
|
+
gameOrigin: string;
|
|
55
|
+
/** What the game needs to join its round. A function is read fresh per send. */
|
|
56
|
+
context: EmbedContext | (() => EmbedContext);
|
|
57
|
+
host: Host;
|
|
58
|
+
endpoint?: HostListenSource;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function windowListenSource(): HostListenSource {
|
|
62
|
+
return {
|
|
63
|
+
listen(handler) {
|
|
64
|
+
const onMessage = (event: MessageEvent) => handler(event.data, event.origin, event.source);
|
|
65
|
+
window.addEventListener('message', onMessage);
|
|
66
|
+
return () => window.removeEventListener('message', onMessage);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const refusalOf = (error: unknown): { code: HostErrorCode; message: string } =>
|
|
72
|
+
error instanceof HostRefusal
|
|
73
|
+
? { code: error.code, message: error.message }
|
|
74
|
+
: { code: 'failed', message: error instanceof Error ? error.message : 'something went wrong' };
|
|
75
|
+
|
|
76
|
+
/** Attach the page's Host to a game iframe. Returns detach; after it, nothing is ever posted again. */
|
|
77
|
+
export function attachGameHost(options: AttachGameHostOptions): () => void {
|
|
78
|
+
const { frame, gameOrigin, host } = options;
|
|
79
|
+
const endpoint = options.endpoint ?? windowListenSource();
|
|
80
|
+
// Detached is checked at every send because a settling buy outlives many renders: the answer to
|
|
81
|
+
// a request accepted by THIS attachment must never be posted into a frame we no longer own.
|
|
82
|
+
let detached = false;
|
|
83
|
+
|
|
84
|
+
const send = (message: HostToGameFrame): void => {
|
|
85
|
+
if (detached) return;
|
|
86
|
+
frame.contentWindow?.postMessage(message, gameOrigin);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const sendContext = (): void => {
|
|
90
|
+
const context = typeof options.context === 'function' ? options.context() : options.context;
|
|
91
|
+
send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:context', context });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const unlisten = endpoint.listen((data, origin, source) => {
|
|
95
|
+
// Both checks, always: origin says who wrote the frame, source says which window sent it.
|
|
96
|
+
// This listener shares the page's window with every other frame the page hosts.
|
|
97
|
+
if (detached || origin !== gameOrigin || source !== frame.contentWindow) return;
|
|
98
|
+
const parsed = gameFrameFrom(data);
|
|
99
|
+
if (!parsed) return;
|
|
100
|
+
|
|
101
|
+
if (parsed.type === 'gm:hello') {
|
|
102
|
+
sendContext();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { id, call } = parsed;
|
|
107
|
+
void (async () => {
|
|
108
|
+
try {
|
|
109
|
+
if (call.method === 'address') {
|
|
110
|
+
const address = await host.address();
|
|
111
|
+
send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:res', id, ok: true, result: { method: 'address', address } });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (call.method === 'signIn') {
|
|
115
|
+
const signature = await host.signIn(call.message);
|
|
116
|
+
send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:res', id, ok: true, result: { method: 'signIn', signature } });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// The wire allows hookData to be absent (older frames), but a buy cannot be executed
|
|
120
|
+
// without its opaque proof — refuse here, at the execution boundary, in the protocol's
|
|
121
|
+
// own vocabulary rather than letting a type hole reach the wallet call.
|
|
122
|
+
const { hookData } = call.authorisation;
|
|
123
|
+
if (hookData === undefined) {
|
|
124
|
+
send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:res', id, ok: false, error: { code: 'failed', message: 'that authorisation carries no hookData' } });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const outcome = await host.buy({ ...call.authorisation, hookData }, (progress) => {
|
|
128
|
+
send({
|
|
129
|
+
v: FRAME_PROTOCOL_VERSION,
|
|
130
|
+
type: 'gm:progress',
|
|
131
|
+
id,
|
|
132
|
+
progress:
|
|
133
|
+
progress.transactionHash !== undefined
|
|
134
|
+
? { state: 'pending', transactionHash: progress.transactionHash }
|
|
135
|
+
: { state: 'pending' },
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
if ('spentWei' in outcome) {
|
|
139
|
+
send({
|
|
140
|
+
v: FRAME_PROTOCOL_VERSION,
|
|
141
|
+
type: 'gm:res',
|
|
142
|
+
id,
|
|
143
|
+
ok: true,
|
|
144
|
+
result: { method: 'buy', outcome: { spentWei: outcome.spentWei.toString() } },
|
|
145
|
+
});
|
|
146
|
+
} else if (outcome.failed.bought === false) {
|
|
147
|
+
send({
|
|
148
|
+
v: FRAME_PROTOCOL_VERSION,
|
|
149
|
+
type: 'gm:res',
|
|
150
|
+
id,
|
|
151
|
+
ok: true,
|
|
152
|
+
result: { method: 'buy', outcome: { failed: outcome.failed } },
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
// A Host reporting success through the failure channel is a bug on the page's side;
|
|
156
|
+
// the game is told the plain truth it can act on.
|
|
157
|
+
send({
|
|
158
|
+
v: FRAME_PROTOCOL_VERSION,
|
|
159
|
+
type: 'gm:res',
|
|
160
|
+
id,
|
|
161
|
+
ok: true,
|
|
162
|
+
result: { method: 'buy', outcome: { spentWei: outcome.failed.spentWei.toString() } },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
send({ v: FRAME_PROTOCOL_VERSION, type: 'gm:res', id, ok: false, error: refusalOf(error) });
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Unprompted, because the frame may have said hello before this listener existed.
|
|
172
|
+
sendContext();
|
|
173
|
+
|
|
174
|
+
return () => {
|
|
175
|
+
detached = true;
|
|
176
|
+
unlisten();
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,15 +29,14 @@ export type {
|
|
|
29
29
|
} from '@flayerlabs/gamemode-spec/live';
|
|
30
30
|
|
|
31
31
|
export { joinEconomy, joinRoom, NoWallet } from './live.js';
|
|
32
|
-
export { connectEmbeddedGame, serveEmbeddedGame } from './embed.js';
|
|
33
|
-
export type {
|
|
34
|
-
ConnectEmbeddedGameOptions,
|
|
35
|
-
EmbeddedGameConnection,
|
|
36
|
-
ServeEmbeddedGameOptions,
|
|
37
|
-
} from './embed.js';
|
|
38
32
|
export { BUSY_LAUNCH, replayMarket } from './replay-market.js';
|
|
39
33
|
export type { MarketFixture, ReplayMarket } from './replay-market.js';
|
|
40
34
|
export type { Authorisation, Host, HostBuyProgress, LiveOptions } from './live.js';
|
|
35
|
+
export { connectHost } from './embed.js';
|
|
36
|
+
export type { ConnectHostOptions, EmbeddedHost, FrameEndpoint } from './embed.js';
|
|
37
|
+
export { attachGameHost, HostRefusal } from './host.js';
|
|
38
|
+
export type { AttachGameHostOptions, HostListenSource, PostTarget } from './host.js';
|
|
39
|
+
export type { EmbedContext } from '@flayerlabs/gamemode-spec/frame';
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
42
|
* What a game talks to. Gameplay stays small; reusable platform concerns live in named capabilities.
|
|
@@ -134,13 +133,9 @@ export type BuyProgress =
|
|
|
134
133
|
|
|
135
134
|
export type BuyResult = { bought: true; spentWei: bigint } | { bought: false; reason: BuyFailure };
|
|
136
135
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
* Deliberately a short list of plain outcomes rather than anything from the chain. A game should
|
|
141
|
-
* never render a revert string or an error code at a player: the client maps each of these to its
|
|
142
|
-
* own copy, and the copy is free to change without the meaning moving.
|
|
143
|
-
*/
|
|
136
|
+
// BuyFailure lives in @flayerlabs/gamemode-spec/live (re-exported above): the live room and the
|
|
137
|
+
// embedding page both produce those values, so every end shares one definition.
|
|
138
|
+
|
|
144
139
|
export interface Social {
|
|
145
140
|
react(id: string): void;
|
|
146
141
|
onReaction(listener: (reaction: Reaction) => void): () => void;
|