/** * 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> { 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 `#_`) 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 `.//`. 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|` segments to obtain the endpoint the WS * should connect to. Port of `player.ls:16`. We do *not* go through a * `