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

Realtime Client & Cryptography

Realtime Client & Cryptography

The browser-side realtime messaging stack of the realtime-core package: a dependency-free gRPC-Web transport, a hand-rolled protobuf wire-format codec (the binary “cryptography”/encoding layer), a streaming SignalClient with automatic reconnection, and the RealtimeEngine facade that ties chat messaging, history, and media sessions together.

Purpose and Scope

This page documents the Realtime Client & Cryptography capability of the web frontend, implemented by the realtime-core package. It covers:

  • Transport layer — the gRPC-Web wire protocol client built directly on fetch (realtime-core/src/transport.ts), including message framing, trailer parsing, unary calls, and server streaming.
  • Codec layer — the zero-dependency protobuf wire-format encoder/decoder (realtime-core/src/codec.ts) that serializes and deserializes every message exchanged with the chat backend. This is the “cryptography” surface of the page title: the binary codec that protects message integrity on the wire by deterministically encoding structured data into compact binary frames.
  • Signal clientSignalClient (realtime-core/src/signal-client.ts), the persistent realtime connection that streams chat events, sends messages and signaling payloads, fetches history, and marks conversations as read.
  • Public API — the package entry point (realtime-core/src/index.ts) and the RealtimeEngine factory.

The following related topics are intentionally left to sibling pages:

  • Media subsystemrealtime-core/src/media/adapter.ts, manager.ts, and session.ts (call media handling) are referenced here only as engine dependencies.
  • Server-side chat service — the chat.ChatService gRPC endpoints are consumed but not implemented by this package.
  • UI prototypes — static HTML mockups of secure-mesh chat/call screens are not part of the runtime client.

Overview

realtime-core is a TypeScript client library designed to run in the browser against a gRPC-Web backend. It deliberately avoids generated protobuf stubs: message serialization is done by a small, hand-written codec, and all remote calls go through a thin gRPC-Web client implemented on the standard fetch API.

The library solves four problems for a realtime chat/calling frontend:

  1. Binary serialization — every outgoing request and incoming event is encoded/decoded with a minimal protobuf wire-format Writer/Reader pair, producing compact messages without pulling in heavy codegen dependencies.
  2. Persistent event streaming — the SignalClient.connect() method opens a server-streaming call to /chat.ChatService/StreamMessages and translates each decoded frame into a typed RealtimeEvent delivered to the application through a single onEvent callback.
  3. Resilience — if the stream drops, the client reconnects automatically after a configurable delay (reconnectDelayMs, default 3000 ms) and reports failures through config.onError; an explicit disconnect() permanently stops reconnection.
  4. Authentication — every request attaches a Bearer token obtained lazily from a tokenProvider callback, so tokens can be refreshed without recreating the client.

The architecture follows a strict layering: engine facade → signal client → transport → gRPC-Web endpoint, with the codec shared by the message encode/decode helpers in messages.ts.

Architecture

Component roles:

  • RealtimeEngine (engine.ts, exported from index.ts) — the facade that owns a SignalClient plus the media subsystem and exposes a single configuration object (EngineConfig) to the host application.
  • SignalClient — the realtime workhorse. It manages the streaming connection lifecycle (connect, reconnect, disconnect), authentication headers, and the five unary/streaming operations against chat.ChatService.
  • messages.ts — typed encode/decode functions (encodeSendMessage, encodeSendSignal, encodeGetHistory, encodeMarkRead, decodeSignalEvent, decodeGetHistoryResponse) that translate TypeScript domain objects to and from protobuf bytes via the codec.
  • codec.ts — the low-level Writer and Reader implementing protobuf varint encoding, length-delimited fields, and unknown-field skipping.
  • transport.ts — the gRPC-Web client: 5-byte framing (1 flag byte + 4-byte big-endian length), trailer parsing, grpc-status validation, and both unary and server-streaming fetch-based transports.

Source: realtime-core/src/index.ts, realtime-core/src/signal-client.ts, realtime-core/src/transport.ts

Transport Layer: gRPC-Web over fetch

The transport module (realtime-core/src/transport.ts) is a complete gRPC-Web client implemented without any third-party dependency. It speaks the two wire shapes the gRPC-Web protocol defines:

  • Unary calls (/service/Method) — one request frame in, one data frame plus a trailer frame out.
  • Server streaming — one request frame in, many data frames followed by a trailer frame out.

Framing

Every message on the wire is prefixed with a 5-byte frame header: a single flag byte followed by a 4-byte big-endian unsigned length. The flag byte distinguishes data (0x00) from trailers (0x80).

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: realtime-core/src/transport.ts

