@memizy/host-sdk 0.0.0-stage → 1.0.0-rc.6
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/dist/frame.cjs +1 -0
- package/dist/frame.js +78 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +10 -0
- package/dist/learning.cjs +1 -0
- package/dist/learning.js +43 -0
- package/dist/plugin.cjs +1 -0
- package/dist/plugin.js +52 -0
- package/dist/relay/host.cjs +1 -0
- package/dist/relay/host.js +542 -0
- package/dist/relay/messages.cjs +1 -0
- package/dist/relay/messages.js +27 -0
- package/dist/relay/player.cjs +1 -0
- package/dist/relay/player.js +327 -0
- package/dist/relay/socket.cjs +1 -0
- package/dist/relay/socket.js +74 -0
- package/dist/session.cjs +1 -0
- package/dist/session.js +548 -0
- package/dist/src/frame.d.ts +43 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/learning.d.ts +18 -0
- package/dist/src/plugin.d.ts +51 -0
- package/dist/src/relay/host.d.ts +156 -0
- package/dist/src/relay/messages.d.ts +137 -0
- package/dist/src/relay/player.d.ts +127 -0
- package/dist/src/relay/socket.d.ts +34 -0
- package/dist/src/session.d.ts +248 -0
- package/dist/src/storage.d.ts +27 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/storage.cjs +1 -0
- package/dist/storage.js +40 -0
- package/dist/version.cjs +1 -0
- package/dist/version.js +4 -0
- package/package.json +62 -4
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { createPluginFrame as e } from "../frame.js";
|
|
2
|
+
import { RelaySocket as t, relayWebSocketUrl as n } from "./socket.js";
|
|
3
|
+
import { isSerializedBlob as r, jsonToBlob as i, serializeError as a, unknownMethod as o } from "./messages.js";
|
|
4
|
+
import { HOST_API_METHODS as s, PLUGIN_API_METHODS as c, RELAY_PROTOCOL as l, extractManifestFromHtml as u } from "@memizy/protocol";
|
|
5
|
+
//#region src/relay/player.ts
|
|
6
|
+
var d = new Set(c), f = 1e3, p = class {
|
|
7
|
+
playerId = null;
|
|
8
|
+
token;
|
|
9
|
+
name;
|
|
10
|
+
state = null;
|
|
11
|
+
hostConnected = !0;
|
|
12
|
+
hostAppVersion = null;
|
|
13
|
+
seat = void 0;
|
|
14
|
+
options;
|
|
15
|
+
socket;
|
|
16
|
+
listeners = /* @__PURE__ */ new Set();
|
|
17
|
+
bundle = null;
|
|
18
|
+
frame = null;
|
|
19
|
+
plugin = null;
|
|
20
|
+
gen = 0;
|
|
21
|
+
mountGen = 0;
|
|
22
|
+
callCounter = 0;
|
|
23
|
+
pending = /* @__PURE__ */ new Map();
|
|
24
|
+
skewMs = 0;
|
|
25
|
+
hostConfig = null;
|
|
26
|
+
mountQueue = Promise.resolve();
|
|
27
|
+
sessionEndedSent = !1;
|
|
28
|
+
constructor(e) {
|
|
29
|
+
this.options = e, this.token = e.token ?? null, this.name = e.name, this.socket = new t({
|
|
30
|
+
url: n(e.serverUrl),
|
|
31
|
+
attach: () => ({
|
|
32
|
+
t: "join",
|
|
33
|
+
protocol: l,
|
|
34
|
+
pin: e.pin,
|
|
35
|
+
name: this.name,
|
|
36
|
+
token: this.token ?? void 0,
|
|
37
|
+
...e.auth !== void 0 && { auth: e.auth }
|
|
38
|
+
}),
|
|
39
|
+
WebSocket: e.WebSocket
|
|
40
|
+
}), this.socket.onStatus((e) => this.emit({
|
|
41
|
+
type: "status",
|
|
42
|
+
status: e
|
|
43
|
+
})), this.socket.onMessage((e) => this.onServer(e));
|
|
44
|
+
}
|
|
45
|
+
get status() {
|
|
46
|
+
return this.socket.status;
|
|
47
|
+
}
|
|
48
|
+
on(e) {
|
|
49
|
+
return this.listeners.add(e), () => this.listeners.delete(e);
|
|
50
|
+
}
|
|
51
|
+
emit(e) {
|
|
52
|
+
for (let t of this.listeners) t(e);
|
|
53
|
+
}
|
|
54
|
+
refreshConfig() {
|
|
55
|
+
this.plugin && this.hostConfig && this.plugin.configChanged(this.localConfig(this.hostConfig)).catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
localConfig(e) {
|
|
58
|
+
return {
|
|
59
|
+
...e,
|
|
60
|
+
...this.options.config?.()
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
rename(e) {
|
|
64
|
+
this.name = e, this.socket.send({
|
|
65
|
+
t: "rename",
|
|
66
|
+
name: e
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
claimSeat(e) {
|
|
70
|
+
this.up({
|
|
71
|
+
k: "claim",
|
|
72
|
+
seat: e
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
reconnectNow() {
|
|
76
|
+
this.socket.reconnectNow();
|
|
77
|
+
}
|
|
78
|
+
leave() {
|
|
79
|
+
this.destroyFrame(), this.socket.close();
|
|
80
|
+
}
|
|
81
|
+
onServer(e) {
|
|
82
|
+
switch (e.t) {
|
|
83
|
+
case "joined":
|
|
84
|
+
this.playerId = e.playerId, this.token = e.token, this.name = e.name, this.hostConnected = e.hostConnected, this.emit({
|
|
85
|
+
type: "joined",
|
|
86
|
+
playerId: e.playerId,
|
|
87
|
+
token: e.token,
|
|
88
|
+
name: e.name
|
|
89
|
+
}), this.up({
|
|
90
|
+
k: "hello",
|
|
91
|
+
gen: this.plugin ? this.gen : 0,
|
|
92
|
+
v: 1
|
|
93
|
+
});
|
|
94
|
+
break;
|
|
95
|
+
case "host-presence":
|
|
96
|
+
this.hostConnected = e.connected, this.emit({
|
|
97
|
+
type: "host",
|
|
98
|
+
connected: e.connected
|
|
99
|
+
}), e.connected && this.up({
|
|
100
|
+
k: "hello",
|
|
101
|
+
gen: this.plugin ? this.gen : 0,
|
|
102
|
+
v: 1
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
case "data":
|
|
106
|
+
this.onHost(e.data);
|
|
107
|
+
break;
|
|
108
|
+
case "error":
|
|
109
|
+
this.emit({
|
|
110
|
+
type: "error",
|
|
111
|
+
code: e.code,
|
|
112
|
+
message: e.message,
|
|
113
|
+
fatal: e.fatal
|
|
114
|
+
});
|
|
115
|
+
break;
|
|
116
|
+
case "closed": {
|
|
117
|
+
let t = e.reason === "kicked" ? "kicked" : e.reason === "host" ? "closed" : "host-left";
|
|
118
|
+
this.mountQueue = this.mountQueue.then(async () => {
|
|
119
|
+
await this.endGame(t), this.emit({
|
|
120
|
+
type: "closed",
|
|
121
|
+
reason: e.reason
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
up(e) {
|
|
129
|
+
return this.socket.send({
|
|
130
|
+
t: "up",
|
|
131
|
+
data: e
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async onHost(e) {
|
|
135
|
+
if (typeof e == "object" && e) switch (e.k) {
|
|
136
|
+
case "state":
|
|
137
|
+
e.state.seats || (this.seat = void 0), this.state = e.state, this.emit({
|
|
138
|
+
type: "state",
|
|
139
|
+
state: e.state
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
case "mount":
|
|
143
|
+
if (this.hostAppVersion = typeof e.v == "number" ? e.v : 0, e.gen === this.mountGen) {
|
|
144
|
+
this.mountQueue = this.mountQueue.then(() => {
|
|
145
|
+
this.plugin && this.gen === e.gen && this.up({
|
|
146
|
+
k: "mounted",
|
|
147
|
+
gen: e.gen
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.mountGen = e.gen, this.mountQueue = this.mountQueue.then(() => this.mount(e.gen, e.bundle));
|
|
153
|
+
return;
|
|
154
|
+
case "unmount":
|
|
155
|
+
this.mountGen = 0, this.mountQueue = this.mountQueue.then(() => this.endGame("closed"));
|
|
156
|
+
return;
|
|
157
|
+
case "seat":
|
|
158
|
+
if (e.seat !== null && typeof e.seat != "string") return;
|
|
159
|
+
this.seat = e.seat, e.seat !== null && typeof e.name == "string" && e.name && e.name !== this.name && this.rename(e.name), this.emit({
|
|
160
|
+
type: "seat",
|
|
161
|
+
seat: e.seat
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
case "result": {
|
|
165
|
+
let t = this.pending.get(e.id);
|
|
166
|
+
if (!t) return;
|
|
167
|
+
if (this.pending.delete(e.id), clearTimeout(t.timer), !e.ok) {
|
|
168
|
+
t.reject(Error(e.error?.message ?? "Host call failed"));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let n = e.value;
|
|
172
|
+
if (t.method === "hello" && this.bundle) {
|
|
173
|
+
if (e.hostNow !== void 0) {
|
|
174
|
+
let n = Date.now();
|
|
175
|
+
this.skewMs = e.hostNow + (n - t.sentAt) / 2 - n;
|
|
176
|
+
}
|
|
177
|
+
let r = n;
|
|
178
|
+
this.hostConfig = r.config, n = {
|
|
179
|
+
...r,
|
|
180
|
+
set: structuredClone(this.bundle.data.set),
|
|
181
|
+
clock: {
|
|
182
|
+
...r.clock,
|
|
183
|
+
offsetMs: r.clock.offsetMs + this.skewMs
|
|
184
|
+
},
|
|
185
|
+
config: this.localConfig(r.config)
|
|
186
|
+
};
|
|
187
|
+
} else r(n) && (n = i(n));
|
|
188
|
+
t.resolve(n);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
case "call": {
|
|
192
|
+
let t = (e) => this.up(e);
|
|
193
|
+
if (!d.has(e.method)) {
|
|
194
|
+
t({
|
|
195
|
+
k: "result",
|
|
196
|
+
id: e.id,
|
|
197
|
+
ok: !1,
|
|
198
|
+
error: o(e.method)
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (e.gen !== this.gen || !this.plugin) {
|
|
203
|
+
t({
|
|
204
|
+
k: "result",
|
|
205
|
+
id: e.id,
|
|
206
|
+
ok: !1,
|
|
207
|
+
error: { message: "[SESSION_ENDED] No active plugin instance." }
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
let n = e.args;
|
|
212
|
+
if (e.method === "sessionEnded" && (this.sessionEndedSent = !0), e.method === "configChanged" && (this.hostConfig = n[0], n = [this.localConfig(this.hostConfig)]), e.method === "clockChanged") {
|
|
213
|
+
let e = n[0];
|
|
214
|
+
n = [{
|
|
215
|
+
...e,
|
|
216
|
+
offsetMs: e.offsetMs + this.skewMs
|
|
217
|
+
}];
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
let r = this.plugin[e.method], i = await r(...n);
|
|
221
|
+
t({
|
|
222
|
+
k: "result",
|
|
223
|
+
id: e.id,
|
|
224
|
+
ok: !0,
|
|
225
|
+
value: i ?? null
|
|
226
|
+
});
|
|
227
|
+
} catch (n) {
|
|
228
|
+
t({
|
|
229
|
+
k: "result",
|
|
230
|
+
id: e.id,
|
|
231
|
+
ok: !1,
|
|
232
|
+
error: a(n)
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async ensureBundle(e) {
|
|
240
|
+
if (this.bundle?.version === e) return this.bundle.data;
|
|
241
|
+
let t = await (this.options.fetch ?? fetch)(new URL(`/api/rooms/${this.options.pin}/bundle`, this.options.serverUrl));
|
|
242
|
+
if (!t.ok) throw Error(`Could not download the game (${t.status}).`);
|
|
243
|
+
let n = await t.json();
|
|
244
|
+
return this.bundle = {
|
|
245
|
+
version: Number(t.headers.get("X-Bundle-Version") ?? e),
|
|
246
|
+
data: n
|
|
247
|
+
}, n;
|
|
248
|
+
}
|
|
249
|
+
async endGame(e) {
|
|
250
|
+
let t = this.plugin;
|
|
251
|
+
if (t && !this.sessionEndedSent) {
|
|
252
|
+
this.sessionEndedSent = !0;
|
|
253
|
+
let n;
|
|
254
|
+
await Promise.race([t.sessionEnded(e).catch(() => {}), new Promise((e) => n = setTimeout(e, f))]), clearTimeout(n);
|
|
255
|
+
}
|
|
256
|
+
this.destroyFrame();
|
|
257
|
+
}
|
|
258
|
+
async mount(t, n) {
|
|
259
|
+
this.destroyFrame(), this.sessionEndedSent = !1, this.gen = t;
|
|
260
|
+
try {
|
|
261
|
+
let r = await this.ensureBundle(n);
|
|
262
|
+
if (this.gen !== t) return;
|
|
263
|
+
let i = this.options.container();
|
|
264
|
+
if (!i) throw Error("No place for the game on this page.");
|
|
265
|
+
let a = u(r.pluginHtml), o = a.success ? String(a.data.appName ?? "Game") : "Game", s = this.options.transformHtml ? this.options.transformHtml(r.pluginHtml) : r.pluginHtml, c = (this.options.createFrame ?? e)(s, o, this.hostApiFor(t), i.ownerDocument);
|
|
266
|
+
this.frame = c, i.replaceChildren(c.iframe), this.emit({
|
|
267
|
+
type: "frame",
|
|
268
|
+
active: !0
|
|
269
|
+
});
|
|
270
|
+
let l = await c.plugin;
|
|
271
|
+
if (this.gen !== t) return;
|
|
272
|
+
this.plugin = l, this.up({
|
|
273
|
+
k: "mounted",
|
|
274
|
+
gen: t
|
|
275
|
+
});
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (this.gen !== t) return;
|
|
278
|
+
this.emit({
|
|
279
|
+
type: "error",
|
|
280
|
+
code: "MOUNT_FAILED",
|
|
281
|
+
message: e.message,
|
|
282
|
+
fatal: !1
|
|
283
|
+
}), this.up({
|
|
284
|
+
k: "mount-failed",
|
|
285
|
+
gen: t,
|
|
286
|
+
error: a(e)
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
hostApiFor(e) {
|
|
291
|
+
let t = {};
|
|
292
|
+
for (let n of s) t[n] = (...t) => {
|
|
293
|
+
if (this.gen !== e) return Promise.reject(/* @__PURE__ */ Error("[SESSION_ENDED] This plugin instance was replaced."));
|
|
294
|
+
let r = ++this.callCounter;
|
|
295
|
+
return new Promise((i, a) => {
|
|
296
|
+
let o = setTimeout(() => {
|
|
297
|
+
this.pending.delete(r), a(/* @__PURE__ */ Error(`[INTERNAL_ERROR] The host did not answer ${n} in time.`));
|
|
298
|
+
}, this.options.callTimeoutMs ?? 15e3);
|
|
299
|
+
this.pending.set(r, {
|
|
300
|
+
method: n,
|
|
301
|
+
sentAt: Date.now(),
|
|
302
|
+
resolve: i,
|
|
303
|
+
reject: a,
|
|
304
|
+
timer: o
|
|
305
|
+
}), this.up({
|
|
306
|
+
k: "call",
|
|
307
|
+
id: r,
|
|
308
|
+
gen: e,
|
|
309
|
+
method: n,
|
|
310
|
+
args: t
|
|
311
|
+
}) || (clearTimeout(o), this.pending.delete(r), n === "hello" ? a(/* @__PURE__ */ Error("[INTERNAL_ERROR] Offline.")) : i(void 0));
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
return t;
|
|
315
|
+
}
|
|
316
|
+
destroyFrame() {
|
|
317
|
+
let e = this.frame !== null;
|
|
318
|
+
this.frame?.destroy(), this.frame?.iframe.remove(), this.frame = null, this.plugin = null, this.gen = 0;
|
|
319
|
+
for (let e of this.pending.values()) clearTimeout(e.timer), e.reject(/* @__PURE__ */ Error("[SESSION_ENDED] The plugin instance was closed."));
|
|
320
|
+
this.pending.clear(), e && this.emit({
|
|
321
|
+
type: "frame",
|
|
322
|
+
active: !1
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
327
|
+
export { p as RelayPlayer };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=class{status=`connecting`;options;ws=null;retry=0;retryTimer;pingTimer;stopped=!1;messageListeners=new Set;statusListeners=new Set;constructor(e){this.options=e,this.open()}onMessage(e){return this.messageListeners.add(e),()=>this.messageListeners.delete(e)}onStatus(e){return this.statusListeners.add(e),()=>this.statusListeners.delete(e)}send(e){return this.status!==`online`||this.ws?.readyState!==1?!1:(this.ws.send(JSON.stringify(e)),!0)}close(){this.stopped=!0,clearTimeout(this.retryTimer),clearInterval(this.pingTimer),this.ws?.close(1e3,`bye`),this.setStatus(`closed`)}reconnectNow(){this.stopped||this.status===`online`||this.status===`connecting`||(clearTimeout(this.retryTimer),this.open())}setStatus(e){if(this.status!==e){this.status=e;for(let t of this.statusListeners)t(e)}}open(){if(this.stopped)return;this.setStatus(`connecting`);let e=new(this.options.WebSocket??WebSocket)(this.options.url);this.ws=e,e.onopen=()=>{this.ws===e&&e.send(JSON.stringify(this.options.attach()))},e.onmessage=t=>{if(this.ws!==e)return;let n;try{n=JSON.parse(String(t.data))}catch{return}(n.t===`hosting`||n.t===`joined`)&&(this.retry=0,this.setStatus(`online`),clearInterval(this.pingTimer),this.pingTimer=setInterval(()=>this.send({t:`ping`}),2e4)),(n.t===`error`&&n.fatal||n.t===`closed`)&&(this.stopped=!0);for(let e of this.messageListeners)e(n);this.stopped&&this.close()},e.onclose=t=>{if(this.ws!==e)return;if(clearInterval(this.pingTimer),this.ws=null,t.reason?.startsWith(`replaced`)){this.stopped=!0,this.setStatus(`replaced`);return}if(this.stopped){this.setStatus(`closed`);return}this.setStatus(`offline`);let n=Math.min(5e3,300*2**this.retry++)+Math.random()*300;this.retryTimer=setTimeout(()=>this.open(),n)},e.onerror=()=>{}}};function t(e){let t=new URL(e);return t.protocol=t.protocol===`https:`?`wss:`:`ws:`,t.pathname=`${t.pathname.replace(/\/$/,``)}/ws`,t.href}exports.RelaySocket=e,exports.relayWebSocketUrl=t;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//#region src/relay/socket.ts
|
|
2
|
+
var e = class {
|
|
3
|
+
status = "connecting";
|
|
4
|
+
options;
|
|
5
|
+
ws = null;
|
|
6
|
+
retry = 0;
|
|
7
|
+
retryTimer;
|
|
8
|
+
pingTimer;
|
|
9
|
+
stopped = !1;
|
|
10
|
+
messageListeners = /* @__PURE__ */ new Set();
|
|
11
|
+
statusListeners = /* @__PURE__ */ new Set();
|
|
12
|
+
constructor(e) {
|
|
13
|
+
this.options = e, this.open();
|
|
14
|
+
}
|
|
15
|
+
onMessage(e) {
|
|
16
|
+
return this.messageListeners.add(e), () => this.messageListeners.delete(e);
|
|
17
|
+
}
|
|
18
|
+
onStatus(e) {
|
|
19
|
+
return this.statusListeners.add(e), () => this.statusListeners.delete(e);
|
|
20
|
+
}
|
|
21
|
+
send(e) {
|
|
22
|
+
return this.status !== "online" || this.ws?.readyState !== 1 ? !1 : (this.ws.send(JSON.stringify(e)), !0);
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
this.stopped = !0, clearTimeout(this.retryTimer), clearInterval(this.pingTimer), this.ws?.close(1e3, "bye"), this.setStatus("closed");
|
|
26
|
+
}
|
|
27
|
+
reconnectNow() {
|
|
28
|
+
this.stopped || this.status === "online" || this.status === "connecting" || (clearTimeout(this.retryTimer), this.open());
|
|
29
|
+
}
|
|
30
|
+
setStatus(e) {
|
|
31
|
+
if (this.status !== e) {
|
|
32
|
+
this.status = e;
|
|
33
|
+
for (let t of this.statusListeners) t(e);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
open() {
|
|
37
|
+
if (this.stopped) return;
|
|
38
|
+
this.setStatus("connecting");
|
|
39
|
+
let e = new (this.options.WebSocket ?? WebSocket)(this.options.url);
|
|
40
|
+
this.ws = e, e.onopen = () => {
|
|
41
|
+
this.ws === e && e.send(JSON.stringify(this.options.attach()));
|
|
42
|
+
}, e.onmessage = (t) => {
|
|
43
|
+
if (this.ws !== e) return;
|
|
44
|
+
let n;
|
|
45
|
+
try {
|
|
46
|
+
n = JSON.parse(String(t.data));
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
(n.t === "hosting" || n.t === "joined") && (this.retry = 0, this.setStatus("online"), clearInterval(this.pingTimer), this.pingTimer = setInterval(() => this.send({ t: "ping" }), 2e4)), (n.t === "error" && n.fatal || n.t === "closed") && (this.stopped = !0);
|
|
51
|
+
for (let e of this.messageListeners) e(n);
|
|
52
|
+
this.stopped && this.close();
|
|
53
|
+
}, e.onclose = (t) => {
|
|
54
|
+
if (this.ws !== e) return;
|
|
55
|
+
if (clearInterval(this.pingTimer), this.ws = null, t.reason?.startsWith("replaced")) {
|
|
56
|
+
this.stopped = !0, this.setStatus("replaced");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (this.stopped) {
|
|
60
|
+
this.setStatus("closed");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.setStatus("offline");
|
|
64
|
+
let n = Math.min(5e3, 300 * 2 ** this.retry++) + Math.random() * 300;
|
|
65
|
+
this.retryTimer = setTimeout(() => this.open(), n);
|
|
66
|
+
}, e.onerror = () => {};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
function t(e) {
|
|
70
|
+
let t = new URL(e);
|
|
71
|
+
return t.protocol = t.protocol === "https:" ? "wss:" : "ws:", t.pathname = `${t.pathname.replace(/\/$/, "")}/ws`, t.href;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { e as RelaySocket, t as relayWebSocketUrl };
|
package/dist/session.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require("./version.cjs");const e=require("./plugin.cjs"),t=require("./learning.cjs"),n=require("./storage.cjs");let r=require("@memizy/protocol"),i=require("@memizy/oqse"),a=require("@memizy/protocol/internal");function o(e){try{return structuredClone(e)}catch{return JSON.parse(JSON.stringify(e))}}var s=class{id;plugin;runtime;mode;hostAs;authority;prepared;roster;settingValues;settingsOk=!0;hostConfig;isStarted=!1;isEnded=!1;pauseState={pausedMs:0,pausedAt:null};options;storage;algorithm;instances=new Map;listeners=new Set;authorityConnected=!0;startPromise=null;resumeStartPending=!1;pendingSnapshot=void 0;finished=!1;hasResult=!1;finishedSnapshot=null;snapshotTimer;pendingData=new Map;get players(){return this.roster}get settings(){return this.settingValues}get settingsValid(){return this.settingsOk}get config(){return this.hostConfig}get started(){return this.isStarted}get ended(){return this.isEnded}constructor(i){if(this.options=i,this.id=i.sessionId??`session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,this.plugin=i.plugin,this.runtime=i.plugin.runtime,this.mode=i.mode,this.storage=i.storage??new n.MemoryStorage,this.algorithm=i.algorithm??t.leitner,this.hostConfig={locale:i.locale??`en`,theme:i.theme??`light`},i.mode===`solo`){if(!this.runtime.solo)throw Error(`The plugin does not support solo mode.`);this.hostAs=null}else{if(!this.runtime.multiplayer)throw Error(`The plugin does not support multiplayer.`);let e=i.hostAs??this.runtime.multiplayer.hostAs[0];if(!this.runtime.multiplayer.hostAs.includes(e))throw Error(`The plugin does not support hostAs "${e}".`);this.hostAs=e}let a=i.players??(i.mode===`solo`?[{id:`player`,name:`Player`,isHost:!0}]:[]);if(i.mode===`solo`&&a.length!==1)throw Error(`A solo session has exactly one player.`);if(new Set(a.map(e=>e.id)).size!==a.length)throw Error(`Player IDs must be unique.`);if(a.some(e=>e.id===r.BOARD_ADDRESS||e.id===r.SETTINGS_ADDRESS||e.id===`server`))throw Error(`Player IDs "board", "settings" and "server" are reserved.`);if(this.roster=a.map(e=>({id:e.id,name:e.name,isHost:i.mode===`solo`?!0:e.isHost??!1,connected:!0})),i.mode===`solo`)this.authority=this.roster[0].id;else if(this.hostAs===`presenter`)this.authority=r.BOARD_ADDRESS;else{let e=this.roster.find(e=>e.isHost);if(!e)throw Error(`With hostAs "player", one player must have isHost: true.`);this.authority=e.id}let s=(0,r.resolveSettings)(this.runtime.settings,i.settings??{});if(s.errors.length>0)throw Error(`Invalid settings: ${s.errors.join(`; `)}`);this.settingValues=s.values;let c=i.shuffleSeed===null?void 0:i.shuffleSeed??this.id;this.prepared=e.prepareSetForPlugin(o(i.set),i.plugin.manifest,{seed:c}),i.restore&&(this.isStarted=!0,this.resumeStartPending=!0),i.clock&&(this.pauseState={pausedMs:i.clock.pausedMs??0,pausedAt:i.clock.pausedAt??null})}on(e){return this.listeners.add(e),()=>this.listeners.delete(e)}emit(e){for(let t of this.listeners)try{t(e)}catch(e){console.error(`[memizy host] session listener failed:`,e)}}viewOf(e){return e===r.SETTINGS_ADDRESS?`settings`:this.mode===`solo`?`solo`:e===r.BOARD_ADDRESS?`board`:`controller`}addresses(){return[...this.hostAs===`presenter`?[r.BOARD_ADDRESS]:[],...this.roster.map(e=>e.id)]}async connect(e,t,n={}){let i=this.viewOf(e);if(i===`settings`){if(!this.runtime.multiplayer||!this.runtime.settingsScreen)throw Error(`The plugin has no settings screen (settingsScreen).`);if(this.isStarted)throw Error(`The settings screen is only available before the game starts.`)}else if(!this.addresses().includes(e))throw Error(`Unknown address "${e}" in this session.`);if(this.isEnded)throw Error(`The session has ended.`);this.disposeInstance(e);let o={address:e,view:i,hostApi:void 0,endpoint:t,onDispose:n.onDispose,plugin:null,ready:!1,connected:!0,disposed:!1,resumed:!1,greeted:!1,limiter:new a.RateLimiter(r.LIMITS.messagesPerSecond,r.LIMITS.messageBurst),serviceLimiter:new a.RateLimiter(r.LIMITS.servicesPerMinute/60,10),answerLimiter:new a.RateLimiter(r.LIMITS.answersPerSecond,r.LIMITS.answerBurst),assetLimiter:new a.RateLimiter(r.LIMITS.assetsPerSecond,r.LIMITS.assetBurst),errorLimiter:new a.RateLimiter(r.LIMITS.errorReportsPerSecond,r.LIMITS.errorReportBurst)};o.hostApi=this.createHostApi(o),this.instances.set(e,o),e===this.authority&&this.isStarted&&this.setAuthorityConnected(!1);try{o.plugin=await t(o.hostApi)}catch(e){if(o.disposed)return o;throw e}return o}async reload(e){let t=this.instances.get(e);if(!t)throw Error(`No instance at "${e}".`);return e===this.authority&&await this.flushSnapshot(),this.connect(e,t.endpoint,{onDispose:t.onDispose})}disconnect(e){this.disposeInstance(e),e===this.authority&&this.isStarted&&this.setAuthorityConnected(!1)}disposeInstance(e){let t=this.instances.get(e);if(t){t.disposed=!0,this.instances.delete(e);try{t.onDispose?.()}catch(e){console.warn(`[memizy host] dispose failed:`,e)}}}getInstance(e){return this.instances.get(e)}start(){if(this.mode===`solo`||this.isStarted&&!this.startPromise)return Promise.resolve();if(!this.settingsOk)return Promise.reject(Error(`The settings are not valid.`));let e=this.roster.length,t=this.runtime.multiplayer.players;return e<t.min||e>t.max?Promise.reject(Error(`This plugin needs ${t.min}–${t.max} players (now ${e}).`)):(this.startPromise??=this.runStart(),this.startPromise)}async runStart(){this.disposeInstance(r.SETTINGS_ADDRESS);let e=Date.now()+(this.options.readyTimeoutMs??15e3),t=()=>[...this.instances.values()].every(e=>e.ready),n=()=>this.instances.get(this.authority)?.ready===!0;for(;!(t()&&n())&&(Date.now()<e||!n());){if(this.isEnded)return;await c(25)}let i=this.options.countdownMs??3e3;for(let e=Math.ceil(i/1e3);e>0;e--)this.emit({type:`countdown`,secondsLeft:e}),await c(Math.min(1e3,i-(Math.ceil(i/1e3)-e)*1e3));this.isEnded||(this.isStarted=!0,this.emit({type:`started`}),await this.callPlugin(this.authority,e=>e.start()))}addPlayer(e){if(this.mode===`solo`)throw Error(`Players cannot join a solo session.`);if(this.roster.some(t=>t.id===e.id)||[r.BOARD_ADDRESS,r.SETTINGS_ADDRESS,`server`].includes(e.id))throw Error(`Player ID "${e.id}" is already used or reserved.`);if(this.isStarted&&!this.runtime.multiplayer.lateJoin)throw Error(`This game does not allow joining after the start.`);if(this.roster.length>=this.runtime.multiplayer.players.max)throw Error(`The game is full.`);this.roster=[...this.roster,{id:e.id,name:e.name,isHost:!1,connected:!0}],this.broadcastPlayers()}removePlayer(e){if(e===this.authority)throw Error(`The host cannot be removed.`);this.disposeInstance(e),this.roster=this.roster.filter(t=>t.id!==e),this.broadcastPlayers()}renamePlayer(e,t){this.roster=this.roster.map(n=>n.id===e?{...n,name:t}:n),this.broadcastPlayers()}setConnected(e,t){let n=this.instances.get(e);n&&(n.connected=t),this.roster.some(t=>t.id===e)&&(this.roster=this.roster.map(n=>n.id===e?{...n,connected:t}:n),this.broadcastPlayers()),e===this.authority?this.setAuthorityConnected(t):t&&n&&this.callPlugin(e,e=>e.authorityChanged({connected:!0}))}get services(){return this.runtime.services.filter(e=>typeof this.options.services?.[e]==`function`)}get paused(){return this.pauseState.pausedAt!==null}get clock(){return{offsetMs:0,...this.pauseState}}pause(){!this.isStarted||this.isEnded||this.paused||(this.pauseState={...this.pauseState,pausedAt:Date.now()},this.broadcastClock())}resume(){this.paused&&(this.pauseState={pausedMs:this.pauseState.pausedMs+Math.max(0,Date.now()-this.pauseState.pausedAt),pausedAt:null},this.broadcastClock())}broadcastClock(){let e=this.clock;for(let t of this.instances.keys())this.callPlugin(t,t=>t.clockChanged(e));this.emit({type:`paused`,paused:this.paused,clock:{...this.pauseState}})}setConfig(e){this.hostConfig={...this.hostConfig,...e};for(let e of this.instances.keys())this.callPlugin(e,e=>e.configChanged(this.hostConfig))}async end(e=`closed`){this.isEnded||(this.isEnded=!0,e===`finished`?await this.discardSnapshot():await this.flushSnapshot(),await this.flushData(),await Promise.all([...this.instances.keys()].map(t=>this.callPlugin(t,t=>t.sessionEnded(e)))))}createHostApi(e){let t=(t,n)=>(async()=>{if(e.disposed)throw new r.ProtocolError(`SESSION_ENDED`,`This instance has been closed.`);if(this.isEnded&&t!==`reportError`)throw new r.ProtocolError(`SESSION_ENDED`,`The session has ended.`);return n()})().catch(n=>{let i=(0,r.toProtocolError)(n);throw this.emit({type:`rejected`,address:e.address,method:t,code:i.code,message:i.detail}),i}),n=()=>e.view===`solo`||e.view===`controller`,s=(t,n)=>{if(!n)throw new r.ProtocolError(`NOT_ALLOWED_IN_VIEW`,`${t} is not available in view "${e.view}".`)},c=t=>{if(e.address!==this.authority)throw new r.ProtocolError(`NOT_AUTHORITY`,`${t} may only be called by the authority.`)};return{hello:(...n)=>t(`hello`,async()=>{let[t]=(0,a.parseHostCall)(`hello`,n);if(e.greeted)throw new r.ProtocolError(`INVALID_ARGUMENT`,`hello may only be called once per instance.`);let i=(0,r.negotiateProtocolVersion)(t.protocol,r.PROTOCOL_VERSION);if(!i)throw new r.ProtocolError(`UNSUPPORTED_PROTOCOL`,`The plugin speaks protocol ${t.protocol}; this host supports ${r.PROTOCOL_VERSION}.`);return e.greeted=!0,t.plugin.id!==this.plugin.manifest.id&&this.emit({type:`pluginError`,address:e.address,code:`PLUGIN_ID_MISMATCH`,message:`The SDK reports plugin id "${t.plugin.id}", the manifest says "${this.plugin.manifest.id}".`}),this.buildInit(e,i)}),ready:(...n)=>t(`ready`,()=>{(0,a.parseHostCall)(`ready`,n),!e.ready&&(e.ready=!0,this.emit({type:`ready`,address:e.address}),e.address===this.authority&&(this.mode===`solo`&&!this.isStarted&&!e.resumed?(this.isStarted=!0,this.emit({type:`started`}),this.callPlugin(e.address,e=>e.start())):this.isStarted&&(this.resumeStartPending&&(this.resumeStartPending=!1,e.resumed||this.callPlugin(e.address,e=>e.start())),this.setAuthorityConnected(!0))))}),send:(...n)=>t(`send`,()=>{let[t]=(0,a.parseHostCall)(`send`,n);s(`send`,e.view!==`settings`);let i=(0,a.assertJsonWithin)(t.data,r.LIMITS.messageBytes,`MESSAGE_TOO_LARGE`,`message`),o=e.address===this.authority?1/Math.max(1,this.instances.size-1):1;if(!e.limiter.tryTake(o))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.messagesPerSecond} messages per second.`);if(!e.connected)return;if(t.to===`authority`){let n=this.instances.get(this.authority);if(!n||!n.connected||!this.authorityConnected)throw new r.ProtocolError(`AUTHORITY_UNAVAILABLE`,`The host is not reachable right now.`);this.deliver(e.address,this.authority,t.data,i);return}let c=t.to===`all`?[...this.instances.keys()].filter(t=>t!==e.address&&t!==r.SETTINGS_ADDRESS):t.to.filter(e=>e!==r.SETTINGS_ADDRESS);for(let n of c)this.deliver(e.address,n,t.data,i)}),saveSnapshot:(...e)=>t(`saveSnapshot`,()=>{let[t]=(0,a.parseHostCall)(`saveSnapshot`,e);if(c(`saveSnapshot`),(0,a.assertJsonWithin)(t,r.LIMITS.snapshotBytes,`SNAPSHOT_TOO_LARGE`,`snapshot`),this.finished){this.finishedSnapshot=structuredClone(t);return}this.pendingSnapshot=structuredClone(t),this.snapshotTimer??=setTimeout(()=>void this.flushSnapshot(),1e3/r.LIMITS.snapshotsPerSecond)}),recordAnswer:(...n)=>t(`recordAnswer`,async()=>{let[t]=(0,a.parseHostCall)(`recordAnswer`,n),i=this.roster.some(t=>t.id===e.address)?e.address:null,o=t.playerId??i;if(!o)throw new r.ProtocolError(`INVALID_ARGUMENT`,`recordAnswer needs a playerId on the board.`);if(o!==i&&c(`recordAnswer for another player`),!this.roster.some(e=>e.id===o))throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown player "${o}".`);let s=e.address===this.authority?1/Math.max(1,this.roster.length):1;if(!e.answerLimiter.tryTake(s))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.answersPerSecond} answers per second.`);t.answer!==void 0&&(0,a.assertJsonWithin)(t.answer,r.LIMITS.answerBytes,`DATA_TOO_LARGE`,`answer`);let l=this.prepared.set.items.find(e=>e.id===t.itemId),u=e=>Array.isArray(e)?e.filter(e=>typeof e==`string`):[];if(!l){if(!t.item)throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown item "${t.itemId}" (attach the item for a generated one).`);(0,a.assertJsonWithin)(t.item,r.LIMITS.generatedItemBytes,`DATA_TOO_LARGE`,`item`),this.emit({type:`answer`,playerId:o,record:null,answer:t,generated:!0,skills:u(t.item.skills),tags:u(t.item.tags)});return}let d=this.userKey(o),f=this.prepared.set.meta.id,p=await this.storage.loadProgress(d,f),{item:m,...h}=t,g=this.algorithm.apply(p[t.itemId],h,new Date,structuredClone(l));await this.storage.saveProgress(d,f,{[t.itemId]:g}),this.emit({type:`answer`,playerId:o,record:g,answer:h,generated:!1,skills:u(l.skills),tags:u(l.tags)})}),saveData:(...i)=>t(`saveData`,()=>{let[t,o]=(0,a.parseHostCall)(`saveData`,i);s(`saveData`,n()),(0,a.assertJsonWithin)(o,r.LIMITS.dataBytes,`DATA_TOO_LARGE`,`plugin data (${t})`);let c=`${e.address}|${t}`,l=this.pendingData.get(c);if(l)l.value=structuredClone(o);else{let n={playerId:e.address,scope:t,value:structuredClone(o),timer:setTimeout(()=>void this.flushData(c),1e3/r.LIMITS.dataWritesPerSecond)};this.pendingData.set(c,n)}}),updateSettings:(...n)=>t(`updateSettings`,()=>{let[t]=(0,a.parseHostCall)(`updateSettings`,n);s(`updateSettings`,e.view===`settings`);let{values:i,errors:o}=(0,r.resolveSettings)(this.runtime.settings,t.values);this.settingValues=i,this.settingsOk=t.valid&&o.length===0;let c=t.message??o[0];this.emit({type:`settings`,values:i,valid:this.settingsOk,...c&&{message:c}})}),getAsset:(...n)=>t(`getAsset`,async()=>{let[t,o]=(0,a.parseHostCall)(`getAsset`,n);if(!e.assetLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.assetsPerSecond} assets per second.`);let s=o?this.options.set.items.find(e=>e.id===o):void 0,c=(0,i.resolveAsset)(t,s,this.options.set.meta);if(!c)throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" does not exist.`);if(this.options.loadAsset)return this.options.loadAsset(t,o);if(/^https?:/i.test(c.value)){let e=await fetch(c.value);if(e.ok)return e.blob()}throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" cannot be loaded by this host.`)}),end:(...e)=>t(`end`,async()=>{let[t]=(0,a.parseHostCall)(`end`,e);c(`end`),!this.hasResult&&(this.hasResult=!0,await this.discardSnapshot(),this.emit({type:`result`,result:t}))}),reportError:(...n)=>t(`reportError`,()=>{let[t]=(0,a.parseHostCall)(`reportError`,n);e.errorLimiter.tryTake()&&this.emit({type:`pluginError`,address:e.address,code:t.code,message:t.message,...t.context&&{context:t.context}})}),service:(...n)=>t(`service`,async()=>{let[t,i]=(0,a.parseHostCall)(`service`,n),s=this.services.includes(t)?this.options.services[t]:void 0;if(!s)throw new r.ProtocolError(`SERVICE_UNAVAILABLE`,`Service "${t}" is not available here (declare it in the manifest "services"; this host offers: ${this.services.join(`, `)||`none`}).`);if((0,a.assertJsonWithin)(i,r.LIMITS.serviceBytes,`MESSAGE_TOO_LARGE`,`service ${t} request`),!e.serviceLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.servicesPerMinute} service calls per minute.`);let c=this.roster.some(t=>t.id===e.address)?e.address:null,l=await s(o(i),{playerId:c,address:e.address,pluginId:this.plugin.manifest.id,setId:this.prepared.set.meta.id});return(0,a.assertJsonWithin)(l??null,r.LIMITS.serviceResultBytes,`MESSAGE_TOO_LARGE`,`service ${t} result`),o(l??null)})}}async buildInit(e,t){let n=e.view===`solo`||e.view===`controller`,r=this.prepared.set.meta.id,i=n?this.userKey(e.address):null,a=this.plugin.manifest.id;e.address===this.authority&&await this.flushSnapshot();let o=e.address===this.authority?this.finished?structuredClone(this.finishedSnapshot):await this.storage.loadSnapshot(this.id):null;return e.resumed=o!==null,{protocol:t,host:this.options.host??{name:`@memizy/host-sdk`,version:`1.0.0-rc.6`},oqseVersion:`1.0`,features:[],session:{id:this.id,mode:this.mode,hostAs:this.hostAs,view:e.view,self:e.address,authority:this.authority,lateJoin:this.isStarted&&e.view!==`settings`},players:this.roster,set:structuredClone(this.mode===`multiplayer`&&e.address!==this.authority?this.prepared.publicSet:this.prepared.set),settings:{...this.settingValues},config:this.hostConfig,clock:this.clock,services:this.services,progress:i?await this.storage.loadProgress(i,r):{},data:i?{plugin:this.pendingDataValue(e.address,`plugin`)??await this.storage.loadData(i,a,`plugin`,r)??null,set:this.pendingDataValue(e.address,`set`)??await this.storage.loadData(i,a,`set`,r)??null}:{plugin:null,set:null},snapshot:o}}userKey(e){return this.options.userKeyFor?.(e)??e}deliver(e,t,n,r){let i=this.instances.get(t);if(!i||!i.connected||!i.plugin)return;this.emit({type:`traffic`,from:e,to:t,bytes:r,data:n});let a=structuredClone(n);setTimeout(()=>void this.callPlugin(t,t=>t.deliver({from:e,data:a,sentAt:Date.now()})),0)}broadcastPlayers(){this.emit({type:`players`,players:this.roster});for(let e of this.instances.keys())this.callPlugin(e,e=>e.playersChanged(this.roster))}setAuthorityConnected(e){if(this.authorityConnected!==e){this.authorityConnected=e,this.emit({type:`authority`,connected:e});for(let t of this.instances.keys())t!==this.authority&&t!==r.SETTINGS_ADDRESS&&this.callPlugin(t,t=>t.authorityChanged({connected:e}))}}async callPlugin(e,t){let n=this.instances.get(e);if(n?.plugin&&!n.disposed)try{await t(n.plugin)}catch(t){this.emit({type:`pluginError`,address:e,code:`PLUGIN_CALL_FAILED`,message:(0,r.toProtocolError)(t).detail})}}async discardSnapshot(){this.finished||(this.finished=!0,clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.finishedSnapshot=this.pendingSnapshot===void 0?await this.storage.loadSnapshot(this.id):this.pendingSnapshot,this.pendingSnapshot=void 0,await this.storage.deleteSnapshot?.(this.id))}async flushSnapshot(){if(clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.pendingSnapshot===void 0||this.finished)return;let e=this.pendingSnapshot;this.pendingSnapshot=void 0,await this.storage.saveSnapshot(this.id,e),this.finished&&await this.storage.deleteSnapshot?.(this.id)}pendingDataValue(e,t){return this.pendingData.get(`${e}|${t}`)?.value}async flushData(e){let t=e?[[e,this.pendingData.get(e)]]:[...this.pendingData.entries()];for(let[e,n]of t)n&&(clearTimeout(n.timer),this.pendingData.delete(e),await this.storage.saveData(this.userKey(n.playerId),this.plugin.manifest.id,n.scope,this.prepared.set.meta.id,n.value))}};function c(e){return new Promise(t=>setTimeout(t,Math.max(0,e)))}exports.LocalSession=s;
|