ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
145 lines (129 loc) • 5.01 kB
text/typescript
/**
* socket.io v0.9 wire-frame codec.
*
* Protocol reference: https://github.com/socketio/socket.io-protocol/tree/v1
* (v0.9 uses what the spec calls "protocol 1" — the colon-delimited text
* format, *not* the later Engine.IO packet encoding.)
*
* Frame shape: `<type>:<id>:<endpoint>:<data>`
*
* Fields:
* - type: 0-8 single digit (see PacketType below)
* - id: message id for ack tracking; optional; can end with `+` for
* "ack auto-reply on server receive" (we accept but discard)
* - endpoint: namespace path, typically empty or `/namespace`
* - data: opaque remainder. For type 5 (event) this is a JSON blob
* `{"name":"…","args":[…]}`.
*
* Only `data` may contain colons — we split exactly three times from the left
* so `data` keeps its colons intact. This matches the reference socket.io
* 0.9 parser behavior (`lib/parser.js:110`).
*/
/** socket.io v0.9 packet type codes. */
export const PacketType = {
Disconnect: 0,
Connect: 1,
Heartbeat: 2,
Message: 3,
Json: 4,
Event: 5,
Ack: 6,
Error: 7,
Noop: 8,
} as const;
export type PacketTypeCode = (typeof PacketType)[keyof typeof PacketType];
/**
* A decoded packet. `id`/`endpoint`/`data` are all optional; callers decide
* which combinations make sense per type. We keep `data` as the raw string
* rather than parsing the JSON eagerly: translate.ts owns the JSON layer.
*/
export interface Packet {
type: PacketTypeCode;
id?: number;
endpoint?: string;
data?: string;
}
const VALID_TYPES: readonly number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8];
/**
* Encode a Packet back to the colon-delimited wire form.
*
* Omitted id/endpoint/data are serialized as empty segments — the reference
* socket.io implementation is sensitive to the exact colon count. We always
* emit all three separator colons to keep the framing unambiguous, then
* trim trailing ones only when strictly safe (no data and no endpoint).
*/
export function encodeFrame(packet: Packet): string {
const typePart = String(packet.type);
const idPart = packet.id === undefined ? '' : String(packet.id);
const endpointPart = packet.endpoint ?? '';
const dataPart = packet.data ?? '';
// Always emit at least `<type>:<id>:<endpoint>`. Append `:<data>` only if
// data is present — trailing empty segment is legal but noisy, and the
// reference parser is happy with either form.
if (dataPart === '') {
return `${typePart}:${idPart}:${endpointPart}`;
}
return `${typePart}:${idPart}:${endpointPart}:${dataPart}`;
}
/**
* Decode a raw wire string into a Packet, or return null for anything
* unparseable.
*
* Accepts:
* - `N` (just the type)
* - `N:` / `N::` / `N:::` (trailing empty segments)
* - `N:<id>:<endpoint>:<data>` (full form)
*
* Rejects:
* - Empty input
* - Non-digit first character
* - Type code outside 0-8
* - Non-numeric id segment (e.g. `5:abc:/:...`)
*/
export function decodeFrame(raw: string): Packet | null {
if (typeof raw !== 'string' || raw.length === 0) return null;
// Split at most 3 times from the left so `data` retains any embedded colons.
// JS's String.split with a limit *truncates* — it doesn't pack the tail
// into the last chunk — so we roll our own.
const parts: string[] = [];
let start = 0;
for (let i = 0; i < 3 && start <= raw.length; i++) {
const colon = raw.indexOf(':', start);
if (colon === -1) {
parts.push(raw.slice(start));
start = raw.length + 1;
break;
}
parts.push(raw.slice(start, colon));
start = colon + 1;
}
if (start <= raw.length) parts.push(raw.slice(start));
// `parts[0]` is always a string — the splitter always pushes at least
// one slice (possibly `''`). Cast away the `| undefined` that
// noUncheckedIndexedAccess adds; removing the cast would require a
// defensive `??` whose fallback branch is structurally unreachable.
const typeStr = parts[0] as string;
// Empty string (input starts with a colon) or a multi-digit / non-digit
// type are all rejected. Engine.IO's later "42…" format falls here too.
if (typeStr.length !== 1 || typeStr < '0' || typeStr > '9') return null;
const typeNum = Number(typeStr);
if (!VALID_TYPES.includes(typeNum)) return null;
const packet: Packet = { type: typeNum as PacketTypeCode };
const idRaw = parts[1];
if (idRaw !== undefined && idRaw !== '') {
// The `+` suffix means "auto-ack on server receive" in v0.9. We don't
// generate acks, so strip and keep only the numeric portion.
const trimmed = idRaw.endsWith('+') ? idRaw.slice(0, -1) : idRaw;
if (trimmed === '' || !/^\d+$/.test(trimmed)) return null;
packet.id = Number(trimmed);
}
const endpointRaw = parts[2];
if (endpointRaw !== undefined && endpointRaw !== '') {
packet.endpoint = endpointRaw;
}
const dataRaw = parts[3];
if (dataRaw !== undefined && dataRaw !== '') {
packet.data = dataRaw;
}
return packet;
}