24 KiB
04 — Transport Layer (WebSocket / KCP / Channel Layer)
This chapter covers Greenfield's transport: how Wayland wire traffic flows between the Linux-side proxy and the browser-side compositor over WebSocket, the binary framing, message ordering, latency/backpressure, KCP usage, multiplexing, and connection lifecycle.
Architecture at a glance
Greenfield uses a multi-WebSocket, KCP-wrapped, asymmetric flow-control design:
- Two-tier process model: A
main.tsHTTP hub forks oneSessionProcessper browser session (percompositorSessionId). Rawnet.Sockets are handed to children via Node IPCsendHandle. Each session child owns its ownwl_display(viawayland-server-addon.node). - 9 WebSocket endpoints per session:
/signal(JSON control),/channel(binary data channels),/mkfifo,/mkstemp-mmap,/read-fd,/read-fd-as-stream,/write-fd-as-stream,/close-fd,/request-keyframe. All withperMessageDeflate: false, TCPnoDelay: true.
Channel types
There are 4 logical channel types per app client, each implemented as a separate WebSocket:
| Type | Class | KCP? | Purpose |
|---|---|---|---|
| PROTOCOL | ARQChannel | Yes | Wayland wire protocol, per wl_client |
| FRAME | ARQChannel | Yes | H.264/PNG encoded surface content, per wl_surface |
| XWM | ARQChannel | Yes | XWayland window manager protocol |
| FEEDBACK | SimpleChannel | No | 4-byte telemetry (refreshInterval + decode avg), 1 Hz |
There is no multiplexing of multiple Wayland clients onto one WS. Each logical channel is its own WS, eliminating head-of-line blocking between video, input, and control at the cost of many connections per app (10+ for complex apps).
KCP-over-WebSocket
kcp.ts is a ~1100-line TypeScript port of the canonical C ikcp.c.
Greenfield runs it on top of WebSocket (not real UDP) for:
- 30 ms min RTO vs TCP's 200 ms.
- 100 ms update tick (
setInterval(kcp.update, 100)). - Per-channel congestion domains independent of TCP.
setNoDelay(1, 100, 0, 1)— nodelay enabled, congestion control disabled (nc=1).- 24-byte segment header:
conv|cmd|frg|wnd|ts|sn|una|len. - MTU=64000 (commented "webrtc datachannel MTU" — legacy of a prior WebRTC design).
Asymmetric windows (deliberate)
- Proxy side:
snd_wnd=128, rcv_wnd=1024 - Browser side:
snd_wnd=1024, rcv_wnd=128
So proxy→browser (heavy video) has a smaller in-flight cap to bound latency; browser→proxy (bursty input) has a large window to avoid stalls.
Why KCP-over-WebSocket?
Because real-time video over plain TCP suffers from head-of-line blocking and slow retransmit. KCP gives Greenfield a UDP-like congestion behavior in user-space, while still riding on WebSocket (the only bidirectional binary transport universally available in browsers today).
The long-term plan, documented in docs/pages/design/index.md, is to
switch to WebTransport (real UDP) when browser support is widespread,
and pair it with KCP for reliability. The KCP code is already in place;
only the WebSocket transport layer needs to be swapped out.
Binary message format
All multi-byte fields are little-endian.
PROTOCOL channel
u32 outOfBandOpcode (0 = in-band events, else 1=ResourceDestroyed,
2=BufferCreation, 6=RecycledResourceIds, 7=CommitSerial)
u32 eventSerial
u32 fdsCount
for each fd: u32 jsonLen + u8[] JSON{handle,type,host} (4-byte aligned)
concatenated Wayland wire messages (object_id + opcode/size + args)
FRAME channel (per H.264 frame)
u32 bufferId
u32 bufferCreationSerial
u32 bufferContentSerial
encodedFrame:
u32 contentSerial, u32 encodingType (0=h264/1=png),
u32 width, u32 height, u32 encodedWidth, u32 encodedHeight,
u32 opaqueLength, u8[] opaqueData,
u32 alphaLength, u8[] alphaData ← alpha encoded as a SEPARATE H.264 stream
FEEDBACK channel
Just 4 bytes: u16 refreshIntervalMs + u16 avgDecodeDurationMs. Sent at
1 Hz from browser to proxy. Used by the proxy to pace wl_frame.done
callbacks so the native app sees a virtual monitor refresh rate matching
the browser's actual RAF cadence.
File descriptor handling
Can't send FDs over WebSocket — Greenfield virtualizes them:
- ProxyFD =
{handle:number, type:'pipe-read'|'pipe-write'|'shm'|'unknown', host:string}. - Serialized as JSON inline in the message stream with a u32 length prefix.
- Browser opens separate ad-hoc WebSocket to
/read-fd,/write-fd-as-stream, etc., only when it actually needs the bytes. - Most
wl_bufferFDs never need to be read because the proxy interceptswl_surface.commit, reads the buffer locally via DRM/SHM, encodes to H.264, and ships only the encoded bytes. - Foreign ProxyFD (
proxyFD.host !== local baseURL) triggers proxy-to-proxy relay: local proxy creates a native pipe, opens a WS to the foreign proxy's/write-fd-as-stream, and pipes the data through. This is how copy-paste between two proxy instances avoids a browser round-trip.
Reconnection & lifecycle
Browser side
Vendored ReconnectingWebSocket (pladaria) with:
minDelay=1-5s,factor=1.3,maxDelay=10sminUptime=5s,connTimeout=4sretries=∞
Channel state preservation: only close codes 4000–4999 (application-level) tear down KCP. Transient codes (1006, 1011) are absorbed — KCP keeps ticking and the WS reconnects underneath.
Proxy side
No auto-reconnect (it's the server). The Channel object lives on, waiting
for a new WS upgrade with matching id + key. Outbound messages queue
in outboundMessages and flush on onOpen.
App kill timers
When the browser disconnects from the /signal WebSocket:
- 10-minute SIGHUP grace period — gives the user time to reconnect (e.g. if they refresh the browser tab).
- After 10 minutes of no reconnect, escalating to SIGKILL after another 10s.
- Close code 1001 → immediate SIGHUP (user closed tab intentionally).
- Close code 4001 → immediate SIGTERM (user clicked "force quit" in the shell).
Session close
When all wl_clients disconnect, 1-second grace then session.close() →
process.exit() in the session child. Main process deletes the entry
from its sessionProcesses map.
Process model — three layers
Layer 1: CLI Front Door (compositor-proxy-cli/src/main.ts)
A single Node.js process listens on :8081. Per browser session, it
fork()s one SessionProcess child — one child per compositorSessionId,
not per app. Multiple apps in the same browser tab share one child.
WebSocket upgrades are handed off to the matching child via
childProcess.send(wsUpgrade, socket).
Layer 2: Session Process (SessionProcess.ts)
The forked child. Receives start (creates Session +
SessionController), launchApp (spawns the actual app binary), and
wsUpgrade (hands WS to the controller) IPC messages. On launchApp it
calls launchApplication() from @gfld/compositor-proxy, which spawn()s
the executable, captures pid, replies to main with {pid, key}. On
session.close() the process exits.
Layer 3: Proxy Core (compositor-proxy/src/)
NativeWaylandCompositorSession— creates a realwl_displayviacreateDisplay(), callsaddSocketAuto()which creates the UNIX socket at$XDG_RUNTIME_DIR/wayland-Nand returns the namewayland-N(this becomesWAYLAND_DISPLAY). CallsinitShm()(adds YUV420/NV12/YUV444/ NV21 formats) andinitDrm()(opens/dev/dri/renderD128via EGL/GBM, registerszwp_linux_dmabuf_v1+wl_drmglobals). Integrates the wl_display FD into Node's libuv loop viastartPoll().NativeWaylandClientSession— per-client wire-message interceptor. UsesMessageInterceptor+ auto-generated per-resource interceptor modules (src/protocol/wl_*_interceptor.js). Each request returns{native, browser, neverReplies?}routing decision. Handleswl_display.syncfast-path (skips browser round-trip when no reply expected). Marshalls FDs asProxyFDJSON.NativeAppContext— per-app state:pid,key(16-hex random signaling auth), signaling WS, client sessions, data channels. Lifecycle: 10-min SIGHUP grace on browser disconnect, SIGTERM→SIGKILL escalation (10 s) on force-quit, auto-destroy on app exit.SessionController— WS demultiplexer by pathname:/signal(per-app control),/channel(multiplexed data channels),/mkfifo,/mkstemp-mmap,/read-fd,/write-fd-as-stream,/request-keyframe.XWaylandSession— lazy Xwayland fork (only on first X client connect). Xwayland itself becomes a Wayland client of the proxy. Thewm_fdis bridged via xtsb to the browser, which implements the actual XWM logic.Channel—ARQChannel(KCP-over-WebSocket, reliable, low-latency) for PROTOCOL/FRAME/XWM;SimpleChannelfor FEEDBACK.
ASCII diagram of the full transport stack
LINUX SIDE | BROWSER SIDE
|
┌─────────────────────────┐ | ┌──────────────────────────────┐
│ gtk4-demo (process) │ | │ Browser tab │
│ libwayland-client.so │ | │ (Greenfield compositor) │
└───────────┬─────────────┘ | └──────────────┬───────────────┘
│ WAYLAND_DISPLAY=wayland-0 │
│ Unix socket + SCM_RIGHTS FD passing │ DOM events → input
▼ │ WebSocket API
┌──────────────────────────────────────────┐ │
│ SessionProcess (fork'd Node child) │ │
│ ┌────────────────────────────────────┐ │ │
│ │ wayland-server-addon.node │ │ │
│ │ (libwayland-server N-API) │ │ │
│ │ - wl_display + wl_client │ │ │
│ │ - epoll via proxy-poll-addon │ │ │
│ └─────────────┬──────────────────────┘ │ │
│ │ wire msg + FDs │ │
│ ▼ │ │
│ ┌─────────────────────────────────────┐ │ │
│ │ NativeWaylandClientSession │ │ │
│ │ - interceptRequest / interceptEvent│ │ │
│ │ - onWireMessageEnd: serialize │ │ │
│ │ FDs as JSON ProxyFDs inline │ │ │
│ │ - fastSync optimization │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ │ outbound buffer │ │
│ ▼ │ │
│ ┌─────────────────────────────────────┐ │ │
│ │ ARQChannel (KCP-wrapped) │ │ │
│ │ MTU=64000, snd_wnd=128, rcv_wnd=1024│ │ │
│ │ KCP seg: conv|cmd|frg|wnd|ts|sn|una|len (24B) + data │
│ │ kcp.update() every 100ms │ │ │
│ │ - PROTOCOL (per wl_client) │ │ │
│ │ - FRAME (per wl_surface) │ │ │
│ │ - XWM (per XWayland) │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────┴───────────────────────┐ │ │
│ │ SimpleChannel (FEEDBACK, no KCP) │ │ │
│ │ 4 bytes: refreshIntervalMs + │ │ │
│ │ avgDecodeDurationMs │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────▼───────────────────────┐ │ │
│ │ NativeAppContext │ │ │
│ │ - signalingWebSocket (JSON msgs) │ │ │
│ │ - kill timers: SIGHUP@10min, │ │ │
│ │ SIGKILL@+10s │ │ │
│ └─────────────┬───────────────────────┘ │ │
│ ┌─────────────▼───────────────────────┐ │ │
│ │ SessionController │ │ │
│ │ ws.WebSocketServer (noServer) │ │ │
│ │ perMessageDeflate: false │ │ │
│ │ Endpoints: /signal /channel /mkfifo│ │ │
│ │ /mkstemp-mmap /read-fd /write-fd-as-stream│ │
│ │ /read-fd-as-stream /close-fd /request-keyframe│ │
│ └─────────────┬───────────────────────┘ │ │
└────────────────┼─────────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ main.ts (HTTP+WS hub) │ │
│ │ - socket.pause() + IPC │ │
│ │ send to session proc │ │
│ │ - noDelay:true (TCP) │ │
│ └────────────┬────────────┘ │
│ │ │
│ ◄──── 1 WebSocket per channel ────► │
│ ◄── 1 signaling WS per app ──► │
│ ◄── ad-hoc WebFD streams ─► │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ ReconnectingWebSocket │ │
│ │ min 1-5s, factor 1.3, max 10s │ │
│ │ minUptime 5s, connTimeout 4s │ │
│ └────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ ARQChannel / SimpleChannel (mirror image) │ │
│ │ snd_wnd=1024, rcv_wnd=128 │ │
│ │ kcp.input → peekSize/recv → onMessage │ │
│ └────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ RemoteAppLauncher.onChannel │ │
│ │ PROTOCOL → session.display.createClient │ │
│ │ FRAME → deliverContentToBufferStream │ │
│ │ XWM → XWindowManagerConnection │ │
│ │ FEEDBACK → ClientEncodersFeedback │ │
│ └────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ @gfld/compositor-protocol Client │ │
│ │ - parses Wayland wire format │ │
│ │ - dispatches to wl_*_resource impls │ │
│ │ - H264NALDecoder.worker.ts decodes frames │ │
│ └────────────┬───────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Surface + Scene rendering │ │
│ │ - ImageBitmap / VideoFrame / Canvas │ │
│ │ - DOM input → wl_keyboard/pointer reqs │ │
│ └────────────┬───────────────────────────────┘ │
│ │ input events flow back via the │
│ │ PROTOCOL channel as ordinary │
│ │ Wayland requests (same framing) │
└──────────────────┘ │
Browser DOM ◄─┘
Key takeaways for building your own transport
- Separate control plane from data plane — Greenfield uses
/signal(JSON) vs/channel(binary). The control plane handles app launch, termination, kill timers; the data plane handles Wayland wire + encoded frames. - One WS per logical stream — simpler, no HoL blocking, but many TCP connections.
- KCP-over-WS trades double-reliability overhead for fast (30 ms) retransmits; reasonable for real-time video.
- Virtualize FDs as JSON tokens with lazy separate-WS endpoints for actual byte transfer.
bufferedAmountcheck ≠ backpressure — Greenfield's check is a drop gate, not real flow control (acknowledged TODO).- Distinguish transient vs permanent close — codes 4000-4999 tear down; lower codes auto-reconnect.
- Piggyback out-of-band opcodes on the data channel via a leading u32 to avoid extra control WS.
- Asymmetric KCP windows match each direction's bandwidth profile.
- Decode feedback drives frame pacing — the proxy synthesizes
wl_frame.donecallbacks at the browser's reported cadence. - One Node child per browser session for fault isolation and
per-session
wl_displayownership.
File reference
Native side
| File | Role |
|---|---|
packages/compositor-proxy/src/Channel.ts |
ARQChannel + SimpleChannel |
packages/compositor-proxy/src/kcp.ts |
~1100-line KCP implementation |
packages/compositor-proxy/src/Session.ts |
Top-level proxy session |
packages/compositor-proxy/src/SessionController.ts |
WS server with 9 endpoint paths |
packages/compositor-proxy/src/NativeAppContext.ts |
Per-app state, signaling WS, kill timers |
packages/compositor-proxy/src/NativeWaylandClientSession.ts |
Per-client message interceptor |
packages/compositor-proxy/src/NativeWaylandCompositorSession.ts |
wl_display creation, initShm, initDrm |
packages/compositor-proxy/src/FrameFeedback.ts |
Receives 4-byte decode feedback, paces wl_frame.done |
packages/compositor-proxy/src/ProxyBuffer.ts |
Proxy-side buffer representation |
packages/compositor-proxy/src/SurfaceBufferEncoding.ts |
Encoding pipeline orchestration |
packages/compositor-proxy/src/XWaylandSession.ts |
XWayland spawn + bridge |
packages/compositor-proxy/src/wayland-server.ts |
TS wrapper around wayland-server-addon.node |
packages/compositor-proxy/src/io/ |
WebSocket server glue |
packages/compositor-proxy/src/encoding/ |
High-level encoding orchestration |
packages/compositor-proxy/src/protocol/ |
Generated interceptor modules |
packages/compositor-proxy/src/addons/ |
.node file shims (Linux-x64) |
Browser side
| File | Role |
|---|---|
packages/compositor/src/remote/Channel.ts |
ARQChannel + SimpleChannel (mirror) |
packages/compositor/src/remote/kcp.ts |
KCP mirror |
packages/compositor/src/remote/RemoteAppLauncher.ts |
Channel setup per app, frame delivery |
packages/compositor/src/remote/RemoteOutOfBandChannel.ts |
OOB opcode dispatch |
packages/compositor/src/remote/RemoteInputOutput.ts |
FD-over-WebSocket for native apps |
packages/compositor/src/remote/EncoderFeedback.ts |
Sends 4-byte decode feedback at 1 Hz |
packages/compositor/src/remote/StreamingBuffer.ts |
Per-surface encoded-frame buffer |
packages/compositor/src/remote/BufferStream.ts |
Manages serial → frame promise resolution |
packages/compositor/src/remote/EncodedFrame.ts |
Wire format parser for encoded frames |
packages/compositor/src/remote/webcodec-buffer-decoder.ts |
WebCodecs VideoDecoder path |
packages/compositor/src/remote/wasm-buffer-decoder.ts |
FFmpeg Wasm fallback path |
packages/compositor/src/remote/H264NALDecoder.worker.ts |
Worker entry, runs FFmpeg Wasm decoder |
packages/compositor/src/remote/api.ts |
Public exports |
CLI
| File | Role |
|---|---|
packages/compositor-proxy-cli/src/main.ts |
HTTP + WS hub, forks per session |
packages/compositor-proxy-cli/src/main-controller.ts |
CLI arg parsing |
packages/compositor-proxy-cli/src/main-args.ts |
CLI arg definitions |
packages/compositor-proxy-cli/src/SessionProcess.ts |
Per-session child, IPC handler |
packages/compositor-proxy-cli/example-applications.json |
Pre-configured apps |
packages/compositor-proxy-cli/wait-until-ready.sh |
Startup readiness check |