ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
312 lines (281 loc) • 10.5 kB
text/typescript
/**
* WebSocket adapter — replaces the legacy socket.io client.
*
* This is the transport half of AGENTS.md §8 Phase 10: every `broadcast()`
* call becomes one JSON frame over a native WS. `onMessage` delivers the
* decoded `ServerMessage`s (frames that fail `parseServerMessage` are
* dropped — matches the legacy behavior of ignoring unknown events).
*
* Reconnect policy (ported from `src/player.ls:46`):
* - delay between attempts: 500 ms
* - max attempts: 1800 (≈15 min of retrying, matches legacy socket.io config)
*
* Frames published while the socket is not in `OPEN` are queued and flushed
* on the next `open`. Frames submitted while permanently closed are dropped
* (matches `return unless SocialCalc.isConnected` in the legacy callback).
*/
import {
encodeMessage,
parseServerMessage,
type ClientMessage,
type ServerMessage,
} from '@ethercalc/shared/messages';
// ─── WebSocket surface we rely on ─────────────────────────────────────────
/** Minimal WS shape we depend on. Both `ws` in Node and browser WS satisfy this. */
export interface WsLike {
readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener(
type: 'open' | 'close' | 'error' | 'message',
listener: (ev: { data?: string | undefined; code?: number | undefined; reason?: string | undefined }) => void,
): void;
removeEventListener?: (
type: 'open' | 'close' | 'error' | 'message',
listener: (ev: { data?: string | undefined; code?: number | undefined; reason?: string | undefined }) => void,
) => void;
}
export type WsFactory = (url: string) => WsLike;
// ─── Public options / return shape ────────────────────────────────────────
export interface CreateWsAdapterOptions {
/** Spreadsheet id. */
room: string;
/** Presence identifier (randomly generated per tab in the legacy client). */
user: string;
/** Optional HMAC auth token. */
auth?: string | undefined;
/** Full WS URL or HTTP(S) endpoint — we flip the scheme automatically. */
url: string;
/** Milliseconds between reconnect attempts. Legacy default: 500. */
reconnectDelayMs?: number | undefined;
/** Max reconnect attempts before we give up. Legacy default: 1800. */
maxReconnectAttempts?: number | undefined;
/**
* Overridable socket constructor — lets tests inject a mock. When omitted
* we use `globalThis.WebSocket` (present in browsers + modern Node).
*/
wsFactory?: WsFactory | undefined;
/**
* Overridable timer for reconnect backoff. Defaults to `setTimeout` on the
* global. Tests pass a fake timer so we can assert timing without sleeping.
*/
setTimeoutFn?: ((fn: () => void, ms: number) => number) | undefined;
/** Matching `clearTimeout`. */
clearTimeoutFn?: ((id: number) => void) | undefined;
/** Hook fired on open/close/error/reconnect. Tests observe this. */
onStatus?: ((status: WsStatus) => void) | undefined;
}
export type WsStatus =
| { type: 'open' }
| { type: 'close'; code?: number | undefined; reason?: string | undefined }
| { type: 'error' }
| { type: 'reconnecting'; attempt: number }
| { type: 'reconnect_failed' };
export interface WsAdapter {
/**
* Public surface that `SocialCalc.Callbacks.broadcast` delegates to.
* Signature intentionally mirrors the legacy one: `(type, data={})`.
*/
broadcast(type: string, data?: Record<string, unknown>): void;
/** Subscribe to decoded server messages. Returns an unsubscribe fn. */
onMessage(cb: (msg: ServerMessage) => void): () => void;
/** Stop reconnecting and close the socket for good. */
close(): void;
/** For diagnostics/tests. */
readonly queueLength: number;
readonly connected: boolean;
readonly reconnectAttempts: number;
}
// ─── Implementation ───────────────────────────────────────────────────────
const WS_OPEN = 1;
/**
* Fallbacks for when the caller doesn't pass factories.
*
* Kept as getters so the module can be imported in Node test environments
* that don't have `WebSocket` on the global — it's only required at call
* time, after the test has injected its own factory.
*/
function defaultWsFactory(url: string): WsLike {
const ctor = (globalThis as unknown as { WebSocket?: new (u: string) => WsLike }).WebSocket;
if (!ctor) {
throw new Error('ws-adapter: no global WebSocket; pass wsFactory explicitly');
}
return new ctor(url);
}
function defaultSetTimeout(fn: () => void, ms: number): number {
return globalThis.setTimeout(fn, ms) as unknown as number;
}
function defaultClearTimeout(id: number): void {
globalThis.clearTimeout(id);
}
/**
* Turn an `http(s)://…` URL into `ws(s)://…`. Anything else (including a
* URL that's already ws/wss) is passed through untouched.
*/
export function toWsUrl(raw: string): string {
if (raw.startsWith('http://')) return 'ws://' + raw.slice(7);
if (raw.startsWith('https://')) return 'wss://' + raw.slice(8);
return raw;
}
/**
* Build `wss://<base>/_ws/:room?user=…&auth=…` from the options.
* Exported so the main entry can display it for debugging.
*/
export function buildWsEndpoint(opts: {
url: string;
room: string;
user: string;
auth?: string | undefined;
}): string {
const base = toWsUrl(opts.url.replace(/\/+$/, ''));
const params = new URLSearchParams();
params.set('user', opts.user);
if (opts.auth !== undefined) params.set('auth', opts.auth);
// `:room` is URL-encoded — matches the server-side `encodeURI` rule
// (AGENTS.md §7 item 15).
return `${base}/_ws/${encodeURIComponent(opts.room)}?${params.toString()}`;
}
/**
* Envelope that the adapter injects before shipping a frame. Mirrors the
* legacy `data.user`/`data.room`/`data.auth` fill-in at `src/player.ls:74`.
*/
function envelope(
type: string,
data: Record<string, unknown>,
opts: Pick<CreateWsAdapterOptions, 'room' | 'user' | 'auth'>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...data };
out.type = type;
out.user = opts.user;
if (out.room === undefined) out.room = opts.room;
if (opts.auth !== undefined) out.auth = opts.auth;
return out;
}
export function createWsAdapter(opts: CreateWsAdapterOptions): WsAdapter {
const delay = opts.reconnectDelayMs ?? 500;
const maxAttempts = opts.maxReconnectAttempts ?? 1800;
const wsFactory = opts.wsFactory ?? defaultWsFactory;
const setTimeoutFn = opts.setTimeoutFn ?? defaultSetTimeout;
const clearTimeoutFn = opts.clearTimeoutFn ?? defaultClearTimeout;
const endpoint = buildWsEndpoint(opts);
const subscribers = new Set<(msg: ServerMessage) => void>();
const queue: string[] = [];
let socket: WsLike | null = null;
let closed = false;
let reconnectAttempts = 0;
let pendingTimer: number | null = null;
function notifyStatus(status: WsStatus): void {
opts.onStatus?.(status);
}
function connect(): void {
let s: WsLike;
try {
s = wsFactory(endpoint);
} catch {
// Factory threw (network offline, test dummy) — schedule a retry just
// like the socket.io client used to.
scheduleReconnect();
return;
}
socket = s;
s.addEventListener('open', onOpen);
s.addEventListener('close', onClose);
s.addEventListener('error', onError);
s.addEventListener('message', onMessageFrame);
}
function onOpen(): void {
reconnectAttempts = 0;
notifyStatus({ type: 'open' });
flushQueue();
}
function onClose(ev: { code?: number | undefined; reason?: string | undefined }): void {
notifyStatus({ type: 'close', code: ev.code, reason: ev.reason });
socket = null;
scheduleReconnect();
}
function onError(): void {
notifyStatus({ type: 'error' });
// `error` is normally followed by `close`; let `close` drive reconnect.
}
function onMessageFrame(ev: { data?: string | undefined }): void {
if (typeof ev.data !== 'string') return;
const parsed = parseServerMessage(ev.data);
if (!parsed) return;
for (const cb of subscribers) cb(parsed);
}
function scheduleReconnect(): void {
if (closed) return;
if (reconnectAttempts >= maxAttempts) {
notifyStatus({ type: 'reconnect_failed' });
return;
}
reconnectAttempts++;
notifyStatus({ type: 'reconnecting', attempt: reconnectAttempts });
pendingTimer = setTimeoutFn(() => {
pendingTimer = null;
connect();
}, delay);
}
function flushQueue(): void {
// Only called from the `open` event handler, so we know `socket` is set
// and in `OPEN` state. No defensive guard needed.
while (queue.length > 0) {
const frame = queue.shift()!;
socket!.send(frame);
}
}
function broadcast(type: string, data: Record<string, unknown> = {}): void {
if (closed) return;
const enveloped = envelope(type, data, opts);
// Cast is safe — we accept arbitrary `type` strings at the broadcast
// surface for parity (`ask.ecell` is emitted internally and isn't in the
// `ClientMessage` union), but the JSON shape is a superset.
const frame = encodeMessage(enveloped as unknown as ClientMessage);
if (socket && socket.readyState === WS_OPEN) {
socket.send(frame);
} else {
queue.push(frame);
}
}
function onMessage(cb: (msg: ServerMessage) => void): () => void {
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}
function close(): void {
closed = true;
if (pendingTimer !== null) {
clearTimeoutFn(pendingTimer);
pendingTimer = null;
}
if (socket) {
try {
socket.close();
} catch {
// Some mock sockets throw on double-close — ignore.
}
socket = null;
}
subscribers.clear();
queue.length = 0;
}
// Kick off the first connection synchronously so tests that await a
// microtask can observe the socket creation.
connect();
const adapter: WsAdapter = {
broadcast,
onMessage,
close,
get queueLength() {
return queue.length;
},
get connected() {
return socket !== null && socket.readyState === WS_OPEN;
},
get reconnectAttempts() {
return reconnectAttempts;
},
};
return adapter;
}