Note the false argument to setUint32 — the length is written big-endian as required by the gRPC-Web spec, so DataView byte order must be explicitly overridden from the platform default (little-endian on virtually all browsers).

Unary calls

unary() POSTs a framed request body to ${baseUrl}${path} with the gRPC-Web content types and the x-grpc-web: 1 header, then walks the response frames: flag 0x00 payloads accumulate into the returned Uint8Array, and flag 0x80 payloads are parsed as trailers and checked for a non-zero grpc-status.

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: realtime-core/src/transport.ts

The unary parser is defensive: it stops scanning when a frame’s declared length would overrun the buffer (if (pos + 5 + len > buf.length) break;), so truncated responses never cause an out-of-bounds read.

Server streaming

serverStream() is the heart of the realtime client. It reads the response body as a ReadableStream, concatenates chunks into an internal buffer, and emits complete frames as they arrive — handling the case where a frame spans multiple network chunks and where multiple frames arrive in a single chunk.

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: realtime-core/src/transport.ts

Design intent: the double-loop structure (outer reader.read() loop, inner frame-extraction loop) means the stream handler never blocks waiting for the network; complete frames are dispatched to onMessage the moment enough bytes are buffered. The AbortSignal is threaded through to fetch so SignalClient.disconnect() can terminate an in-flight stream.

Trailers and status checking

parseTrailers() decodes the trailer frame text into a header map (lowercased keys, trimmed values, split on :), and checkStatus() turns a non-zero grpc-status into a thrown Error with the server-provided grpc-message (percent-decoding %20 spaces):

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: realtime-core/src/transport.ts

Because serverStream processes trailers inside the inner loop, a server-side error mid-stream surfaces as an exception that propagates out of the streaming function — which SignalClient.connect() catches and routes through onError.

Codec Layer: Protobuf Wire Format Without Dependencies

The codec (realtime-core/src/codec.ts) is the “cryptography” core of the page title in the strict sense used by this repository: it is the deterministic binary encoding layer that turns structured chat messages into compact, unambiguous byte sequences for transmission. It implements the protobuf wire format by hand — varints, tags, and length-delimited fields — with no runtime dependencies beyond TextEncoder/TextDecoder.

The Writer

Writer accumulates bytes into a plain number[] and flushes to a Uint8Array via finish(). Each field-writing method follows the protobuf convention of omitting default/zero values (field presence is implicit on the wire), which keeps payloads small.

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: realtime-core/src/codec.ts

Key design decisions:

  • bigint for varints — protobuf int64 values exceed JavaScript’s safe integer range, so varint() operates on bigint and message IDs (toBigInt(...) in signal-client.ts) are converted at the call site. Wire tags are computed in bigint too ((field << 3) | wire) and shifted in 7-bit groups.
  • Wire typestag(field, 0) emits varint fields (int32/int64/bool), tag(field, 2) emits length-delimited fields (string/bytes), matching the protobuf wire-type table.
  • Omission of defaultsif (v !== 0n), if (v !== 0), if (v), if (!s) return all skip emitting zero-valued fields, a deliberate size optimization that assumes receivers treat missing fields as default values.

The Reader

Reader wraps a Uint8Array with a cursor and decodes the same wire format, including a skip() that advances past unknown fields by their wire type so forward-compatible messages (new fields added by the server) can still be parsed.

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: realtime-core/src/codec.ts

Design intent: Reader uses subarray (zero-copy views) rather than copying bytes, keeping decode hot paths allocation-light. The skip() switch covers all protobuf wire types — varint (0), 64-bit (1), length-delimited (2), 32-bit (5) — and treats any unknown wire type as a fatal position advance to the end of the buffer, which is the standard “stop decoding” fallback for unrecognized data.

UTF-8 helpers

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

Source: realtime-core/src/codec.ts

Strings are always UTF-8 encoded on the wire via the platform TextEncoder/TextDecoder, which every modern browser and Bun runtime provides.

SignalClient: The Realtime Connection

SignalClient (realtime-core/src/signal-client.ts) is the component that turns the low-level transport into a usable realtime chat client. It is constructed with an EngineConfig (shared with the engine) and an onEvent callback, and it owns the streaming connection lifecycle.

Lifecycle: connect → stream → reconnect

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: realtime-core/src/signal-client.ts

