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
+16 -4
View File
@@ -1,7 +1,8 @@
// The shared documents (/sync, plan §5): Hocuspocus, a project a document.
// Who comes in is decided by the session the browser's cookie names (plan
// §8.5): its grant to the project, read or write; a frozen project is read
// by everyone; a refusal says why (SYNC_REFUSALS). A project is loaded from
// by everyone; a refusal says why (SYNC_REFUSALS); the access a connection
// has is told to it at once (and again when it changes). A project is loaded from
// the store when its first person comes (projects are made through the
// service), every
// update stored as it comes, compacted a minute after the last change, when
@@ -34,6 +35,8 @@ export const SESSION_COOKIE = 'filius_session';
export interface ConnectionContext {
readonly person: SessionPerson;
readonly level: Level;
/** Whether the project was frozen when the connection came. */
readonly frozen: boolean;
}
export interface SyncOptions {
@@ -51,9 +54,14 @@ export interface Sync {
discard(project: string): Promise<void>;
}
/** A refusal of a connection, with the reason the person's editor is told (Hocuspocus sends an error's `reason`). */
/**
* A refusal of a connection, with the reason the person's editor is told
* (Hocuspocus sends an error's `reason`). Without a message: Hocuspocus
* writes a failed hook's message onto the console, outside F's JSON log,
* which has the refusal already (`sync.refused`, `doc.unknown`).
*/
export function refusal(reason: SyncRefusal): Error & { readonly reason: SyncRefusal } {
return Object.assign(new Error(reason), { reason });
return Object.assign(new Error(), { reason });
}
/** A cookie's value from a Cookie header. */
@@ -109,7 +117,11 @@ export function createSync({ store, sessions, log, compactAfterIdleMilliseconds
}
connectionConfig.readOnly = access.level === 'read' || access.frozen;
log.info('sync.joined', { project, user: access.person.login, readOnly: connectionConfig.readOnly });
return { person: access.person, level: access.level };
return { person: access.person, level: access.level, frozen: access.frozen };
},
// Told at once, so that the editor knows from the start whether it reads, and why.
async connected({ connection, context }) {
connection.sendStateless(accessMessage(connection.readOnly, (context as ConnectionContext).frozen));
},
async onLoadDocument({ documentName, document }) {
const persistence = new DocumentPersistence(store, documentName, log);