feat: F5.3b the editor on a project of server F

?project=<uuid> opens the shared document over /sync and the room over
/sim (RemoteSimHost: messages wait for the socket, a lost socket comes
back with what it watched, F's refusal or parting ends it); instead of
the editor, a word why when F refuses or lets go. F tells each
connection its access at once. Who only reads sees everything and
changes nothing: no palette or undo, a locked surface, the sheet's
fields disabled (tabs stay), no starting or stopping; thawed, the
document comes anew. The toolbar names who started the simulation.
Refusals carry no message, so Hocuspocus writes nothing outside F's
JSON log. A browser test runs three people against a real F.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:00:18 +00:00
co-authored by Claude Opus 5.5
parent 8cfc518f0c
commit f52852493a
28 changed files with 1142 additions and 58 deletions
@@ -0,0 +1,51 @@
// The editor on a project of server F (F5.3b): the shared document over
// /sync and the room over /sim, both signed in by the cookie F set at
// /__auth. Until the document is there, a word that it is coming; when F
// refuses or lets go — of the document or the room — a word why, instead of
// the editor. Who only reads gets the editor locked (Editor's readOnly).
import { useEffect, useMemo, useState } from 'react';
import { Editor } from '../Editor.js';
import { CATALOGS, useLanguage, useTranslate } from '../i18n/i18n.js';
import type { SimHostFactory } from '../simulation/useSimulation.js';
import { RemoteSimHost, simUrl } from './remoteSimHost.js';
import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
export interface SharedProjectProps {
/** F's address. */
readonly server: string;
readonly project: string;
}
export function SharedProject({ server, project }: SharedProjectProps) {
const t = useTranslate();
const language = useLanguage();
const [state, setState] = useState<SharedDocState>({ kind: 'connecting' });
/** F ended the room for this page: the editor goes as when the document is let go. */
const [roomEnd, setRoomEnd] = useState<string | null>(null);
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
const createSimHost = useMemo<SimHostFactory>(
() => () => {
const host = new RemoteSimHost(simUrl(server, project));
host.onEnd((end) => setRoomEnd(end.reason));
return host;
},
[server, project],
);
const ended = roomEnd ?? (state.kind === 'refused' || state.kind === 'closed' ? state.reason : null);
if (ended !== null) {
const key = `remote.${ended}`;
return (
<p role="alert" className="remote-message">
{key in CATALOGS[language] ? t(key) : t('remote.refused', { reason: ended })}
</p>
);
}
if (state.kind !== 'ready')
return (
<p role="status" className="remote-message">
{t('remote.connecting')}
</p>
);
return <Editor key={state.doc.guid} doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} />;
}
+136
View File
@@ -0,0 +1,136 @@
// The simulation room on server F (plan §6.1): a WebSocket to
// /sim?project=…, the same messages as the room in a worker of the page
// (LocalSimHost). The socket comes back after it is lost (the network, F
// restarting, the room's thread failing), with the topics it watched; F's
// own word ends it for good — refused (no session, no grant) or let go
// (revoked, deleted) — and is told to whoever listens (`onEnd`).
import {
parseHostMessage,
SIM_CLOSED_CODE,
SIM_PROTOCOL_VERSION,
SIM_REFUSED_CODE,
type ClientMessage,
type HostMessage,
type SimHost,
type SyncClosing,
type SyncRefusal,
type Topic,
} from '@mdg-filius/protocol';
/** The first wait before the socket comes back, doubled each time up to RECONNECT_MAXIMUM_MILLISECONDS. */
export const RECONNECT_FIRST_MILLISECONDS = 500;
export const RECONNECT_MAXIMUM_MILLISECONDS = 10_000;
/** Why F ended the room for this page. */
export type RoomEnd = { readonly kind: 'refused'; readonly reason: SyncRefusal } | { readonly kind: 'closed'; readonly reason: SyncClosing };
/** What RemoteSimHost needs of a WebSocket (the browser's, or ws in tests). */
export interface SocketLike {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
onopen: (() => void) | null;
onmessage: ((event: { readonly data: unknown }) => void) | null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null;
}
export type SocketFactory = (url: string) => SocketLike;
const OPEN = 1;
export class RemoteSimHost implements SimHost {
private socket: SocketLike | undefined;
private readonly listeners = new Set<(message: HostMessage) => void>();
private readonly endListeners = new Set<(end: RoomEnd) => void>();
/** What waits for the socket to open. */
private queue: ClientMessage[] = [];
/** What this page watches: sent again when the socket comes back. */
private readonly topics = new Set<Topic>();
private wait = RECONNECT_FIRST_MILLISECONDS;
private timer: ReturnType<typeof setTimeout> | undefined;
private over = false;
private openedBefore = false;
constructor(
private readonly url: string,
private readonly open: SocketFactory = (address) => new WebSocket(address) as unknown as SocketLike,
) {
this.connect();
}
private connect(): void {
const socket = this.open(this.url);
this.socket = socket;
socket.onopen = () => {
this.wait = RECONNECT_FIRST_MILLISECONDS;
let queued = this.queue;
this.queue = [];
// Back after a loss, in a room that knows nothing of this page: all it watches, before anything else.
if (this.openedBefore) {
if (this.topics.size > 0) socket.send(JSON.stringify({ v: 1, kind: 'sub', topics: [...this.topics] }));
queued = queued.filter((message) => message.kind !== 'sub' && message.kind !== 'unsub');
}
this.openedBefore = true;
for (const message of queued) socket.send(JSON.stringify(message));
};
socket.onmessage = (event) => {
let value: unknown;
try {
value = JSON.parse(String(event.data));
} catch {
value = undefined;
}
const parsed = parseHostMessage(value);
const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail };
for (const listener of this.listeners) listener(message);
};
socket.onclose = ({ code, reason }) => {
if (this.over) return;
if (code === SIM_REFUSED_CODE || code === SIM_CLOSED_CODE) {
this.over = true;
const end = (code === SIM_REFUSED_CODE ? { kind: 'refused', reason } : { kind: 'closed', reason }) as RoomEnd;
for (const listener of this.endListeners) listener(end);
return;
}
this.timer = setTimeout(() => this.connect(), this.wait);
this.wait = Math.min(RECONNECT_MAXIMUM_MILLISECONDS, this.wait * 2);
};
}
send(message: ClientMessage): void {
if (message.kind === 'sub') for (const topic of message.topics) this.topics.add(topic);
if (message.kind === 'unsub') for (const topic of message.topics) this.topics.delete(topic);
if (this.over) return;
if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(message));
// Sent once the socket is open (again).
else this.queue.push(message);
}
onMessage(listener: (message: HostMessage) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Hears F end the room for this page; returns how to stop hearing. */
onEnd(listener: (end: RoomEnd) => void): () => void {
this.endListeners.add(listener);
return () => this.endListeners.delete(listener);
}
close(): void {
this.over = true;
if (this.timer !== undefined) clearTimeout(this.timer);
this.listeners.clear();
this.endListeners.clear();
this.socket?.close();
}
}
/** The address of a project's room on F, from F's address (http or https). */
export function simUrl(server: string, project: string): string {
const url = new URL('/sim', server);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.searchParams.set('project', project);
return url.href;
}
+105
View File
@@ -0,0 +1,105 @@
// The project's shared document on server F (plan §5): Hocuspocus' client
// on /sync, signed in by the cookie F set at /__auth. What it says is the
// editor's state: still connecting; ready — with the document, whether this
// person only reads, and whether because the project is frozen; refused
// (no session, no grant, no such project); or let go (revoked, deleted).
//
// A person who read and may write again (a frozen project thawed) gets the
// document anew: a change made while reading stayed in the page's copy and
// F did not take it, and Yjs on F would hold back every later change that
// builds on it (measured in F5.2). The editor locks editing while it reads,
// so that nothing gets there; the fresh copy is for what got there anyway.
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { SYNC_CLOSINGS, type AccessMessage } from '@mdg-filius/protocol';
export type SharedDocState =
| { readonly kind: 'connecting' }
| { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean }
/** F refused the connection; the reason is one of SYNC_REFUSALS (or Hocuspocus' own word). */
| { readonly kind: 'refused'; readonly reason: string }
/** F let the connection go; the reason is one of SYNC_CLOSINGS. */
| { readonly kind: 'closed'; readonly reason: string };
export interface SharedDocOptions {
/** F's address (http or https). */
readonly server: string;
readonly project: string;
readonly onState: (state: SharedDocState) => void;
/** A WebSocket other than the page's (tests in Node). */
readonly WebSocketPolyfill?: unknown;
}
/** The address of F's shared documents, from F's address. */
export function syncUrl(server: string): string {
const url = new URL('/sync', server);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.href;
}
/** Connects to the project's document; returns how to let it go. */
export function connectSharedDoc({ server, project, onState, WebSocketPolyfill }: SharedDocOptions): () => void {
let current: { readonly provider: HocuspocusProvider; readonly socket: HocuspocusProviderWebsocket } | undefined;
const letGo = () => {
current?.provider.destroy();
current?.socket.destroy();
current = undefined;
};
const end = (state: SharedDocState) => {
letGo();
onState(state);
};
const open = () => {
const doc = new Y.Doc();
const socket = new HocuspocusProviderWebsocket({ url: syncUrl(server), ...(WebSocketPolyfill === undefined ? {} : { WebSocketPolyfill }) });
let readOnly = false;
let frozen = false;
let synced = false;
/** F tells the access at once (`access`): ready only with it, so that a reader never sees a writable editor. */
let told = false;
const tell = () => {
if (synced && told) onState({ kind: 'ready', doc, readOnly, frozen });
};
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: project,
document: doc,
// Who comes in is told by the cookie; the token is not looked at.
token: 'cookie',
onSynced: () => {
if (synced) return;
synced = true;
tell();
},
onAuthenticationFailed: ({ reason }) => end({ kind: 'refused', reason }),
onStateless: ({ payload }) => {
let message: Partial<AccessMessage>;
try {
message = JSON.parse(payload) as Partial<AccessMessage>;
} catch {
return;
}
if (message.kind !== 'access' || typeof message.readOnly !== 'boolean') return;
if (readOnly && !message.readOnly && synced) {
letGo();
onState({ kind: 'connecting' });
open();
return;
}
readOnly = message.readOnly;
frozen = message.frozen === true;
told = true;
tell();
},
onClose: ({ event }) => {
if ((SYNC_CLOSINGS as readonly string[]).includes(event.reason)) end({ kind: 'closed', reason: event.reason });
},
});
provider.attach();
current = { provider, socket };
};
onState({ kind: 'connecting' });
open();
return letGo;
}