The lifecycle is a deliberate loop rather than a one-shot call:

  1. connect() guards on this.stopped — after disconnect() no further reconnection can occur.
  2. A fresh AbortController is created per attempt so disconnect() can abort the current fetch while the flag stopped prevents any future attempt.
  3. The serverStream call only resolves when the stream ends (either cleanly or via error). Both paths land in the reconnect block.
  4. onError is invoked only if the client was not explicitly stopped, and the raw thrown value is normalized to an Error before being handed to the application.
  5. Reconnection is scheduled with setTimeout(..., reconnectDelayMs ?? 3000) — the default is a 3-second backoff, and the whole connect cycle restarts, creating a new AbortController and a new stream.

The stream callback pipeline data → decodeSignalEvent(data) → evtToEvent(...) → onEvent(...) shows the layering: raw transport bytes are first decoded into protobuf message structures, then mapped into the public RealtimeEvent type (via evtToEvent and the toBigInt helper from events.ts) before reaching the application.

Authentication headers

Tokens are resolved lazily per request, so a rotating/expiring token never goes stale inside the client:

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

Source: realtime-core/src/signal-client.ts

If tokenProvider is absent or returns a falsy value, requests go out unauthenticated — useful for local development or public endpoints.

Unary operations

Four unary RPCs mirror the backend chat.ChatService methods. Each converts the input IDs with toBigInt(...) (since chat IDs are int64) and encodes the request via the messages.ts helpers:

Method gRPC path Request encoding Returns
sendMessage(input) /chat.ChatService/SendMessage encodeSendMessage(receiverId, groupId, payload) void
sendSignal(input) /chat.ChatService/SendSignal encodeSendSignal(targetId, groupId, signalType, payload ?? '{}') void
getHistory(input) /chat.ChatService/GetHistory encodeGetHistory(peerId, groupId, cursorId, limit ?? 30) { items, nextCursor }
markRead(input) /chat.ChatService/MarkRead encodeMarkRead(peerId, groupId) void

Source: realtime-core/src/signal-client.ts

getHistory is the only call that decodes a response: the raw bytes go through decodeGetHistoryResponse(buf), and the cursor is stringified via res.next_cursor_id.toString() so the application can pass it back as cursorId on the next page. sendSignal defaults the payload JSON to '{}' when omitted, giving call signaling (offer/answer/ICE) a well-formed starting body.

Public API and Factory

The package entry point (realtime-core/src/index.ts) exports the engine, the signal client, the bigint helper, and the public type surface:

export { RealtimeEngine } from './engine';
export { SignalClient } from './signal-client';
export { toBigInt } from './events';
export * from './messages';
export type {
  CallKind,
  EngineConfig,
  RealtimeEvent,
  SendSignalInput,
  MessageInput,
  HistoryInput,
  MediaAdapter,
  MediaCallbacks,
} from './types';

import { RealtimeEngine } from './engine';
import type { EngineConfig } from './types';

export function createRealtimeEngine(config: EngineConfig): RealtimeEngine {
  return new RealtimeEngine(config);
}

Source: realtime-core/src/index.ts

The factory function createRealtimeEngine(config) is the single sanctioned construction path for host applications: it accepts one EngineConfig object (which also flows into SignalClient) and returns a fully wired RealtimeEngine. Type-only exports (CallKind, EngineConfig, RealtimeEvent, SendSignalInput, MessageInput, HistoryInput, MediaAdapter, MediaCallbacks) give consumers compile-time contracts without runtime overhead.

Core Flow: Event Streaming End-to-End

Step-by-step walkthrough:

  1. The host creates the engine via the factory; the engine constructs a SignalClient with the same EngineConfig and an onEvent handler.
  2. connect() opens a server-streaming call to /chat.ChatService/StreamMessages with an empty request body and the current Bearer token.
  3. The server pushes framed messages; serverStream extracts complete frames from its buffer and hands each 0x00 payload to the callback.
  4. Each payload is decoded by decodeSignalEvent (codec Reader), converted by evtToEvent into the public RealtimeEvent shape, and delivered to the host via onEvent.
  5. When the stream terminates — cleanly or with an error — the client reports the failure through onError (if not stopped) and schedules a reconnection after reconnectDelayMs.
  6. Sending a message, signal, history request, or read receipt follows the parallel unary path: encode via Writer helpers, unary() POST to the matching /chat.ChatService/... endpoint, and (for getHistory) decode the response with the Reader.

Usage Examples

Writing and reading a protobuf message with the codec

The Writer/Reader pair is used directly by messages.ts. This pattern — tag, length-prefix, payload — is what every chat message on the wire looks like:

