@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.
@@ -0,0 +1,156 @@
1
+ import { CreateRoomResponse, RelayErrorCode, RoomPlayer } from '@memizy/protocol';
2
+ import { LocalSession, PluginEndpoint } from '../session.js';
3
+ import { PreparedSet } from '../plugin.js';
4
+ import { RelayStatus } from './socket.js';
5
+ import { RemoteLobbyState } from './messages.js';
6
+ export type RelayHostEvent = {
7
+ type: 'status';
8
+ status: RelayStatus;
9
+ } | {
10
+ type: 'players';
11
+ players: RoomPlayer[];
12
+ }
13
+ /** A continued game: who has which place changed. */
14
+ | {
15
+ type: 'seats';
16
+ seats: HostSeat[];
17
+ } | {
18
+ type: 'error';
19
+ code: RelayErrorCode;
20
+ message: string;
21
+ } | {
22
+ type: 'closed';
23
+ reason: string;
24
+ };
25
+ export interface RelayHostOptions {
26
+ /** Server base URL, e.g. `https://mp.memizy.com`. */
27
+ serverUrl: string;
28
+ fetch?: typeof fetch;
29
+ WebSocket?: typeof WebSocket;
30
+ /** How long a player device may take to load the game (default 20 s). */
31
+ mountTimeoutMs?: number;
32
+ /** Timeout of one call to a remote plugin (default 15 s). */
33
+ callTimeoutMs?: number;
34
+ }
35
+ /** A place in a continued game: an earlier player and the room player who has it now. */
36
+ export interface HostSeat {
37
+ /** The player ID in the session (from the saved game). */
38
+ id: string;
39
+ name: string;
40
+ /** The room player on this place (`RoomPlayer.id`), or `null` when it is free. */
41
+ roomPlayerId: string | null;
42
+ }
43
+ export interface AttachOptions {
44
+ /**
45
+ * A continued (saved) game: the session's players are the places of the earlier
46
+ * game, with their old IDs (the game state refers to them). Room players take a
47
+ * place on their device (or join as new players when the game allows late joins);
48
+ * the host can change it with `assignSeat`. Until then a place is disconnected.
49
+ */
50
+ seats?: boolean;
51
+ /** Places already taken (room player ID -> session player ID), e.g. after a reload of the host page. */
52
+ taken?: Record<string, string>;
53
+ }
54
+ export declare class RelayHost {
55
+ readonly pin: string;
56
+ readonly hostToken: string;
57
+ players: RoomPlayer[];
58
+ /** The lobby/game state shown by the player apps. */
59
+ state: RemoteLobbyState;
60
+ private readonly options;
61
+ private readonly socket;
62
+ private readonly listeners;
63
+ private readonly channels;
64
+ private bundleVersion;
65
+ private genCounter;
66
+ private callCounter;
67
+ private session;
68
+ private hosting;
69
+ private readonly hostingWaiters;
70
+ private stopSession;
71
+ /** The place of each room player in the session: room player ID -> session player ID. */
72
+ private readonly seatOf;
73
+ /** A continued game (see `AttachOptions.seats`). */
74
+ private seatsMode;
75
+ /** The app message version of each player's app (from its `hello`; 0 = an app before versions). */
76
+ private readonly appVersions;
77
+ private constructor();
78
+ /** Creates a new room on the server. */
79
+ static create(options: RelayHostOptions): Promise<RelayHost>;
80
+ /** Re-attaches to an existing room (e.g. after a reload of the host page). */
81
+ static rejoin(options: RelayHostOptions, room: CreateRoomResponse): RelayHost;
82
+ /** Resolves once the server confirmed the room and sent the current players. */
83
+ whenHosting(): Promise<void>;
84
+ get status(): RelayStatus;
85
+ on(listener: (event: RelayHostEvent) => void): () => void;
86
+ private emit;
87
+ /**
88
+ * Uploads the plugin and its set for the players' devices. Only `prepared.publicSet`
89
+ * (items without answers, SPEC 4.4) leaves this machine: anyone with the PIN can
90
+ * download the bundle. Prepare the set with the seed passed as `shuffleSeed`.
91
+ */
92
+ uploadBundle(pluginHtml: string, prepared: PreparedSet): Promise<number>;
93
+ /** Allows or stops new players joining. */
94
+ setOpen(open: boolean): void;
95
+ kick(playerId: string): void;
96
+ /** Closes the room for everyone. */
97
+ close(): void;
98
+ /**
99
+ * Leaves the room without closing it (the page is going away). The room and its
100
+ * players stay on the server, so the host can come back (reload) or take it over.
101
+ */
102
+ disconnect(): void;
103
+ /** Updates the lobby/game state shown by the player apps. */
104
+ setState(update: Partial<RemoteLobbyState>): void;
105
+ /**
106
+ * Connects the session's remote players and keeps the session in sync with the
107
+ * room: presence, late joins, reconnects, renames and kicks. In a new game the
108
+ * session's players are room players (same IDs); for a continued game pass
109
+ * `{ seats: true }` (see `AttachOptions`).
110
+ */
111
+ attach(session: LocalSession, options?: AttachOptions): void;
112
+ /** Disconnects from the session; player devices return to the lobby. */
113
+ detach(): void;
114
+ /** The places of a continued game and who has them (empty for a new game). */
115
+ get seats(): HostSeat[];
116
+ /** Which room player has which place (room player ID -> session player ID); keep it to re-attach after a reload. */
117
+ get seatAssignments(): Record<string, string>;
118
+ /**
119
+ * A continued game: puts a room player on a place (`seatId` = a session player ID from the
120
+ * saved game; `null` = takes the place away). Whoever had that place before loses it.
121
+ */
122
+ assignSeat(roomPlayerId: string, seatId: string | null): void;
123
+ /**
124
+ * The app message version of a room player's app (`RELAY_APP_VERSION` of its host-sdk), or
125
+ * `null` before it said hello. An app below 1 cannot choose its place: the host assigns it.
126
+ */
127
+ appVersionOf(playerId: string): number | null;
128
+ private relayIdOf;
129
+ private seatState;
130
+ private seatsChanged;
131
+ /** Tells a device its place (and the place's nickname). */
132
+ private sendSeat;
133
+ private seat;
134
+ private unseat;
135
+ /** A device chose its place in a continued game (or to join as a new player). */
136
+ private claim;
137
+ private onSessionEvent;
138
+ private connectRemote;
139
+ /** A session endpoint whose plugin instance runs on the player's device. */
140
+ endpointFor(playerId: string): PluginEndpoint;
141
+ private sendMount;
142
+ private remotePlugin;
143
+ private callRemote;
144
+ private channel;
145
+ private resetChannel;
146
+ /** Calls to a device that went away are dropped (resolved), like messages. */
147
+ private settleCalls;
148
+ private onServer;
149
+ private onPresence;
150
+ /** A message of the device of room player `relayId`; game messages go to its place in the session. */
151
+ private onPlayer;
152
+ private sendTo;
153
+ /** Sends to the device on a place of the session (dropped when the place is free). */
154
+ private sendToSeat;
155
+ private broadcast;
156
+ }
@@ -0,0 +1,137 @@
1
+ import { Player, SessionResult } from '@memizy/protocol';
2
+ /**
3
+ * Version of the messages between the host app and the player apps. Within one relay
4
+ * protocol they only change additively: receivers ignore unknown `k` and unknown fields,
5
+ * and an app that sends nothing is version 0. Player apps send it in `hello`, hosts in
6
+ * `mount`, so each side knows what the other understands (e.g. a cached older player app).
7
+ *
8
+ * 1 – places in a continued game (`seat` / `claim`), `UNKNOWN_METHOD` errors.
9
+ */
10
+ export declare const RELAY_APP_VERSION = 1;
11
+ export type RemotePhase = 'lobby' | 'loading' | 'countdown' | 'running' | 'ended';
12
+ export interface RemoteLobbyState {
13
+ phase: RemotePhase;
14
+ appName: string | null;
15
+ players: Player[];
16
+ /** Seconds left while counting down. */
17
+ countdown?: number;
18
+ /** Whether the authority is reachable (false = "waiting for the host"). */
19
+ authorityConnected: boolean;
20
+ result?: SessionResult;
21
+ /**
22
+ * A continued (saved) game: the places of the earlier players. A device takes one
23
+ * (`claim`) or the host assigns it; `taken` places have a device.
24
+ */
25
+ seats?: RemoteSeat[];
26
+ /** A continued game: whether a device may also join as a new player (late join, space left). */
27
+ newPlayers?: boolean;
28
+ }
29
+ /** A place of a player in a continued game (the earlier player's ID and nickname). */
30
+ export interface RemoteSeat {
31
+ id: string;
32
+ name: string;
33
+ taken: boolean;
34
+ }
35
+ export type SerializedError = {
36
+ message: string;
37
+ };
38
+ export type HostToPlayer =
39
+ /** Lobby / game state for the player app's own screens. */
40
+ {
41
+ k: 'state';
42
+ state: RemoteLobbyState;
43
+ }
44
+ /**
45
+ * Create (or recreate) the plugin instance; download the bundle if its version changed.
46
+ * `locale` is the host's language (informational: player apps use their own); `v` = RELAY_APP_VERSION.
47
+ */
48
+ | {
49
+ k: 'mount';
50
+ gen: number;
51
+ bundle: number;
52
+ locale: string;
53
+ v?: number;
54
+ }
55
+ /** Remove the plugin instance. */
56
+ | {
57
+ k: 'unmount';
58
+ }
59
+ /** A continued game: the place of this device (`null` = none yet) and its nickname. */
60
+ | {
61
+ k: 'seat';
62
+ seat: string | null;
63
+ name?: string;
64
+ }
65
+ /** A PluginApi call. */
66
+ | {
67
+ k: 'call';
68
+ id: number;
69
+ gen: number;
70
+ method: string;
71
+ args: unknown[];
72
+ }
73
+ /** Result of a HostApi call. `hostNow` comes with `hello` (clock sync). */
74
+ | {
75
+ k: 'result';
76
+ id: number;
77
+ ok: true;
78
+ value: unknown;
79
+ hostNow?: number;
80
+ } | {
81
+ k: 'result';
82
+ id: number;
83
+ ok: false;
84
+ error: SerializedError;
85
+ };
86
+ export type PlayerToHost =
87
+ /** After (re)joining: the generation of the plugin instance still alive (0 = none); `v` = RELAY_APP_VERSION. */
88
+ {
89
+ k: 'hello';
90
+ gen: number;
91
+ v?: number;
92
+ }
93
+ /** A continued game: take the place `seat`, or join as a new player (`null`). */
94
+ | {
95
+ k: 'claim';
96
+ seat: string | null;
97
+ }
98
+ /** The plugin instance `gen` connected (Penpal handshake done). */
99
+ | {
100
+ k: 'mounted';
101
+ gen: number;
102
+ } | {
103
+ k: 'mount-failed';
104
+ gen: number;
105
+ error: SerializedError;
106
+ }
107
+ /** A HostApi call of instance `gen`. */
108
+ | {
109
+ k: 'call';
110
+ id: number;
111
+ gen: number;
112
+ method: string;
113
+ args: unknown[];
114
+ }
115
+ /** Result of a PluginApi call. */
116
+ | {
117
+ k: 'result';
118
+ id: number;
119
+ ok: true;
120
+ value: unknown;
121
+ } | {
122
+ k: 'result';
123
+ id: number;
124
+ ok: false;
125
+ error: SerializedError;
126
+ };
127
+ export declare function serializeError(error: unknown): SerializedError;
128
+ /** The error for a call whose method the receiving app does not know (an older or newer version). */
129
+ export declare function unknownMethod(method: unknown): SerializedError;
130
+ /** Blobs (getAsset) are not JSON: they travel as base64. */
131
+ export interface SerializedBlob {
132
+ $blob: string;
133
+ type: string;
134
+ }
135
+ export declare function blobToJson(blob: Blob): Promise<SerializedBlob>;
136
+ export declare function jsonToBlob(value: SerializedBlob): Blob;
137
+ export declare function isSerializedBlob(value: unknown): value is SerializedBlob;
@@ -0,0 +1,127 @@
1
+ import { HostApi, HostConfig, PluginApi, RelayErrorCode } from '@memizy/protocol';
2
+ import { RelayStatus } from './socket.js';
3
+ import { RemoteLobbyState } from './messages.js';
4
+ export type RelayPlayerEvent = {
5
+ type: 'status';
6
+ status: RelayStatus;
7
+ } | {
8
+ type: 'joined';
9
+ playerId: string;
10
+ token: string;
11
+ name: string;
12
+ } | {
13
+ type: 'state';
14
+ state: RemoteLobbyState;
15
+ }
16
+ /** A continued game: this device's place (`null` = choose one with `claimSeat`). */
17
+ | {
18
+ type: 'seat';
19
+ seat: string | null;
20
+ } | {
21
+ type: 'host';
22
+ connected: boolean;
23
+ }
24
+ /** The plugin instance was created (`true`) or removed (`false`). */
25
+ | {
26
+ type: 'frame';
27
+ active: boolean;
28
+ } | {
29
+ type: 'error';
30
+ code: RelayErrorCode | 'MOUNT_FAILED';
31
+ message: string;
32
+ fatal: boolean;
33
+ } | {
34
+ type: 'closed';
35
+ reason: 'host' | 'expired' | 'kicked';
36
+ };
37
+ export interface RelayPlayerOptions {
38
+ serverUrl: string;
39
+ pin: string;
40
+ name: string;
41
+ /** Token from an earlier `joined` event: rejoin as the same player. */
42
+ token?: string;
43
+ /**
44
+ * Passed to the host only (e.g. a signed token of a signed-in student); the relay server
45
+ * does not check it. At most `RELAY_LIMITS.authLength` characters.
46
+ */
47
+ auth?: string;
48
+ /** Where the plugin iframe goes (called when the game starts). */
49
+ container: () => HTMLElement | null;
50
+ fetch?: typeof fetch;
51
+ WebSocket?: typeof WebSocket;
52
+ callTimeoutMs?: number;
53
+ /** Adjusts the downloaded plugin HTML before it runs (e.g. points the SDK import to a local build). */
54
+ transformHtml?: (html: string) => string;
55
+ /**
56
+ * Language and theme of this device. They replace the host's values, so every
57
+ * player sees the game in the language and theme of their own app.
58
+ */
59
+ config?: () => Partial<HostConfig>;
60
+ /** Creates the plugin instance (default: a sandboxed iframe; tests connect the SDK directly). */
61
+ createFrame?: FrameFactory;
62
+ }
63
+ export type FrameFactory = (html: string, title: string, hostApi: HostApi, doc: Document) => RemoteFrame;
64
+ export interface RemoteFrame {
65
+ readonly iframe: HTMLElement;
66
+ readonly plugin: Promise<PluginApi>;
67
+ destroy(): void;
68
+ }
69
+ export declare class RelayPlayer {
70
+ playerId: string | null;
71
+ token: string | null;
72
+ name: string;
73
+ state: RemoteLobbyState | null;
74
+ hostConnected: boolean;
75
+ /** The app message version of the host (from `mount`; 0 = a host before versions, `null` = not known yet). */
76
+ hostAppVersion: number | null;
77
+ /**
78
+ * A continued game: this device's place in the game (`null` = not chosen yet;
79
+ * the free places are in `state.seats`). `undefined` in a new game.
80
+ */
81
+ seat: string | null | undefined;
82
+ private readonly options;
83
+ private readonly socket;
84
+ private readonly listeners;
85
+ private bundle;
86
+ private frame;
87
+ private plugin;
88
+ private gen;
89
+ /** The generation of the last `mount` taken (a repeated `mount` of it loads nothing new). */
90
+ private mountGen;
91
+ private callCounter;
92
+ private readonly pending;
93
+ /** Host clock minus local clock (ms). */
94
+ private skewMs;
95
+ /** The config the host sent last (before this device's overrides). */
96
+ private hostConfig;
97
+ private mountQueue;
98
+ /** The current game instance was told that its session ended (`sessionEnded`). */
99
+ private sessionEndedSent;
100
+ constructor(options: RelayPlayerOptions);
101
+ get status(): RelayStatus;
102
+ on(listener: (event: RelayPlayerEvent) => void): () => void;
103
+ private emit;
104
+ /** Call when this device's language or theme changed (see `config`). */
105
+ refreshConfig(): void;
106
+ private localConfig;
107
+ rename(name: string): void;
108
+ /**
109
+ * A continued game: takes the place `seat` (one of `state.seats`), or joins as a new
110
+ * player (`null`, when `state.newPlayers`). The host answers with a `seat` event; when
111
+ * the place was taken meanwhile, `seat` stays `null` and the device chooses again.
112
+ */
113
+ claimSeat(seat: string | null): void;
114
+ /** Reconnect right away (e.g. the page became visible again). */
115
+ reconnectNow(): void;
116
+ /** Leaves (the token stays valid for a rejoin until the room closes). */
117
+ leave(): void;
118
+ private onServer;
119
+ private up;
120
+ private onHost;
121
+ private ensureBundle;
122
+ /** Tells the game that its session ended (once, with a short grace period), then removes it. */
123
+ private endGame;
124
+ private mount;
125
+ private hostApiFor;
126
+ private destroyFrame;
127
+ }
@@ -0,0 +1,34 @@
1
+ import { RelayClientMessage, RelayServerMessage } from '@memizy/protocol';
2
+ /** `replaced`: the same host or player connected from another tab or device; this one stops. */
3
+ export type RelayStatus = 'connecting' | 'online' | 'offline' | 'closed' | 'replaced';
4
+ export interface RelaySocketOptions {
5
+ /** `ws(s)://…/ws` */
6
+ url: string;
7
+ /** The attach message sent after every (re)connect. */
8
+ attach: () => RelayClientMessage;
9
+ WebSocket?: typeof WebSocket;
10
+ }
11
+ export declare class RelaySocket {
12
+ status: RelayStatus;
13
+ private readonly options;
14
+ private ws;
15
+ private retry;
16
+ private retryTimer;
17
+ private pingTimer;
18
+ private stopped;
19
+ private readonly messageListeners;
20
+ private readonly statusListeners;
21
+ constructor(options: RelaySocketOptions);
22
+ onMessage(listener: (msg: RelayServerMessage) => void): () => void;
23
+ onStatus(listener: (status: RelayStatus) => void): () => void;
24
+ /** Sends when online; returns `false` when the message was dropped. */
25
+ send(msg: RelayClientMessage): boolean;
26
+ /** Stops for good (no reconnect). */
27
+ close(): void;
28
+ /** Reconnects right away (e.g. when the page becomes visible again). */
29
+ reconnectNow(): void;
30
+ private setStatus;
31
+ private open;
32
+ }
33
+ /** `http(s)://server` → `ws(s)://server/ws` */
34
+ export declare function relayWebSocketUrl(serverUrl: string): string;