Skip to content
Secure Mesh Docs
Esc
navigateopen⌘Jpreview
On this page

Signaling, Transport & Codec

Signaling, Transport & Codec

The realtime-core signaling stack is a dependency-free, browser-compatible gRPC-Web client split into three cooperating layers: a hand-rolled protobuf-style Codec (codec.ts), a gRPC-Web Transport over fetch (transport.ts), and a typed SignalClient that exposes chat RPCs and an auto-reconnecting event stream (signal-client.ts), with messages.ts bridging codec primitives and RPC payloads.

Purpose and Scope

This page documents the end-to-end signaling path of the realtime-core package: how binary protobuf-style messages are encoded/decoded, how they are framed and carried over gRPC-Web using the browser fetch API, and how the SignalClient turns those primitives into a usable real-time chat API (streaming events, sending messages/signals, history, read receipts).

The following related topics are intentionally left to sibling pages:

  • Engine lifecycle & event mappingengine.ts orchestrates SignalClient, and events.ts converts raw signal events into RealtimeEvent objects (evtToEvent, toBigInt). See the Engine page.
  • Media sessionsmedia/adapter.ts, media/manager.ts, media/session.ts handle audio/video capture and playback on top of the signaling events. See the Media page.
  • Shared typestypes.ts defines EngineConfig, MessageInput, SendSignalInput, HistoryInput, and RealtimeEvent, consumed by the signaling layer.

Note: the same realtime-core sources are mirrored under streaming-frontend/lib/realtime-core/ and streaming/src/lib/realtime-core/; the descriptions below apply to all copies.

Overview

The package implements a real-time chat client with a three-layer architecture that deliberately keeps concerns separated:

  1. Codec (codec.ts) — a minimal protobuf wire-format reader/writer. It knows nothing about chat; it only knows how to serialize integers, strings, and byte fields into LEB128 varints and length-delimited blobs. IDs are handled as bigint so 64-bit gRPC message IDs survive JavaScript’s Number precision limits.
  2. Messages (messages.ts) — the protocol layer. It maps each RPC request/response to concrete field numbers (SignalEvent, MessageStreamItem, GetHistoryResponse) and provides encode*/decode* functions built on Reader/Writer.
  3. Transport (transport.ts) — the gRPC-Web wire protocol. It frames requests with the standard 5-byte gRPC-Web prefix (1 flag byte + 4-byte big-endian length), POSTs them via fetch with application/grpc-web+proto content type, and parses response frames, including trailer frames that carry grpc-status/grpc-message.
  4. SignalClient (signal-client.ts) — the user-facing API. It wires the three layers above to a baseUrl and tokenProvider, exposes connect/disconnect/sendMessage/sendSignal/getHistory/markRead, and automatically reconnects the streaming endpoint after failures.

The transport deliberately uses HTTP(S) fetch (gRPC-Web) rather than WebSockets, so the client works from any modern browser without special headers, as confirmed by the comment in streaming-frontend/lib/realtime.ts that the gRPC-Web traffic is transported over HTTP(S) fetch, not WebSocket, and therefore must use https: when the page is served over HTTPS.

Architecture

Layer responsibilities:

  • SignalClient is the only entry point consumers touch. It owns the connection lifecycle and converts user inputs (MessageInput, SendSignalInput, HistoryInput) into encoded bytes, then hands results back as plain objects or RealtimeEvents.
  • messages.ts is a pure function layer — no state, no I/O. Every function takes/returns Uint8Array or plain interfaces, which makes it trivially testable and reusable outside the client (e.g., in a service worker).
  • transport.ts is also stateless except for per-call buffers. It implements exactly the gRPC-Web framing contract and nothing else, so it could be reused for any gRPC-Web service, not just chat.
  • codec.ts sits at the bottom: generic binary serialization with zero domain knowledge.

