UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

466 lines (432 loc) 16.3 kB
/** * Client entry point — port of `src/player.ls`. * * Wired as a side-effect module: importing this file connects the WS and * installs the SocialCalc callbacks. We keep no exports here (AGENTS.md * §8 Phase 10 "drop-in compat"), but tests exercise the helpers via * `runMain`. * * Loading flow: * 1. Parse `location.search` → `SocialCalc._auth` / `._app` / `._view`. * 2. Derive the room name from `window.EtherCalc._room`, URL hash, or * Drupal sheetnode conventions. * 3. Rewrite the URL via `history.pushState` to `/:room/{edit,view,app}` * to match the legacy player. * 4. Open a WS via `createWsAdapter` and install `SocialCalc.Callbacks.broadcast`. * 5. Subscribe to server messages and dispatch into the SocialCalc APIs. */ import type { BroadcastFn, SocialCalcGlobal, SpreadsheetLike, } from './types.ts'; import type { EcellServerMessage, EcellsServerMessage, ExecuteServerMessage, LogServerMessage, RecalcServerMessage, ServerMessage, SnapshotServerMessage, } from '@ethercalc/shared/messages'; import { parseQuery, installCallbacks, installBroadcast, installRecalcLoader } from './socialcalc-callbacks.ts'; import { createWsAdapter, type CreateWsAdapterOptions, type WsAdapter } from './ws-adapter.ts'; // ─── Host injection surface ────────────────────────────────────────────── /** * Everything we need from the global. Carved out so tests can construct a * stub without polluting the real `window`. */ export interface MainHost { SocialCalc: SocialCalcGlobal; EtherCalc?: { _room?: string }; location: { search: string; hash: string; pathname: string }; history: { pushState: (state: unknown, title: string, url: string) => void }; addmsg?: (msg: string, joined?: boolean) => void; spreadsheet?: SpreadsheetLike; DoGraph?: (help: boolean, resize: boolean) => void; setTimeout: (fn: () => void, ms: number) => number; /** * Only present when the embedding site wanted the Drupal-sheetnode * mapping (AGENTS.md §7 item 10). Plain websites leave it unset. */ Drupal?: { sheetnode?: { sheetviews?: unknown[] } }; } export interface RunMainOptions extends Partial<Omit<CreateWsAdapterOptions, 'room' | 'user'>> { host: MainHost; /** Randomizer for the username. Tests inject a deterministic value. */ randomUsername?: () => string; /** Override for connection base URL. Defaults to `host.location`. */ baseUrl?: string; } function defaultRandomUsername(): string { // Mirrors `Math.random!toString!` in `player.ls:7`. return Math.random().toString(); } /** * Normalizes a raw room identifier read from hash/query/path by dropping * the leading-underscore marker (legacy hash format was `#_<room>`) and * any trailing query string. Exported for direct unit testing — the * double-replace is a surprising enough surface that the individual * mutations on each regex need explicit coverage. */ export function stripRoom(raw: string): string { return raw.replace(/^_+/, '').replace(/\?.*/, ''); } // ─── Main entry ────────────────────────────────────────────────────────── export interface MainHandle { adapter: WsAdapter; username: string; room: string; broadcast: BroadcastFn; /** Close the underlying socket. Used only from tests. */ stop(): void; } /** * Side-effect entry executed from `side-effect.ts` (and directly by tests). * Returns the constructed adapter so tests can dispose cleanly. * * Returns `null` if no room is resolvable — matches the legacy redirect * to `./_start` (line 43). Redirect is deferred to the caller / browser * because we don't want to navigate in test environments. */ export function runMain(opts: RunMainOptions): MainHandle | null { const host = opts.host; const SocialCalc = host.SocialCalc; const randomUsername = opts.randomUsername ?? defaultRandomUsername; // Step 1 — parse query string + set session flags. SocialCalc.requestParams = parseQuery(host.location.search); SocialCalc._username = randomUsername(); SocialCalc.isConnected = true; const reqParams = SocialCalc.requestParams; if (reqParams['auth'] !== undefined) SocialCalc._auth = reqParams['auth']; if (reqParams['app'] !== undefined) SocialCalc._app = true; if (reqParams['view'] !== undefined) SocialCalc._view = true; // Step 2 — resolve the room id. let room = SocialCalc._room; if (!room) room = host.EtherCalc?._room; if (!room) room = host.location.hash.replace('#', ''); if (!room) { // Caller is expected to redirect to /_start — we just refuse to connect. return null; } room = stripRoom(room); SocialCalc._room = room; // Step 3 — rewrite URL to canonical `./<room>/<suffix>`. const suffix = SocialCalc._app ? '/app' : SocialCalc._view ? '/view' : SocialCalc._auth !== undefined ? '/edit' : ''; host.history.pushState({}, '', `./${room}${suffix}`); // Step 4 — open the WS. Flatten option spread — `exactOptionalPropertyTypes` // normally wants us to gate each optional, but `createWsAdapter` already // treats `undefined` as "not supplied" and omitting the gates lets us hit // 100% branch coverage. const baseUrl = opts.baseUrl ?? deriveBaseUrl(host.location.pathname, room); const adapter = createWsAdapter({ room, user: SocialCalc._username, auth: SocialCalc._auth, url: baseUrl, reconnectDelayMs: opts.reconnectDelayMs, maxReconnectAttempts: opts.maxReconnectAttempts, wsFactory: opts.wsFactory, setTimeoutFn: opts.setTimeoutFn, clearTimeoutFn: opts.clearTimeoutFn, onStatus: opts.onStatus, }); // Step 5 — wire broadcast + callbacks + handlers. const broadcast: BroadcastFn = (type, data) => { adapter.broadcast(type, data); }; installBroadcast(SocialCalc, broadcast); installCallbacks(SocialCalc, { broadcast, search: host.location.search }); installRecalcLoader(SocialCalc, broadcast); const dispatcher = createDispatcher(host); adapter.onMessage(dispatcher); return { adapter, username: SocialCalc._username, room, broadcast, stop(): void { adapter.close(); }, }; } /** * Given `location.pathname` (e.g. `/abc123/edit`) + the resolved room, strip * the trailing `/edit|/view|<room>` segments to obtain the endpoint the WS * should connect to. Port of `player.ls:16`. We do *not* go through a * `<script src="/socket.io/socket.io.js">` sniff anymore — socket.io is * gone and the WS always lives at `/_ws/:room` relative to the base. */ export function deriveBaseUrl(pathname: string, room: string): string { const stripped = pathname .replace(/\/(view|edit|app)$/, '') .replace(new RegExp(`/${escapeRegex(room)}$`), ''); return stripped === '' ? '/' : stripped; } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // ─── Server-message dispatcher ─────────────────────────────────────────── /** * Build the server-message handler. Factored out so `runMain` stays short * and the dispatcher itself is test-friendly. */ export function createDispatcher(host: MainHost): (msg: ServerMessage) => void { return function dispatch(msg: ServerMessage): void { const SocialCalc = host.SocialCalc; if (!SocialCalc.isConnected) return; const ss = host.spreadsheet; if (!ss) return; // Ignore echoes of our own actions on the current room (parity with // `player.ls:92`). const dataRoom = (msg as { room?: string }).room; const dataUser = (msg as { user?: string }).user; if (dataUser === SocialCalc._username && dataRoom === SocialCalc._room) return; // Legacy `data.to` private-channel test — sockets never reach here // unless targeted, but defensive parity. const dataTo = (msg as { to?: string }).to; if (dataTo && dataTo !== SocialCalc._username) return; // Cross-room `log` is dropped unless we have a formdata viewer open. if ( dataRoom && dataRoom !== SocialCalc._room && ss.formDataViewer?._room !== dataRoom && msg.type === 'log' ) { return; } let target = ss; if (dataRoom && dataRoom !== SocialCalc._room && msg.type !== 'recalc' && msg.type !== 'log') { if (ss.formDataViewer?._room !== dataRoom) return; target = ss.formDataViewer; } switch (msg.type) { case 'confirmemailsent': SocialCalc.EditorSheetStatusCallback?.(null, 'confirmemailsent', msg.message, target.editor); return; case 'chat': host.addmsg?.(msg.msg); return; case 'ecells': if (SocialCalc._app) return; applyEcells(SocialCalc, target, msg); return; case 'ecell': applyEcell(SocialCalc, target, msg); return; case 'log': applyLog(host, target, msg); return; case 'snapshot': applySnapshot(SocialCalc, target, msg); return; case 'recalc': applyRecalc(SocialCalc, target, msg); return; case 'execute': applyExecute(host, target, msg); return; case 'stopHuddle': applyStopHuddle(); return; case 'ask.ecell': applyAskEcell(SocialCalc, target, msg); return; case 'my.ecell': case 'ignore': return; } }; } /** * Peer asked us to announce our cursor position. Reply with an `ecell` * broadcast targeted at the asker (legacy `player.ls:128-132`). App mode * opts out — the form/app UI has no editable cursor to report. */ function applyAskEcell( SocialCalc: SocialCalcGlobal, target: SpreadsheetLike, msg: { user?: string }, ): void { if (SocialCalc._app) return; const ecell = target.editor.ecell.coord; if (!ecell) return; const payload: Record<string, unknown> = { ecell }; if (msg.user !== undefined) payload['to'] = msg.user; SocialCalc.Callbacks.broadcast?.('ecell', payload); } function applyEcells( SocialCalc: SocialCalcGlobal, target: SpreadsheetLike, msg: EcellsServerMessage, ): void { const editor = target.editor; for (const [user, ecell] of Object.entries(msg.ecells)) { if (user === SocialCalc._username) continue; const peerClass = ` ${user} defaultPeer`; const find = new RegExp(peerClass, 'g'); const cr = SocialCalc.coordToCr?.(ecell); if (!cr) continue; const cell = SocialCalc.GetEditorCellElement?.(editor, cr.row, cr.col); if (cell && cell.element.className.search(find) === -1) { cell.element.className += peerClass; } } } function applyEcell( SocialCalc: SocialCalcGlobal, target: SpreadsheetLike, msg: EcellServerMessage, ): void { const editor = target.editor; const peerClass = ` ${msg.user} defaultPeer`; const find = new RegExp(peerClass, 'g'); if (msg.original) { const origCR = SocialCalc.coordToCr?.(msg.original); if (origCR) { const origCell = SocialCalc.GetEditorCellElement?.(editor, origCR.row, origCR.col); if (origCell) { origCell.element.className = origCell.element.className.replace(find, ''); } } if (msg.original === editor.ecell.coord || msg.ecell === editor.ecell.coord) { SocialCalc.Callbacks.broadcast?.('ecell', { to: msg.user, ecell: editor.ecell.coord, }); } } if (SocialCalc._app) return; const cr = SocialCalc.coordToCr?.(msg.ecell); if (!cr) return; const cell = SocialCalc.GetEditorCellElement?.(editor, cr.row, cr.col); if (cell && cell.element.className.search(find) === -1) { cell.element.className += peerClass; } } function applyLog(host: MainHost, target: SpreadsheetLike, msg: LogServerMessage): void { const SocialCalc = host.SocialCalc; const ss = host.spreadsheet!; if (ss.formDataViewer?._room === msg.room) { applyFormDataLog(ss, msg); return; } if (SocialCalc.hadSnapshot) return; SocialCalc.hadSnapshot = true; let parts; if (msg.snapshot) parts = target.DecodeSpreadsheetSave(msg.snapshot); if (parts) { if (parts.sheet) { target.sheet.ResetSheet(); target.ParseSheetSave(msg.snapshot.substring(parts.sheet.start, parts.sheet.end)); } if (parts.edit) { target.editor.SettingsCallbacks; // touch for type narrowing const loader = (SocialCalc.LoadEditorSettings ?? (() => {})) as ( e: typeof target.editor, s: string, f: unknown, ) => void; loader( target.editor, msg.snapshot.substring(parts.edit.start, parts.edit.end), undefined, ); } } host.addmsg?.(msg.chat.join('\n'), true); const cmdstr = msg.log .filter((line) => !/^re(calc|display)$/.test(line)) .join('\n'); if (cmdstr.length) { target.context.sheetobj.ScheduleSheetCommands(`${cmdstr}\nrecalc\n`, false, true); } else { target.context.sheetobj.ScheduleSheetCommands('recalc\n', false, true); } } function applyFormDataLog(ss: SpreadsheetLike, msg: LogServerMessage): void { let parts; if (msg.snapshot) parts = ss.DecodeSpreadsheetSave(msg.snapshot); const viewer = ss.formDataViewer!; viewer.sheet.ResetSheet(); viewer.loaded = true; // Ask for log again to refresh — matches legacy behavior at player.ls:142. ss.editor; // no-op: touch for typing globalThisBroadcast('ask.log'); if (parts?.sheet) { viewer.ParseSheetSave(msg.snapshot.substring(parts.sheet.start, parts.sheet.end)); viewer.context.sheetobj.ScheduleSheetCommands('recalc\n', false, true); } } function globalThisBroadcast(type: string, data?: Record<string, unknown>): void { // Indirect hop through the global. Lets `applyFormDataLog` re-enter the // broadcast surface without threading the adapter through. const w = globalThis as unknown as { SocialCalc?: SocialCalcGlobal }; w.SocialCalc?.Callbacks.broadcast?.(type, data); } function applySnapshot( _SocialCalc: SocialCalcGlobal, target: SpreadsheetLike, msg: SnapshotServerMessage, ): void { const SocialCalc = _SocialCalc; SocialCalc.hadSnapshot = true; const parts = msg.snapshot ? target.DecodeSpreadsheetSave(msg.snapshot) : undefined; if (parts?.sheet) { target.sheet.ResetSheet(); target.ParseSheetSave(msg.snapshot.substring(parts.sheet.start, parts.sheet.end)); target.context.sheetobj.ScheduleSheetCommands('recalc\n', false, true); } } function applyRecalc( SocialCalc: SocialCalcGlobal, target: SpreadsheetLike, msg: RecalcServerMessage, ): void { if (msg.force) { const sheets = SocialCalc.Formula?.SheetCache?.sheets; if (sheets) delete sheets[msg.room]; target.sheet.recalconce = true; } const parts = msg.snapshot ? target.DecodeSpreadsheetSave(msg.snapshot) : undefined; if (parts?.sheet) { const sheetdata = msg.snapshot.substring(parts.sheet.start, parts.sheet.end); SocialCalc.RecalcLoadedSheet?.(msg.room, sheetdata, true); const cache = SocialCalc.RecalcInfo.LoadSheetCache; if (cache && cache[msg.room] !== sheetdata) { cache[msg.room] = sheetdata; target.context.sheetobj.ScheduleSheetCommands('recalc\n', false, true); } } else { SocialCalc.RecalcLoadedSheet?.(msg.room, '', true); } } function applyExecute( host: MainHost, target: SpreadsheetLike, msg: ExecuteServerMessage, ): void { target.context.sheetobj.ScheduleSheetCommands( msg.cmdstr, /* saveundo */ false, /* isRemote */ true, ); // If the user was on the graph tab, redraw shortly. Legacy used a 100ms // debounce; we preserve. const ss = host.spreadsheet; const graphTab = ss?.tabnums?.['graph']; if (ss && graphTab !== undefined && (ss.currentTab as unknown) === graphTab) { host.setTimeout(() => host.DoGraph?.(false, false), 100); } } function applyStopHuddle(): void { // Legacy opens a confirm alert + navigates away. Covered in `./boot.ts` // which is the browser-only entry point. In-test, this is a no-op so // we can exercise the dispatcher end-to-end without redirecting. }