ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
272 lines (253 loc) • 9.48 kB
text/typescript
/**
* 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);
});
}