feat(sim): F4.2 the sim protocol, the simulation room and LocalSimHost; M9 measured

packages/protocol holds the one list of /sim messages and checks every
field, naming what is wrong. packages/sim has the simulation room for F and
the web worker: the document's snapshot at start, the engine's clock on the
wall clock with Java's speed as cable delay, subscriptions to cable
activity, a card's new frames and a program's new window lines, capture and
clock kept over a stop, the command line as first action, a core failure
told to every client. LocalSimHost mirrors the document into the worker and
back without echo. M9 in Chromium: 17 examples give the same capture as in
Node, character for character; the room runs in a real worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 08:32:20 +00:00
co-authored by Claude Opus 5.5
parent 800e5de1d3
commit 0fd42f10e2
30 changed files with 1465 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@mdg-filius/protocol",
"version": "0.0.0",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@mdg-filius/core": "0.0.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
// The editor's side of a simulation room: LocalSimHost (a web worker) and
// RemoteSimHost (F, from F5) look alike to it.
import type { ClientMessage, HostMessage } from './sim.js';
export interface SimHost {
/** Sends a message to the room. */
send(message: ClientMessage): void;
/** Hears the room's messages; returns how to stop hearing them. */
onMessage(listener: (message: HostMessage) => void): () => void;
/** Leaves the room. */
close(): void;
}
+3
View File
@@ -0,0 +1,3 @@
// Public surface of the wire protocol.
export * from './host.js';
export * from './sim.js';
+123
View File
@@ -0,0 +1,123 @@
// 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:<portId>`), a program's window
* (`app:<nodeId>/<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<Record<string, unknown>>;
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<T> = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } };
const isRecord = (value: unknown): value is Record<string, unknown> => 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<ClientMessage> {
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<HostMessage> {
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 };
}
+49
View File
@@ -0,0 +1,49 @@
// The one list of messages: what the room accepts from a client, and what
// it refuses, each with its reason.
import { describe, expect, it } from 'vitest';
import { CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, isTopic, parseClientMessage, parseHostMessage } from '../src/index.js';
describe("a client's messages", () => {
it('reads each kind', () => {
const messages = [
{ v: 1, kind: 'sub', topics: ['activity', 'trace:port-1', 'app:node-1/terminal'] },
{ v: 1, kind: 'unsub', topics: ['activity'] },
{ v: 1, kind: 'start' },
{ v: 1, kind: 'stop' },
{ v: 1, kind: 'speed', value: 50 },
{ v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' },
];
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each });
});
it('names what is wrong', () => {
const code = (value: unknown) => ('error' in parseClientMessage(value) ? (parseClientMessage(value) as { error: { code: string } }).error.code : 'ok');
expect(code(null)).toBe('badMessage');
expect(code([])).toBe('badMessage');
expect(code({ v: 2, kind: 'start' })).toBe('badVersion');
expect(code({ v: 1, kind: 'reset' })).toBe('unknownKind');
expect(code({ v: 1, kind: 'sub', topics: 'activity' })).toBe('badMessage');
expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic');
expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic');
expect(code({ v: 1, kind: 'speed', value: '50' })).toBe('badMessage');
expect(code({ v: 1, kind: 'speed', value: Number.NaN })).toBe('badMessage');
expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: {} })).toBe('badMessage');
expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: [], reqId: 'r' })).toBe('badMessage');
expect(code({ v: 1, kind: 'input', node: 'n', app: 'paint', action: 'enter', args: {}, reqId: 'r' })).toBe('unknownApp');
});
it('knows its topics', () => {
expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser'].every(isTopic)).toBe(true);
expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:n', 7].some(isTopic)).toBe(false);
});
});
describe("a host's messages", () => {
it('reads each kind, and names an unknown one', () => {
for (const kind of HOST_MESSAGE_KINDS) expect('message' in parseHostMessage({ v: 1, kind })).toBe(true);
expect(parseHostMessage({ v: 1, kind: 'node' })).toEqual({ error: { code: 'unknownKind', detail: 'kind node' } });
expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } });
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"types": [
"node"
],
"resolveJsonModule": true
},
"include": [
"."
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../core"
}
]
}