@memizy/host-sdk 0.0.0-stage → 1.0.0-rc.5
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 +31 -2
- package/dist/frame.cjs +1 -0
- package/dist/frame.js +77 -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 +370 -0
- package/dist/relay/messages.cjs +1 -0
- package/dist/relay/messages.js +22 -0
- package/dist/relay/player.cjs +1 -0
- package/dist/relay/player.js +274 -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 +530 -0
- package/dist/src/frame.d.ts +38 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/learning.d.ts +14 -0
- package/dist/src/plugin.d.ts +51 -0
- package/dist/src/relay/host.d.ts +94 -0
- package/dist/src/relay/messages.d.ts +97 -0
- package/dist/src/relay/player.d.ts +98 -0
- package/dist/src/relay/socket.d.ts +34 -0
- package/dist/src/session.d.ts +233 -0
- package/dist/src/storage.d.ts +24 -0
- package/dist/storage.cjs +1 -0
- package/dist/storage.js +37 -0
- package/package.json +59 -4
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @memizy/host-sdk
|
|
2
2
|
|
|
3
|
-
|
|
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](../protocol/SPEC.md) and run game sessions.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { loadOQSEFile } from '@memizy/oqse';
|
|
7
|
+
import { loadPluginFromHtml, LocalSession, mountPlugin } from '@memizy/host-sdk';
|
|
8
|
+
|
|
9
|
+
const loaded = loadPluginFromHtml(html); // manifest validated without running the plugin
|
|
10
|
+
if (!loaded.success) throw new Error(loaded.errors.join('\n'));
|
|
11
|
+
|
|
12
|
+
const session = new LocalSession({
|
|
13
|
+
plugin: loaded.plugin,
|
|
14
|
+
set: loadOQSEFile(setJson).data!,
|
|
15
|
+
mode: 'multiplayer',
|
|
16
|
+
hostAs: 'presenter',
|
|
17
|
+
players: [{ id: 'anna', name: 'Anna' }, { id: 'ben', name: 'Ben' }],
|
|
18
|
+
});
|
|
19
|
+
session.on((event) => console.log(event)); // ready, countdown, answer, ended, traffic, rejected…
|
|
20
|
+
|
|
21
|
+
await mountPlugin(session, 'board', boardElement); // sandboxed iframe + Penpal
|
|
22
|
+
await mountPlugin(session, 'anna', annaElement);
|
|
23
|
+
await mountPlugin(session, 'ben', benElement);
|
|
24
|
+
await session.start(); // waits for everyone, countdown, start
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
* **`LocalSession`** – all instances in one page (Lab, solo games in the app). 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. Multiplayer across devices will use the same API with a relay transport.
|
|
28
|
+
* **`HostStorage`** – plug in your persistence (`MemoryStorage` for the Lab and tests).
|
|
29
|
+
* **`LearningAlgorithm`** – how answers change learning progress (`leitner` by default).
|
|
30
|
+
* **`prepareSetForPlugin` / `checkCompatibility`** – keep only item types the plugin supports, decide which plugins can play a set.
|
|
31
|
+
|
|
32
|
+
Iframes are sandboxed with `allow-scripts allow-forms allow-modals allow-pointer-lock` and **never** `allow-same-origin`.
|
package/dist/frame.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let e=require("@memizy/protocol"),t=require("penpal");var n=`allow-scripts allow-forms allow-modals allow-pointer-lock`;function r(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 i(i,a,o,s=document){let c=s.createElement(`iframe`);c.setAttribute(`sandbox`,n),c.setAttribute(`title`,a),c.style.cssText=`width:100%;height:100%;border:0;display:block;touch-action:manipulation;touch-action:pan-x pan-y;`;let l=(0,e.readPluginManifestFromHtml)(i),u=l.success?l.runtime:null,d=(0,e.pluginAllowAttribute)(u);d&&c.setAttribute(`allow`,d);let f=s.defaultView?.location?.origin??null;c.srcdoc=(0,e.injectContentPolicy)(i,(0,e.pluginContentPolicy)(u,f));let p=null;return{iframe:c,plugin:new Promise((n,i)=>{queueMicrotask(()=>{if(!c.contentWindow){i(Error(`The plugin iframe is not in the document.`));return}(0,e.ensureConnectionPolyfills)(),p=(0,t.connect)({messenger:new t.WindowMessenger({remoteWindow:c.contentWindow,allowedOrigins:[`*`]}),methods:o,timeout:e.LIMITS.helloTimeoutMs}),p.promise.then(e=>n(r(e)),i)})}),destroy:()=>p?.destroy()}}async function a(e,t,n,r={}){let a=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=a.createElement(`div`);c.className=`mz-host-frame`,c.style.cssText=`position:relative;width:100%;height:100%;`;let l=a.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):n.type===`resize`&&n.address===t&&(c.style.height=n.height===`auto`?``:`${n.height}px`)});return{instance:await e.connect(t,async t=>{d?.(),u?.remove();let n=i(e.plugin.html,e.plugin.manifest.appName,t,a);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=n,exports.createPluginFrame=i,exports.mountPlugin=a;
|
package/dist/frame.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { LIMITS as e, PLUGIN_API_METHODS as t, ensureConnectionPolyfills as n, injectContentPolicy as r, pluginAllowAttribute as i, pluginContentPolicy as a, readPluginManifestFromHtml as o, toProtocolError as s } from "@memizy/protocol";
|
|
2
|
+
import { WindowMessenger as c, connect as l } from "penpal";
|
|
3
|
+
//#region src/frame.ts
|
|
4
|
+
var u = "allow-scripts allow-forms allow-modals allow-pointer-lock";
|
|
5
|
+
function d(e) {
|
|
6
|
+
let n = {};
|
|
7
|
+
for (let r of t) n[r] = async (...t) => {
|
|
8
|
+
try {
|
|
9
|
+
return await e[r](...t);
|
|
10
|
+
} catch (e) {
|
|
11
|
+
throw s(e);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
return n;
|
|
15
|
+
}
|
|
16
|
+
function f(t, s, f, p = document) {
|
|
17
|
+
let m = p.createElement("iframe");
|
|
18
|
+
m.setAttribute("sandbox", u), m.setAttribute("title", s), m.style.cssText = "width:100%;height:100%;border:0;display:block;touch-action:manipulation;touch-action:pan-x pan-y;";
|
|
19
|
+
let h = o(t), g = h.success ? h.runtime : null, _ = i(g);
|
|
20
|
+
_ && m.setAttribute("allow", _);
|
|
21
|
+
let v = p.defaultView?.location?.origin ?? null;
|
|
22
|
+
m.srcdoc = r(t, a(g, v));
|
|
23
|
+
let y = null;
|
|
24
|
+
return {
|
|
25
|
+
iframe: m,
|
|
26
|
+
plugin: new Promise((t, r) => {
|
|
27
|
+
queueMicrotask(() => {
|
|
28
|
+
if (!m.contentWindow) {
|
|
29
|
+
r(/* @__PURE__ */ Error("The plugin iframe is not in the document."));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
n(), y = l({
|
|
33
|
+
messenger: new c({
|
|
34
|
+
remoteWindow: m.contentWindow,
|
|
35
|
+
allowedOrigins: ["*"]
|
|
36
|
+
}),
|
|
37
|
+
methods: f,
|
|
38
|
+
timeout: e.helloTimeoutMs
|
|
39
|
+
}), y.promise.then((e) => t(d(e)), r);
|
|
40
|
+
});
|
|
41
|
+
}),
|
|
42
|
+
destroy: () => y?.destroy()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function p(e, t, n, r = {}) {
|
|
46
|
+
let i = n.ownerDocument, a = e.config.locale.startsWith("cs"), o = {
|
|
47
|
+
waitingForHost: r.texts?.waitingForHost ?? (a ? "Čekáme na hostitele…" : "Waiting for the host…"),
|
|
48
|
+
countdown: r.texts?.countdown ?? ((e) => String(e))
|
|
49
|
+
}, s = i.createElement("div");
|
|
50
|
+
s.className = "mz-host-frame", s.style.cssText = "position:relative;width:100%;height:100%;";
|
|
51
|
+
let c = i.createElement("div");
|
|
52
|
+
c.className = "mz-host-overlay", c.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;";
|
|
53
|
+
let l = null, u = null;
|
|
54
|
+
n.appendChild(s);
|
|
55
|
+
let d = (e) => {
|
|
56
|
+
r.overlays !== !1 && (c.textContent = e ?? "", c.style.display = e ? "grid" : "none");
|
|
57
|
+
}, p = e.on((n) => {
|
|
58
|
+
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) : n.type === "resize" && n.address === t && (s.style.height = n.height === "auto" ? "" : `${n.height}px`);
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
instance: await e.connect(t, async (t) => {
|
|
62
|
+
u?.(), l?.remove();
|
|
63
|
+
let n = f(e.plugin.html, e.plugin.manifest.appName, t, i);
|
|
64
|
+
return l = n.iframe, s.replaceChildren(l, c), u = n.destroy, n.plugin;
|
|
65
|
+
}, { onDispose: () => {
|
|
66
|
+
u?.(), u = null;
|
|
67
|
+
} }),
|
|
68
|
+
get iframe() {
|
|
69
|
+
return l;
|
|
70
|
+
},
|
|
71
|
+
unmount() {
|
|
72
|
+
p(), e.disconnect(t), s.remove();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { u as PLUGIN_SANDBOX, f as createPluginFrame, p as mountPlugin };
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./plugin.cjs"),t=require("./learning.cjs"),n=require("./storage.cjs"),r=require("./session.cjs"),i=require("./frame.cjs"),a=require("./relay/socket.cjs"),o=require("./relay/messages.cjs"),s=require("./relay/host.cjs"),c=require("./relay/player.cjs");exports.LEITNER_INTERVAL_DAYS=t.LEITNER_INTERVAL_DAYS,exports.LocalSession=r.LocalSession,exports.MemoryStorage=n.MemoryStorage,exports.PLUGIN_SANDBOX=i.PLUGIN_SANDBOX,exports.RelayHost=s.RelayHost,exports.RelayPlayer=c.RelayPlayer,exports.RelaySocket=a.RelaySocket,exports.SETTINGS_ADDRESS=r.SETTINGS_ADDRESS,exports.blobToJson=o.blobToJson,exports.createEmptySet=e.createEmptySet,exports.createPluginFrame=i.createPluginFrame,exports.isSerializedBlob=o.isSerializedBlob,exports.jsonToBlob=o.jsonToBlob,exports.leitner=t.leitner,exports.loadPluginFromHtml=e.loadPluginFromHtml,exports.loadPluginFromUrl=e.loadPluginFromUrl,exports.mountPlugin=i.mountPlugin,exports.prepareSetForPlugin=e.prepareSetForPlugin,exports.relayWebSocketUrl=a.relayWebSocketUrl,exports.serializeError=o.serializeError;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createEmptySet as e, loadPluginFromHtml as t, loadPluginFromUrl as n, prepareSetForPlugin as r } from "./plugin.js";
|
|
2
|
+
import { LEITNER_INTERVAL_DAYS as i, leitner as a } from "./learning.js";
|
|
3
|
+
import { MemoryStorage as o } from "./storage.js";
|
|
4
|
+
import { LocalSession as s, SETTINGS_ADDRESS as c } from "./session.js";
|
|
5
|
+
import { PLUGIN_SANDBOX as l, createPluginFrame as u, mountPlugin as d } from "./frame.js";
|
|
6
|
+
import { RelaySocket as f, relayWebSocketUrl as p } from "./relay/socket.js";
|
|
7
|
+
import { blobToJson as m, isSerializedBlob as h, jsonToBlob as g, serializeError as _ } from "./relay/messages.js";
|
|
8
|
+
import { RelayHost as v } from "./relay/host.js";
|
|
9
|
+
import { RelayPlayer as y } from "./relay/player.js";
|
|
10
|
+
export { i as LEITNER_INTERVAL_DAYS, s as LocalSession, o as MemoryStorage, l as PLUGIN_SANDBOX, v as RelayHost, y as RelayPlayer, f as RelaySocket, c as SETTINGS_ADDRESS, m as blobToJson, e as createEmptySet, u as createPluginFrame, h as isSerializedBlob, g as jsonToBlob, a as leitner, t as loadPluginFromHtml, n as loadPluginFromUrl, d as mountPlugin, r as prepareSetForPlugin, p as relayWebSocketUrl, _ as serializeError };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=[0,1,3,7,21],t={id:`leitner`,apply(t,n,r){let i=t?.stats??{attempts:0,incorrect:0,streak:0},a={isCorrect:!n.isSkipped&&n.isCorrect,answeredAt:r.toISOString(),...n.confidence!==void 0&&{confidence:n.confidence},...n.timeSpentMs!==void 0&&{timeSpent:n.timeSpentMs},...n.hintsUsed!==void 0&&{hintsUsed:n.hintsUsed},...n.isSkipped&&{isSkipped:!0}};if(n.isSkipped)return{...t??{bucket:0},stats:i,lastAnswer:a};let o=n.isCorrect?Math.min(4,Math.max(1,t?.bucket??0)+1):1,s=new Date(r.getTime()+e[o]*24*60*60*1e3);return{...t,bucket:o,nextReviewAt:s.toISOString(),stats:{attempts:i.attempts+1,incorrect:i.incorrect+ +!n.isCorrect,streak:n.isCorrect?i.streak+1:0},lastAnswer:a}}};exports.LEITNER_INTERVAL_DAYS=e,exports.leitner=t;
|
package/dist/learning.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/learning.ts
|
|
2
|
+
var e = [
|
|
3
|
+
0,
|
|
4
|
+
1,
|
|
5
|
+
3,
|
|
6
|
+
7,
|
|
7
|
+
21
|
|
8
|
+
], t = {
|
|
9
|
+
id: "leitner",
|
|
10
|
+
apply(t, n, r) {
|
|
11
|
+
let i = t?.stats ?? {
|
|
12
|
+
attempts: 0,
|
|
13
|
+
incorrect: 0,
|
|
14
|
+
streak: 0
|
|
15
|
+
}, a = {
|
|
16
|
+
isCorrect: !n.isSkipped && n.isCorrect,
|
|
17
|
+
answeredAt: r.toISOString(),
|
|
18
|
+
...n.confidence !== void 0 && { confidence: n.confidence },
|
|
19
|
+
...n.timeSpentMs !== void 0 && { timeSpent: n.timeSpentMs },
|
|
20
|
+
...n.hintsUsed !== void 0 && { hintsUsed: n.hintsUsed },
|
|
21
|
+
...n.isSkipped && { isSkipped: !0 }
|
|
22
|
+
};
|
|
23
|
+
if (n.isSkipped) return {
|
|
24
|
+
...t ?? { bucket: 0 },
|
|
25
|
+
stats: i,
|
|
26
|
+
lastAnswer: a
|
|
27
|
+
};
|
|
28
|
+
let o = n.isCorrect ? Math.min(4, Math.max(1, t?.bucket ?? 0) + 1) : 1, s = new Date(r.getTime() + e[o] * 24 * 60 * 60 * 1e3);
|
|
29
|
+
return {
|
|
30
|
+
...t,
|
|
31
|
+
bucket: o,
|
|
32
|
+
nextReviewAt: s.toISOString(),
|
|
33
|
+
stats: {
|
|
34
|
+
attempts: i.attempts + 1,
|
|
35
|
+
incorrect: i.incorrect + +!n.isCorrect,
|
|
36
|
+
streak: n.isCorrect ? i.streak + 1 : 0
|
|
37
|
+
},
|
|
38
|
+
lastAnswer: a
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { e as LEITNER_INTERVAL_DAYS, t as leitner };
|
package/dist/plugin.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let e=require("@memizy/protocol"),t=require("@memizy/oqse");function n(t){let n=(0,e.readPluginManifestFromHtml)(t);return n.success?(0,e.isProtocolSupported)(n.runtime.protocol)?{success:!0,plugin:{html:t,manifest:n.manifest,runtime:n.runtime}}:{success:!1,errors:[`The plugin needs Memizy Plugin Protocol ${n.runtime.protocol}, which this host does not support.`]}:n}async function r(e,t=fetch){let r=await t(e);return r.ok?n(await r.text()):{success:!1,errors:[`Could not download the plugin (${r.status} ${r.statusText}).`]}}function i(n,r,i={}){let a=r.capabilities?.types??[],o=a.includes(`*`)?n.items:n.items.filter(e=>a.includes(e.type)),s=(0,e.prepareDisplaySet)({meta:n.meta,items:o},i.seed);return{set:s.set,publicSet:s.publicSet,compatibility:(0,t.checkCompatibility)(n,r),skippedItems:n.items.length-o.length}}function a(e){let n=new Date().toISOString();return{version:t.OQSE_VERSION,meta:{id:(0,t.generateUUID)(),title:e.title,language:e.language,createdAt:n,updatedAt:n},items:[]}}exports.createEmptySet=a,exports.loadPluginFromHtml=n,exports.loadPluginFromUrl=r,exports.prepareSetForPlugin=i;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isProtocolSupported as e, prepareDisplaySet as t, readPluginManifestFromHtml as n } from "@memizy/protocol";
|
|
2
|
+
import { OQSE_VERSION as r, checkCompatibility as i, generateUUID as a } from "@memizy/oqse";
|
|
3
|
+
//#region src/plugin.ts
|
|
4
|
+
function o(t) {
|
|
5
|
+
let r = n(t);
|
|
6
|
+
return r.success ? e(r.runtime.protocol) ? {
|
|
7
|
+
success: !0,
|
|
8
|
+
plugin: {
|
|
9
|
+
html: t,
|
|
10
|
+
manifest: r.manifest,
|
|
11
|
+
runtime: r.runtime
|
|
12
|
+
}
|
|
13
|
+
} : {
|
|
14
|
+
success: !1,
|
|
15
|
+
errors: [`The plugin needs Memizy Plugin Protocol ${r.runtime.protocol}, which this host does not support.`]
|
|
16
|
+
} : r;
|
|
17
|
+
}
|
|
18
|
+
async function s(e, t = fetch) {
|
|
19
|
+
let n = await t(e);
|
|
20
|
+
return n.ok ? o(await n.text()) : {
|
|
21
|
+
success: !1,
|
|
22
|
+
errors: [`Could not download the plugin (${n.status} ${n.statusText}).`]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function c(e, n, r = {}) {
|
|
26
|
+
let a = n.capabilities?.types ?? [], o = a.includes("*") ? e.items : e.items.filter((e) => a.includes(e.type)), s = t({
|
|
27
|
+
meta: e.meta,
|
|
28
|
+
items: o
|
|
29
|
+
}, r.seed);
|
|
30
|
+
return {
|
|
31
|
+
set: s.set,
|
|
32
|
+
publicSet: s.publicSet,
|
|
33
|
+
compatibility: i(e, n),
|
|
34
|
+
skippedItems: e.items.length - o.length
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function l(e) {
|
|
38
|
+
let t = (/* @__PURE__ */ new Date()).toISOString();
|
|
39
|
+
return {
|
|
40
|
+
version: r,
|
|
41
|
+
meta: {
|
|
42
|
+
id: a(),
|
|
43
|
+
title: e.title,
|
|
44
|
+
language: e.language,
|
|
45
|
+
createdAt: t,
|
|
46
|
+
updatedAt: t
|
|
47
|
+
},
|
|
48
|
+
items: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { l as createEmptySet, o as loadPluginFromHtml, s as loadPluginFromUrl, c as prepareSetForPlugin };
|
|
@@ -0,0 +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;
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { RelaySocket as e, relayWebSocketUrl as t } from "./socket.js";
|
|
2
|
+
import { blobToJson as n, serializeError as r } from "./messages.js";
|
|
3
|
+
import { HOST_API_METHODS as i, PLUGIN_API_METHODS as a, RELAY_PROTOCOL as o } from "@memizy/protocol";
|
|
4
|
+
//#region src/relay/host.ts
|
|
5
|
+
var s = new Set(i), c = class i {
|
|
6
|
+
pin;
|
|
7
|
+
hostToken;
|
|
8
|
+
players = [];
|
|
9
|
+
state = {
|
|
10
|
+
phase: "lobby",
|
|
11
|
+
appName: null,
|
|
12
|
+
players: [],
|
|
13
|
+
authorityConnected: !0
|
|
14
|
+
};
|
|
15
|
+
options;
|
|
16
|
+
socket;
|
|
17
|
+
listeners = /* @__PURE__ */ new Set();
|
|
18
|
+
channels = /* @__PURE__ */ new Map();
|
|
19
|
+
bundleVersion = 0;
|
|
20
|
+
genCounter = 0;
|
|
21
|
+
callCounter = 0;
|
|
22
|
+
session = null;
|
|
23
|
+
hosting = !1;
|
|
24
|
+
hostingWaiters = [];
|
|
25
|
+
stopSession = null;
|
|
26
|
+
constructor(n, r) {
|
|
27
|
+
this.options = n, this.pin = r.pin, this.hostToken = r.hostToken, this.socket = new e({
|
|
28
|
+
url: t(n.serverUrl),
|
|
29
|
+
attach: () => ({
|
|
30
|
+
t: "host",
|
|
31
|
+
protocol: o,
|
|
32
|
+
pin: this.pin,
|
|
33
|
+
hostToken: this.hostToken
|
|
34
|
+
}),
|
|
35
|
+
WebSocket: n.WebSocket
|
|
36
|
+
}), this.socket.onStatus((e) => this.emit({
|
|
37
|
+
type: "status",
|
|
38
|
+
status: e
|
|
39
|
+
})), this.socket.onMessage((e) => this.onServer(e));
|
|
40
|
+
}
|
|
41
|
+
static async create(e) {
|
|
42
|
+
let t = await (e.fetch ?? fetch)(new URL("/api/rooms", e.serverUrl), { method: "POST" });
|
|
43
|
+
if (!t.ok) throw Error(`Could not create a room (${t.status}): ${await t.text()}`);
|
|
44
|
+
return new i(e, await t.json());
|
|
45
|
+
}
|
|
46
|
+
static resume(e, t) {
|
|
47
|
+
return new i(e, t);
|
|
48
|
+
}
|
|
49
|
+
whenHosting() {
|
|
50
|
+
return this.hosting ? Promise.resolve() : new Promise((e) => this.hostingWaiters.push(e));
|
|
51
|
+
}
|
|
52
|
+
get status() {
|
|
53
|
+
return this.socket.status;
|
|
54
|
+
}
|
|
55
|
+
on(e) {
|
|
56
|
+
return this.listeners.add(e), () => this.listeners.delete(e);
|
|
57
|
+
}
|
|
58
|
+
emit(e) {
|
|
59
|
+
for (let t of this.listeners) t(e);
|
|
60
|
+
}
|
|
61
|
+
async uploadBundle(e, t) {
|
|
62
|
+
let n = await (this.options.fetch ?? fetch)(new URL(`/api/rooms/${this.pin}/bundle`, this.options.serverUrl), {
|
|
63
|
+
method: "PUT",
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${this.hostToken}`,
|
|
66
|
+
"Content-Type": "application/json"
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
pluginHtml: e,
|
|
70
|
+
set: t
|
|
71
|
+
})
|
|
72
|
+
});
|
|
73
|
+
if (!n.ok) throw Error(`Could not upload the game (${n.status}): ${await n.text()}`);
|
|
74
|
+
return this.bundleVersion = (await n.json()).version, this.bundleVersion;
|
|
75
|
+
}
|
|
76
|
+
setOpen(e) {
|
|
77
|
+
this.socket.send({
|
|
78
|
+
t: "open",
|
|
79
|
+
open: e
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
kick(e) {
|
|
83
|
+
this.socket.send({
|
|
84
|
+
t: "kick",
|
|
85
|
+
playerId: e
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
close() {
|
|
89
|
+
this.detach(), this.socket.send({ t: "close" }), this.socket.close();
|
|
90
|
+
}
|
|
91
|
+
disconnect() {
|
|
92
|
+
this.detach(), this.socket.close();
|
|
93
|
+
}
|
|
94
|
+
setState(e) {
|
|
95
|
+
this.state = {
|
|
96
|
+
...this.state,
|
|
97
|
+
...e
|
|
98
|
+
}, this.broadcast({
|
|
99
|
+
k: "state",
|
|
100
|
+
state: this.state
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
attach(e) {
|
|
104
|
+
this.detach(), this.session = e;
|
|
105
|
+
let t = new Set(this.players.map((e) => e.id));
|
|
106
|
+
for (let t of this.players) !t.connected && e.players.some((e) => e.id === t.id) && e.setConnected(t.id, !1);
|
|
107
|
+
for (let n of e.players) t.has(n.id) && this.connectRemote(n.id);
|
|
108
|
+
this.stopSession = e.on((e) => this.onSessionEvent(e)), this.setState({
|
|
109
|
+
phase: "loading",
|
|
110
|
+
appName: e.plugin.manifest.appName,
|
|
111
|
+
players: e.players,
|
|
112
|
+
authorityConnected: !0,
|
|
113
|
+
result: void 0,
|
|
114
|
+
countdown: void 0
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
detach() {
|
|
118
|
+
this.stopSession?.(), this.stopSession = null, this.session = null;
|
|
119
|
+
for (let [e, t] of this.channels) this.resetChannel(e, t);
|
|
120
|
+
this.broadcast({ k: "unmount" });
|
|
121
|
+
}
|
|
122
|
+
onSessionEvent(e) {
|
|
123
|
+
switch (e.type) {
|
|
124
|
+
case "countdown":
|
|
125
|
+
this.setState({
|
|
126
|
+
phase: "countdown",
|
|
127
|
+
countdown: e.secondsLeft
|
|
128
|
+
});
|
|
129
|
+
break;
|
|
130
|
+
case "started":
|
|
131
|
+
this.setState({
|
|
132
|
+
phase: "running",
|
|
133
|
+
countdown: void 0
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
case "players":
|
|
137
|
+
this.setState({ players: e.players });
|
|
138
|
+
break;
|
|
139
|
+
case "authority":
|
|
140
|
+
this.setState({ authorityConnected: e.connected });
|
|
141
|
+
break;
|
|
142
|
+
case "ended": this.setState({
|
|
143
|
+
phase: "ended",
|
|
144
|
+
result: e.result
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
connectRemote(e) {
|
|
149
|
+
let t = this.session;
|
|
150
|
+
t && t.connect(e, this.endpointFor(e)).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
endpointFor(e) {
|
|
153
|
+
return (t) => new Promise((n, r) => {
|
|
154
|
+
let i = this.channel(e);
|
|
155
|
+
this.resetChannel(e, i);
|
|
156
|
+
let a = ++this.genCounter;
|
|
157
|
+
i.gen = a, i.hostApi = t, i.pendingMount = {
|
|
158
|
+
gen: a,
|
|
159
|
+
resolve: () => n(this.remotePlugin(e, a)),
|
|
160
|
+
reject: r,
|
|
161
|
+
timer: setTimeout(() => {
|
|
162
|
+
i.pendingMount?.gen === a && (i.pendingMount = null, r(/* @__PURE__ */ Error(`The device of player ${e} did not load the game in time.`)));
|
|
163
|
+
}, this.options.mountTimeoutMs ?? 2e4)
|
|
164
|
+
}, this.sendMount(e, a);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
sendMount(e, t) {
|
|
168
|
+
this.sendTo(e, {
|
|
169
|
+
k: "mount",
|
|
170
|
+
gen: t,
|
|
171
|
+
bundle: this.bundleVersion,
|
|
172
|
+
locale: this.session?.config.locale ?? "cs"
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
remotePlugin(e, t) {
|
|
176
|
+
let n = {};
|
|
177
|
+
for (let r of a) n[r] = (...n) => this.callRemote(e, t, r, n);
|
|
178
|
+
return n;
|
|
179
|
+
}
|
|
180
|
+
callRemote(e, t, n, r) {
|
|
181
|
+
let i = this.channels.get(e);
|
|
182
|
+
if (!i || i.gen !== t) return Promise.reject(/* @__PURE__ */ Error("[SESSION_ENDED] The player instance was replaced."));
|
|
183
|
+
if (!this.players.find((t) => t.id === e)?.connected) return Promise.resolve();
|
|
184
|
+
let a = ++this.callCounter;
|
|
185
|
+
return new Promise((o, s) => {
|
|
186
|
+
let c = setTimeout(() => {
|
|
187
|
+
i.calls.delete(a), s(/* @__PURE__ */ Error(`[INTERNAL_ERROR] ${n} on ${e} timed out.`));
|
|
188
|
+
}, this.options.callTimeoutMs ?? 15e3);
|
|
189
|
+
i.calls.set(a, {
|
|
190
|
+
resolve: o,
|
|
191
|
+
reject: s,
|
|
192
|
+
timer: c
|
|
193
|
+
}), this.sendTo(e, {
|
|
194
|
+
k: "call",
|
|
195
|
+
id: a,
|
|
196
|
+
gen: t,
|
|
197
|
+
method: n,
|
|
198
|
+
args: r
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
channel(e) {
|
|
203
|
+
let t = this.channels.get(e);
|
|
204
|
+
return t || (t = {
|
|
205
|
+
gen: 0,
|
|
206
|
+
hostApi: null,
|
|
207
|
+
pendingMount: null,
|
|
208
|
+
calls: /* @__PURE__ */ new Map()
|
|
209
|
+
}, this.channels.set(e, t)), t;
|
|
210
|
+
}
|
|
211
|
+
resetChannel(e, t) {
|
|
212
|
+
t.pendingMount &&= (clearTimeout(t.pendingMount.timer), t.pendingMount.reject(/* @__PURE__ */ Error("[SESSION_ENDED] Replaced.")), null), this.settleCalls(t), t.gen = 0, t.hostApi = null;
|
|
213
|
+
}
|
|
214
|
+
settleCalls(e) {
|
|
215
|
+
for (let t of e.calls.values()) clearTimeout(t.timer), t.resolve(void 0);
|
|
216
|
+
e.calls.clear();
|
|
217
|
+
}
|
|
218
|
+
onServer(e) {
|
|
219
|
+
switch (e.t) {
|
|
220
|
+
case "hosting":
|
|
221
|
+
this.hosting = !0, this.hostingWaiters.splice(0).forEach((e) => e()), this.players = e.players, this.emit({
|
|
222
|
+
type: "players",
|
|
223
|
+
players: this.players
|
|
224
|
+
}), this.broadcast({
|
|
225
|
+
k: "state",
|
|
226
|
+
state: this.state
|
|
227
|
+
});
|
|
228
|
+
break;
|
|
229
|
+
case "presence":
|
|
230
|
+
this.onPresence(e.player, e.event);
|
|
231
|
+
break;
|
|
232
|
+
case "from":
|
|
233
|
+
this.onPlayer(e.from, e.data);
|
|
234
|
+
break;
|
|
235
|
+
case "error":
|
|
236
|
+
this.emit({
|
|
237
|
+
type: "error",
|
|
238
|
+
code: e.code,
|
|
239
|
+
message: e.message
|
|
240
|
+
});
|
|
241
|
+
break;
|
|
242
|
+
case "closed": this.emit({
|
|
243
|
+
type: "closed",
|
|
244
|
+
reason: e.reason
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
onPresence(e, t) {
|
|
249
|
+
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({
|
|
250
|
+
type: "players",
|
|
251
|
+
players: this.players
|
|
252
|
+
});
|
|
253
|
+
let n = this.session, r = n?.players.some((t) => t.id === e.id) ?? !1;
|
|
254
|
+
switch (t) {
|
|
255
|
+
case "join":
|
|
256
|
+
if (n && !n.ended && !r) try {
|
|
257
|
+
n.addPlayer({
|
|
258
|
+
id: e.id,
|
|
259
|
+
name: e.name
|
|
260
|
+
}), this.connectRemote(e.id);
|
|
261
|
+
} catch {}
|
|
262
|
+
break;
|
|
263
|
+
case "disconnect": {
|
|
264
|
+
let t = this.channels.get(e.id);
|
|
265
|
+
t && this.settleCalls(t), r && n.setConnected(e.id, !1);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case "rename":
|
|
269
|
+
r && n.renamePlayer(e.id, e.name);
|
|
270
|
+
break;
|
|
271
|
+
case "leave": if (this.channels.delete(e.id), r) try {
|
|
272
|
+
n.removePlayer(e.id);
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async onPlayer(e, t) {
|
|
277
|
+
if (typeof t != "object" || !t) return;
|
|
278
|
+
let i = this.channel(e);
|
|
279
|
+
switch (t.k) {
|
|
280
|
+
case "hello": {
|
|
281
|
+
this.sendTo(e, {
|
|
282
|
+
k: "state",
|
|
283
|
+
state: this.state
|
|
284
|
+
});
|
|
285
|
+
let n = this.session;
|
|
286
|
+
if (!n || !n.players.some((t) => t.id === e)) return;
|
|
287
|
+
if (i.pendingMount) {
|
|
288
|
+
this.sendMount(e, i.pendingMount.gen);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
n.setConnected(e, !0), (i.gen === 0 || t.gen !== i.gen) && (n.getInstance(e) ? n.reload(e).catch(() => {}) : this.connectRemote(e));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
case "mounted":
|
|
295
|
+
if (i.pendingMount?.gen === t.gen) {
|
|
296
|
+
clearTimeout(i.pendingMount.timer);
|
|
297
|
+
let e = i.pendingMount;
|
|
298
|
+
i.pendingMount = null, e.resolve();
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
case "mount-failed":
|
|
302
|
+
if (i.pendingMount?.gen === t.gen) {
|
|
303
|
+
clearTimeout(i.pendingMount.timer);
|
|
304
|
+
let e = i.pendingMount;
|
|
305
|
+
i.pendingMount = null, e.reject(Error(t.error?.message ?? "The game could not be loaded on the device."));
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
case "result": {
|
|
309
|
+
let e = i.calls.get(t.id);
|
|
310
|
+
if (!e) return;
|
|
311
|
+
i.calls.delete(t.id), clearTimeout(e.timer), t.ok ? e.resolve(t.value) : e.reject(Error(t.error?.message ?? "Remote call failed"));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
case "call": {
|
|
315
|
+
let a = (t) => this.sendTo(e, t);
|
|
316
|
+
if (t.gen !== i.gen || !i.hostApi || !s.has(t.method) || !Array.isArray(t.args)) {
|
|
317
|
+
a({
|
|
318
|
+
k: "result",
|
|
319
|
+
id: t.id,
|
|
320
|
+
ok: !1,
|
|
321
|
+
error: { message: "[SESSION_ENDED] This plugin instance is no longer active." }
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
let e = i.hostApi[t.method], r = await e(...t.args);
|
|
327
|
+
if (t.method === "hello") {
|
|
328
|
+
r = {
|
|
329
|
+
...r,
|
|
330
|
+
set: null
|
|
331
|
+
}, a({
|
|
332
|
+
k: "result",
|
|
333
|
+
id: t.id,
|
|
334
|
+
ok: !0,
|
|
335
|
+
value: r,
|
|
336
|
+
hostNow: Date.now()
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
t.method === "getAsset" && r instanceof Blob && (r = await n(r)), a({
|
|
341
|
+
k: "result",
|
|
342
|
+
id: t.id,
|
|
343
|
+
ok: !0,
|
|
344
|
+
value: r ?? null
|
|
345
|
+
});
|
|
346
|
+
} catch (e) {
|
|
347
|
+
a({
|
|
348
|
+
k: "result",
|
|
349
|
+
id: t.id,
|
|
350
|
+
ok: !1,
|
|
351
|
+
error: r(e)
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
sendTo(e, t) {
|
|
359
|
+
this.socket.send({
|
|
360
|
+
t: "to",
|
|
361
|
+
to: e,
|
|
362
|
+
data: t
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
broadcast(e) {
|
|
366
|
+
for (let t of this.players) t.connected && this.sendTo(t.id, e);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
//#endregion
|
|
370
|
+
export { c as RelayHost };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function e(e){return{message:e instanceof Error?e.message:String(e)}}async function t(e){let t=new Uint8Array(await e.arrayBuffer()),n=``;for(let e=0;e<t.length;e+=32768)n+=String.fromCharCode(...t.subarray(e,e+32768));return{$blob:btoa(n),type:e.type}}function n(e){let t=atob(e.$blob),n=new Uint8Array(t.length);for(let e=0;e<t.length;e++)n[e]=t.charCodeAt(e);return new Blob([n],{type:e.type})}function r(e){return typeof e==`object`&&!!e&&typeof e.$blob==`string`}exports.blobToJson=t,exports.isSerializedBlob=r,exports.jsonToBlob=n,exports.serializeError=e;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/relay/messages.ts
|
|
2
|
+
function e(e) {
|
|
3
|
+
return { message: e instanceof Error ? e.message : String(e) };
|
|
4
|
+
}
|
|
5
|
+
async function t(e) {
|
|
6
|
+
let t = new Uint8Array(await e.arrayBuffer()), n = "";
|
|
7
|
+
for (let e = 0; e < t.length; e += 32768) n += String.fromCharCode(...t.subarray(e, e + 32768));
|
|
8
|
+
return {
|
|
9
|
+
$blob: btoa(n),
|
|
10
|
+
type: e.type
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function n(e) {
|
|
14
|
+
let t = atob(e.$blob), n = new Uint8Array(t.length);
|
|
15
|
+
for (let e = 0; e < t.length; e++) n[e] = t.charCodeAt(e);
|
|
16
|
+
return new Blob([n], { type: e.type });
|
|
17
|
+
}
|
|
18
|
+
function r(e) {
|
|
19
|
+
return typeof e == "object" && !!e && typeof e.$blob == "string";
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { t as blobToJson, r as isSerializedBlob, n as jsonToBlob, e as serializeError };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=require("../frame.cjs"),t=require("./socket.cjs"),n=require("./messages.cjs");let r=require("@memizy/protocol");var i=new Set(r.PLUGIN_API_METHODS),a=class{playerId=null;token;name;state=null;hostConnected=!0;options;socket;listeners=new Set;bundle=null;frame=null;plugin=null;gen=0;callCounter=0;pending=new Map;skewMs=0;hostConfig=null;mountQueue=Promise.resolve();constructor(e){this.options=e,this.token=e.token??null,this.name=e.name,this.socket=new t.RelaySocket({url:t.relayWebSocketUrl(e.serverUrl),attach:()=>({t:`join`,protocol:r.RELAY_PROTOCOL,pin:e.pin,name:this.name,token:this.token??void 0}),WebSocket:e.WebSocket}),this.socket.onStatus(e=>this.emit({type:`status`,status:e})),this.socket.onMessage(e=>this.onServer(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)}updateConfig(){this.plugin&&this.hostConfig&&this.plugin.configChanged(this.localConfig(this.hostConfig)).catch(()=>{})}localConfig(e){return{...e,...this.options.config?.()}}rename(e){this.name=e,this.socket.send({t:`rename`,name:e})}reconnectNow(){this.socket.reconnectNow()}leave(){this.destroyFrame(),this.socket.close()}onServer(e){switch(e.t){case`joined`:this.playerId=e.playerId,this.token=e.token,this.name=e.name,this.hostConnected=e.hostConnected,this.emit({type:`joined`,playerId:e.playerId,token:e.token,name:e.name}),this.up({k:`hello`,gen:this.plugin?this.gen:0});break;case`host-presence`:this.hostConnected=e.connected,this.emit({type:`host`,connected:e.connected}),e.connected&&this.up({k:`hello`,gen:this.plugin?this.gen:0});break;case`data`:this.onHost(e.data);break;case`error`:this.emit({type:`error`,code:e.code,message:e.message,fatal:e.fatal});break;case`closed`:this.destroyFrame(),this.emit({type:`closed`,reason:e.reason})}}up(e){return this.socket.send({t:`up`,data:e})}async onHost(e){if(typeof e==`object`&&e)switch(e.k){case`state`:this.state=e.state,this.emit({type:`state`,state:e.state});return;case`mount`:this.mountQueue=this.mountQueue.then(()=>this.mount(e.gen,e.bundle));return;case`unmount`:this.destroyFrame();return;case`result`:{let t=this.pending.get(e.id);if(!t)return;if(this.pending.delete(e.id),clearTimeout(t.timer),!e.ok){t.reject(Error(e.error?.message??`Host call failed`));return}let r=e.value;if(t.method===`hello`&&this.bundle){if(e.hostNow!==void 0){let n=Date.now();this.skewMs=e.hostNow+(n-t.sentAt)/2-n}let n=r;this.hostConfig=n.config,r={...n,set:structuredClone(this.bundle.data.set),clock:{...n.clock,offsetMs:n.clock.offsetMs+this.skewMs},config:this.localConfig(n.config)}}else n.isSerializedBlob(r)&&(r=n.jsonToBlob(r));t.resolve(r);return}case`call`:{let t=e=>this.up(e);if(e.gen!==this.gen||!this.plugin||!i.has(e.method)){t({k:`result`,id:e.id,ok:!1,error:{message:`[SESSION_ENDED] No active plugin instance.`}});return}let r=e.args;if(e.method===`configChanged`&&(this.hostConfig=r[0],r=[this.localConfig(this.hostConfig)]),e.method===`clockChanged`){let e=r[0];r=[{...e,offsetMs:e.offsetMs+this.skewMs}]}try{let n=this.plugin[e.method],i=await n(...r);t({k:`result`,id:e.id,ok:!0,value:i??null})}catch(r){t({k:`result`,id:e.id,ok:!1,error:n.serializeError(r)})}return}}}async ensureBundle(e){if(this.bundle?.version===e)return this.bundle.data;let t=await(this.options.fetch??fetch)(new URL(`/api/rooms/${this.options.pin}/bundle`,this.options.serverUrl));if(!t.ok)throw Error(`Could not download the game (${t.status}).`);let n=await t.json();return this.bundle={version:Number(t.headers.get(`X-Bundle-Version`)??e),data:n},n}async mount(t,i){this.destroyFrame(),this.gen=t;try{let n=await this.ensureBundle(i);if(this.gen!==t)return;let a=this.options.container();if(!a)throw Error(`No place for the game on this page.`);let o=(0,r.extractManifestFromHtml)(n.pluginHtml),s=o.success?String(o.data.appName??`Game`):`Game`,c=this.options.transformHtml?this.options.transformHtml(n.pluginHtml):n.pluginHtml,l=(this.options.createFrame??e.createPluginFrame)(c,s,this.hostApiFor(t),a.ownerDocument);this.frame=l,a.replaceChildren(l.iframe),this.emit({type:`frame`,active:!0});let u=await l.plugin;if(this.gen!==t)return;this.plugin=u,this.up({k:`mounted`,gen:t})}catch(e){if(this.gen!==t)return;this.emit({type:`error`,code:`MOUNT_FAILED`,message:e.message,fatal:!1}),this.up({k:`mount-failed`,gen:t,error:n.serializeError(e)})}}hostApiFor(e){let t={};for(let n of r.HOST_API_METHODS)t[n]=(...t)=>{if(this.gen!==e)return Promise.reject(Error(`[SESSION_ENDED] This plugin instance was replaced.`));let r=++this.callCounter;return new Promise((i,a)=>{let o=setTimeout(()=>{this.pending.delete(r),a(Error(`[INTERNAL_ERROR] The host did not answer ${n} in time.`))},this.options.callTimeoutMs??15e3);this.pending.set(r,{method:n,sentAt:Date.now(),resolve:i,reject:a,timer:o}),this.up({k:`call`,id:r,gen:e,method:n,args:t})||(clearTimeout(o),this.pending.delete(r),n===`hello`?a(Error(`[INTERNAL_ERROR] Offline.`)):i(void 0))})};return t}destroyFrame(){let e=this.frame!==null;this.frame?.destroy(),this.frame?.iframe.remove(),this.frame=null,this.plugin=null,this.gen=0;for(let e of this.pending.values())clearTimeout(e.timer),e.reject(Error(`[SESSION_ENDED] The plugin instance was closed.`));this.pending.clear(),e&&this.emit({type:`frame`,active:!1})}};exports.RelayPlayer=a;
|