21 KiB
02 — Wayland Protocol Layer
This chapter covers Greenfield's protocol layer in depth: how Wayland XML protocol definitions get parsed, how typed TypeScript stubs get generated for each of the three sides (browser client, browser compositor, native proxy), how wire messages are marshalled/unmarshalled at runtime, how objects lifecycle is managed, and how the FD-over-WebSocket problem is solved.
The three generators
Greenfield ships three sibling generator CLIs that consume the same Wayland XML files but emit slightly different TypeScript flavors:
client-generator |
compositor-generator |
compositor-proxy-generator |
|
|---|---|---|---|
| Output | TS *Proxy classes (browser) |
TS *Resource + *Requests (Node) |
CommonJS *_interface.js + *_interceptor.js (Node + N-API) |
| Listener pattern | listener?: <Itf>Events (optional, duck-typed, async) |
implementation: <Itf>Requests (required) |
requestHandlers / eventHandlers maps |
| Skips | none | wl_display, wl_registry (hand-written in runtime) |
none — proxy must intercept display/registry/bind |
| Arg-type table | ProtocolArguments.mjs returns {signature, jsType, marshallGen} |
Same shape, uses Westfield.${resourceName} for objects |
EndpointProtocolArguments.mjs returns plain wayland sig letters (u, ?s, n, h, ...) |
| XMLs consumed | wayland.xml + xdg-shell.xml + web-bitmapbuf-unstable-v1.xml |
same 3 | wayland.xml + xdg-shell.xml + wayland-drm.xml |
All three share xml2js + camelcase + uppercamelcase and a near-identical
ProtocolParser.mjs. The XML → JSON → walk interface[]/request[]/
event[]/enum[] → per-arg type-dispatch pipeline is identical.
Why three generators?
Because each side has a fundamentally different job:
client-generator→ browser apps that want to be Wayland clients of the browser compositor (viaMessagePort). These are the same-origin iframe JS/WASM apps that ship inexamples/webapps/.compositor-generator→ the browser-resident compositor itself (@gfld/compositor). It receives requests, dispatches them to*Requestsimplementation classes, and emits events back.compositor-proxy-generator→ the Node.js native proxy bridging reallibwayland-clientapps to the browser. Emitswl_interfacestruct definitions for the N-API addon + per-opcode interceptors that decide routing.
Wire format (libs/common/src/Connection.ts)
Standard upstream Wayland wire format, byte-for-byte. No custom binary encoding at the protocol layer.
┌─────────────┬──────────┬──────────┬─────────────────────────────────────────────┐
│ object id │ opcode │ size │ args (variable per arg type, see §4) │
│ uint32 │ uint16 │ uint16 │ u,i,f,n,o = 4 bytes; s,a = 4+len+pad; │
│ (4 bytes) │ (2 B) │ (2 B) │ h = 0 bytes, FD goes in side-band fds[] │
└─────────────┴──────────┴──────────┴─────────────────────────────────────────────┘
▲
└── size includes this 8-byte header.
Total message size = size field.
Per-arg marshalling table
| Arg signature letter | Type | Size on wire | Notes |
|---|---|---|---|
u |
uint32 | 4 bytes | unsigned integer |
i |
int32 | 4 bytes | signed integer |
f |
fixed | 4 bytes | wl_fixed_t (24.8 signed) |
s |
string | 4 + len + pad | length-prefixed u32, padded to 4-byte alignment |
a |
array | 4 + len + pad | length-prefixed u32, padded to 4-byte alignment |
o |
object | 4 bytes | object id (0 means null) |
n |
new_id | 4 bytes | newly-allocated object id |
h |
file descriptor | 0 bytes | FD goes in side-band wireMsg.fds[] |
Reading
Connection.message dispatches by literal wlObject[opcode](message). The
generated classes use computed-property-key methods:
[0](message) { ... }, [1](message) { ... } so opcode is the array index.
Each handler returns void or a Promise; Connection awaits the promise
before reading the next message — this preserves wire-order while still
allowing async implementations. Per-client Connections mean a slow client
doesn't block others.
Object lifecycle & new-id tracking
Client side
Proxy.marshallConstructor calls display.generateNextId() (1-indexed,
recycles ids from wl_display.delete_id), constructs the new Proxy
(self-registers in constructor), patches the new_id arg's value (it was
0 placeholder from newObject()), then flushes.
Server side
Client.marshallConstructor allocates from SERVER_OBJECT_ID_BASE = 0xff000000 (or recycles locally). When a client-allocated id is destroyed,
the server sends wl_display.delete_id so the client can recycle. On
Client.close(), objects are destroyed in descending id order (children
first).
Connection registry
wlObjects: { [id]: WlObject }; registerWlObject throws on collision.
Listener dispatch
Connection.message dispatches by literal wlObject[opcode](message). Each
handler returns void or a Promise; Connection awaits the promise before
reading the next message — this preserves wire-order while still allowing
async implementations. Per-client Connections mean a slow client doesn't
block others.
File descriptor handling — the Greenfield innovation
type FD = unknown — it's intentionally opaque. The actual FD
representation depends on which link it's traversing.
Browser ↔ Browser (in-iframe, via MessagePort)
FDs are transferable JS objects:
ArrayBufferViewforwl_shm.create_pool'sfd(SHM backing memory lives in browser, zero-copy transferred viaMessagePort.postMessage(..., transferList)).ImageBitmapforweb_bitmapbuf_factory.create_buffer'sbitmaparg (GPU-backed texture, zero-copy).MessagePortforwl_data_offer.receive'sfd(in-browser pipe).
Browser ↔ Proxy (over WebSocket/KCP)
FDs are virtualized as JSON ProxyFD descriptors:
ProxyFD = { handle: number, type: 'pipe-read' | 'pipe-write' | 'shm' | 'unknown', host: string }
Where:
handleis the proxy's internal numeric fd idtypeis the kind of fd (so the browser knows what to do with it)hostis the proxy's HTTP base URL (for proxy-to-proxy FD relay)
NativeWaylandClientSession.onWireMessageEnd converts each POSIX fd to a
ProxyFD JSON, length-prefixed + 4-byte aligned, prepended to the wire
message before sending over WebSocket/KCP. The browser's webFS resolves
them by fetching data over a separate ad-hoc WebSocket endpoint
(/read-fd, /write-fd-as-stream, etc.).
Native App ↔ Proxy (libwayland socket)
FDs are real POSIX ints, sent via SCM_RIGHTS by libwayland-server.
Most wl_buffer FDs never need to be read
Because the proxy intercepts wl_surface.commit, reads the buffer locally
via DRM/SHM, encodes to H.264, and ships only the encoded bytes — the
browser never needs to read the original FD.
How the proxy translates between native and browser
It doesn't translate wire bytes — both sides speak identical Wayland wire format. What it translates is:
- FDs: POSIX int ↔
ProxyFDJSON. - Object-id space: native libwayland's id range vs browser's
0xff000000+ server range. - Routing per message via
MessageInterceptor.interceptRequest/interceptEvent. Special cases:wl_display.sync→ both native and browser (must complete beforedoneevent)wl_display.get_registry→ bothwl_registry.bind→ native if id is innativeGlobalNames[](e.g.wl_drm,wl_shm), else browser- Fast-sync optimization skips browser round trip when no prior browser-bound request occurred
The four protocol XMLs
protocol/wayland.xml
Standard upstream Wayland core. 22 interfaces, wl_compositor v4, wl_seat
v6, wl_surface v4 (roughly Wayland 1.20-ish). Kristian Høgsberg copyright.
Used by all 3 generators.
protocol/xdg-shell.xml
Stable xdg_shell v1, 5 interfaces: xdg_wm_base, xdg_surface,
xdg_toplevel, xdg_popup, xdg_positioner. Used by all 3 generators.
protocol/wayland-drm.xml
Legacy wl_drm v2, used by native apps (Mesa, XWayland) to advertise dmabuf
buffers. Only the proxy generator processes this file — so the proxy can
intercept create_buffer / create_planar_buffer / create_prime_buffer
and turn dmabuf fds into ProxyFDs for the browser's GStreamer encoding
pipeline. Browser-side libs do not generate wl_drm stubs.
protocol/web-bitmapbuf-unstable-v1.xml — Greenfield's custom extension
This is Greenfield's own Wayland protocol extension for in-browser JS/WASM clients. It defines a single singleton interface:
web_bitmapbuf_factory(v1) with one requestcreate_buffer(new_id wl_buffer id, fd bitmap)— "Create a wl_buffer object by wrapping an HTML bitmap, so it can be used with a surface."
The fd argument is an HTML bitmap file descriptor (a Greenfield-specific
construct: an ImageBitmap-like object exposed as an FD-like handle). This
lets JavaScript or WebAssembly Wayland clients back a surface with a native
browser bitmap (ImageBitmap, OffscreenCanvas transferable) and zero-copy
it into the compositor's WebGL texture pipeline — bypassing the SHM
pixel-copy path.
The browser-side factory lives in
packages/compositor/src/web/WebBuffersFactory.ts. Not processed by the
proxy generator — native apps never use it.
This is a trade-off worth noting: the fast path is Greenfield-specific. To
get zero-copy ImageBitmap upload to a GPU texture, apps must be
Greenfield-aware (i.e. built against @gfld/client-protocol). Standard
Wayland apps fall back to the H.264-encoded path even when running in the
browser compositor.
ASCII pipeline diagram
┌──────────────────────────────────────────────────────────────────────────────┐
│ PROTOCOL XML SOURCES │
│ wayland.xml xdg-shell.xml wayland-drm.xml web-bitmapbuf-unstable-v1 │
└──────────┬──────────────┬──────────────┬─────────────────┬─────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ client- │ │ compositor- │ │ compositor-proxy- │ │ (no proxy gen for │
│ generator │ │ generator │ │ generator │ │ web-bitmapbuf) │
│ ProtocolParser │ │ ProtocolParser │ │ EndpointProtocol │ │ │
│ .mjs │ │ .mjs │ │ Parser.mjs │ │ │
└───────┬────────┘ └───────┬────────┘ └─────────┬─────────┘ └─────────┬──────────┘
│ xml2js │ xml2js │ xml2js │
▼ ▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ wayland.ts │ │ wayland.ts │ │ *_interface.js │ │ wayland.ts │
│ xdg_shell.ts │ │ xdg_shell.ts │ │ *_interceptor.js │ │ xdg_shell.ts │
│ web_bitmapbuf.ts│ │ web_bitmapbuf.ts│ │ (wayland,xdg,drm) │ │ web_bitmapbuf.ts │
│ *Proxy classes │ │ *Resource + │ │ wl_interface structs│ │ *Proxy classes │
│ *Events ifaces │ │ *Requests │ │ + R*/E* handlers │ │ *Events interfaces │
│ *ProtocolName │ │ *ProtocolName │ │ + requestHandlers │ │ *ProtocolName │
│ *Enum │ │ *Enum │ │ /eventHandlers │ │ *Enum │
└───────┬─────────┘ └───────┬─────────┘ └──────────┬──────────┘ └─────────┬──────────┘
│ imports │ imports │ require()'d │ imports
▼ ▼ ▼ ▼
┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ @gfld/common │ │ packages/compositor-proxy/ │
│ Connection.marshallMsg() ── writes │ │ src/wayland-server.ts │
│ u32 id | u16 opcode | u16 size | args │ │ ↳ re-exports wayland-server-addon │
│ Connection.message() ── parses header, │ │ ↳ MessageInterceptor (R*/E* dispatch) │
│ dispatches wlObject[opcode](message) │ │ ↳ unmarshallArgs(message, 'usun') etc │
│ Per-arg marshallers: uint/int/fixed/ │ │ NativeWaylandClientSession.ts │
│ string/array/object/objectOptional/ │ │ ↳ onWireMessageRequest → interceptRequest │
│ newObject/fileDescriptor │ │ ↳ onWireMessageEnd → pack ProxyFD JSON │
│ Per-arg unmarshallers: u/i/f/s/a/o/n/h │ │ ↳ onWireMessageEvents → sendEvents (C) │
└──────────────┬───────────────────────────┘ └─────────────────────┬────────────────────────┘
│ │
▼ ▼
┌────────────────────────────────────────┐ ┌────────────────────────────────────────────────────┐
│ Browser (client iframe) │ │ Node (compositor-proxy) │
│ │ │ │
│ DisplayImpl.messagePort.onmessage ←───┼─────────┼─ protocolChannel (WS/KCP) sends/receives │
│ connection.message({buffer, fds}) │ │ [u32 oob-op][u32 lastSerial][u32 fdCount] ... │
│ wlObject[opcode](message) │ │ [u32 len][ProxyFD JSON, padded][wayland msg bytes]│
│ listener?.<eventName>(...unmarshalled) │ │ │
│ │ │ ← from native: wl_client wire callback (C addon) │
│ proxy.marshall(id, opcode, [args]) │ │ → to native: sendEvents(wlClient, buf, fds) (C) │
│ connection.marshallMsg writes Uint32 │ │ │
│ onFlush → postMessage(buf, transfer) │ │ Native libwayland-server │
│ ↳ buffer + ArrayBufferView/ImageBitmap│ │ accepts socket from native wayland apps │
│ /MessagePort as transferables │ │ parses wire bytes, dispatches to interceptors │
└────────────────────────────────────────┘ └────────────────────────────────────────────────────┘
WIRE FORMAT (identical on all links — standard Wayland):
┌─────────────┬──────────┬──────────┬─────────────────────────────────────────────┐
│ object id │ opcode │ size │ args (variable per arg type, see §4) │
│ uint32 │ uint16 │ uint16 │ u,i,f,n,o = 4 bytes; s,a = 4+len+pad; │
│ (4 bytes) │ (2 B) │ (2 B) │ h = 0 bytes, FD goes in side-band fds[] │
└─────────────┴──────────┴──────────┴─────────────────────────────────────────────┘
▲
└── size includes this 8-byte header. Total message size = size field.
FD transport varies per link:
Browser↔Browser (MessagePort): FD is a transferable JS object (ArrayBufferView, ImageBitmap, MessagePort).
Browser↔Proxy (WebSocket/KCP): FD is a JSON ProxyFD {handle, type, host}, length-prefixed + 4-byte aligned, prepended before the message header.
Native App↔Proxy (libwayland socket): FD is a real POSIX fd, sent via SCM_RIGHTS by libwayland-server.
Build-time workflow
If you edit a protocol XML in your fork, you regenerate stubs by running
the matching generate script:
yarn workspace @gfld/client-protocol generate— regenerates browser client stubs.yarn workspace @gfld/compositor-protocol generate— regenerates browser compositor stubs.yarn workspace @gfld/compositor-proxy generate— regenerates proxy interceptor modules, thenbuild:native(because*_interface.jsdeclareswl_interfacestructs the N-API addon consumes).
To add a new Greenfield-only extension: drop XML in /protocol/, add a
generate:<name> script to client-protocol / compositor-protocol
package.json, regenerate. Only add to compositor-proxy if native apps
will speak it.
Notable file references
libs/client-generator/{ProtocolParser, ProtocolArguments, index}.mjslibs/compositor-generator/{ProtocolParser, ProtocolArguments, index}.mjslibs/compositor-proxy-generator/src/{EndpointProtocolParser, EndpointProtocolArguments, index}.mjslibs/common/src/Connection.ts— the runtime wire codec (shared)libs/compositor-protocol/src/westfield-runtime-server.ts— server-side runtimelibs/client-protocol/src/westfield-runtime-client.ts— client-side runtimepackages/compositor-proxy/src/wayland-server.ts— proxy's TS wrapper around the N-API addonpackages/compositor-proxy/src/NativeWaylandClientSession.ts— per-client message interceptor dispatchpackages/compositor-proxy/src/protocol/wl_*_interceptor.js— generated per-opcode routing decisionspackages/compositor/src/web/WebBuffersFactory.ts— browser-side implementation of the customweb_bitmapbuf_factoryglobal
One stale file spotted
libs/compositor-protocol/index.js (CommonJS, references non-existent
./src/Resource / ./src/Client / ./src/Display / ./src/Global). The
actual entry point is src/index.ts. Safe to delete in a fork.