UNPKG

ethercalc

Version:

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

272 lines (253 loc) 9.48 kB
/** * Minimal RESP (Redis Serialization Protocol) client. * * Purpose: let `@ethercalc/migrate` consume a legacy dump by pointing * at a RESP-speaking server (real `redis-server` or Zedis) that has * already loaded `dump.rdb`. The server owns the RDB parser; the * migrator owns the shape-shifting into Worker PUTs. Total memory in * the migrator stays O(1-per-room) regardless of the dump size. * * Wire format (per Redis docs): * `+…\r\n` simple string * `-…\r\n` error (surfaced as `RespError`) * `:42\r\n` integer * `$5\r\nhello\r\n` bulk string * `$-1\r\n` null bulk * `*2\r\n…\r\n…` array (length-prefixed, -1 for null) * * We implement the subset EtherCalc needs. The parser is incremental: * each socket chunk drains as many complete responses as possible, * returning `null` when the current message is still in flight. */ import { createConnection, } from 'node:net'; /** Value shape returned from Redis — strings decoded as UTF-8. */ export type RespValue = string | number | null | RespValue[]; /** Raised when the server responds with a `-ERR …` line. */ export class RespError extends Error { constructor(message: string) { super(message); this.name = 'RespError'; } } /** * Minimal socket surface used by {@link RespClient}. Subset of * `node:net.Socket` so tests can inject a fake without a real TCP * handshake. Unit tests cover the parser by driving `onData` directly. */ export interface RespSocket { write(data: string | Buffer): void; end(cb?: () => void): void; on(event: 'data', listener: (chunk: Buffer) => void): void; on(event: 'error' | 'close', listener: (err?: Error) => void): void; } interface Waiter { resolve: (v: RespValue) => void; reject: (err: Error) => void; } /** * Strictly-ordered RESP client. Every `sendCommand` enqueues a waiter * and writes the encoded command; as bytes arrive, the parser resolves * or rejects the oldest waiter. No pipelining (command-per-roundtrip * is fast enough over loopback for the migration use case). */ export class RespClient { readonly #socket: RespSocket; #pending: Buffer = Buffer.alloc(0); #queue: Waiter[] = []; #closed = false; constructor(socket: RespSocket) { this.#socket = socket; socket.on('data', (chunk) => this.#onData(chunk)); socket.on('error', (err) => this.#failAll(err ?? new Error('socket error'))); socket.on('close', () => this.#failAll(new Error('connection closed'))); } /** * Open a TCP connection to `redis://host:port`. Any other scheme * (including bare host:port) is rejected — we want the URL to be * self-describing in log lines. */ static async connect( url: string, connect: (host: string, port: number) => Promise<RespSocket> = defaultConnect, ): Promise<RespClient> { const parsed = new URL(url); if (parsed.protocol !== 'redis:') { throw new Error(`unsupported scheme: ${parsed.protocol} (expected redis://)`); } // URL() always populates `hostname` for a redis:// URL (it rejects // `redis://:6379` outright), so no fallback is needed here. const host = parsed.hostname; const port = parsed.port === '' ? 6379 : Number(parsed.port); const socket = await connect(host, port); return new RespClient(socket); } /** * Serialize an inline command via the array-of-bulks form (the * universal shape every Redis server accepts). Returns whatever the * server sends back, decoded into {@link RespValue}. */ sendCommand(...args: readonly (string | number)[]): Promise<RespValue> { if (this.#closed) { return Promise.reject(new Error('RespClient is closed')); } this.#socket.write(encodeCommand(args)); return new Promise<RespValue>((resolve, reject) => { this.#queue.push({ resolve, reject }); }); } /** * Fire a batch of commands in one TCP write, then await all replies * in order. Standard Redis pipelining — saves N-1 round-trips per * batch over loopback and adds up fast in a per-room migration loop. * The server guarantees it processes commands and replies in order, * so the Nth returned value corresponds to the Nth command. */ pipeline( ...commands: readonly (readonly (string | number)[])[] ): Promise<readonly RespValue[]> { if (this.#closed) { return Promise.reject(new Error('RespClient is closed')); } // Empty batch is legal — no bytes on the wire, empty reply. if (commands.length === 0) return Promise.resolve([]); const bufs: string[] = []; for (const cmd of commands) bufs.push(encodeCommand(cmd)); this.#socket.write(bufs.join('')); const promises: Promise<RespValue>[] = []; for (let i = 0; i < commands.length; i++) { promises.push( new Promise<RespValue>((resolve, reject) => { this.#queue.push({ resolve, reject }); }), ); } return Promise.all(promises); } /** Send QUIT and close the TCP stream. */ close(): Promise<void> { this.#closed = true; return new Promise((resolve) => { this.#socket.end(() => resolve()); }); } #onData(chunk: Buffer): void { this.#pending = this.#pending.length === 0 ? chunk : Buffer.concat([this.#pending, chunk]); while (this.#queue.length > 0) { let result: ParseResult; try { result = tryParse(this.#pending, 0); } catch (err) { // Propagate hard protocol errors to every pending waiter — the // stream is in an unknown state, can't safely deliver more. this.#failAll(err as Error); return; } if (result === null) return; // need more bytes this.#pending = this.#pending.subarray(result.consumed); const waiter = this.#queue.shift() as Waiter; if (result.value instanceof Error) waiter.reject(result.value); else waiter.resolve(result.value); } } #failAll(err: Error): void { this.#closed = true; const q = this.#queue; this.#queue = []; for (const w of q) w.reject(err); } } interface ParseResultOk { readonly value: RespValue | RespError; readonly consumed: number; } type ParseResult = ParseResultOk | null; function tryParse(buf: Buffer, offset: number): ParseResult { if (offset >= buf.length) return null; const tag = buf[offset]; if (tag === 0x2b /* '+' */) { const end = findCRLF(buf, offset + 1); if (end < 0) return null; return { value: buf.subarray(offset + 1, end).toString('utf8'), consumed: end + 2, }; } if (tag === 0x2d /* '-' */) { const end = findCRLF(buf, offset + 1); if (end < 0) return null; return { value: new RespError(buf.subarray(offset + 1, end).toString('utf8')), consumed: end + 2, }; } if (tag === 0x3a /* ':' */) { const end = findCRLF(buf, offset + 1); if (end < 0) return null; const n = Number(buf.subarray(offset + 1, end).toString('utf8')); return { value: n, consumed: end + 2 }; } if (tag === 0x24 /* '$' */) { const end = findCRLF(buf, offset + 1); if (end < 0) return null; const len = Number(buf.subarray(offset + 1, end).toString('utf8')); if (len === -1) return { value: null, consumed: end + 2 }; const start = end + 2; if (start + len + 2 > buf.length) return null; return { value: buf.subarray(start, start + len).toString('utf8'), consumed: start + len + 2, }; } if (tag === 0x2a /* '*' */) { const end = findCRLF(buf, offset + 1); if (end < 0) return null; const n = Number(buf.subarray(offset + 1, end).toString('utf8')); if (n === -1) return { value: null, consumed: end + 2 }; const arr: RespValue[] = []; let pos = end + 2; for (let i = 0; i < n; i++) { const sub = tryParse(buf, pos); if (sub === null) return null; // Errors nested inside arrays are valid RESP but never appear in // the replies EtherCalc's migration issues (KEYS/GET/LRANGE/ // HGETALL). Surface as a protocol error if it ever happens so // we don't silently corrupt room data. if (sub.value instanceof Error) throw sub.value; arr.push(sub.value); pos = sub.consumed; } return { value: arr, consumed: pos }; } // `tag` is always defined when we reach this line — the `offset >= // buf.length` guard above rules out a short buffer, and we only // fall through here when none of the five legal tag bytes matched. throw new RespError(`unsupported RESP type 0x${(tag as number).toString(16)}`); } /** Encode one command in the RESP array-of-bulks form. */ function encodeCommand(args: readonly (string | number)[]): string { const parts: string[] = [`*${args.length}\r\n`]; for (const a of args) { const s = typeof a === 'string' ? a : String(a); parts.push(`$${Buffer.byteLength(s, 'utf8')}\r\n`); parts.push(s); parts.push('\r\n'); } return parts.join(''); } function findCRLF(buf: Buffer, start: number): number { for (let i = start; i < buf.length - 1; i++) { if (buf[i] === 0x0d && buf[i + 1] === 0x0a) return i; } return -1; } function defaultConnect(host: string, port: number): Promise<RespSocket> { return new Promise((resolve, reject) => { const sock = createConnection({ host, port }); sock.once('connect', () => { sock.removeListener('error', reject); resolve(sock as unknown as RespSocket); }); sock.once('error', reject); }); }