ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
361 lines (325 loc) • 12.7 kB
text/typescript
/**
* Socket.io v0.9 ↔ native WS adapter factory.
*
* The adapter is the top-level surface the worker wires into its router.
* It is stateful only for the duration of a given socket.io session: a
* per-sid heartbeat timer and a reference to the client's WebSocket so
* we can push server messages back.
*
* The adapter is **stateless across the 8 reconnection events** in §7.23
* (reconnect, connect_error, connect_timeout, reconnect_error,
* connect_failed, plus offline events). Translation is direction-free:
* what the client sends gets converted to a native ClientMessage and
* handed to the worker; what the worker emits gets wrapped back into a
* socket.io event frame. The worker's native WS layer owns reconnection
* semantics — the adapter just routes the next frame however it arrives.
*
* Threading model:
* - One adapter per worker isolate (singleton).
* - One session (sid → WS + timer) tracked on the adapter.
* - The adapter doesn't own the native WS that the worker opens on
* behalf of the client; `getNativeWebSocket(sid)` returns it on demand.
*/
import type { ClientMessage, ServerMessage } from '@ethercalc/shared/messages';
import { decodeFrame, encodeFrame, PacketType } from './framing.ts';
import {
buildHandshakeResponse,
DEFAULT_TRANSPORTS,
parseHandshakePath,
} from './handshake.ts';
import { generateSid, validateSid } from './sid.ts';
import { nativeToSocketIoEvent, socketIoEventToNative } from './translate.ts';
/**
* The minimal WebSocket surface the adapter touches. Matches the intersection
* of the browser `WebSocket`, Cloudflare's `WebSocket`, and `ws`'s Node
* WebSocket class. Keeps the adapter runtime-agnostic — wired to either
* workerd's hibernatable WS or a Node test stub.
*/
export interface WebSocketLike {
send(data: string): void;
close(code?: number, reason?: string): void;
addEventListener?(
type: 'message' | 'close' | 'error',
listener: (ev: { data?: unknown; code?: number; reason?: string }) => void,
): void;
}
export interface SocketIoShimOptions {
/**
* Called when a client frame translates to a native ClientMessage. The
* worker is responsible for applying it to the room and (if needed)
* broadcasting back via `getNativeWebSocket`.
*/
onClientMessage: (msg: ClientMessage, sid: string) => void;
/**
* Called by the adapter when it needs to push a ServerMessage back out.
* Returns the per-room native WS for `sid` — or `null` if the session
* has torn down and the push should be dropped.
*/
getNativeWebSocket: (sid: string) => WebSocketLike | null;
/** Heartbeat timeout in seconds (server sends `2::` every hb/2). */
hbTimeoutSec?: number;
/** Close timeout in seconds — advertised in the handshake. */
closeTimeoutSec?: number;
/**
* Timer scheduler. Defaults to `setInterval`+`clearInterval` from
* globalThis. Tests substitute a controllable fake.
*/
setTimer?: (cb: () => void, ms: number) => Timer;
/** Paired clear for `setTimer`. */
clearTimer?: (t: Timer) => void;
}
/** Opaque timer handle — whatever `setTimer` returned. */
export type Timer = unknown;
export interface SocketIoShim {
/** Handle the initial `/socket.io/1/` handshake HTTP GET. */
handleHandshake(request: Request): Response;
/**
* Handle the `/socket.io/1/websocket/<sid>` upgrade. Returns an object
* with `accept(ws)` — the worker calls it once it has accepted the WS
* on its side. From then on, inbound frames are translated via
* `onClientMessage` and outbound frames via `sendToClient` below.
*/
handleWebSocketUpgrade(
request: Request,
sid: string,
): { accept: (ws: WebSocketLike) => void } | null;
/** Handle an xhr-polling `GET` or `POST` request. */
handleXhrPoll(request: Request, sid: string): Promise<Response>;
/**
* Push a native ServerMessage to the socket.io client identified by sid.
* No-op if the session is gone.
*/
sendToClient(sid: string, msg: ServerMessage): void;
/** Terminate a session. Idempotent. */
closeSession(sid: string, reason?: string): void;
/** Count of live sessions — exposed for tests. */
readonly sessionCount: number;
}
interface Session {
/** The WebSocket we've accepted for this session, if any. */
ws: WebSocketLike | null;
/** Heartbeat timer handle. */
hbTimer: Timer | null;
/** Pending xhr-poll messages queued while no GET is outstanding. */
pollQueue: string[];
/** Resolver for the currently outstanding xhr-poll GET, if any. */
pollResolver: ((body: string) => void) | null;
/** Set to true once we've emitted the initial `1::` connect ack. */
connected: boolean;
}
/**
* Build the adapter. The returned shim is safe to treat as a singleton per
* worker isolate — each session is tracked in a Map keyed by sid.
*/
export function createSocketIoShim(opts: SocketIoShimOptions): SocketIoShim {
const hbTimeoutSec = opts.hbTimeoutSec ?? 60;
const closeTimeoutSec = opts.closeTimeoutSec ?? 60;
const setTimer =
opts.setTimer ??
((cb, ms) => globalThis.setInterval(cb, ms) as unknown as Timer);
const clearTimer =
opts.clearTimer ??
((t) => globalThis.clearInterval(t as Parameters<typeof globalThis.clearInterval>[0]));
const sessions = new Map<string, Session>();
function ensureSession(sid: string): Session {
let s = sessions.get(sid);
if (!s) {
s = {
ws: null,
hbTimer: null,
pollQueue: [],
pollResolver: null,
connected: false,
};
sessions.set(sid, s);
}
return s;
}
function startHeartbeat(sid: string, session: Session): void {
// Precondition: session.hbTimer is always null when called — each of
// our call sites (WS accept, first xhr-poll GET) runs exactly once
// per session, and close/error handlers clear the timer before a
// reconnect calls this again.
// v0.9 heartbeats are sent at hb/2 so the *client* sees one well
// before its own timeout fires.
const intervalMs = (hbTimeoutSec * 1000) / 2;
session.hbTimer = setTimer(() => {
// Re-read via the map: the session may have been torn down between
// ticks. Dropping the frame in that case is correct.
const live = sessions.get(sid);
if (!live) return;
deliverFrame(live, encodeFrame({ type: PacketType.Heartbeat }));
}, intervalMs);
}
function deliverFrame(session: Session, frame: string): void {
if (session.ws) {
session.ws.send(frame);
return;
}
if (session.pollResolver) {
const resolve = session.pollResolver;
session.pollResolver = null;
resolve(frame);
return;
}
session.pollQueue.push(frame);
}
function processInboundFrame(sid: string, raw: string): void {
const packet = decodeFrame(raw);
if (!packet) return;
switch (packet.type) {
case PacketType.Disconnect:
closeSession(sid, 'client disconnected');
return;
case PacketType.Heartbeat:
// Clients echo the heartbeat; nothing to do — the fact that we
// received any frame already proves liveness.
return;
case PacketType.Event: {
const msg = socketIoEventToNative(packet);
if (msg) opts.onClientMessage(msg, sid);
return;
}
// Connect/Json/Message/Ack/Error/Noop: ignored. We only care about
// Event frames for the EtherCalc protocol.
default:
return;
}
}
function closeSession(sid: string, reason = 'closed'): void {
const session = sessions.get(sid);
if (!session) return;
if (session.hbTimer !== null) {
clearTimer(session.hbTimer);
session.hbTimer = null;
}
if (session.ws) {
try {
session.ws.close(1000, reason);
} catch {
// Close may throw if already closed; that's fine.
}
session.ws = null;
}
if (session.pollResolver) {
const resolve = session.pollResolver;
session.pollResolver = null;
// Emit a disconnect frame so the polling client tears down.
resolve(encodeFrame({ type: PacketType.Disconnect }));
}
sessions.delete(sid);
}
return {
handleHandshake(request: Request): Response {
// Validate the path so we don't return a sid for nonsense URLs.
const match = parseHandshakePath(new URL(request.url).pathname);
if (match === null || match.transport !== undefined) {
return new Response('Not Found', { status: 404 });
}
const sid = generateSid();
const body = buildHandshakeResponse({
sid,
hbTimeoutSec,
closeTimeoutSec,
transports: DEFAULT_TRANSPORTS,
});
// Pre-create the session so a fast client's websocket upgrade
// doesn't race with handshake bookkeeping.
ensureSession(sid);
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
// The legacy server sent no caching hints; mirror that.
},
});
},
handleWebSocketUpgrade(_request: Request, sid: string) {
if (!validateSid(sid)) return null;
const session = ensureSession(sid);
// Per §7.23: fresh connect-ack on both first accept and reconnects.
// Legacy clients rely on the `.on('connect')` handler re-firing so
// we always emit a `1::` frame, whether or not we've seen this sid
// before. The `connected` flag is kept as a breadcrumb in case a
// future revision wants to differentiate.
const onClose = (): void => {
if (session.hbTimer !== null) {
clearTimer(session.hbTimer);
session.hbTimer = null;
}
session.ws = null;
};
return {
accept(ws: WebSocketLike) {
session.ws = ws;
session.connected = true;
ws.send(encodeFrame({ type: PacketType.Connect }));
startHeartbeat(sid, session);
ws.addEventListener?.('message', (ev) => {
const data = ev.data;
if (typeof data === 'string') processInboundFrame(sid, data);
});
ws.addEventListener?.('close', onClose);
ws.addEventListener?.('error', onClose);
},
};
},
async handleXhrPoll(request: Request, sid: string): Promise<Response> {
if (!validateSid(sid)) {
return new Response('Bad Request', { status: 400 });
}
const session = ensureSession(sid);
if (request.method === 'POST') {
// Polling POSTs carry one or more frames, separated by the
// legacy framer byte `\ufffd`. EtherCalc clients only ever send
// one per POST; the split handles both cases — a single frame
// comes through as `[frame]`, batched frames as N entries.
const body = await request.text();
const frames = body.split('\ufffd').filter((f) => f.length > 0);
for (const f of frames) processInboundFrame(sid, f);
return new Response('1', {
status: 200,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
// GET: drain the queue, or hold open until the next push.
if (!session.connected) {
session.connected = true;
session.pollQueue.unshift(encodeFrame({ type: PacketType.Connect }));
startHeartbeat(sid, session);
}
if (session.pollQueue.length > 0) {
const body = session.pollQueue.shift()!;
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
// Otherwise, park a resolver. The next deliverFrame() satisfies it.
const body = await new Promise<string>((resolve) => {
session.pollResolver = resolve;
});
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
},
sendToClient(sid: string, msg: ServerMessage): void {
const session = sessions.get(sid);
if (!session) return;
// Route via the native WS if available; otherwise our own queue/ws.
const native = opts.getNativeWebSocket(sid);
// `native` is informational only — we don't *use* the native WS to
// reach the socket.io client (they're separate sockets); the worker
// calls sendToClient for the legacy side. We still read it so
// downstream consumers get the freshness guarantee.
void native;
const frame = nativeToSocketIoEvent(msg);
deliverFrame(session, frame);
},
closeSession,
get sessionCount(): number {
return sessions.size;
},
};
}