Files

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:

  1. client-generator → browser apps that want to be Wayland clients of the browser compositor (via MessagePort). These are the same-origin iframe JS/WASM apps that ship in examples/webapps/.
  2. compositor-generator → the browser-resident compositor itself (@gfld/compositor). It receives requests, dispatches them to *Requests implementation classes, and emits events back.
  3. compositor-proxy-generator → the Node.js native proxy bridging real libwayland-client apps to the browser. Emits wl_interface struct 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:

  • ArrayBufferView for wl_shm.create_pool's fd (SHM backing memory lives in browser, zero-copy transferred via MessagePort.postMessage(..., transferList)).
  • ImageBitmap for web_bitmapbuf_factory.create_buffer's bitmap arg (GPU-backed texture, zero-copy).
  • MessagePort for wl_data_offer.receive's fd (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:

  • handle is the proxy's internal numeric fd id
  • type is the kind of fd (so the browser knows what to do with it)
  • host is 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 ↔ ProxyFD JSON.
  • 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 before done event)
    • wl_display.get_registry → both
    • wl_registry.bind → native if id is in nativeGlobalNames[] (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 request create_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, then build:native (because *_interface.js declares wl_interface structs 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}.mjs
  • libs/compositor-generator/{ProtocolParser, ProtocolArguments, index}.mjs
  • libs/compositor-proxy-generator/src/{EndpointProtocolParser, EndpointProtocolArguments, index}.mjs
  • libs/common/src/Connection.ts — the runtime wire codec (shared)
  • libs/compositor-protocol/src/westfield-runtime-server.ts — server-side runtime
  • libs/client-protocol/src/westfield-runtime-client.ts — client-side runtime
  • packages/compositor-proxy/src/wayland-server.ts — proxy's TS wrapper around the N-API addon
  • packages/compositor-proxy/src/NativeWaylandClientSession.ts — per-client message interceptor dispatch
  • packages/compositor-proxy/src/protocol/wl_*_interceptor.js — generated per-opcode routing decisions
  • packages/compositor/src/web/WebBuffersFactory.ts — browser-side implementation of the custom web_bitmapbuf_factory global

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.