Why this separation? The layering mirrors the classic protocol-stack design: codec stability (wire format rarely changes), transport replaceability (swap fetch for XMLHttpRequest or WebSocket without touching message encoding), and client ergonomics (callers never see varints or frame flags).

Codec Layer — codec.ts

The codec implements a small, self-contained subset of the protobuf binary wire format. It is the foundation everything else builds on, and it has zero runtime dependencies.

UTF-8 helpers

export const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
export const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b);

Source: codec.ts

All string fields are length-delimited UTF-8 byte sequences. The native TextEncoder/TextDecoder pair is used instead of a string-length guess because UTF-8 is multibyte — s.length would be wrong for non-ASCII text.

Writer — serialization

export class Writer {
  out: number[] = [];

  varint(v: bigint): void {
    while (v >= 0x80n) { this.out.push(Number(v & 0x7fn) | 0x80); v >>= 7n; }
    this.out.push(Number(v));
  }

  tag(field: number, wire: number): void { this.varint(BigInt((field << 3) | wire)); }
  int64(field: number, v: bigint): void { if (v !== 0n) { this.tag(field, 0); this.varint(v); } }
  int32(field: number, v: number): void { if (v !== 0) { this.tag(field, 0); this.varint(BigInt(v)); } }
  bool(field: number, v: boolean): void { if (v) { this.tag(field, 0); this.varint(1n); } }
  string(field: number, s: string): void {
    if (!s) return;
    const b = utf8(s);
    this.tag(field, 2); this.varint(BigInt(b.length)); this.out.push(...b);
  }
  bytes(field: number, b: Uint8Array): void {
    this.tag(field, 2); this.varint(BigInt(b.length)); this.out.push(...b);
  }

  finish(): Uint8Array { return Uint8Array.from(this.out); }
}

Source: codec.ts

Key design points:

  • LEB128 varints: each byte carries 7 bits of payload; the high bit (0x80) marks continuation. The writer emits the least-significant group first, which matches the protobuf wire format exactly.
  • Tag encoding: tag(field, wire) shifts the field number left by 3 and ORs in the wire type (0 = varint, 2 = length-delimited). Decoders recover field/wire with tag >> 3n and tag & 7n.
  • Proto3 zero-elision: int64/int32/bool/string skip writing when the value is the default (0n, 0, false, ""). This is the standard proto3 optional-field optimization — omitted fields decode back to defaults, so no information is lost, and small messages (like MarkRead, which may be all zeros) collapse to nearly empty payloads.
  • Output strategy: bytes are pushed into a plain number[] and materialized once in finish() via Uint8Array.from. This trades a small intermediate array for simplicity; for the small messages in this protocol the cost is negligible.

Reader — deserialization

export class Reader {
  bytes: Uint8Array;
  pos = 0;

  constructor(bytes: Uint8Array) { this.bytes = bytes; }

  get done(): boolean { return this.pos >= this.bytes.length; }

  varint(): bigint {
    let result = 0n; let shift = 0n;
    while (true) {
      const b = this.bytes[this.pos++];
      result |= BigInt(b & 0x7f) << shift;
      if ((b & 0x80) === 0) break;
      shift += 7n;
    }
    return result;
  }

  bytesField(): Uint8Array {
    const len = Number(this.varint());
    const out = this.bytes.subarray(this.pos, this.pos + len);
    this.pos += len;
    return out;
  }

  string(): string { return fromUtf8(this.bytesField()); }

  skip(wire: number): void {
    switch (wire) {
      case 0: this.varint(); break;
      case 1: this.pos += 8; break;
      case 2: this.bytesField(); break;
      case 5: this.pos += 4; break;
      default: this.pos = this.bytes.length;
    }
  }
}

Source: codec.ts