const w = new Writer();
w.int64(1, 1234567890123456789n);   // receiverId (varint field 1)
w.string(2, "hello");               // payload (length-delimited field 2)
const bytes = w.finish();           // Uint8Array ready for transport

const r = new Reader(bytes);
// decode: read tag, switch on wire type, read field...

Source: realtime-core/src/codec.ts

Creating the engine and connecting

const engine = createRealtimeEngine({
  baseUrl: 'https://api.example.com',
  tokenProvider: async () => localStorage.getItem('token') ?? undefined,
  reconnectDelayMs: 2000,
  onError: (e) => console.error('realtime error', e),
});
engine.connect(); // opens /chat.ChatService/StreamMessages

Source: realtime-core/src/index.tscreateRealtimeEngine accepts the EngineConfig and returns a RealtimeEngine; realtime-core/src/signal-client.tsconnect() behavior with reconnectDelayMs default.

Sending a message and fetching history

await signalClient.sendMessage({ receiverId: '1001', groupId: undefined, payload: '{"text":"hi"}' });
const { items, nextCursor } = await signalClient.getHistory({ peerId: '1001', limit: 30 });

Source: realtime-core/src/signal-client.tssendMessage encodes with encodeSendMessage(toBigInt(input.receiverId), toBigInt(input.groupId), input.payload); getHistory returns { items: res.messages, nextCursor: res.next_cursor_id.toString() }.

Configuration Options

All configuration flows through the EngineConfig type (consumed by both RealtimeEngine and SignalClient), as evidenced by its usage across the client code:

Option Type Default Description
baseUrl string — (required) Origin prefix prepended to every gRPC-Web path, e.g. https://api.example.com
tokenProvider () => Promise<string | undefined> undefined Lazily resolves the Bearer token attached to each request; absent token ⇒ unauthenticated request
reconnectDelayMs number 3000 Delay between stream termination and the next connect() attempt
onError (e: Error) => void undefined Called when the stream fails and the client was not explicitly stopped

Source: realtime-core/src/signal-client.ts (tokenProvider), realtime-core/src/signal-client.ts (onError, reconnectDelayMs, baseUrl)

API Reference

codec.tsWriter

Method Signature Description
varint (v: bigint): void Writes a base-128 varint, 7 bits per byte, continuation bit 0x80
tag (field: number, wire: number): void Writes a field tag (field << 3) | wire as a varint
int64 (field: number, v: bigint): void Emits tag + varint when v !== 0n
int32 (field: number, v: number): void Emits tag + varint when v !== 0
bool (field: number, v: boolean): void Emits tag + 1n when v is true
string (field: number, s: string): void Emits tag + UTF-8 length + bytes when s is non-empty
bytes (field: number, b: Uint8Array): void Emits tag + length + raw bytes
finish (): Uint8Array Flushes accumulated bytes

codec.tsReader

Member Signature Description
done get: boolean True when the cursor reached the end of the buffer
varint (): bigint Decodes the next base-128 varint
bytesField (): Uint8Array Reads a length-delimited field, returning a zero-copy subarray
string (): string Reads a length-delimited field as UTF-8 text
skip (wire: number): void Advances past an unknown field by wire type (0/1/2/5); unknown wire types jump to end

transport.ts

Function Signature Description
unary (baseUrl, path, body, headers): Promise<Uint8Array> POSTs a framed request, accumulates 0x00 data frames, throws on non-zero grpc-status
serverStream (baseUrl, path, body, headers, onMessage, signal): Promise<void> Streams frames via fetch reader, dispatching 0x00 payloads to onMessage, throwing on trailer errors; abortable via signal

signal-client.tsSignalClient

Method Signature Description
connect (): Promise<void> Opens /chat.ChatService/StreamMessages; on failure reports via onError and reconnects after reconnectDelayMs unless stopped
disconnect (): void Sets stopped = true and aborts the in-flight stream — reconnection is permanently disabled
sendMessage (input: MessageInput): Promise<void> Unary /chat.ChatService/SendMessage with encoded receiver/group IDs and payload
sendSignal (input: SendSignalInput): Promise<void> Unary /chat.ChatService/SendSignal for call signaling; payload defaults to '{}'
getHistory (input: HistoryInput): Promise<{ items: MessageStreamItem[]; nextCursor: string }> Unary /chat.ChatService/GetHistory, paginated via cursorId/nextCursor; limit defaults to 30
markRead (input: { peerId?; groupId? }): Promise<void> Unary /chat.ChatService/MarkRead; at least one of peer/group ID should be set

