@memizy/protocol 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +22 -2
  4. package/SPEC.md +497 -0
  5. package/dist/errors.cjs +1 -0
  6. package/dist/errors.js +37 -0
  7. package/dist/index.cjs +1 -0
  8. package/dist/index.js +13 -0
  9. package/dist/internal.cjs +1 -0
  10. package/dist/internal.js +8 -0
  11. package/dist/items.cjs +1 -0
  12. package/dist/items.js +114 -0
  13. package/dist/limits.cjs +1 -0
  14. package/dist/limits.js +73 -0
  15. package/dist/manifestSchema.cjs +2 -0
  16. package/dist/manifestSchema.js +107 -0
  17. package/dist/pluginRuntime.cjs +1 -0
  18. package/dist/pluginRuntime.js +48 -0
  19. package/dist/polyfills.cjs +1 -0
  20. package/dist/polyfills.js +23 -0
  21. package/dist/relay.cjs +1 -0
  22. package/dist/relay.js +22 -0
  23. package/dist/schemas.cjs +1 -0
  24. package/dist/schemas.js +74 -0
  25. package/dist/security.cjs +1 -0
  26. package/dist/security.js +40 -0
  27. package/dist/settings.cjs +1 -0
  28. package/dist/settings.js +35 -0
  29. package/dist/settingsSchema.cjs +1 -0
  30. package/dist/settingsSchema.js +58 -0
  31. package/dist/src/errors.d.ts +21 -0
  32. package/dist/src/index.d.ts +20 -0
  33. package/dist/src/internal.d.ts +12 -0
  34. package/dist/src/items.d.ts +27 -0
  35. package/dist/src/limits.d.ts +78 -0
  36. package/dist/src/manifestSchema.d.ts +159 -0
  37. package/dist/src/pluginRuntime.d.ts +47 -0
  38. package/dist/src/polyfills.d.ts +6 -0
  39. package/dist/src/relay.d.ts +186 -0
  40. package/dist/src/schemas.d.ts +111 -0
  41. package/dist/src/security.d.ts +21 -0
  42. package/dist/src/settings.d.ts +22 -0
  43. package/dist/src/settingsSchema.d.ts +54 -0
  44. package/dist/src/types.d.ts +182 -0
  45. package/dist/src/version.d.ts +30 -0
  46. package/dist/types.cjs +1 -0
  47. package/dist/types.js +24 -0
  48. package/dist/version.cjs +1 -0
  49. package/dist/version.js +31 -0
  50. package/package.json +63 -4
