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 mapping —
engine.tsorchestratesSignalClient, andevents.tsconverts raw signal events intoRealtimeEventobjects (evtToEvent,toBigInt). See the Engine page. - Media sessions —
media/adapter.ts,media/manager.ts,media/session.tshandle audio/video capture and playback on top of the signaling events. See the Media page. - Shared types —
types.tsdefinesEngineConfig,MessageInput,SendSignalInput,HistoryInput, andRealtimeEvent, consumed by the signaling layer.
Note: the same
realtime-coresources are mirrored understreaming-frontend/lib/realtime-core/andstreaming/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:
- 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 asbigintso 64-bit gRPC message IDs survive JavaScript’sNumberprecision limits. - Messages (
messages.ts) — the protocol layer. It maps each RPC request/response to concrete field numbers (SignalEvent,MessageStreamItem,GetHistoryResponse) and providesencode*/decode*functions built onReader/Writer. - 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 viafetchwithapplication/grpc-web+protocontent type, and parses response frames, including trailer frames that carrygrpc-status/grpc-message. - SignalClient (
signal-client.ts) — the user-facing API. It wires the three layers above to abaseUrlandtokenProvider, exposesconnect/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:
SignalClientis 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 orRealtimeEvents.messages.tsis a pure function layer — no state, no I/O. Every function takes/returnsUint8Arrayor plain interfaces, which makes it trivially testable and reusable outside the client (e.g., in a service worker).transport.tsis 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.tssits 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 withtag >> 3nandtag & 7n. - Proto3 zero-elision:
int64/int32/bool/stringskip 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 (likeMarkRead, which may be all zeros) collapse to nearly empty payloads. - Output strategy: bytes are pushed into a plain
number[]and materialized once infinish()viaUint8Array.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 toNumber, preserving precision for snowflake-style IDs. bytesField()returns asubarray(a view, not a copy) of the underlying buffer for the declared length, avoiding an allocation on decode; theposcursor 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, thex-grpc-web: 1version header, andaccept: 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, butSignalClientonly 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 trailingdataframe 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. Thesignal: AbortSignalparameter is howSignalClient.disconnect()tears down the stream. - Frame reassembly across chunks: network reads do not align to frame boundaries, so a persistent
bufferaccumulates bytes; the innerwhileloop extracts every complete frame currently buffered (5 + lenbytes), delivers data frames viaonMessage, and validates trailer frames withcheckStatus. Partial frames simply wait for the nextreader.read(). concatallocates a freshUint8Arrayper 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 inSignalClientrelies 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:
- Guard against reconnection after an explicit
disconnect()(this.stopped). - Create a fresh
AbortControllerso the previous attempt’s signal never leaks into the new stream. - Open the server stream with an empty body (
new Uint8Array(0)—StreamMessagestakes no request message). - Every data frame is decoded by
decodeSignalEventthen mapped throughevtToEventinto aRealtimeEventbefore being delivered to the engine’s callback — the raw wire shape never escapes the client. - Any error (network drop, gRPC status from trailers, abort) is reported to
config.onErrorand, unless stopped, triggers a reconnect afterreconnectDelayMs(default 3000 ms). Becauseconnect()recurses viasetTimeout, the reconnect cadence is self-perpetuating untildisconnect().
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 | numberIDs (toBigIntinevents.ts), because JSON can’t carry 64-bit integers losslessly; internally everything isbigint. getHistorypagination: the default page size is30(input.limit ?? 30), and the server’snext_cursor_idbigint is surfaced as a string cursor so callers can pass it straight back into the nextcursorIdwithout precision loss.sendSignalcarriessignalTypeand a JSONpayload(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
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'throwsgRPC error <status>: <message>from bothunaryandserverStream(transport.ts). In the streaming path this surfaces toonErrorand triggers reconnect; in the unary path it rejects the caller’s promise. - Mid-stream disconnects:
serverStreamreads untildone; a server that closes the connection simply resolves.SignalClient.connecttreats 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()setsstoppedbefore aborting, so a concurrently scheduledsetTimeoutreconnect is a no-op (if (this.stopped) return). This prevents zombie reconnection after teardown. - Partial frames on the wire:
serverStreamaccumulates a buffer and only dispatches frames with a complete5 + lenprefix; 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
bigintend-to-end;toBigIntconvertsstring | numberinputs, andgetHistoryreturns the cursor as a string to avoidNumberrounding on 64-bit cursors. - Auth failures: a missing/expired token results in a request without (or with a stale)
authorizationheader; the server’s gRPC error surfaces as above, and streaming reconnects re-evaluatetokenProvidereach attempt, naturally recovering from token refresh. - No in-flight concurrency control: each unary call is independent; if the engine issues concurrent
sendMessagecalls, 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 oneconcatper 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 freshAbortController, anddisconnect()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/, andstreaming/src/lib/realtime-core/; changes must be applied to all three copies.
Extension Points
- New RPCs: add an
encode*/decode*pair inmessages.ts(choose unused field numbers, keepskipfor unknowns), then add a method onSignalClientcallingunarywith the new path — no transport or codec changes needed. - New event types: extend
SignalEvent.event_typehandling inevents.ts(evtToEvent) — the wire schema already supports arbitrary string event types with a JSON payload. - Alternative transports:
unary/serverStreamare the only places that touchfetch; a WebSocket or XHR transport could be swapped in while reusingmessages.tsandcodec.tsunchanged. - Authentication schemes:
tokenProvideris 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:
baseUrland the path strings passed tounary/serverStreamare plain strings — the client can target any gRPC-Web gateway exposing the samechat.ChatServicecontract.
Related Links
- Engine — orchestrates
SignalClient, ownsEngineConfig, deliversRealtimeEvents to consumers. - Events —
evtToEventandtoBigIntmappings between wire events and domain events. - Media — WebRTC/media sessions driven by
SendSignal/SignalEventpayloads exchanged through this signaling layer. - Types —
EngineConfig,MessageInput,SendSignalInput,HistoryInput,RealtimeEventdefinitions. - Index — package entry point exporting the public API surface.