// The messages between the editor and a simulation room (plan §6.4) — the // one list both sides import, so that a kind one side sends and the other // does not know is a reported error, never silently dropped (Scratch's trap // 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker // (LocalSimHost); the messages are the same. import type { CaptureRow } from '@mdg-filius/core'; export const SIM_PROTOCOL_VERSION = 1; /** The programs whose windows a client can watch and act in. */ export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella'] as const; export type AppKind = (typeof APP_KINDS)[number]; /** * What a client subscribes to: which cables carry frames (`activity`), a * card's capture (`trace:`), a program's window * (`app:/`). */ export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}`; export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const; export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number]; export type ClientMessage = | { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] } | { readonly v: 1; readonly kind: 'unsub'; readonly topics: readonly Topic[] } /** Starts the simulation with the document as it is now. */ | { readonly v: 1; readonly kind: 'start' } | { readonly v: 1; readonly kind: 'stop' } /** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */ | { readonly v: 1; readonly kind: 'speed'; readonly value: number } /** An action in a program's window, answered by `ack` or `error` with the same reqId. */ | { readonly v: 1; readonly kind: 'input'; readonly node: string; readonly app: AppKind; readonly action: string; readonly args: Readonly>; readonly reqId: string; }; export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'ack', 'error'] as const; export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number]; export type SimMode = 'design' | 'simulation'; /** A frame's rows as the capture window shows them: with addresses (data exchange) and layered. */ export interface TraceFrame { readonly number: number; readonly rows: readonly CaptureRow[]; readonly layeredRows: readonly CaptureRow[]; } /** engineFailed: the core threw (a part it has not got yet, such as remote links); the room is back in design mode. */ export const ERROR_CODES = ['badMessage', 'badVersion', 'unknownKind', 'badTopic', 'notRunning', 'unknownNode', 'unknownApp', 'unknownAction', 'badArgs', 'engineFailed'] as const; export type ErrorCode = (typeof ERROR_CODES)[number]; export type HostMessage = /** The room's mode, speed and time; sent to every client on a change and when it connects. */ | { readonly v: 1; readonly kind: 'state'; readonly mode: SimMode; readonly speed: number; readonly t: number } /** The cables that carried a frame since the last tick. */ | { readonly v: 1; readonly kind: 'activity'; readonly links: readonly string[] } /** A card's new frames; on subscribing, all it holds (`reset`). */ | { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean } /** A program's new window lines from line `from` on; on subscribing, all (from 0). */ | { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] } | { readonly v: 1; readonly kind: 'ack'; readonly reqId: string } | { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string }; export type Parsed = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const bad = (code: ErrorCode, detail: string) => ({ error: { code, detail } }); /** Whether a topic is one the room knows. */ export function isTopic(value: unknown): value is Topic { if (value === 'activity') return true; if (typeof value !== 'string') return false; if (value.startsWith('trace:')) return value.length > 'trace:'.length; const app = /^app:([^/]+)\/(.+)$/.exec(value); return app !== null && (APP_KINDS as readonly string[]).includes(app[2]!); } /** A client's message as the room reads it: every field checked, an unknown kind named. */ export function parseClientMessage(value: unknown): Parsed { if (!isRecord(value)) return bad('badMessage', 'not an object'); if (value.v !== SIM_PROTOCOL_VERSION) return bad('badVersion', `version ${String(value.v)}`); const kind = value.kind; if (!(CLIENT_MESSAGE_KINDS as readonly unknown[]).includes(kind)) return bad('unknownKind', `kind ${String(kind)}`); switch (kind as ClientMessageKind) { case 'sub': case 'unsub': { const topics = value.topics; if (!Array.isArray(topics)) return bad('badMessage', 'topics is no list'); const unknown = topics.find((topic) => !isTopic(topic)); if (unknown !== undefined) return bad('badTopic', `topic ${String(unknown)}`); return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } }; } case 'start': case 'stop': return { message: { v: 1, kind: kind as 'start' | 'stop' } }; case 'speed': if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number'); return { message: { v: 1, kind: 'speed', value: value.value } }; case 'input': { const { node, app, action, args, reqId } = value; if (typeof reqId !== 'string') return bad('badMessage', 'reqId is no string'); if (typeof node !== 'string' || typeof action !== 'string' || !isRecord(args)) return bad('badMessage', 'input needs node, action and args'); if (!(APP_KINDS as readonly unknown[]).includes(app)) return bad('unknownApp', `app ${String(app)}`); return { message: { v: 1, kind: 'input', node, app: app as AppKind, action, args, reqId } }; } } } /** A host's message as the editor reads it: the kind known, else an error the editor shows. */ export function parseHostMessage(value: unknown): Parsed { if (!isRecord(value)) return bad('badMessage', 'not an object'); if (value.v !== SIM_PROTOCOL_VERSION) return bad('badVersion', `version ${String(value.v)}`); if (!(HOST_MESSAGE_KINDS as readonly unknown[]).includes(value.kind)) return bad('unknownKind', `kind ${String(value.kind)}`); return { message: value as unknown as HostMessage }; }