@memizy/host-sdk 1.0.0-rc.5 → 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 +39 -5
- package/dist/frame.cjs +1 -1
- package/dist/frame.js +15 -14
- package/dist/index.cjs +1 -1
- package/dist/index.js +10 -10
- package/dist/relay/host.cjs +1 -1
- package/dist/relay/host.js +244 -72
- package/dist/relay/messages.cjs +1 -1
- package/dist/relay/messages.js +11 -6
- package/dist/relay/player.cjs +1 -1
- package/dist/relay/player.js +76 -23
- package/dist/session.cjs +1 -1
- package/dist/session.js +150 -132
- package/dist/src/frame.d.ts +7 -2
- package/dist/src/index.d.ts +15 -11
- package/dist/src/learning.d.ts +6 -2
- package/dist/src/relay/host.d.ts +76 -14
- package/dist/src/relay/messages.d.ts +42 -2
- package/dist/src/relay/player.d.ts +34 -5
- package/dist/src/session.d.ts +38 -23
- package/dist/src/storage.d.ts +3 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/storage.cjs +1 -1
- package/dist/storage.js +3 -0
- package/dist/version.cjs +1 -0
- package/dist/version.js +4 -0
- package/package.json +10 -7
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog of @memizy/host-sdk
|
|
2
|
+
|
|
3
|
+
## 1.0.0-rc.6 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
Enforces the final protocol 1.0 ([SPEC](https://github.com/memizy/memizy/blob/main/packages/protocol/SPEC.md)).
|
|
6
|
+
|
|
7
|
+
* `LocalSession` enforces the protocol rules: a second `hello` of an instance rejects with `INVALID_ARGUMENT`; only the first `end(result)` emits `result`; `recordAnswer` (per player for the authority) and `getAsset` are rate-limited (`RATE_LIMITED`); `reportError` beyond its rate is dropped; service results use `LIMITS.serviceResultBytes`.
|
|
8
|
+
* `RelayHost` refuses a result too large for a relay frame with `DATA_TOO_LARGE` (such a frame would close the host's connection); a 2 MB asset fits.
|
|
9
|
+
* `RelayPlayer` ignores a repeated `mount` of the same generation (the host repeats it when a device reconnects while loading) and confirms it again, so the game is not loaded twice.
|
|
10
|
+
* `RelayPlayer` tells the game `sessionEnded` (`kicked`, `closed`, `host-left`; once, with a 1 s grace period) before it removes it, also when the host takes the game away (`unmount`).
|
|
11
|
+
* Since 1.0.0-rc.5:
|
|
12
|
+
* Saving and continuing games: `SessionConfig.restore`, `HostStorage.deleteSnapshot?`, a finished game deletes its snapshot (late saves stay in memory only); `RelayHost.attach(session, { seats, taken })`, `assignSeat`, `seats`, `seatAssignments`, the `seats` event; `RelayPlayer.seat` and `claimSeat()`; `RelayHost.rejoin`.
|
|
13
|
+
* `LearningAlgorithm.apply(previous, answer, now, item)` gets the answered item.
|
|
14
|
+
* Relay compatibility: `RELAY_APP_VERSION` in `hello` / `mount`, `RelayHost.appVersionOf`; calls of unknown methods answer `UNKNOWN_METHOD`; `join.auth` reaches the host only (`RoomPlayer.auth`).
|
|
15
|
+
* The sandbox adds `allow-popups allow-popups-to-escape-sandbox` (never `allow-top-navigation`).
|
|
16
|
+
* Renamed without aliases: `SessionConfig.resume` → `restore`, `RelayHost.resume` → `rejoin`, the session event `ended` → `result`; `locale` / `theme` are direct `SessionConfig` fields (`session.config`, `setConfig`); `players`, `settings`, `settingsValid`, `config`, `started`, `ended` are read-only getters; `RelayPlayer.refreshConfig()`; `uploadBundle(pluginHtml, prepared)` uploads only the public set; the default locale is `'en'`; `HOST_SDK_VERSION`.
|
|
17
|
+
|
|
18
|
+
## 1.0.0-rc.5
|
|
19
|
+
|
|
20
|
+
The public API of the host SDK: `loadPluginFromHtml`, `prepareSetForPlugin` (`PreparedSet`), `LocalSession`, `mountPlugin`, `RelayHost` / `RelayPlayer`, `HostStorage`, `LearningAlgorithm`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 memizy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @memizy/host-sdk
|
|
2
2
|
|
|
3
|
-
Run Memizy plugins in a host application (the Memizy app, the Plugin Lab): load and validate plugins, mount them in sandboxed iframes, enforce the [Memizy Plugin Protocol](
|
|
3
|
+
Run Memizy plugins in a host application (the Memizy app, the Plugin Lab): load and validate plugins, mount them in sandboxed iframes, enforce the [Memizy Plugin Protocol](https://github.com/memizy/memizy/blob/main/packages/protocol/SPEC.md) and run game sessions.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { loadOQSEFile } from '@memizy/oqse';
|
|
@@ -16,7 +16,7 @@ const session = new LocalSession({
|
|
|
16
16
|
hostAs: 'presenter',
|
|
17
17
|
players: [{ id: 'anna', name: 'Anna' }, { id: 'ben', name: 'Ben' }],
|
|
18
18
|
});
|
|
19
|
-
session.on((event) => console.log(event)); // ready, countdown, answer,
|
|
19
|
+
session.on((event) => console.log(event)); // ready, countdown, answer, result, traffic, rejected…
|
|
20
20
|
|
|
21
21
|
await mountPlugin(session, 'board', boardElement); // sandboxed iframe + Penpal
|
|
22
22
|
await mountPlugin(session, 'anna', annaElement);
|
|
@@ -24,9 +24,43 @@ await mountPlugin(session, 'ben', benElement);
|
|
|
24
24
|
await session.start(); // waits for everyone, countdown, start
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
* **`LocalSession`** –
|
|
27
|
+
* **`LocalSession`** – runs a game: validates every plugin call (schemas, size and rate limits, views, authority), routes messages, runs the start barrier and countdown, handles authority outages (`setConnected`), reloads (`reload`), late joining (`addPlayer`), snapshots, learning progress and plugin data. Instances are mounted in this page (`mountPlugin`) or on players' devices (`RelayHost`).
|
|
28
|
+
* **`RelayHost` / `RelayPlayer`** – multiplayer across devices through the relay server (below).
|
|
28
29
|
* **`HostStorage`** – plug in your persistence (`MemoryStorage` for the Lab and tests).
|
|
29
30
|
* **`LearningAlgorithm`** – how answers change learning progress (`leitner` by default).
|
|
30
|
-
* **`prepareSetForPlugin
|
|
31
|
+
* **`prepareSetForPlugin`** – keeps only the item types the plugin declares, puts options, pairs… in display order (seeded) and makes the public copy without answers (SPEC 4.4). Which plugins can play a set: `checkCompatibility` in `@memizy/oqse`.
|
|
32
|
+
* **`HOST_SDK_VERSION`** – the version of this package (sent to plugins in `hello`).
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
The exports listed in `src/index.ts` are the public API; everything else is internal. Without `locale` the host uses `'en'` (`new LocalSession({ …, locale, theme })`; change them later with `setConfig`).
|
|
35
|
+
|
|
36
|
+
**Within 1.x** (for apps that implement or consume the interfaces):
|
|
37
|
+
|
|
38
|
+
* New methods of `HostStorage` and `LearningAlgorithm` are always optional (`deleteSnapshot?` is one); new parameters come last.
|
|
39
|
+
* `SessionEvent` and `RelayHostEvent` may get new event types: ignore types you do not know (no exhaustive `switch` that fails on them).
|
|
40
|
+
* The language and theme of a session are a `HostConfig` (`session.config`, read-only; `setConfig` changes it). The session's `players`, `settings`, `started` and `ended` are read-only too: change them through the methods.
|
|
41
|
+
* The game's result arrives as the `result` event (`session.end()` then closes the session).
|
|
42
|
+
|
|
43
|
+
## Multiplayer over the relay
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { RelayHost, RelayPlayer, LocalSession, prepareSetForPlugin } from '@memizy/host-sdk';
|
|
47
|
+
|
|
48
|
+
// The host (teacher's machine) runs the session, and so the authority.
|
|
49
|
+
const host = await RelayHost.create({ serverUrl: 'https://mp.memizy.com' }); // room with a PIN
|
|
50
|
+
const seed = crypto.randomUUID();
|
|
51
|
+
await host.uploadBundle(pluginHtml, prepareSetForPlugin(set, plugin.manifest, { seed }));
|
|
52
|
+
// …players join, then:
|
|
53
|
+
const session = new LocalSession({ plugin, set, mode: 'multiplayer', hostAs: 'presenter',
|
|
54
|
+
players: host.players.map((p) => ({ id: p.id, name: p.name })), shuffleSeed: seed });
|
|
55
|
+
host.attach(session); // players' instances connect through the relay
|
|
56
|
+
await session.start();
|
|
57
|
+
|
|
58
|
+
// A player's device: downloads the bundle once and runs the plugin locally.
|
|
59
|
+
const player = new RelayPlayer({ serverUrl: 'https://mp.memizy.com', pin, name: nickname, container: () => element });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`uploadBundle` takes the `PreparedSet` and uploads only its `publicSet`: anyone with the PIN can download the bundle, so answers never leave the host. Use the same seed for the bundle and `shuffleSeed`, so every device shows the same order.
|
|
63
|
+
|
|
64
|
+
**Continuing a saved game** (SPEC 3.3): create the session with the saved players (their old IDs), the same `sessionId`, `shuffleSeed` and storage, `restore: true` and a paused `clock`, then `host.attach(session, { seats: true })`. Devices see the earlier places in `state.seats` and take one with `player.claimSeat(id)` (or `claimSeat(null)` to join as a new player when the game allows late joins); the host can change it with `host.assignSeat(roomPlayerId, seatId)` (`host.seats` lists the places with their `roomPlayerId`). Keep `host.seatAssignments` and pass it as `taken` to attach again after a reload of the host page. Memizy Play (`apps/play`, `HostView` and `JoinView`) is the reference host.
|
|
65
|
+
|
|
66
|
+
Iframes are sandboxed with `allow-scripts allow-forms allow-modals allow-pointer-lock allow-popups allow-popups-to-escape-sandbox` (`PLUGIN_SANDBOX`; links open in a new tab) and **never** `allow-same-origin` or `allow-top-navigation`.
|
package/dist/frame.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
let e=require("@memizy/protocol"),t=require("penpal");var
|
|
1
|
+
let e=require("@memizy/protocol"),t=require("@memizy/protocol/internal"),n=require("penpal");var r=`allow-scripts allow-forms allow-modals allow-pointer-lock allow-popups allow-popups-to-escape-sandbox`;function i(t){let n={};for(let r of e.PLUGIN_API_METHODS)n[r]=async(...n)=>{try{return await t[r](...n)}catch(t){throw(0,e.toProtocolError)(t)}};return n}function a(a,o,s,c=document){let l=c.createElement(`iframe`);l.setAttribute(`sandbox`,r),l.setAttribute(`title`,o),l.style.cssText=`width:100%;height:100%;border:0;display:block;touch-action:manipulation;touch-action:pan-x pan-y;`;let u=(0,e.readPluginManifestFromHtml)(a),d=u.success?u.runtime:null,f=(0,t.pluginAllowAttribute)(d);f&&l.setAttribute(`allow`,f);let p=c.defaultView?.location?.origin??null;l.srcdoc=(0,t.injectContentPolicy)(a,(0,t.pluginContentPolicy)(d,p));let m=null;return{iframe:l,plugin:new Promise((r,a)=>{queueMicrotask(()=>{if(!l.contentWindow){a(Error(`The plugin iframe is not in the document.`));return}(0,t.ensureConnectionPolyfills)(),m=(0,n.connect)({messenger:new n.WindowMessenger({remoteWindow:l.contentWindow,allowedOrigins:[`*`]}),methods:s,timeout:e.LIMITS.helloTimeoutMs}),m.promise.then(e=>r(i(e)),a)})}),destroy:()=>m?.destroy()}}async function o(e,t,n,r={}){let i=n.ownerDocument,o=e.config.locale.startsWith(`cs`),s={waitingForHost:r.texts?.waitingForHost??(o?`Čekáme na hostitele…`:`Waiting for the host…`),countdown:r.texts?.countdown??(e=>String(e))},c=i.createElement(`div`);c.className=`mz-host-frame`,c.style.cssText=`position:relative;width:100%;height:100%;`;let l=i.createElement(`div`);l.className=`mz-host-overlay`,l.style.cssText=`position:absolute;inset:0;display:none;place-items:center;background:rgba(0,0,0,.55);color:#fff;font:600 1.5rem system-ui,sans-serif;text-align:center;padding:16px;`;let u=null,d=null;n.appendChild(c);let f=e=>{r.overlays!==!1&&(l.textContent=e??``,l.style.display=e?`grid`:`none`)},p=e.on(n=>{n.type===`countdown`?f(s.countdown(n.secondsLeft)):n.type===`started`?f(null):n.type===`authority`&&t!==e.authority&&f(n.connected?null:s.waitingForHost)});return{instance:await e.connect(t,async t=>{d?.(),u?.remove();let n=a(e.plugin.html,e.plugin.manifest.appName,t,i);return u=n.iframe,c.replaceChildren(u,l),d=n.destroy,n.plugin},{onDispose:()=>{d?.(),d=null}}),get iframe(){return u},unmount(){p(),e.disconnect(t),c.remove()}}}exports.PLUGIN_SANDBOX=r,exports.createPluginFrame=a,exports.mountPlugin=o;
|
package/dist/frame.js
CHANGED
|
@@ -1,42 +1,43 @@
|
|
|
1
|
-
import { LIMITS as e, PLUGIN_API_METHODS as t,
|
|
1
|
+
import { LIMITS as e, PLUGIN_API_METHODS as t, readPluginManifestFromHtml as n, toProtocolError as r } from "@memizy/protocol";
|
|
2
|
+
import { ensureConnectionPolyfills as i, injectContentPolicy as a, pluginAllowAttribute as o, pluginContentPolicy as s } from "@memizy/protocol/internal";
|
|
2
3
|
import { WindowMessenger as c, connect as l } from "penpal";
|
|
3
4
|
//#region src/frame.ts
|
|
4
|
-
var u = "allow-scripts allow-forms allow-modals allow-pointer-lock";
|
|
5
|
+
var u = "allow-scripts allow-forms allow-modals allow-pointer-lock allow-popups allow-popups-to-escape-sandbox";
|
|
5
6
|
function d(e) {
|
|
6
7
|
let n = {};
|
|
7
|
-
for (let
|
|
8
|
+
for (let i of t) n[i] = async (...t) => {
|
|
8
9
|
try {
|
|
9
|
-
return await e[
|
|
10
|
+
return await e[i](...t);
|
|
10
11
|
} catch (e) {
|
|
11
|
-
throw
|
|
12
|
+
throw r(e);
|
|
12
13
|
}
|
|
13
14
|
};
|
|
14
15
|
return n;
|
|
15
16
|
}
|
|
16
|
-
function f(t,
|
|
17
|
+
function f(t, r, f, p = document) {
|
|
17
18
|
let m = p.createElement("iframe");
|
|
18
|
-
m.setAttribute("sandbox", u), m.setAttribute("title",
|
|
19
|
-
let h =
|
|
19
|
+
m.setAttribute("sandbox", u), m.setAttribute("title", r), m.style.cssText = "width:100%;height:100%;border:0;display:block;touch-action:manipulation;touch-action:pan-x pan-y;";
|
|
20
|
+
let h = n(t), g = h.success ? h.runtime : null, _ = o(g);
|
|
20
21
|
_ && m.setAttribute("allow", _);
|
|
21
22
|
let v = p.defaultView?.location?.origin ?? null;
|
|
22
|
-
m.srcdoc =
|
|
23
|
+
m.srcdoc = a(t, s(g, v));
|
|
23
24
|
let y = null;
|
|
24
25
|
return {
|
|
25
26
|
iframe: m,
|
|
26
|
-
plugin: new Promise((t,
|
|
27
|
+
plugin: new Promise((t, n) => {
|
|
27
28
|
queueMicrotask(() => {
|
|
28
29
|
if (!m.contentWindow) {
|
|
29
|
-
|
|
30
|
+
n(/* @__PURE__ */ Error("The plugin iframe is not in the document."));
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
i(), y = l({
|
|
33
34
|
messenger: new c({
|
|
34
35
|
remoteWindow: m.contentWindow,
|
|
35
36
|
allowedOrigins: ["*"]
|
|
36
37
|
}),
|
|
37
38
|
methods: f,
|
|
38
39
|
timeout: e.helloTimeoutMs
|
|
39
|
-
}), y.promise.then((e) => t(d(e)),
|
|
40
|
+
}), y.promise.then((e) => t(d(e)), n);
|
|
40
41
|
});
|
|
41
42
|
}),
|
|
42
43
|
destroy: () => y?.destroy()
|
|
@@ -55,7 +56,7 @@ async function p(e, t, n, r = {}) {
|
|
|
55
56
|
let d = (e) => {
|
|
56
57
|
r.overlays !== !1 && (c.textContent = e ?? "", c.style.display = e ? "grid" : "none");
|
|
57
58
|
}, p = e.on((n) => {
|
|
58
|
-
n.type === "countdown" ? d(o.countdown(n.secondsLeft)) : n.type === "started" ? d(null) : n.type === "authority" && t !== e.authority
|
|
59
|
+
n.type === "countdown" ? d(o.countdown(n.secondsLeft)) : n.type === "started" ? d(null) : n.type === "authority" && t !== e.authority && d(n.connected ? null : o.waitingForHost);
|
|
59
60
|
});
|
|
60
61
|
return {
|
|
61
62
|
instance: await e.connect(t, async (t) => {
|
package/dist/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./version.cjs"),t=require("./plugin.cjs"),n=require("./learning.cjs"),r=require("./storage.cjs"),i=require("./session.cjs"),a=require("./frame.cjs"),o=require("./relay/messages.cjs"),s=require("./relay/host.cjs"),c=require("./relay/player.cjs");exports.HOST_SDK_VERSION=e.HOST_SDK_VERSION,exports.LEITNER_INTERVAL_DAYS=n.LEITNER_INTERVAL_DAYS,exports.LocalSession=i.LocalSession,exports.MemoryStorage=r.MemoryStorage,exports.PLUGIN_SANDBOX=a.PLUGIN_SANDBOX,exports.RELAY_APP_VERSION=o.RELAY_APP_VERSION,exports.RelayHost=s.RelayHost,exports.RelayPlayer=c.RelayPlayer,exports.createEmptySet=t.createEmptySet,exports.leitner=n.leitner,exports.loadPluginFromHtml=t.loadPluginFromHtml,exports.loadPluginFromUrl=t.loadPluginFromUrl,exports.mountPlugin=a.mountPlugin,exports.prepareSetForPlugin=t.prepareSetForPlugin;
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { RelayHost as
|
|
9
|
-
import { RelayPlayer as
|
|
10
|
-
export {
|
|
1
|
+
import { HOST_SDK_VERSION as e } from "./version.js";
|
|
2
|
+
import { createEmptySet as t, loadPluginFromHtml as n, loadPluginFromUrl as r, prepareSetForPlugin as i } from "./plugin.js";
|
|
3
|
+
import { LEITNER_INTERVAL_DAYS as a, leitner as o } from "./learning.js";
|
|
4
|
+
import { MemoryStorage as s } from "./storage.js";
|
|
5
|
+
import { LocalSession as c } from "./session.js";
|
|
6
|
+
import { PLUGIN_SANDBOX as l, mountPlugin as u } from "./frame.js";
|
|
7
|
+
import { RELAY_APP_VERSION as d } from "./relay/messages.js";
|
|
8
|
+
import { RelayHost as f } from "./relay/host.js";
|
|
9
|
+
import { RelayPlayer as p } from "./relay/player.js";
|
|
10
|
+
export { e as HOST_SDK_VERSION, a as LEITNER_INTERVAL_DAYS, c as LocalSession, s as MemoryStorage, l as PLUGIN_SANDBOX, d as RELAY_APP_VERSION, f as RelayHost, p as RelayPlayer, t as createEmptySet, o as leitner, n as loadPluginFromHtml, r as loadPluginFromUrl, u as mountPlugin, i as prepareSetForPlugin };
|
package/dist/relay/host.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const e=require("./socket.cjs"),t=require("./messages.cjs");let n=require("@memizy/protocol");var r=new Set(n.HOST_API_METHODS),i=class i{pin;hostToken;players=[];state={phase:`lobby`,appName:null,players:[],authorityConnected:!0};options;socket;listeners=new Set;channels=new Map;bundleVersion=0;genCounter=0;callCounter=0;session=null;hosting=!1;hostingWaiters=[];stopSession=null;constructor(t,r){this.options=t,this.pin=r.pin,this.hostToken=r.hostToken,this.socket=new e.RelaySocket({url:e.relayWebSocketUrl(t.serverUrl),attach:()=>({t:`host`,protocol:n.RELAY_PROTOCOL,pin:this.pin,hostToken:this.hostToken}),WebSocket:t.WebSocket}),this.socket.onStatus(e=>this.emit({type:`status`,status:e})),this.socket.onMessage(e=>this.onServer(e))}static async create(e){let t=await(e.fetch??fetch)(new URL(`/api/rooms`,e.serverUrl),{method:`POST`});if(!t.ok)throw Error(`Could not create a room (${t.status}): ${await t.text()}`);return new i(e,await t.json())}static resume(e,t){return new i(e,t)}whenHosting(){return this.hosting?Promise.resolve():new Promise(e=>this.hostingWaiters.push(e))}get status(){return this.socket.status}on(e){return this.listeners.add(e),()=>this.listeners.delete(e)}emit(e){for(let t of this.listeners)t(e)}async uploadBundle(e,t){let n=await(this.options.fetch??fetch)(new URL(`/api/rooms/${this.pin}/bundle`,this.options.serverUrl),{method:`PUT`,headers:{Authorization:`Bearer ${this.hostToken}`,"Content-Type":`application/json`},body:JSON.stringify({pluginHtml:e,set:t})});if(!n.ok)throw Error(`Could not upload the game (${n.status}): ${await n.text()}`);return this.bundleVersion=(await n.json()).version,this.bundleVersion}setOpen(e){this.socket.send({t:`open`,open:e})}kick(e){this.socket.send({t:`kick`,playerId:e})}close(){this.detach(),this.socket.send({t:`close`}),this.socket.close()}disconnect(){this.detach(),this.socket.close()}setState(e){this.state={...this.state,...e},this.broadcast({k:`state`,state:this.state})}attach(e){this.detach(),this.session=e;let t=new Set(this.players.map(e=>e.id));for(let t of this.players)!t.connected&&e.players.some(e=>e.id===t.id)&&e.setConnected(t.id,!1);for(let n of e.players)t.has(n.id)&&this.connectRemote(n.id);this.stopSession=e.on(e=>this.onSessionEvent(e)),this.setState({phase:`loading`,appName:e.plugin.manifest.appName,players:e.players,authorityConnected:!0,result:void 0,countdown:void 0})}detach(){this.stopSession?.(),this.stopSession=null,this.session=null;for(let[e,t]of this.channels)this.resetChannel(e,t);this.broadcast({k:`unmount`})}onSessionEvent(e){switch(e.type){case`countdown`:this.setState({phase:`countdown`,countdown:e.secondsLeft});break;case`started`:this.setState({phase:`running`,countdown:void 0});break;case`players`:this.setState({players:e.players});break;case`authority`:this.setState({authorityConnected:e.connected});break;case`ended`:this.setState({phase:`ended`,result:e.result})}}connectRemote(e){let t=this.session;t&&t.connect(e,this.endpointFor(e)).catch(()=>{})}endpointFor(e){return t=>new Promise((n,r)=>{let i=this.channel(e);this.resetChannel(e,i);let a=++this.genCounter;i.gen=a,i.hostApi=t,i.pendingMount={gen:a,resolve:()=>n(this.remotePlugin(e,a)),reject:r,timer:setTimeout(()=>{i.pendingMount?.gen===a&&(i.pendingMount=null,r(Error(`The device of player ${e} did not load the game in time.`)))},this.options.mountTimeoutMs??2e4)},this.sendMount(e,a)})}sendMount(e,t){this.sendTo(e,{k:`mount`,gen:t,bundle:this.bundleVersion,locale:this.session?.config.locale??`cs`})}remotePlugin(e,t){let r={};for(let i of n.PLUGIN_API_METHODS)r[i]=(...n)=>this.callRemote(e,t,i,n);return r}callRemote(e,t,n,r){let i=this.channels.get(e);if(!i||i.gen!==t)return Promise.reject(Error(`[SESSION_ENDED] The player instance was replaced.`));if(!this.players.find(t=>t.id===e)?.connected)return Promise.resolve();let a=++this.callCounter;return new Promise((o,s)=>{let c=setTimeout(()=>{i.calls.delete(a),s(Error(`[INTERNAL_ERROR] ${n} on ${e} timed out.`))},this.options.callTimeoutMs??15e3);i.calls.set(a,{resolve:o,reject:s,timer:c}),this.sendTo(e,{k:`call`,id:a,gen:t,method:n,args:r})})}channel(e){let t=this.channels.get(e);return t||(t={gen:0,hostApi:null,pendingMount:null,calls:new Map},this.channels.set(e,t)),t}resetChannel(e,t){t.pendingMount&&=(clearTimeout(t.pendingMount.timer),t.pendingMount.reject(Error(`[SESSION_ENDED] Replaced.`)),null),this.settleCalls(t),t.gen=0,t.hostApi=null}settleCalls(e){for(let t of e.calls.values())clearTimeout(t.timer),t.resolve(void 0);e.calls.clear()}onServer(e){switch(e.t){case`hosting`:this.hosting=!0,this.hostingWaiters.splice(0).forEach(e=>e()),this.players=e.players,this.emit({type:`players`,players:this.players}),this.broadcast({k:`state`,state:this.state});break;case`presence`:this.onPresence(e.player,e.event);break;case`from`:this.onPlayer(e.from,e.data);break;case`error`:this.emit({type:`error`,code:e.code,message:e.message});break;case`closed`:this.emit({type:`closed`,reason:e.reason})}}onPresence(e,t){this.players=t===`leave`?this.players.filter(t=>t.id!==e.id):this.players.some(t=>t.id===e.id)?this.players.map(t=>t.id===e.id?e:t):[...this.players,e],this.emit({type:`players`,players:this.players});let n=this.session,r=n?.players.some(t=>t.id===e.id)??!1;switch(t){case`join`:if(n&&!n.ended&&!r)try{n.addPlayer({id:e.id,name:e.name}),this.connectRemote(e.id)}catch{}break;case`disconnect`:{let t=this.channels.get(e.id);t&&this.settleCalls(t),r&&n.setConnected(e.id,!1);break}case`rename`:r&&n.renamePlayer(e.id,e.name);break;case`leave`:if(this.channels.delete(e.id),r)try{n.removePlayer(e.id)}catch{}}}async onPlayer(e,n){if(typeof n!=`object`||!n)return;let i=this.channel(e);switch(n.k){case`hello`:{this.sendTo(e,{k:`state`,state:this.state});let t=this.session;if(!t||!t.players.some(t=>t.id===e))return;if(i.pendingMount){this.sendMount(e,i.pendingMount.gen);return}t.setConnected(e,!0),(i.gen===0||n.gen!==i.gen)&&(t.getInstance(e)?t.reload(e).catch(()=>{}):this.connectRemote(e));return}case`mounted`:if(i.pendingMount?.gen===n.gen){clearTimeout(i.pendingMount.timer);let e=i.pendingMount;i.pendingMount=null,e.resolve()}return;case`mount-failed`:if(i.pendingMount?.gen===n.gen){clearTimeout(i.pendingMount.timer);let e=i.pendingMount;i.pendingMount=null,e.reject(Error(n.error?.message??`The game could not be loaded on the device.`))}return;case`result`:{let e=i.calls.get(n.id);if(!e)return;i.calls.delete(n.id),clearTimeout(e.timer),n.ok?e.resolve(n.value):e.reject(Error(n.error?.message??`Remote call failed`));return}case`call`:{let a=t=>this.sendTo(e,t);if(n.gen!==i.gen||!i.hostApi||!r.has(n.method)||!Array.isArray(n.args)){a({k:`result`,id:n.id,ok:!1,error:{message:`[SESSION_ENDED] This plugin instance is no longer active.`}});return}try{let e=i.hostApi[n.method],r=await e(...n.args);if(n.method===`hello`){r={...r,set:null},a({k:`result`,id:n.id,ok:!0,value:r,hostNow:Date.now()});return}n.method===`getAsset`&&r instanceof Blob&&(r=await t.blobToJson(r)),a({k:`result`,id:n.id,ok:!0,value:r??null})}catch(e){a({k:`result`,id:n.id,ok:!1,error:t.serializeError(e)})}return}}}sendTo(e,t){this.socket.send({t:`to`,to:e,data:t})}broadcast(e){for(let t of this.players)t.connected&&this.sendTo(t.id,e)}};exports.RelayHost=i;
|
|
1
|
+
const e=require("./socket.cjs"),t=require("./messages.cjs");let n=require("@memizy/protocol"),r=require("@memizy/protocol/internal");var i=new Set(n.HOST_API_METHODS),a=class a{pin;hostToken;players=[];state={phase:`lobby`,appName:null,players:[],authorityConnected:!0};options;socket;listeners=new Set;channels=new Map;bundleVersion=0;genCounter=0;callCounter=0;session=null;hosting=!1;hostingWaiters=[];stopSession=null;seatOf=new Map;seatsMode=!1;appVersions=new Map;constructor(t,r){this.options=t,this.pin=r.pin,this.hostToken=r.hostToken,this.socket=new e.RelaySocket({url:e.relayWebSocketUrl(t.serverUrl),attach:()=>({t:`host`,protocol:n.RELAY_PROTOCOL,pin:this.pin,hostToken:this.hostToken}),WebSocket:t.WebSocket}),this.socket.onStatus(e=>this.emit({type:`status`,status:e})),this.socket.onMessage(e=>this.onServer(e))}static async create(e){let t=await(e.fetch??fetch)(new URL(`/api/rooms`,e.serverUrl),{method:`POST`});if(!t.ok)throw Error(`Could not create a room (${t.status}): ${await t.text()}`);return new a(e,await t.json())}static rejoin(e,t){return new a(e,t)}whenHosting(){return this.hosting?Promise.resolve():new Promise(e=>this.hostingWaiters.push(e))}get status(){return this.socket.status}on(e){return this.listeners.add(e),()=>this.listeners.delete(e)}emit(e){for(let t of this.listeners)t(e)}async uploadBundle(e,t){let n=t.publicSet,r=await(this.options.fetch??fetch)(new URL(`/api/rooms/${this.pin}/bundle`,this.options.serverUrl),{method:`PUT`,headers:{Authorization:`Bearer ${this.hostToken}`,"Content-Type":`application/json`},body:JSON.stringify({pluginHtml:e,set:n})});if(!r.ok)throw Error(`Could not upload the game (${r.status}): ${await r.text()}`);return this.bundleVersion=(await r.json()).version,this.bundleVersion}setOpen(e){this.socket.send({t:`open`,open:e})}kick(e){this.socket.send({t:`kick`,playerId:e})}close(){this.detach(),this.socket.send({t:`close`}),this.socket.close()}disconnect(){this.detach(),this.socket.close()}setState(e){this.state={...this.state,...e},this.broadcast({k:`state`,state:this.state})}attach(e,t={}){this.detach(),this.session=e,this.seatsMode=t.seats===!0;let n=t=>e.players.some(e=>e.id===t&&!e.isHost),r=e=>this.players.some(t=>t.id===e);if(this.seatsMode)for(let[e,i]of Object.entries(t.taken??{}))r(e)&&n(i)&&this.relayIdOf(i)===null&&this.seatOf.set(e,i);else for(let e of this.players)n(e.id)&&this.seatOf.set(e.id,e.id);for(let t of e.players){if(t.isHost)continue;let n=this.relayIdOf(t.id);this.players.find(e=>e.id===n)?.connected||e.setConnected(t.id,!1)}for(let e of this.seatOf.values())this.connectRemote(e);if(this.stopSession=e.on(e=>this.onSessionEvent(e)),this.setState({phase:`loading`,appName:e.plugin.manifest.appName,players:[...e.players],authorityConnected:!0,result:void 0,countdown:void 0,...this.seatState()}),this.seatsMode)for(let e of this.players)e.connected&&this.sendSeat(e.id)}detach(){this.stopSession?.(),this.stopSession=null,this.session=null;for(let[e,t]of this.channels)this.resetChannel(e,t);this.seatOf.clear(),this.seatsMode&&(this.seatsMode=!1,this.state={...this.state,seats:void 0,newPlayers:void 0},this.broadcast({k:`state`,state:this.state})),this.broadcast({k:`unmount`})}get seats(){let e=this.session;return!this.seatsMode||!e?[]:e.players.filter(e=>!e.isHost).map(e=>({id:e.id,name:e.name,roomPlayerId:this.relayIdOf(e.id)}))}get seatAssignments(){return Object.fromEntries(this.seatOf)}assignSeat(e,t){let n=this.session;if(!this.seatsMode||!n)throw Error(`Places can be assigned only in a continued game.`);if(!this.players.some(t=>t.id===e))throw Error(`Unknown room player "${e}".`);if(t!==null&&!n.players.some(e=>e.id===t&&!e.isHost))throw Error(`Unknown place "${t}".`);if((this.seatOf.get(e)??null)!==t){if(this.unseat(e),t!==null){let n=this.relayIdOf(t);n!==null&&this.unseat(n),this.seat(e,t)}this.seatsChanged()}}appVersionOf(e){return this.appVersions.get(e)??null}relayIdOf(e){for(let[t,n]of this.seatOf)if(n===e)return t;return null}seatState(){let e=this.session;if(!this.seatsMode||!e)return{seats:void 0,newPlayers:void 0};let t=e.plugin.runtime.multiplayer;return{seats:this.seats.map(e=>({id:e.id,name:e.name,taken:e.roomPlayerId!==null})),newPlayers:!e.ended&&!!t?.lateJoin&&e.players.length<t.players.max}}seatsChanged(){this.seatsMode&&(this.setState(this.seatState()),this.emit({type:`seats`,seats:this.seats}))}sendSeat(e){let t=this.seatOf.get(e)??null,n=t===null?void 0:this.session?.players.find(e=>e.id===t)?.name;this.sendTo(e,{k:`seat`,seat:t,...n!==void 0&&{name:n}})}seat(e,t){this.seatOf.set(e,t);let n=this.players.find(t=>t.id===e)?.connected??!1;this.session?.setConnected(t,n),n&&this.connectRemote(t),this.sendSeat(e)}unseat(e){let t=this.seatOf.get(e);if(t===void 0)return;this.seatOf.delete(e);let n=this.channels.get(t);n&&this.resetChannel(t,n),this.channels.delete(t),this.session?.disconnect(t),this.session?.setConnected(t,!1),this.sendTo(e,{k:`unmount`}),this.sendSeat(e)}claim(e,t){let n=this.session,r=this.players.find(t=>t.id===e);if(!this.seatsMode||!n||n.ended||!r||this.seatOf.has(e)){this.sendSeat(e);return}if(t===null){try{n.addPlayer({id:e,name:r.name})}catch{this.sendSeat(e);return}this.seat(e,e)}else if(typeof t==`string`&&n.players.some(e=>e.id===t&&!e.isHost)&&this.relayIdOf(t)===null)this.seat(e,t);else{this.sendSeat(e);return}this.seatsChanged()}onSessionEvent(e){switch(e.type){case`countdown`:this.setState({phase:`countdown`,countdown:e.secondsLeft});break;case`started`:this.setState({phase:`running`,countdown:void 0});break;case`players`:this.setState({players:e.players,...this.seatState()}),this.seatsMode&&this.emit({type:`seats`,seats:this.seats});break;case`authority`:this.setState({authorityConnected:e.connected});break;case`result`:this.setState({phase:`ended`,result:e.result})}}connectRemote(e){let t=this.session;t&&t.connect(e,this.endpointFor(e)).catch(()=>{})}endpointFor(e){return t=>new Promise((n,r)=>{let i=this.channel(e);this.resetChannel(e,i);let a=++this.genCounter;i.gen=a,i.hostApi=t,i.pendingMount={gen:a,resolve:()=>n(this.remotePlugin(e,a)),reject:r,timer:setTimeout(()=>{i.pendingMount?.gen===a&&(i.pendingMount=null,r(Error(`The device of player ${e} did not load the game in time.`)))},this.options.mountTimeoutMs??2e4)},this.sendMount(e,a)})}sendMount(e,t){this.sendToSeat(e,{k:`mount`,gen:t,bundle:this.bundleVersion,locale:this.session?.config.locale??`en`,v:1})}remotePlugin(e,t){let r={};for(let i of n.PLUGIN_API_METHODS)r[i]=(...n)=>this.callRemote(e,t,i,n);return r}callRemote(e,t,n,r){let i=this.channels.get(e);if(!i||i.gen!==t)return Promise.reject(Error(`[SESSION_ENDED] The player instance was replaced.`));let a=this.relayIdOf(e);if(!this.players.find(e=>e.id===a)?.connected)return Promise.resolve();let o=++this.callCounter;return new Promise((a,s)=>{let c=setTimeout(()=>{i.calls.delete(o),s(Error(`[INTERNAL_ERROR] ${n} on ${e} timed out.`))},this.options.callTimeoutMs??15e3);i.calls.set(o,{resolve:a,reject:s,timer:c}),this.sendToSeat(e,{k:`call`,id:o,gen:t,method:n,args:r})})}channel(e){let t=this.channels.get(e);return t||(t={gen:0,hostApi:null,pendingMount:null,calls:new Map},this.channels.set(e,t)),t}resetChannel(e,t){t.pendingMount&&=(clearTimeout(t.pendingMount.timer),t.pendingMount.reject(Error(`[SESSION_ENDED] Replaced.`)),null),this.settleCalls(t),t.gen=0,t.hostApi=null}settleCalls(e){for(let t of e.calls.values())clearTimeout(t.timer),t.resolve(void 0);e.calls.clear()}onServer(e){switch(e.t){case`hosting`:this.hosting=!0,this.hostingWaiters.splice(0).forEach(e=>e()),this.players=e.players,this.emit({type:`players`,players:this.players}),this.broadcast({k:`state`,state:this.state});break;case`presence`:this.onPresence(e.player,e.event);break;case`from`:this.onPlayer(e.from,e.data);break;case`error`:this.emit({type:`error`,code:e.code,message:e.message});break;case`closed`:this.emit({type:`closed`,reason:e.reason})}}onPresence(e,t){this.players=t===`leave`?this.players.filter(t=>t.id!==e.id):this.players.some(t=>t.id===e.id)?this.players.map(t=>t.id===e.id?e:t):[...this.players,e],this.emit({type:`players`,players:this.players});let n=this.session,r=this.seatOf.get(e.id)??null;switch(t){case`join`:if(n&&!n.ended&&r===null&&!this.seatsMode)try{n.addPlayer({id:e.id,name:e.name}),this.seatOf.set(e.id,e.id),this.connectRemote(e.id)}catch{}break;case`disconnect`:{let e=r===null?void 0:this.channels.get(r);e&&this.settleCalls(e),n&&r!==null&&n.setConnected(r,!1);break}case`rename`:n&&r!==null&&!this.seatsMode&&n.renamePlayer(r,e.name);break;case`leave`:if(this.appVersions.delete(e.id),r!==null&&this.seatsMode)this.unseat(e.id),this.seatsChanged();else if(r!==null){this.seatOf.delete(e.id),this.channels.delete(r);try{n?.removePlayer(r)}catch{}}}}async onPlayer(e,n){if(typeof n!=`object`||!n)return;let a=this.seatOf.get(e)??null;if(n.k===`hello`)this.appVersions.set(e,typeof n.v==`number`&&Number.isFinite(n.v)?n.v:0),this.sendTo(e,{k:`state`,state:this.state}),this.seatsMode&&this.sendSeat(e);else if(n.k===`claim`){this.claim(e,n.seat);return}if(a===null){if(n.k===`call`){let r=i.has(n.method)?{message:`[SESSION_ENDED] This device has no place in the game.`}:t.unknownMethod(n.method);this.sendTo(e,{k:`result`,id:n.id,ok:!1,error:r})}return}let o=this.channel(a);switch(n.k){case`hello`:{let e=this.session;if(!e||!e.players.some(e=>e.id===a))return;if(o.pendingMount){this.sendMount(a,o.pendingMount.gen);return}e.setConnected(a,!0),(o.gen===0||n.gen!==o.gen)&&(e.getInstance(a)?e.reload(a).catch(()=>{}):this.connectRemote(a));return}case`mounted`:if(o.pendingMount?.gen===n.gen){clearTimeout(o.pendingMount.timer);let e=o.pendingMount;o.pendingMount=null,e.resolve()}return;case`mount-failed`:if(o.pendingMount?.gen===n.gen){clearTimeout(o.pendingMount.timer);let e=o.pendingMount;o.pendingMount=null,e.reject(Error(n.error?.message??`The game could not be loaded on the device.`))}return;case`result`:{let e=o.calls.get(n.id);if(!e)return;o.calls.delete(n.id),clearTimeout(e.timer),n.ok?e.resolve(n.value):e.reject(Error(n.error?.message??`Remote call failed`));return}case`call`:{let a=t=>this.sendTo(e,t);if(!i.has(n.method)){a({k:`result`,id:n.id,ok:!1,error:t.unknownMethod(n.method)});return}if(n.gen!==o.gen||!o.hostApi||!Array.isArray(n.args)){a({k:`result`,id:n.id,ok:!1,error:{message:`[SESSION_ENDED] This plugin instance is no longer active.`}});return}try{let i=o.hostApi[n.method],s=await i(...n.args);if(n.method===`hello`){s={...s,set:null},a({k:`result`,id:n.id,ok:!0,value:s,hostNow:Date.now()});return}n.method===`getAsset`&&s instanceof Blob&&(s=await t.blobToJson(s));let c={k:`result`,id:n.id,ok:!0,value:s??null};if((0,r.jsonByteSize)({t:`to`,to:e,data:c})>r.RELAY_LIMITS.frameBytes){a({k:`result`,id:n.id,ok:!1,error:{message:`[DATA_TOO_LARGE] The result of ${n.method} is too large to send to the device.`}});return}a(c)}catch(e){a({k:`result`,id:n.id,ok:!1,error:t.serializeError(e)})}return}}}sendTo(e,t){this.socket.send({t:`to`,to:e,data:t})}sendToSeat(e,t){let n=this.relayIdOf(e);n!==null&&this.sendTo(n,t)}broadcast(e){for(let t of this.players)t.connected&&this.sendTo(t.id,e)}};exports.RelayHost=a;
|