Skip to main content

WebSocket protocol

The engine's WebSocket at ws://<host>:<port>/ws is the one place where state updates, panel discovery, config bundles, license operations, and panel pixel streams all meet.

Text frames are JSON. Binary frames are panel pixel data with a small header.

Connection

ws://<host>:<port>/ws?name=<display-name>&appKey=<key>&connectionType=<process|viewer>
Query paramPurpose
nameHuman-readable name, surfaced in /status and logs.
appKeyApp key for analytics and future billing.
connectionTypeprocess keeps the engine alive; viewer doesn't.

On connect, the engine sends a welcome frame:

{
"type": "welcome",
"clientId": "c_89d2c1ef",
"state": {
/* EngineState */
},
"panels": [
/* Panel[] */
],
"configs": [
/* ConfigBundle[] */
]
}

Server → client (text frames, JSON)

TypePayloadSent when
welcome{ clientId, state, panels, configs }Immediately after the WS opens.
stateFlat EngineState fields (isAdmin, msfsConnection, directX, markerInjection, …)Engine state changed (MSFS connection, DirectX, marker-injection cycle, etc.).
panels{ panels: Panel[] }Panel list changes.
configs{ configs: ConfigBundle[] }A process client pushed / cleared profile bundles.
heartbeat{}~10 s app-level keepalive sent alongside the WS ping. Browsers can't observe protocol pongs from JS, so the heartbeat keeps idle viewers from self-evicting.
time.sync.reply{ clientSentMs, engineMs }Reply to a time.sync request. clientSentMs is echoed verbatim; engineMs is Date.now() on the engine when the reply was sent.
panel.debug{ panelId, pendingAgeMs, bufferedBytes, qualityLevel, transitionCount, skipped }Per-tick backpressure telemetry. Only sent if the client passed debug: true in subscribe.panel; zero cost otherwise.
license.response{ id, ok, error?, credentials?, product?, deactivatedCount?, source? }Reply to any license.* request (correlated by id).

Client → server (text frames, JSON)

TypeFieldsPurpose
subscribe.panelpanelId, optional crop: {x,y,w,h}, optional targetFps (10–60), optional debug, optional forceEveryFrameStart receiving binary frames. See Subscription options below.
unsubscribe.panelpanelIdStop.
panel.inputpanelId, x, y, kind: "click", optional delayMs (default 300)Synthesise a click at the source panel's native pixel coords. The engine forwards via Coherent's CDP Runtime.evaluate.
config.pushownerName, profiles: Profile[]Push profile bundles. Engine keeps them in memory only; broadcasts the aggregated configs list to all clients. Process clients only.
time.syncclientSentMs (client Date.now())NTP-style offset probe; engine replies with engineMs so the client can compute clock skew (see Clock sync).
license.activateid, licenseKeyActivate a license. Response carries fresh credentials for the caller to persist.
license.deactivateid, licenseKey, deviceTokenRelease a license. Engine is stateless — the caller supplies the stored credentials.
license.validateid, licenseKey, deviceTokenRe-validate on startup / reconnect. On success, the caller's name is added to the engine's validatedOwners set, which gates the DEMO overlay.

License requests are correlated to license.response by the id you pass. The SDK's activateLicense / deactivateLicense / validateLicense methods wrap this for you.

Subscription options

subscribe.panel accepts the following fields beyond panelId:

FieldTypeDefaultMeaning
crop{x, y, w, h}Layout hint only — the engine still streams the full panel; the rect is broadcast so viewers can crop client-side (typical for fragments).
targetFpsnumber30Per-subscription frame rate. Server-clamped to [10, 60]. The engine ticks at the max across all subscribers and decimates per client, so a 30 fps phone and a 60 fps PC sharing a panel run on one 60 fps tick loop.
debugbooleanfalseEnables panel.debug text frames every tick (backpressure, pending-frame age, current quality bucket, transition count, skipped flag). Used by the debug overlay; zero overhead when off.
forceEveryFramebooleanfalseBypass the static-frame fingerprint skip. The engine normally hashes ~576 sample pixels per tick and skips encode + send when the hash matches the last frame; setting this forces a send on every tick whose frameId advanced. Per-panel, not per-client — if any subscriber sets it, the engine sends to everyone. Use only on the local PC viewer where bandwidth is free.

Clock sync

