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 client —
SignalClient(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 theRealtimeEnginefactory.
The following related topics are intentionally left to sibling pages:
- Media subsystem —
realtime-core/src/media/adapter.ts,manager.ts, andsession.ts(call media handling) are referenced here only as engine dependencies. - Server-side chat service — the
chat.ChatServicegRPC 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:
- Binary serialization — every outgoing request and incoming event is encoded/decoded with a minimal protobuf wire-format
Writer/Readerpair, producing compact messages without pulling in heavy codegen dependencies. - Persistent event streaming — the
SignalClient.connect()method opens a server-streaming call to/chat.ChatService/StreamMessagesand translates each decoded frame into a typedRealtimeEventdelivered to the application through a singleonEventcallback. - Resilience — if the stream drops, the client reconnects automatically after a configurable delay (
reconnectDelayMs, default 3000 ms) and reports failures throughconfig.onError; an explicitdisconnect()permanently stops reconnection. - Authentication — every request attaches a
Bearertoken obtained lazily from atokenProvidercallback, 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 fromindex.ts) — the facade that owns aSignalClientplus 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 againstchat.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-levelWriterandReaderimplementing 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-statusvalidation, and both unary and server-streamingfetch-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:
bigintfor varints — protobuf int64 values exceed JavaScript’s safe integer range, sovarint()operates onbigintand message IDs (toBigInt(...)insignal-client.ts) are converted at the call site. Wire tags are computed inbiginttoo ((field << 3) | wire) and shifted in 7-bit groups.- Wire types —
tag(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 defaults —
if (v !== 0n),if (v !== 0),if (v),if (!s) returnall 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();
}
The lifecycle is a deliberate loop rather than a one-shot call:
connect()guards onthis.stopped— afterdisconnect()no further reconnection can occur.- A fresh
AbortControlleris created per attempt sodisconnect()can abort the currentfetchwhile the flagstoppedprevents any future attempt. - The
serverStreamcall only resolves when the stream ends (either cleanly or via error). Both paths land in the reconnect block. onErroris invoked only if the client was not explicitly stopped, and the raw thrown value is normalized to anErrorbefore being handed to the application.- Reconnection is scheduled with
setTimeout(..., reconnectDelayMs ?? 3000)— the default is a 3-second backoff, and the wholeconnectcycle restarts, creating a newAbortControllerand 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}` } : {};
}
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 |
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:
- The host creates the engine via the factory; the engine constructs a
SignalClientwith the sameEngineConfigand anonEventhandler. connect()opens a server-streaming call to/chat.ChatService/StreamMessageswith an empty request body and the current Bearer token.- The server pushes framed messages;
serverStreamextracts complete frames from its buffer and hands each0x00payload to the callback. - Each payload is decoded by
decodeSignalEvent(codecReader), converted byevtToEventinto the publicRealtimeEventshape, and delivered to the host viaonEvent. - When the stream terminates — cleanly or with an error — the client reports the failure through
onError(if not stopped) and schedules a reconnection afterreconnectDelayMs. - Sending a message, signal, history request, or read receipt follows the parallel unary path: encode via
Writerhelpers,unary()POST to the matching/chat.ChatService/...endpoint, and (forgetHistory) decode the response with theReader.
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.ts —
createRealtimeEngineaccepts theEngineConfigand returns aRealtimeEngine; realtime-core/src/signal-client.ts —connect()behavior withreconnectDelayMsdefault.
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.ts —
sendMessageencodes withencodeSendMessage(toBigInt(input.receiverId), toBigInt(input.groupId), input.payload);getHistoryreturns{ 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.ts — Writer
| 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.ts — Reader
| 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.ts — SignalClient
| 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:
serverStreamthrows when trailers carry a non-zerogrpc-status;connect()catches it, invokesconfig.onError(only when!this.stopped), and unconditionally schedules the next attempt withsetTimeout(..., 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:
- An in-flight
fetchis aborted byabort(). - The catch block sees
this.stopped === trueand suppressesonError. - The reconnect block checks
!this.stoppedand 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 discipline —
Reader.bytesField()returnssubarrayviews (zero-copy);Writeraccumulates in anumber[]and materializes a singleUint8Arrayatfinish(); 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
bigintend-to-end (toBigIntinevents.ts,varint(v: bigint)in the codec), preventing precision loss that plagues naivenumber-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 fromtypes.tsthat let the host plug a concrete media transport (WebRTC or otherwise) into theRealtimeEngine’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 returnundefinedfor anonymous mode.onEventcallback — the single sink for all decoded realtime events; hosts can fan events out to stores (Redux/Zustand), UI state, or notification layers.onErrorcallback — 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.
Related Links
- realtime-core/src/index.ts — public entry point and
createRealtimeEnginefactory - realtime-core/src/signal-client.ts —
SignalClient: streaming lifecycle, reconnection, and unary operations - realtime-core/src/transport.ts — gRPC-Web framing,
unary, andserverStream - realtime-core/src/codec.ts — protobuf wire-format
Writer/Reader - realtime-core/src/messages.ts — typed encode/decode helpers used by the signal client
- realtime-core/src/engine.ts —
RealtimeEnginefacade that wires the signal client and media subsystem - realtime-core/src/events.ts —
evtToEvent/toBigIntevent mapping helpers - realtime-core/src/media/adapter.ts — media adapter contract (media subsystem, sibling topic)
- realtime-core/src/types.ts — shared public types (
EngineConfig,RealtimeEvent,MessageInput, etc.)