Key design points:

  • Varint decode accumulates 7-bit groups into a bigint, shifting left by 7 per byte — the exact inverse of the writer. 64-bit fields are never truncated to Number, preserving precision for snowflake-style IDs.
  • bytesField() returns a subarray (a view, not a copy) of the underlying buffer for the declared length, avoiding an allocation on decode; the pos cursor advances past it.
  • skip(wire) implements unknown-field tolerance: wire types 0/1/2/5 are skipped by advancing the cursor, and anything else is treated as a corrupt/unsupported frame by jumping to the end. This is what lets a client compiled against an older message shape safely parse a server that added new fields.

Protocol Layer — messages.ts

messages.ts defines the concrete wire schemas for the chat service. Every message is a protobuf-style struct with explicit field numbers.

Streaming event schema

export interface SignalEvent { event_type: string; from_id: bigint; group_id: bigint; payload: string; created_at: bigint; }

export function decodeSignalEvent(b: Uint8Array): SignalEvent {
  const r = new Reader(b);
  const e: SignalEvent = { event_type: '', from_id: 0n, group_id: 0n, payload: '', created_at: 0n };
  while (!r.done) {
    const tag = r.varint();
    const field = Number(tag >> 3n);
    const wire = Number(tag & 7n);
    switch (field) {
      case 1: e.event_type = r.string(); break;
      case 2: e.from_id = r.varint(); break;
      case 3: e.group_id = r.varint(); break;
      case 4: e.payload = r.string(); break;
      case 5: e.created_at = r.varint(); break;
      default: r.skip(wire);
    }
  }
  return e;
}

Source: messages.ts

Field map for SignalEvent:

Field # Wire type Name Type
1 2 (len) event_type string
2 0 (varint) from_id bigint (uint64)
3 0 (varint) group_id bigint (uint64)
4 2 (len) payload string (JSON-encoded signal body)
5 0 (varint) created_at bigint (uint64 timestamp)

The generic decode loop (read tag → switch on field → skip unknown) is the same pattern used by every decoder in the file, giving uniform forward-compatibility behavior.

History response with nested messages

export interface MessageStreamItem { id: bigint; sender_id: bigint; receiver_id: bigint; group_id: bigint; payload: string; file_url: string; file_type: string; created_at: bigint; }
export interface GetHistoryResponse { messages: MessageStreamItem[]; next_cursor_id: bigint; }

export function decodeGetHistoryResponse(b: Uint8Array): GetHistoryResponse {
  const r = new Reader(b);
  const res: GetHistoryResponse = { messages: [], next_cursor_id: 0n };
  while (!r.done) {
    const tag = r.varint();
    const field = Number(tag >> 3n);
    const wire = Number(tag & 7n);
    if (field === 1 && wire === 2) {
      res.messages.push(decodeMessageStreamItem(r.bytesField()));
    } else if (field === 2) {
      res.next_cursor_id = r.varint();
    } else {
      r.skip(wire);
    }
  }
  return res;
}

Source: messages.ts

GetHistoryResponse demonstrates the nested-message pattern: field 1 is a repeated length-delimited field, so each occurrence is a sub-message decoded by decodeMessageStreamItem from a bytesField() slice. The cursor-based pagination (next_cursor_id) is passed back to the caller as a string cursor by SignalClient.getHistory (see below).

Request encoders

export function encodeSendMessage(receiverId: bigint, groupId: bigint, payload: string): Uint8Array {
  const w = new Writer();
  w.int64(1, receiverId); w.int64(2, groupId); w.string(3, payload);
  return w.finish();
}

export function encodeGetHistory(peerId: bigint, groupId: bigint, cursorId: bigint, limit: number): Uint8Array {
  const w = new Writer();
  w.int64(1, peerId); w.int64(2, groupId); w.int64(3, cursorId); w.int32(4, limit);
  return w.finish();
}

export function encodeSendSignal(targetId: bigint, groupId: bigint, signalType: string, payload: string): Uint8Array {
  const w = new Writer();
  w.int64(1, targetId); w.int64(2, groupId); w.string(3, signalType); w.string(4, payload);
  return w.finish();
}