The SDK runs a 5-sample time.sync exchange on connect (and every 60 s after) to estimate the engine's wall-clock offset:

rtt = clientRecvMs - clientSentMs
engineOffset = engineMs + rtt/2 - clientRecvMs

The smallest-RTT sample wins (asymmetric one-way delays cancel only when RTT is small). The result is exposed as engine.engineOffsetMs on the SDK and is the basis for end-to-end wire-latency measurement:

const wireMs = Date.now() - (frame.engineTimestampMs + engine.engineOffsetMs);

Binary panel frames

Whenever you're subscribed to a panel, frames arrive as binary WebSocket messages. The header is little-endian packed; the body is a standard JPEG. Every frame is self-contained, so a dropped frame just means you see the next one a tick later.

offset size field
------ ---- -----
0 1 panelId length (u8)
1 N panelId (UTF-8)
N+1 4 frameNumber (u32 LE)
N+5 2 width (u16 LE)
N+7 2 height (u16 LE)
N+9 8 engineTimestampMs (u64 LE) — Date.now() at the start of the
tick that produced this frame. Use it with `time.sync` to
compute one-way wire latency. Ignore if you don't care.
N+17 ... JPEG bytes

Pipe the body into createImageBitmap (browser) or any JPEG decoder.

Frame rate

Each subscriber picks its own frame rate (10–60 fps) via the targetFps field on subscribe.panel. The default when omitted is 30 fps. The engine ticks the capture loop at the maximum targetFps any active subscriber requested, then decimates per client so each viewer sees only its requested fraction of ticks — a phone at 30 fps and a PC at 60 fps watching the same panel share one 60 fps tick loop without the phone receiving extra frames.

Identical source frames (nothing changed in MSFS) are fingerprinted and skipped: the engine hashes an NxN grid of pixels per tick and skips the encode + send when the hash matches the last sent frame for that panel. A single full frame is forced every ~1 s as a backstop. New subscribers get the current frame on the next tick regardless.

Adaptive quality

JPEG quality is per-subscriber and adaptive. Each connected client is placed on a discrete quality ladder (Q40 / Q60 / Q75 / Q85) and moved up or down based on its WebSocket buffer state — a phone on weak WiFi naturally settles at a lower bucket while a wired PC stays at the top. You don't subscribe to a quality; the server picks per client and sends the matching bytes. Setting GLASSOUT_JPEG_QUALITY=N on the engine pins all clients to a fixed quality and disables the ladder, useful for A/B testing.

Cropped subscriptions

If you subscribed with a crop rect, the server still streams the full panel — the SDK provides the crop hint purely so the engine can size layouts and so your code can render only the region you care about.

Idle shutdown

The engine shuts down ~5 seconds after the last process-type client disconnects. Viewer clients can linger forever, but they can't stop the engine from winding down when its last "driver" leaves.

Example bare-minimum client

import { WebSocket } from "ws";
import sharp from "sharp"; // or @julusian/jpeg-turbo, or any JPEG decoder

const ws = new WebSocket("ws://localhost:8787/ws?name=demo&appKey=demo&connectionType=viewer");

ws.on("message", async (data, isBinary) => {
if (!isBinary) {
const msg = JSON.parse(data.toString());
if (msg.type === "welcome") {
ws.send(
JSON.stringify({
type: "subscribe.panel",
panelId: msg.panels[0].id,
targetFps: 30, // optional — 10..60, default 30
}),
);
}
return;
}

const buf = data as Buffer;
const panelIdLen = buf.readUInt8(0);
const panelId = buf.subarray(1, 1 + panelIdLen).toString();
const frameNumber = buf.readUInt32LE(1 + panelIdLen);
const w = buf.readUInt16LE(1 + panelIdLen + 4);
const h = buf.readUInt16LE(1 + panelIdLen + 6);
// 8 bytes of engineTimestampMs (u64 LE) at offset (1 + panelIdLen + 8) —
// read with readBigUInt64LE if you're computing wire latency, skip otherwise.
const jpeg = buf.subarray(1 + panelIdLen + 16);

const rgb = await sharp(jpeg).raw().toBuffer(); // w * h * 3 bytes
// render rgb here — fully decoded panel frame
});

In a browser the example collapses to a createImageBitmap(new Blob([jpeg], { type: "image/jpeg" })) followed by ctx.drawImage. No prior-frame state, no XOR step, no decompressor library — every binary frame is a self-contained image.