@@ -0,0 +1,58 @@
1
+ import { z as e } from "zod";
2
+ //#region src/settingsSchema.ts
3
+ var t = e.union([e.string().min(1, "Text must not be empty"), e.record(e.string().min(2), e.string().min(1)).refine((e) => Object.keys(e).length > 0, "Localized text needs at least one language")]), n = {
4
+ id: e.string().regex(/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/, "Setting id must start with a letter and contain only letters, digits, \"_\" and \"-\""),
5
+ label: t,
6
+ description: t.optional(),
7
+ modes: e.array(e.enum(["solo", "multiplayer"])).min(1, "modes needs at least one mode").optional()
8
+ }, r = e.looseObject({
9
+ ...n,
10
+ type: e.literal("number"),
11
+ default: e.number().finite(),
12
+ min: e.number().finite().optional(),
13
+ max: e.number().finite().optional(),
14
+ step: e.number().positive().optional()
15
+ }).refine((e) => e.min === void 0 || e.max === void 0 || e.min <= e.max, {
16
+ message: "min must not be greater than max",
17
+ path: ["min"]
18
+ }).refine((e) => (e.min === void 0 || e.default >= e.min) && (e.max === void 0 || e.default <= e.max), {
19
+ message: "default is outside min/max",
20
+ path: ["default"]
21
+ }), i = e.looseObject({
22
+ ...n,
23
+ type: e.literal("boolean"),
24
+ default: e.boolean()
25
+ }), a = e.union([
26
+ e.string(),
27
+ e.number().finite(),
28
+ e.boolean()
29
+ ]), o = e.looseObject({
30
+ ...n,
31
+ type: e.literal("select"),
32
+ options: e.array(e.looseObject({
33
+ value: a,
34
+ label: t
35
+ })).min(1, "A select setting needs at least one option"),
36
+ default: a
37
+ }).refine((e) => new Set(e.options.map((e) => e.value)).size === e.options.length, {
38
+ message: "Option values must be unique",
39
+ path: ["options"]
40
+ }).refine((e) => e.options.some((t) => t.value === e.default), {
41
+ message: "default must be one of the option values",
42
+ path: ["default"]
43
+ }), s = e.looseObject({
44
+ ...n,
45
+ type: e.literal("text"),
46
+ default: e.string(),
47
+ maxLength: e.number().int().positive().optional()
48
+ }).refine((e) => e.maxLength === void 0 || e.default.length <= e.maxLength, {
49
+ message: "default is longer than maxLength",
50
+ path: ["default"]
51
+ }), c = e.discriminatedUnion("type", [
52
+ r,
53
+ i,
54
+ o,
55
+ s
56
+ ]);
57
+ //#endregion
58
+ export { t as LocalizedTextSchema, c as SettingDefinitionSchema };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Protocol errors (SPEC section 8.2).
3
+ *
4
+ * Errors cross the iframe boundary through Penpal, which keeps only the
5
+ * error `name` and `message`. The code is therefore also encoded in the
6
+ * message as `[CODE] text`, and {@link toProtocolError} restores it.
7
+ */
8
+ export declare const PROTOCOL_ERROR_CODES: readonly ["UNSUPPORTED_PROTOCOL", "INVALID_ARGUMENT", "NOT_AUTHORITY", "NOT_ALLOWED_IN_VIEW", "AUTHORITY_UNAVAILABLE", "MESSAGE_TOO_LARGE", "SNAPSHOT_TOO_LARGE", "DATA_TOO_LARGE", "RATE_LIMITED", "ASSET_NOT_FOUND", "SESSION_ENDED", "INTERNAL_ERROR", "SERVICE_UNAVAILABLE", "UNKNOWN_METHOD"];
9
+ export type ProtocolErrorCode = (typeof PROTOCOL_ERROR_CODES)[number];
10
+ export declare class ProtocolError extends Error {
11
+ readonly code: ProtocolErrorCode;
12
+ constructor(code: ProtocolErrorCode, message: string);
13
+ /** The message without the `[CODE]` prefix. */
14
+ get detail(): string;
15
+ }
16
+ export declare function isProtocolErrorCode(value: unknown): value is ProtocolErrorCode;
17
+ /**
18
+ * Converts anything thrown by a remote call into a {@link ProtocolError}.
19
+ * Unknown codes and foreign errors become `INTERNAL_ERROR` (SPEC 8.2).
20
+ */
21
+ export declare function toProtocolError(error: unknown): ProtocolError;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @memizy/protocol – Memizy Plugin Protocol v1.
3
+ * The normative specification is SPEC.md in this package.
4
+ *
5
+ * The public API is listed explicitly: it follows semver within 1.x. Helpers shared by the
6
+ * Memizy packages (rate limiting, call parsing, content policies…) are in
7
+ * `@memizy/protocol/internal` and may change in any release.
8
+ */
9
+ export { PROTOCOL_VERSION, parseProtocolVersion, compareProtocolVersions, negotiateProtocolVersion, isProtocolSupported, type ProtocolVersion, type HostProtocolVersions, } from './version.js';
10
+ export { PROTOCOL_ERROR_CODES, ProtocolError, isProtocolErrorCode, toProtocolError, type ProtocolErrorCode } from './errors.js';
11
+ export { LIMITS, type Limits } from './limits.js';
12
+ export { LocalizedTextSchema, SettingDefinitionSchema, type LocalizedText, type SettingDefinition } from './settingsSchema.js';
13
+ export { validateSettingValue, resolveSettings, settingsForMode, localize, type SettingValue } from './settings.js';
14
+ export { HostAsSchema, MultiplayerModeSchema, ServiceNameSchema, PluginPermissionsSchema, MemizyRuntimeSchema, PluginManifestError, safeParsePluginManifest, parsePluginManifest, readPluginManifestFromHtml, type MemizyRuntimeManifest, type PluginManifest, type PluginManifestResult, } from './manifestSchema.js';
15
+ export { extractManifestFromHtml, type HostAs, type PluginView, type PluginRuntime } from './pluginRuntime.js';
16
+ export * from './types.js';
17
+ export { AddressSchema, HandshakeSchema, OutgoingMessageSchema, AnswerRecordSchema, DataScopeSchema, SettingsUpdateSchema, SessionResultSchema, ErrorReportSchema, } from './schemas.js';
18
+ export { RELAY_PROTOCOL, type RelayBundle, type CreateRoomResponse, type RoomInfo, type RoomPlayer, type RelayClientMessage, type RelayErrorCode, type RelayServerMessage, } from './relay.js';
19
+ export { prepareDisplaySet, type DisplaySet, type PublicItemMark } from './items.js';
20
+ export { PLUGIN_DEVICES, type PluginDevice } from './security.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @memizy/protocol/internal – helpers shared by the Memizy packages (host-sdk, plugin-sdk,
3
+ * the relay server, Memizy Play). Not part of the public API: they may change in any
4
+ * release. Use `@memizy/protocol` instead.
5
+ */
6
+ export { findNonJson, jsonByteSize, assertJsonWithin, RateLimiter } from './limits.js';
7
+ export { toRuntime } from './pluginRuntime.js';
8
+ export { HOST_API_ARGS, parseHostCall } from './schemas.js';
9
+ export { RELAY_LIMITS, normalizePlayerName, isPin } from './relay.js';
10
+ export { seededRandom, displayItem, publicItem } from './items.js';
11
+ export { LIBRARY_ORIGINS, pluginContentPolicy, pluginAllowAttribute, injectContentPolicy } from './security.js';
12
+ export { ensureConnectionPolyfills } from './polyfills.js';
@@ -0,0 +1,27 @@
1
+ import { OQSEAnyItem, OQSEMeta } from '@memizy/oqse';
2
+ /** Marks an item whose answer was removed (multiplayer, not the authority). */
3
+ export interface PublicItemMark {
4
+ answerHidden?: true;
5
+ }
6
+ export interface DisplaySet {
7
+ meta: OQSEMeta;
8
+ items: OQSEAnyItem[];
9
+ }
10
+ export declare function seededRandom(seed: string): () => number;
11
+ /** The item in display order. Answers refer to IDs, so only the lists move. */
12
+ export declare function displayItem(item: OQSEAnyItem, random: () => number): OQSEAnyItem;
13
+ /**
14
+ * The item without its answer. Custom (`x-`) types lose only `solution` (where OQSE
15
+ * puts everything that gives their answer away) and the common fields. `hints` stay:
16
+ * a game may show them on any device while the player answers.
17
+ */
18
+ export declare function publicItem(item: OQSEAnyItem): OQSEAnyItem;
19
+ /**
20
+ * The set of a session: `set` in display order with answers (the authority,
21
+ * solo), `publicSet` without answers (the other instances in multiplayer).
22
+ * Without a seed nothing is shuffled.
23
+ */
24
+ export declare function prepareDisplaySet(set: DisplaySet, seed?: string): {
25
+ set: DisplaySet;
26
+ publicSet: DisplaySet;
27
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Limits (SPEC section 8.1) and JSON value checks.
3
+ *
4
+ * The values are guaranteed minimums: a host MUST accept at least this much
5
+ * and MAY allow more. Plugins (and the SDK) MUST stay within them.
6
+ */
7
+ export declare const LIMITS: {
8
+ /** Max serialized size of one `send` message (bytes of UTF-8 JSON). */
9
+ readonly messageBytes: number;
10
+ /** Sustained `send` rate per instance (messages per second). */
11
+ readonly messagesPerSecond: 30;
12
+ /** Burst size of the `send` rate limiter. */
13
+ readonly messageBurst: 60;
14
+ /** Max serialized size of a snapshot. */
15
+ readonly snapshotBytes: number;
16
+ /** Snapshots per second the host stores (more are coalesced). */
17
+ readonly snapshotsPerSecond: 2;
18
+ /** Max serialized size of plugin data per scope. */
19
+ readonly dataBytes: number;
20
+ /** Plugin data writes per second and scope the host stores (more are coalesced). */
21
+ readonly dataWritesPerSecond: 1;
22
+ /** Max time between the iframe load and `hello`. */
23
+ readonly helloTimeoutMs: 10000;
24
+ /** Max serialized size of the player's answer in `recordAnswer`. */
25
+ readonly answerBytes: number;
26
+ /** Max serialized size of a generated item attached to `recordAnswer`. */
27
+ readonly generatedItemBytes: number;
28
+ /** `recordAnswer` calls per second and instance (sustained); the authority: per player. */
29
+ readonly answersPerSecond: 10;
30
+ /** Burst size of the `recordAnswer` rate limiter. */
31
+ readonly answerBurst: 20;
32
+ /** Max serialized size of a service request. */
33
+ readonly serviceBytes: number;
34
+ /** Max serialized size of a service result. */
35
+ readonly serviceResultBytes: number;
36
+ /** Service calls per minute and instance (sustained). */
37
+ readonly servicesPerMinute: 30;
38
+ /** `getAsset` calls per second and instance (sustained). */
39
+ readonly assetsPerSecond: 10;
40
+ /** Burst size of the `getAsset` rate limiter. */
41
+ readonly assetBurst: 20;
42
+ /** Size of one asset every host can deliver through `getAsset`. */
43
+ readonly assetBytes: number;
44
+ /** `reportError` calls per second and instance the host keeps (more are dropped). */
45
+ readonly errorReportsPerSecond: 5;
46
+ /** Burst size of the `reportError` limiter. */
47
+ readonly errorReportBurst: 10;
48
+ };
49
+ export type Limits = {
50
+ [K in keyof typeof LIMITS]: number;
51
+ };
52
+ /**
53
+ * Checks that `value` is plain JSON (null, booleans, finite numbers, strings,
54
+ * arrays and plain objects; no cycles, no `undefined`, functions, Dates, Maps…).
55
+ * Returns an error description, or `null` when the value is valid.
56
+ */
57
+ export declare function findNonJson(value: unknown, path?: string, depth?: number, seen?: Set<object>): string | null;
58
+ /** UTF-8 byte size of the JSON serialization of a (valid JSON) value. */
59
+ export declare function jsonByteSize(value: unknown): number;
60
+ /**
61
+ * Throws `INVALID_ARGUMENT` if `value` is not plain JSON, or `tooLargeCode`
62
+ * if its serialized size exceeds `maxBytes`. Returns the size in bytes.
63
+ */
64
+ export declare function assertJsonWithin(value: unknown, maxBytes: number, tooLargeCode: 'MESSAGE_TOO_LARGE' | 'SNAPSHOT_TOO_LARGE' | 'DATA_TOO_LARGE', what: string): number;
65
+ /**
66
+ * Token bucket rate limiter (`rate` tokens per second, up to `burst` stored).
67
+ * Used by the host for `send`; also usable by the SDK to stay within limits.
68
+ */
69
+ export declare class RateLimiter {
70
+ private readonly rate;
71
+ private readonly burst;
72
+ private readonly now;
73
+ private tokens;
74
+ private last;
75
+ constructor(rate: number, burst: number, now?: () => number);
76
+ /** Takes `cost` tokens (default one). Returns `false` when the limit is exceeded. */
77
+ tryTake(cost?: number): boolean;
78
+ }
@@ -0,0 +1,159 @@
1
+ import { z } from 'zod';
2
+ import { OQSEManifest } from '@memizy/oqse';
3
+ import { PluginRuntime } from './pluginRuntime.js';
4
+ export declare const HostAsSchema: z.ZodEnum<{
5
+ presenter: "presenter";
6
+ player: "player";
7
+ }>;
8
+ export declare const MultiplayerModeSchema: z.ZodObject<{
9
+ players: z.ZodObject<{
10
+ min: z.ZodNumber;
11
+ max: z.ZodNumber;
12
+ recommended: z.ZodOptional<z.ZodNumber>;
13
+ }, z.core.$loose>;
14
+ hostAs: z.ZodArray<z.ZodEnum<{
15
+ presenter: "presenter";
16
+ player: "player";
17
+ }>>;
18
+ lateJoin: z.ZodOptional<z.ZodBoolean>;
19
+ }, z.core.$loose>;
20
+ /** Service names are dot-separated lowercase segments, e.g. "ai.chat" or "chess.puzzles". */
21
+ export declare const ServiceNameSchema: z.ZodString;
22
+ export declare const PluginPermissionsSchema: z.ZodObject<{
23
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
24
+ devices: z.ZodOptional<z.ZodArray<z.ZodEnum<{
25
+ camera: "camera";
26
+ microphone: "microphone";
27
+ geolocation: "geolocation";
28
+ serial: "serial";
29
+ bluetooth: "bluetooth";
30
+ }>>>;
31
+ }, z.core.$loose>;
32
+ export declare const MemizyRuntimeSchema: z.ZodObject<{
33
+ protocol: z.ZodString;
34
+ set: z.ZodOptional<z.ZodEnum<{
35
+ optional: "optional";
36
+ required: "required";
37
+ none: "none";
38
+ }>>;
39
+ modes: z.ZodObject<{
40
+ solo: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
41
+ multiplayer: z.ZodOptional<z.ZodObject<{
42
+ players: z.ZodObject<{
43
+ min: z.ZodNumber;
44
+ max: z.ZodNumber;
45
+ recommended: z.ZodOptional<z.ZodNumber>;
46
+ }, z.core.$loose>;
47
+ hostAs: z.ZodArray<z.ZodEnum<{
48
+ presenter: "presenter";
49
+ player: "player";
50
+ }>>;
51
+ lateJoin: z.ZodOptional<z.ZodBoolean>;
52
+ }, z.core.$loose>>;
53
+ }, z.core.$loose>;
54
+ settings: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
55
+ type: z.ZodLiteral<"number">;
56
+ default: z.ZodNumber;
57
+ min: z.ZodOptional<z.ZodNumber>;
58
+ max: z.ZodOptional<z.ZodNumber>;
59
+ step: z.ZodOptional<z.ZodNumber>;
60
+ id: z.ZodString;
61
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
62
+ description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
63
+ modes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
64
+ solo: "solo";
65
+ multiplayer: "multiplayer";
66
+ }>>>;
67
+ }, z.core.$loose>, z.ZodObject<{
68
+ type: z.ZodLiteral<"boolean">;
69
+ default: z.ZodBoolean;
70
+ id: z.ZodString;
71
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
72
+ description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
73
+ modes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
74
+ solo: "solo";
75
+ multiplayer: "multiplayer";
76
+ }>>>;
77
+ }, z.core.$loose>, z.ZodObject<{
78
+ type: z.ZodLiteral<"select">;
79
+ options: z.ZodArray<z.ZodObject<{
80
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
81
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
82
+ }, z.core.$loose>>;
83
+ default: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
84
+ id: z.ZodString;
85
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
86
+ description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
87
+ modes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
88
+ solo: "solo";
89
+ multiplayer: "multiplayer";
90
+ }>>>;
91
+ }, z.core.$loose>, z.ZodObject<{
92
+ type: z.ZodLiteral<"text">;
93
+ default: z.ZodString;
94
+ maxLength: z.ZodOptional<z.ZodNumber>;
95
+ id: z.ZodString;
96
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
97
+ description: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
98
+ modes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
99
+ solo: "solo";
100
+ multiplayer: "multiplayer";
101
+ }>>>;
102
+ }, z.core.$loose>], "type">>>;
103
+ settingsScreen: z.ZodOptional<z.ZodObject<{
104
+ size: z.ZodEnum<{
105
+ compact: "compact";
106
+ large: "large";
107
+ }>;
108
+ }, z.core.$loose>>;
109
+ display: z.ZodOptional<z.ZodObject<{
110
+ orientation: z.ZodOptional<z.ZodEnum<{
111
+ any: "any";
112
+ portrait: "portrait";
113
+ landscape: "landscape";
114
+ }>>;
115
+ }, z.core.$loose>>;
116
+ permissions: z.ZodOptional<z.ZodObject<{
117
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
118
+ devices: z.ZodOptional<z.ZodArray<z.ZodEnum<{
119
+ camera: "camera";
120
+ microphone: "microphone";
121
+ geolocation: "geolocation";
122
+ serial: "serial";
123
+ bluetooth: "bluetooth";
124
+ }>>>;
125
+ }, z.core.$loose>>;
126
+ services: z.ZodOptional<z.ZodArray<z.ZodString>>;
127
+ }, z.core.$loose>;
128
+ export type MemizyRuntimeManifest = z.infer<typeof MemizyRuntimeSchema>;
129
+ /** A plugin manifest: OQSEM plus the required `appSpecific.memizy` block. */
130
+ export type PluginManifest = OQSEManifest & {
131
+ appSpecific: Record<string, unknown> & {
132
+ memizy: MemizyRuntimeManifest;
133
+ };
134
+ };
135
+ export type PluginManifestResult = {
136
+ success: true;
137
+ manifest: PluginManifest;
138
+ runtime: PluginRuntime;
139
+ } | {
140
+ success: false;
141
+ errors: string[];
142
+ };
143
+ /**
144
+ * Validates a plugin manifest (OQSEM part and Memizy runtime part).
145
+ * The returned `manifest` is the original object (unknown keys preserved).
146
+ */
147
+ export declare function safeParsePluginManifest(data: unknown): PluginManifestResult;
148
+ /** Error thrown by {@link parsePluginManifest}. */
149
+ export declare class PluginManifestError extends Error {
150
+ readonly errors: string[];
151
+ constructor(errors: string[]);
152
+ }
153
+ /** Like {@link safeParsePluginManifest}, but throws {@link PluginManifestError}. */
154
+ export declare function parsePluginManifest(data: unknown): {
155
+ manifest: PluginManifest;
156
+ runtime: PluginRuntime;
157
+ };
158
+ /** Extracts and validates the manifest of a plugin HTML file. */
159
+ export declare function readPluginManifestFromHtml(html: string): PluginManifestResult;
@@ -0,0 +1,47 @@
1
+ import { MemizyRuntimeManifest } from './manifestSchema.js';
2
+ import { SettingDefinition } from './settingsSchema.js';
3
+ export type HostAs = 'presenter' | 'player';
4
+ export type PluginView = 'solo' | 'board' | 'controller' | 'settings';
5
+ /** The runtime part with all defaults applied – what hosts work with. */
6
+ export interface PluginRuntime {
7
+ protocol: string;
8
+ /** Whether the plugin uses a study set: `none` = a game without questions. */
9
+ set: 'required' | 'optional' | 'none';
10
+ solo: boolean;
11
+ multiplayer: null | {
12
+ players: {
13
+ min: number;
14
+ max: number;
15
+ recommended?: number;
16
+ };
17
+ hostAs: HostAs[];
18
+ lateJoin: boolean;
19
+ };
20
+ settings: SettingDefinition[];
21
+ settingsScreen: null | {
22
+ size: 'compact' | 'large';
23
+ };
24
+ orientation: 'any' | 'portrait' | 'landscape';
25
+ /** Views the plugin must render (SPEC 3.2). */
26
+ views: PluginView[];
27
+ /** Declared network origins and devices (SPEC 8.4). */
28
+ permissions: {
29
+ network: string[];
30
+ devices: ('camera' | 'microphone' | 'geolocation' | 'serial' | 'bluetooth')[];
31
+ };
32
+ /** Memizy services the plugin uses (SPEC 5.3). */
33
+ services: string[];
34
+ }
35
+ /**
36
+ * Reads the manifest data island from plugin HTML **without executing it**
37
+ * (works in browsers and in Node). Returns the parsed JSON, or an error.
38
+ */
39
+ export declare function extractManifestFromHtml(html: string): {
40
+ success: true;
41
+ data: unknown;
42
+ } | {
43
+ success: false;
44
+ error: string;
45
+ };
46
+ /** Applies defaults and derives the views (SPEC 3.2). */
47
+ export declare function toRuntime(memizy: MemizyRuntimeManifest): PluginRuntime;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Small polyfills for what the connection library (Penpal 8) needs from the browser, so
3
+ * older school devices (Safari before 17.4) still run games. Called right before
4
+ * connecting; it only adds what is missing.
5
+ */
6
+ export declare function ensureConnectionPolyfills(): void;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Relay wire protocol: the host app and player apps ↔ the Memizy multiplayer
3
+ * server (WebSocket, JSON text frames). Plugins never see it (SPEC D2).
4
+ *
5
+ * The server is a room relay: PINs, player identity (reconnect tokens),
6
+ * presence and forwarding between the host and the players. The game session
7
+ * itself (validation, limits, authority, state) runs in the host's browser.
8
+ * The plugin and the study set are uploaded once over HTTP and downloaded by
9
+ * the players over HTTP (`RelayBundle`), not sent through the WebSocket.
10
+ *
11
+ * HTTP API (JSON):
12
+ * - `POST /api/rooms` → `CreateRoomResponse`
13
+ * - `GET /api/rooms/:pin` → `RoomInfo` (404 if unknown)
14
+ * - `PUT /api/rooms/:pin/bundle` (header `Authorization: Bearer <hostToken>`, body `RelayBundle`) → `{ version }`
15
+ * - `GET /api/rooms/:pin/bundle` → `RelayBundle` (header `X-Bundle-Version`)
16
+ * - `GET /api/health`
17
+ * WebSocket: `/ws`.
18
+ */
19
+ /**
20
+ * Version of the relay wire protocol (client <-> server). Within version 1 messages only
21
+ * change additively: receivers ignore unknown fields; the server answers an unknown `t`
22
+ * with a non-fatal `NOT_ALLOWED` error. The messages between the host app and the player
23
+ * apps (inside `data`) have their own version (`RELAY_APP_VERSION` in @memizy/host-sdk).
24
+ */
25
+ export declare const RELAY_PROTOCOL = 1;
26
+ export declare const RELAY_LIMITS: {
27
+ /** Max size of one WebSocket frame (UTF-8 JSON); fits an asset of `LIMITS.assetBytes` as base64. */
28
+ readonly frameBytes: number;
29
+ /** Max size of an uploaded bundle (plugin + set). */
30
+ readonly bundleBytes: number;
31
+ /** Max players in a room (the plugin may allow fewer). */
32
+ readonly playersPerRoom: 100;
33
+ /** Player frames per second (sustained / burst). */
34
+ readonly playerFramesPerSecond: 60;
35
+ readonly playerFrameBurst: 120;
36
+ /** Host frames per second (it talks to every player). */
37
+ readonly hostFramesPerSecond: 3000;
38
+ readonly hostFrameBurst: 6000;
39
+ /** Max player name length. */
40
+ readonly nameLength: 32;
41
+ /** Max length of the `auth` a player sends when joining. */
42
+ readonly authLength: 4096;
43
+ /** A room is closed when its host has been away this long. */
44
+ readonly hostAwayMs: number;
45
+ /** A room is closed after this time in any case. */
46
+ readonly roomTtlMs: number;
47
+ };
48
+ /** What the players download: the plugin HTML and the set prepared for it. */
49
+ export interface RelayBundle {
50
+ pluginHtml: string;
51
+ /** The set as delivered to plugins (`InitPayload.set`). */
52
+ set: unknown;
53
+ }
54
+ export interface CreateRoomResponse {
55
+ pin: string;
56
+ hostToken: string;
57
+ }
58
+ export interface RoomInfo {
59
+ pin: string;
60
+ /** Whether new players can join right now. */
61
+ open: boolean;
62
+ /** Name of the game (once the host uploaded a bundle). */
63
+ appName: string | null;
64
+ players: number;
65
+ }
66
+ export interface RoomPlayer {
67
+ id: string;
68
+ name: string;
69
+ connected: boolean;
70
+ /**
71
+ * What the player's app sent as `auth` when joining (e.g. a signed token of a signed-in
72
+ * student). Only the host gets it; the server does not check it – the host app does.
73
+ */
74
+ auth?: string;
75
+ }
76
+ export type RelayClientMessage =
77
+ /** The host attaches to its room (also after a reconnect). */
78
+ {
79
+ t: 'host';
80
+ protocol: number;
81
+ pin: string;
82
+ hostToken: string;
83
+ }
84
+ /**
85
+ * A player joins (with `token`: rejoins as the same player). `auth` (optional, at most
86
+ * `RELAY_LIMITS.authLength` characters) is passed to the host only, for apps with
87
+ * signed-in players; a rejoin with `auth` replaces the earlier one.
88
+ */
89
+ | {
90
+ t: 'join';
91
+ protocol: number;
92
+ pin: string;
93
+ name: string;
94
+ token?: string;
95
+ auth?: string;
96
+ }
97
+ /** Host → one player. */
98
+ | {
99
+ t: 'to';
100
+ to: string;
101
+ data: unknown;
102
+ }
103
+ /** Host: allow or stop new players joining. */
104
+ | {
105
+ t: 'open';
106
+ open: boolean;
107
+ }
108
+ /** Host: remove a player (they cannot rejoin with their token). */
109
+ | {
110
+ t: 'kick';
111
+ playerId: string;
112
+ }
113
+ /** Host: close the room for everyone. */
114
+ | {
115
+ t: 'close';
116
+ }
117
+ /** Player → host. */
118
+ | {
119
+ t: 'up';
120
+ data: unknown;
121
+ }
122
+ /** Player: change the name. */
123
+ | {
124
+ t: 'rename';
125
+ name: string;
126
+ } | {
127
+ t: 'ping';
128
+ };
129
+ export type RelayErrorCode = 'BAD_MESSAGE' | 'UNSUPPORTED_PROTOCOL' | 'ROOM_NOT_FOUND' | 'ROOM_CLOSED' | 'ROOM_FULL' | 'NOT_ALLOWED' | 'KICKED' | 'RATE_LIMITED' | 'TOO_LARGE';
130
+ export type RelayServerMessage =
131
+ /** To the host after `host`: the current roster. */
132
+ {
133
+ t: 'hosting';
134
+ pin: string;
135
+ players: RoomPlayer[];
136
+ }
137
+ /** To a player after `join`. Keep `token` to rejoin as the same player. */
138
+ | {
139
+ t: 'joined';
140
+ pin: string;
141
+ playerId: string;
142
+ token: string;
143
+ name: string;
144
+ hostConnected: boolean;
145
+ }
146
+ /** To the host: a player joined, reconnected, disconnected or was renamed. */
147
+ | {
148
+ t: 'presence';
149
+ player: RoomPlayer;
150
+ event: 'join' | 'connect' | 'disconnect' | 'rename' | 'leave';
151
+ }
152
+ /** To players: the host connection state. */
153
+ | {
154
+ t: 'host-presence';
155
+ connected: boolean;
156
+ }
157
+ /** Player → host. */
158
+ | {
159
+ t: 'from';
160
+ from: string;
161
+ data: unknown;
162
+ }
163
+ /** Host → player. */
164
+ | {
165
+ t: 'data';
166
+ data: unknown;
167
+ }
168
+ /** A request failed; fatal errors are followed by closing the socket. */
169
+ | {
170
+ t: 'error';
171
+ code: RelayErrorCode;
172
+ message: string;
173
+ fatal: boolean;
174
+ }
175
+ /** The room was closed by the host or expired. */
176
+ | {
177
+ t: 'closed';
178
+ reason: 'host' | 'expired' | 'kicked';
179
+ } | {
180
+ t: 'pong';
181
+ time: number;
182
+ };
183
+ /** Normalizes a player name (trimmed, collapsed spaces, max length). */
184
+ export declare function normalizePlayerName(name: unknown): string;
185
+ /** A 6-digit PIN. */
186
+ export declare function isPin(value: unknown): value is string;