export function encodeMarkRead(peerId: bigint, groupId: bigint): Uint8Array {
  const w = new Writer();
  w.int64(1, peerId); w.int64(2, groupId);
  return w.finish();
}

Source: messages.ts

All encoders follow the same shape: build a Writer, emit fields in ascending field-number order (required by the wire format for deterministic output), return finish(). Optional semantics are inherited from the codec’s zero-elision — e.g., encodeMarkRead(0n, 0n) produces an empty byte array, which is exactly the protobuf encoding of an all-default message.

Transport Layer — transport.ts

The transport implements the gRPC-Web wire protocol on top of the browser fetch API. It is what makes the whole stack work in browsers without WebSocket or gRPC-native support.

gRPC-Web framing

Every gRPC-Web message on the wire is prefixed with a 5-byte frame header: 1 flag byte followed by a 4-byte big-endian length.

function frame(data: Uint8Array): Uint8Array<ArrayBuffer> {
  const out = new Uint8Array(5 + data.length);
  new DataView(out.buffer).setUint32(1, data.length, false);
  out.set(data, 5);
  return out;
}

Source: transport.ts

The flag byte (left as 0) marks a data frame; 0x80 marks a trailer frame. The length is written big-endian (false argument to setUint32), matching the gRPC-Web spec. Requests are framed with this helper; the response parser does the inverse.

Trailer parsing and status checking

function parseTrailers(bytes: Uint8Array): Record<string, string> {
  const text = new TextDecoder().decode(bytes);
  const result: Record<string, string> = {};
  for (const line of text.split(/\r?\n/)) {
    const idx = line.indexOf(':');
    if (idx > 0) result[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
  }
  return result;
}

function checkStatus(trailers: Record<string, string>): void {
  const status = trailers['grpc-status'];
  if (status && status !== '0') {
    const message = (trailers['grpc-message'] ?? '').replace(/%20/g, ' ');
    throw new Error(`gRPC error ${status}: ${message}`);
  }
}

Source: transport.ts

Trailer frames carry HTTP-header-style key/value lines; parseTrailers lowercases keys so grpc-status is found regardless of server casing. checkStatus throws a descriptive Error when the server reports a non-zero gRPC status code, unescaping %20 from grpc-message. This thrown error propagates to the caller of unary/serverStream and is what SignalClient.connect reports through onError.

Unary RPC

export async function unary(
  baseUrl: string,
  path: string,
  body: Uint8Array,
  headers: Headers,
): Promise<Uint8Array> {
  const res = await fetch(`${baseUrl}${path}`, {
    method: 'POST',
    headers: { 'content-type': 'application/grpc-web+proto', 'x-grpc-web': '1', 'accept': 'application/grpc-web+proto', ...headers },
    body: frame(body),
  });
  const buf = new Uint8Array(await res.arrayBuffer());
  let data = new Uint8Array(0);
  let pos = 0;
  while (pos + 5 <= buf.length) {
    const flag = buf[pos];
    const len = new DataView(buf.buffer, buf.byteOffset + pos, 5).getUint32(1, false);
    if (pos + 5 + len > buf.length) break;
    const payload = buf.subarray(pos + 5, pos + 5 + len);
    if (flag === 0x00) data = payload;
    if (flag === 0x80) checkStatus(parseTrailers(payload));
    pos += 5 + len;
  }
  return data;
}

Source: transport.ts

Design notes:

  • The full gRPC-Web request contract is set inline: content-type: application/grpc-web+proto, the x-grpc-web: 1 version header, and accept: application/grpc-web+proto. Caller-supplied headers (e.g., authorization) are spread after these, so authentication headers merge in without overriding the wire contract (the spread order means a caller could override them, but SignalClient only passes auth).
  • The response is a single frame or a small sequence: the loop walks 5-byte headers, collects the data frame (flag === 0x00), and validates trailers. A trailing data frame overwrites earlier ones, which matches unary-response semantics where there is exactly one data message.
  • The function returns the raw payload Uint8Array; decoding into typed objects is left to the caller (e.g., decodeGetHistoryResponse).

Server streaming RPC

export async function serverStream(
  baseUrl: string,
  path: string,
  body: Uint8Array,
  headers: Headers,
  onMessage: (data: Uint8Array) => void,
  signal: AbortSignal,
): Promise<void> {
  const res = await fetch(`${baseUrl}${path}`, {
    method: 'POST',
    headers: { 'content-type': 'application/grpc-web+proto', 'x-grpc-web': '1', 'accept': 'application/grpc-web+proto', ...headers },
    body: frame(body),
    signal,
  });
  const reader = res.body!.getReader();
  let buffer = new Uint8Array(0);

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    if (value) buffer = concat(buffer, value);

    while (buffer.length >= 5) {
      const flag = buffer[0];
      const len = new DataView(buffer.buffer, buffer.byteOffset, 5).getUint32(1, false);
      if (buffer.length < 5 + len) break;
      const payload = buffer.subarray(5, 5 + len);
      buffer = buffer.subarray(5 + len);
      if (flag === 0x00) onMessage(payload);
      if (flag === 0x80) checkStatus(parseTrailers(payload));
    }
  }
}

