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:
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user