Throws: all unary and streaming calls can throw Error with the message gRPC error <status>: <message> when the backend returns a non-zero grpc-status (see transport.ts).

Failure Modes, Edge Cases & Concurrency

Stream failure and reconnection

The dominant failure mode is the streaming connection dropping — network loss, server restart, proxy timeout, or a mid-stream grpc-status error. The design handles this as a retry loop, not an exception:

  • serverStream throws when trailers carry a non-zero grpc-status; connect() catches it, invokes config.onError (only when !this.stopped), and unconditionally schedules the next attempt with setTimeout(..., reconnectDelayMs ?? 3000).
  • Because the retry is scheduled from within connect() itself — not from an external supervisor — the loop is self-sustaining: one function owns both the attempt and the retry policy.
  • Every attempt gets a fresh AbortController; a stale aborted controller from a previous attempt can never cancel a newer connection.

Explicit disconnect vs. transient failure

disconnect() is the only way to leave the loop. It sets stopped = true before aborting, so:

  1. An in-flight fetch is aborted by abort().
  2. The catch block sees this.stopped === true and suppresses onError.
  3. The reconnect block checks !this.stopped and declines to schedule another attempt.

This ordering — flag first, abort second — is what makes disconnect idempotent and race-free: even if the abort causes an exception after the flag is set, no error is surfaced and no timer is scheduled.

Partial and coalesced frames

The server stream parser must tolerate arbitrary network chunk boundaries. The inner loop only consumes a frame when buffer.length >= 5 + len; otherwise it waits for more bytes. A single chunk may also contain several frames (multiple events per read), which the loop drains before the next reader.read(). This is the classic incremental-parser pattern, and it is why concat() — not push — is used to merge chunks without losing the Uint8Array type.

Truncated unary responses

unary() guards against a response whose declared frame length overruns the buffer (if (pos + 5 + len > buf.length) break;). Instead of throwing, it returns whatever data frames were fully parsed, leaving integrity enforcement to the gRPC status trailers.

Concurrency model

SignalClient is single-connection by design: connect() is expected to be called once (or via the self-retry loop), and a second manual connect() while a stream is active would open a duplicate stream. The stopped/abort pair provides the only synchronization needed, and all callbacks (onEvent, onError) are invoked on the microtask/event-loop thread, so hosts must treat them as single-threaded event handlers (no locking required, but no multi-threaded guarantees either).

Edge case: missing optional IDs

markRead and sendMessage accept peerId/groupId as optional. toBigInt(undefined) semantics must therefore be considered: the codec’s int64/int32 writers skip zero values, so an unset group ID is simply omitted from the wire message — the backend interprets presence/absence rather than a sentinel value.

Performance & Operational Considerations

  • Zero dependencies — the entire codec + transport + client stack relies only on platform APIs (fetch, TextEncoder, DataView, ReadableStream). No protobuf codegen, no websocket polyfills, no bundler plugins; bundle size stays minimal.
  • Allocation disciplineReader.bytesField() returns subarray views (zero-copy); Writer accumulates in a number[] and materializes a single Uint8Array at finish(); the stream buffer is reassigned rather than mutated, avoiding accidental aliasing bugs.
  • Single persistent connection — all events flow over one server-streaming call, avoiding per-event handshakes. Reconnection cost is one new HTTP request per failure, throttled by reconnectDelayMs (default 3000 ms).
  • Bigint for IDs — int64 chat/message IDs are handled as bigint end-to-end (toBigInt in events.ts, varint(v: bigint) in the codec), preventing precision loss that plagues naive number-based IDs.
  • Authentication churn — because headers() resolves the token per request, short-lived tokens work without client reconfiguration; the cost is one async callback per RPC, which is negligible.

Extension Points

  • MediaAdapter / MediaCallbacks — exported types from types.ts that let the host plug a concrete media transport (WebRTC or otherwise) into the RealtimeEngine’s call/media subsystem; the engine owns the session lifecycle while the adapter owns device/media details.
  • tokenProvider — injectable auth hook; implement it to refresh tokens, pull from a session store, or return undefined for anonymous mode.
  • onEvent callback — the single sink for all decoded realtime events; hosts can fan events out to stores (Redux/Zustand), UI state, or notification layers.
  • onError callback — observability hook for stream failures; pair with reconnect telemetry to monitor connection health without modifying client code.
  • EngineConfig — the entire client is configured through one plain object, so hosts can build per-tenant or per-environment configs and share them between engine and signal client.

Was this page helpful?