Source: transport.ts

Design notes:

  • Streaming over fetch works because res.body.getReader() exposes the response body as an async byte stream. The signal: AbortSignal parameter is how SignalClient.disconnect() tears down the stream.
  • Frame reassembly across chunks: network reads do not align to frame boundaries, so a persistent buffer accumulates bytes; the inner while loop extracts every complete frame currently buffered (5 + len bytes), delivers data frames via onMessage, and validates trailer frames with checkStatus. Partial frames simply wait for the next reader.read().
  • concat allocates a fresh Uint8Array per chunk — acceptable for a signaling stream where chunks are small and infrequent compared to, say, media data.
  • Trailer errors throw inside the loop and propagate out of serverStream, which is exactly what the reconnect logic in SignalClient relies on.

Signaling Client — signal-client.ts

SignalClient is the public face of the stack. It binds the protocol and transport layers to a concrete service (/chat.ChatService/*) and manages connection state.

Lifecycle: connect, reconnect, disconnect

async connect(): Promise<void> {
  if (this.stopped) return;
  this.abort = new AbortController();
  try {
    await serverStream(
      this.config.baseUrl,
      '/chat.ChatService/StreamMessages',
      new Uint8Array(0),
      await this.headers(),
      (data) => this.onEvent(evtToEvent(decodeSignalEvent(data))),
      this.abort.signal,
    );
  } catch (e) {
    if (!this.stopped) this.config.onError?.(e instanceof Error ? e : new Error(String(e)));
  }
  if (!this.stopped) {
    setTimeout(() => void this.connect(), this.config.reconnectDelayMs ?? 3000);
  }
}

disconnect(): void {
  this.stopped = true;
  this.abort?.abort();
}

Source: signal-client.ts

The connect loop is deliberately simple and robust:

  1. Guard against reconnection after an explicit disconnect() (this.stopped).
  2. Create a fresh AbortController so the previous attempt’s signal never leaks into the new stream.
  3. Open the server stream with an empty body (new Uint8Array(0)StreamMessages takes no request message).
  4. Every data frame is decoded by decodeSignalEvent then mapped through evtToEvent into a RealtimeEvent before being delivered to the engine’s callback — the raw wire shape never escapes the client.
  5. Any error (network drop, gRPC status from trailers, abort) is reported to config.onError and, unless stopped, triggers a reconnect after reconnectDelayMs (default 3000 ms). Because connect() recurses via setTimeout, the reconnect cadence is self-perpetuating until disconnect().

This “reconnect-until-stopped” pattern means the engine can rely on the stream being eventually consistent without owning retry state itself.

Authentication

private async headers(): Promise<Record<string, string>> {
  const token = await this.config.tokenProvider?.();
  return token ? { authorization: `Bearer ${token}` } : {};
}

Source: signal-client.ts

Auth is injected via the tokenProvider callback configured on EngineConfig. It is evaluated per request (each unary call and each reconnect), so short-lived tokens are refreshed automatically on reconnect without extra plumbing. If no provider or token exists, requests go out without an authorization header.

RPC methods

Method gRPC endpoint Encoder Decoder
sendMessage /chat.ChatService/SendMessage encodeSendMessage
sendSignal /chat.ChatService/SendSignal encodeSendSignal
getHistory /chat.ChatService/GetHistory encodeGetHistory decodeGetHistoryResponse
markRead /chat.ChatService/MarkRead encodeMarkRead
async sendMessage(input: MessageInput): Promise<void> {
  await unary(
    this.config.baseUrl,
    '/chat.ChatService/SendMessage',
    encodeSendMessage(toBigInt(input.receiverId), toBigInt(input.groupId), input.payload),
    await this.headers(),
  );
}

async getHistory(input: HistoryInput): Promise<{ items: MessageStreamItem[]; nextCursor: string }> {
  const buf = await unary(
    this.config.baseUrl,
    '/chat.ChatService/GetHistory',
    encodeGetHistory(toBigInt(input.peerId), toBigInt(input.groupId), toBigInt(input.cursorId), input.limit ?? 30),
    await this.headers(),
  );
  const res: GetHistoryResponse = decodeGetHistoryResponse(buf);
  return { items: res.messages, nextCursor: res.next_cursor_id.toString() };
}

Source: signal-client.ts

Notable behaviors:

  • BigInt conversion: inputs accept string | number IDs (toBigInt in events.ts), because JSON can’t carry 64-bit integers losslessly; internally everything is bigint.
  • getHistory pagination: the default page size is 30 (input.limit ?? 30), and the server’s next_cursor_id bigint is surfaced as a string cursor so callers can pass it straight back into the next cursorId without precision loss.
  • sendSignal carries signalType and a JSON payload (default '{}'), which the media layer uses to exchange WebRTC-style offers/answers/candidates — see the Media page.

Core Flow

Streaming event delivery (connect → event)

Step-by-step: the engine constructs the client with an event callback, connect() opens a POST fetch stream to the StreamMessages endpoint, and every server-pushed frame travels through decodeSignalEvent → evtToEvent → onEvent. When the stream ends or errors, the client reports via onError and schedules a reconnect — the loop continues until disconnect() sets stopped and aborts the in-flight stream.

Unary request flow (sendMessage)

Unary calls are fire-and-forget from the caller’s perspective: sendMessage/sendSignal/markRead resolve once the server’s trailer confirms grpc-status: 0; getHistory additionally decodes the response body before resolving.

Usage Examples

Establishing the signaling stream

const client = new SignalClient(
  {
    baseUrl: 'https://api.example.com',
    tokenProvider: async () => authToken,
    onError: (e) => console.error('signal stream error', e),
    reconnectDelayMs: 5000,
  },
  (event) => handleRealtimeEvent(event),
);

await client.connect();
// ... later
client.disconnect();

Source: signal-client.ts

Sending a message and fetching history

// Fire-and-forget delivery
await client.sendMessage({ receiverId: '987654321', groupId: 'group-42', payload: '{"text":"hello"}' });

// Cursor-paginated history (default page size 30)
const page1 = await client.getHistory({ peerId: '987654321', groupId: 'group-42', limit: 30 });
const page2 = await client.getHistory({ peerId: '987654321', groupId: 'group-42', cursorId: page1.nextCursor });

Source: signal-client.ts

Raw codec round-trip

// Encoding a message the same way encodeSendMessage does
const w = new Writer();
w.int64(1, 12345678901234567890n);
w.int64(2, 42n);
w.string(3, '{"type":"offer"}');
const bytes = w.finish();

// Decoding with the Reader primitives
const r = new Reader(bytes);
const tag = r.varint();
const field = Number(tag >> 3n); // 1
const wire = Number(tag & 7n);   // 0
const id = r.varint();           // 12345678901234567890n

Source: codec.ts and codec.ts

Invoking a transport call directly

// A unary call without the SignalClient abstraction:
const resp = await unary(
  'https://api.example.com',
  '/chat.ChatService/GetHistory',
  encodeGetHistory(1n, 2n, 0n, 30),
  { authorization: 'Bearer token' },
);
const history = decodeGetHistoryResponse(resp);

Source: transport.ts and messages.ts

Configuration Options

The signaling stack is configured entirely through EngineConfig, consumed by SignalClient and provided by the engine. The keys actually read by this layer are:

Option Type Default Description
baseUrl string required Server origin prepended to gRPC-Web paths (e.g., https://api.example.com).
tokenProvider () => Promise<string | undefined> undefined Async callback returning a bearer token; injected per request/reconnect as authorization: Bearer <token>.
onError (e: Error) => void undefined Called when the streaming connection fails (network, gRPC status, abort). Reconnect still proceeds.
reconnectDelayMs number 3000 Delay between stream termination and the automatic reconnect attempt.
Transport constant Value Purpose
content-type application/grpc-web+proto gRPC-Web protobuf content type required by the server.
x-grpc-web 1 gRPC-Web version header.
accept application/grpc-web+proto Response content type negotiation.
Default history limit 30 Page size used by getHistory when input.limit is omitted.

API Reference

Writer (codec.ts)

Method Signature Description
varint varint(v: bigint): void Appends a LEB128 varint (no tag).
tag tag(field: number, wire: number): void Appends a field tag (field << 3) | wire.
int64 int64(field: number, v: bigint): void Writes an int64/uint64 field; elided when v === 0n.
int32 int32(field: number, v: number): void Writes an int32 field; elided when v === 0.
bool bool(field: number, v: boolean): void Writes 1n when v is true.
string string(field: number, s: string): void Writes a length-delimited UTF-8 field; elided when s is empty.
bytes bytes(field: number, b: Uint8Array): void Writes a raw length-delimited byte field.
finish finish(): Uint8Array Materializes the accumulated bytes.

Reader (codec.ts)

Member Signature Description
done get done(): boolean True when the cursor reached the end of the buffer.
varint varint(): bigint Decodes the next LEB128 varint (returns bigint).
bytesField bytesField(): Uint8Array Decodes a length-delimited field as a subarray view.
string string(): string Decodes a length-delimited field as UTF-8 text.
skip skip(wire: number): void Advances past an unknown field of the given wire type.

Transport functions (transport.ts)

Function Signature Behavior
unary (baseUrl, path, body, headers) => Promise<Uint8Array> POSTs a framed request; returns the response data frame. Throws Error("gRPC error <status>: <message>") on non-zero grpc-status.
serverStream (baseUrl, path, body, headers, onMessage, signal) => Promise<void> POSTs a framed request and delivers each data frame to onMessage until the stream ends. Throws on trailer errors; signal aborts the fetch.

SignalClient (signal-client.ts)

Method Signature Behavior
connect connect(): Promise<void> Opens /chat.ChatService/StreamMessages, delivers events via constructor callback, schedules reconnect on failure.
disconnect disconnect(): void Sets stopped and aborts the active stream; prevents further reconnects.
sendMessage sendMessage(input: MessageInput): Promise<void> Unary POST to /chat.ChatService/SendMessage.
sendSignal sendSignal(input: SendSignalInput): Promise<void> Unary POST to /chat.ChatService/SendSignal with signalType + JSON payload (default '{}').
getHistory getHistory(input: HistoryInput): Promise<{ items: MessageStreamItem[]; nextCursor: string }> Unary POST to /chat.ChatService/GetHistory; returns decoded items and a string cursor.
markRead markRead(input: { peerId?; groupId? }): Promise<void> Unary POST to /chat.ChatService/MarkRead.

Failure Modes, Edge Cases & Concurrency

  • Non-zero gRPC status: any trailer with grpc-status !== '0' throws gRPC error <status>: <message> from both unary and serverStream (transport.ts). In the streaming path this surfaces to onError and triggers reconnect; in the unary path it rejects the caller’s promise.
  • Mid-stream disconnects: serverStream reads until done; a server that closes the connection simply resolves. SignalClient.connect treats any completion (error or clean close) as a trigger to reconnect, which is the desired behavior for a long-lived signal feed.
  • Explicit disconnect races: disconnect() sets stopped before aborting, so a concurrently scheduled setTimeout reconnect is a no-op (if (this.stopped) return). This prevents zombie reconnection after teardown.
  • Partial frames on the wire: serverStream accumulates a buffer and only dispatches frames with a complete 5 + len prefix; truncated trailers and split data frames are reassembled transparently.
  • Unknown fields: decoders call r.skip(wire) for unrecognized field numbers, so servers adding fields stay compatible with older clients; a corrupt wire type forces the reader to end (defensive termination).
  • 64-bit precision: all IDs and timestamps are bigint end-to-end; toBigInt converts string | number inputs, and getHistory returns the cursor as a string to avoid Number rounding on 64-bit cursors.
  • Auth failures: a missing/expired token results in a request without (or with a stale) authorization header; the server’s gRPC error surfaces as above, and streaming reconnects re-evaluate tokenProvider each attempt, naturally recovering from token refresh.
  • No in-flight concurrency control: each unary call is independent; if the engine issues concurrent sendMessage calls, they are multiplexed by the browser’s fetch pool. Ordering guarantees, if any, are a server-side concern.

Performance & Operational Considerations

  • Small payloads, bounded allocations: signaling messages are tiny (KBs at most). The codec buffers in a number[] and materializes once; the transport allocates one frame buffer per request and one concat per stream chunk.
  • Reconnect backoff is fixed: reconnectDelayMs (default 3000 ms) is a constant delay, not exponential. For high fan-out deployments, consider raising it to avoid thundering-herd reconnects after a server restart.
  • Abort propagation: each connect() allocates a fresh AbortController, and disconnect() aborts the current fetch, so no orphaned streams leak after teardown.
  • No media over this transport: media bytes are never framed here — this layer carries only signaling events and control messages; audio/video flows through WebRTC via the media layer (see Media page).
  • Multi-copy maintenance: the identical implementation exists under realtime-core/src/, streaming-frontend/lib/realtime-core/, and streaming/src/lib/realtime-core/; changes must be applied to all three copies.

Extension Points

  • New RPCs: add an encode*/decode* pair in messages.ts (choose unused field numbers, keep skip for unknowns), then add a method on SignalClient calling unary with the new path — no transport or codec changes needed.
  • New event types: extend SignalEvent.event_type handling in events.ts (evtToEvent) — the wire schema already supports arbitrary string event types with a JSON payload.
  • Alternative transports: unary/serverStream are the only places that touch fetch; a WebSocket or XHR transport could be swapped in while reusing messages.ts and codec.ts unchanged.
  • Authentication schemes: tokenProvider is the single auth seam; implement any flow (OAuth, API key, short-lived JWT) as long as it returns a bearer token string.
  • Custom service paths: baseUrl and the path strings passed to unary/serverStream are plain strings — the client can target any gRPC-Web gateway exposing the same chat.ChatService contract.
  • Engine — orchestrates SignalClient, owns EngineConfig, delivers RealtimeEvents to consumers.
  • EventsevtToEvent and toBigInt mappings between wire events and domain events.
  • Media — WebRTC/media sessions driven by SendSignal/SignalEvent payloads exchanged through this signaling layer.
  • TypesEngineConfig, MessageInput, SendSignalInput, HistoryInput, RealtimeEvent definitions.
  • Index — package entry point exporting the public API surface.

Was this page helpful?