All files / src framing.ts

100% Statements 44/44
100% Branches 43/43
100% Functions 2/2
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145                                              3x                                                   3x                     96x 96x 96x 96x         96x 54x   42x                                     51x         49x 49x 49x 140x 140x 13x 13x 13x   127x 127x   49x           49x     49x   45x 45x   44x   44x 44x     5x 5x 3x     42x 42x 3x     42x 42x 34x     42x    
/**
 * 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;
}