Files
amethyst/nestsClient/plans/2026-04-26-moq-lite-gap.md
T
Claude 8b5af5d496 docs(audio-rooms): refresh against shipped state + nostrnests gap audit
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.

  - 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
    layout: implementation table for every protocol/transport/UI
    surface, "pending" table for the remaining items (reconnect,
    level meters, Desktop / iOS, Nests parity), pointers section
    refreshed.
  - 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
    that landed it (fb47a4c71cf99d015b0d7); "When picking up"
    section now points at the shipped surface first, raw protocol
    references second.
  - 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
    today's nostrnests reality:
      * status banner up top calling out the revision
      * dependencies dropped IETF MoQ-transport, added moq-lite
        Lite-03 + ALPN "moq-lite-03"
      * HTTP control plane: GET <service>/<room-d-tag> → POST /auth
        with {namespace, publish}, returning {token}; documented the
        JWT claim shape (root, get, put), 600 s lifetime, regex
        on `namespace`, JWKS endpoint, error matrix
      * Audio transport: replaced IETF SETUP / TrackNamespace tuples
        / OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
        per-bidi message types, group uni streams, audio/data track,
        no in-band SETUP, FIN-as-unsubscribe semantics)
      * New event-kind sections: kind 4312 (admin command / kick),
        kind 10112 (audio-room server list)
      * Reconciliation section explaining what changed from the
        original draft and why
  - NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
    every nostrnests/NestsUI feature we don't yet ship, sourced from
    a code-walk of the React app + moq-auth + API.md (which is
    LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
    reactions, role parsing + promotion, hand-raise queue, kick
    (kind 4312), edit/close room, scheduled rooms, listener counter.
    Tier 2: participant grid, augmented presence tags
    (publishing/onstage), per-avatar context menu + zap, share via
    naddr. Tier 3: room theming. Tier 4: token-refresh +
    Connection.Reload sanity checks.

Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
2026-04-26 19:23:41 +00:00

17 KiB
Raw Blame History

Plan: bridge the moq-lite protocol gap

Status: DONE — listener + speaker on moq-lite Lite-03 against the real nostrnests stack. Phases 5a → 5d landed (fb47a4c71cf99d); follow-up cleanup + harness fixes shipped through 015b0d7. Default :nestsClient:jvmTest passes; integration tests gated by -DnestsInterop=true work end-to-end against a Docker'd nostrnests deployment.

Origin: discovered while writing the nostrnests interop test suite (phases 14).

Discovery

:nestsClient implements IETF draft-ietf-moq-transport-17 (TrackNamespace tuples, CLIENT_SETUP / SERVER_SETUP, OBJECT_DATAGRAM with track_alias, two-message ANNOUNCE / SUBSCRIBE shape).

The actual nostrnests stack runs on moq-lite — kixelated's own MoQ flavour, wire-incompatible with IETF MoQ-transport:

  • JS client NestsUI-v2/package.json depends on @moq/lite, @moq/publish, @moq/watch. None implement IETF MoQ-transport.
  • Rust relay kixelated/moq-rs is built on rs/moq-lite/ types throughout.

The phase-4 wire fixes (path = /<namespace>, JWT in ?jwt= query) are on the moq-rs wire shape and let the WebTransport CONNECT succeed. But the first MoQ-framing message we send afterwards (IETF ClientSetup) is unintelligible to moq-rs's moq-lite framing.

Wire spec — moq-lite (Lite-03)

Fully extracted from kixelated/moq-rs/rs/moq-lite/src/ and @moq/lite v0.1.7.

Connection setup

  • ALPN advertised on the WebTransport upgrade: prefer "moq-lite-03" (Lite.ALPN_03); fall back to "moql" (legacy combined ALPN that requires a SETUP exchange). Source: rs/moq-lite/src/version.rs:21-26, @moq/lite/connection/connect.js:277.
  • Lite-03 has NO setup or control message inside the WT session. The WebTransport handshake itself is the handshake. Both sides go straight to opening per-purpose streams. client.rs:86-101, connect.js:113-115.
  • Legacy Lite-01/02 ("moql") DO open a bidi setup stream with byte 0x20ClientSetup (Draft-14 IETF-format), reply 0x21ServerSetup, then exchange SessionInfo {bitrate: u62} forever. We are targeting Lite-03 only.
  • Version is chosen entirely by ALPN. No version, role, or setup parameter is exchanged inside the WT session.

Streams + datagrams

  • No persistent control stream. Each control "request" is a fresh client-initiated bidi, whose first byte is a varint ControlType discriminator and whose body is a size-prefixed message. The bidi stays open for the duration of the request/response/stream.
  • ControlType codes (lite/stream.rs:7-15): Session=0 (unused in Lite-03), Announce=1, Subscribe=2, Fetch=3, Probe=4. All encoded as QUIC varints.
  • Media flows on uni streams, one stream per group. Uni-stream type byte: DataType::Group = 0 (varint, in practice a literal 0 byte). lite/stream.rs:32-36, publisher.js:209.
  • No QUIC datagrams used for media in Lite-03. Each group is its own uni stream; no datagram path.

Announce

Two-step, on a single bidi opened by the subscriber:

  1. Subscriber → publisher (or relay). Bidi opens with control byte varint(1) = Announce, then size-prefixed AnnouncePlease:

    prefix: string   (varint length + UTF-8; broadcast-name prefix
                      the subscriber cares about; empty = "everything")
    

    Source: subscriber.rs:82-89, subscriber.js:46-47, announce.rs:64-81.

  2. Publisher → subscriber. Server emits one size-prefixed Announce message per active broadcast, then keeps the bidi open for live updates:

    status: u8       (0 = Ended, 1 = Active)
    suffix: string   (broadcast path with `prefix` stripped;
                      normalised — `path.rs:78-99`)
    hops:   u62      (Lite-03 only; varint, relay routing depth)
    

    Source: announce.rs:84-90, announce.rs:28-31. active=true is sent on first publish; active=false on explicit unannounce. Disconnect is not an explicit Ended (see Cleanup).

Subscribe

Subscriber opens a fresh bidi, control byte varint(2) = Subscribe, then size-prefixed body:

id          u62 varint   (subscriber-chosen, monotonic)
broadcast   string       (varint length + UTF-8 path)
track       string       (opaque app string —
                          "audio/data" or "catalog.json")
priority    u8           (raw byte 0..255; NOT a varint)
ordered     u8           (Lite-03 only; 0 / 1)
maxLatency  varint       (Lite-03; **milliseconds**, 0 = unlimited)
startGroup  varint       (Lite-03; 0 = "from latest",
                          else group_seq + 1)
endGroup    varint       (Lite-03; 0 = "no end",
                          else group_seq + 1)

Source: subscribe.rs:25-72, subscribe.js:87-104, encode.rs:99-185.

Reply: size-prefixed SubscribeResponse on the same bidi. Lite-03 prefixes a varint type: 0 = Ok, 1 = Drop. SubscribeOk body is (priority, ordered, maxLatency, startGroup, endGroup) — same five fields as Subscribe minus id/broadcast/track.

There is no SUBSCRIBE_ERROR. Failure = stream RESET with an Error::to_code() u32. Track names are arbitrary opaque UTF-8.

Frame / group / object delivery

For each group, the publisher opens a fresh uni stream:

DataType (varint) = 0       (Group)
Group header (size-prefixed):
    subscribe   u62 varint  (echoes Subscribe.id)
    sequence    u62 varint  (group sequence number)

then a sequence of frames until the stream's FIN:
    frame_size  varint      (length in bytes)
    payload     frame_size raw bytes

Source: publisher.rs:330-393, publisher.js:205-232, subscriber.js:152-161.

No per-frame envelope beyond size. No timestamp, no codec config, no flags. All semantic structure (Opus packet boundaries, JSON document) is the track's app-layer convention. End-of-group is QUIC stream FIN.

Cleanup / unsubscribe / unannounce

  • Per-group end / track ended: QUIC FIN the uni stream (publisher.rs:387-388).
  • Broadcast ended: send Announce {status=0=Ended, suffix, hops} on the announce bidi (publisher.rs:206, publisher.js:118).
  • Unsubscribe: FIN the subscribe bidi's send side (subscriber.rs:230). No UNSUBSCRIBE message exists.
  • Cancel a single group from the receiver: QUIC STOP_SENDING on the uni stream.
  • Mid-broadcast publisher disconnect: relay either FINs/resets the announce bidi or emits Announce::Ended if graceful. Consumers detect via the bidi/QUIC close — there is no "publisher gone" message.
  • Errors on any stream: RESET_STREAM with Error::to_code() (u32).

Varint encoding

RFC 9000 §16 QUIC varints (2-bit length tag, 1/2/4/8 byte forms, max value 2^62 1). coding/varint.rs:172-239, stream.js:147-170. The stream.js u53 reader caps at the JS-safe range. priority is a plain byte. Strings = varint length + UTF-8. bool = 1 byte 0/1.

Notable + non-obvious

  • Same WT session is bidirectional w.r.t. roles. Either side can open an Announce or Subscribe bidi; the role is per-bidi via the ControlType byte. There is no role announcement.
  • Path normalisation is mandatory on both sides — leading, trailing, and duplicate / are stripped before encoding. A wire path "/foo//bar/" is identical to "foo/bar". An interop client that doesn't normalise will see broadcast lookups silently fail.
  • startGroup/endGroup use the off-by-one trick: 0 = None, n = Some(n 1). Easy to get wrong.
  • maxLatency is in milliseconds. Not seconds, not microseconds.
  • No head-of-line blocking across groups (one uni stream per group), but no in-order guarantee across them either. The receiver uses sequence to reorder.
  • hops is the only relay-routing metadata on the wire (Lite-03).
  • Probe stream (ControlType=4) is opened by the subscriber but the publisher writes Probe { bitrate: u62 } size-prefixed messages on it — opposite direction from a normal request/response.

Concrete wire shapes nests-side

Wire field JS reference value
WT URL path /nests/30312:<host>:<roomId>
?jwt= query JWT (claims.root = the same path)
claims.put (publish) [<myPubkey>]
claims.get [""]
ANNOUNCE.suffix <myPubkey> (single string)
SUBSCRIBE.broadcast <speakerPubkey> (single string)
SUBSCRIBE.track "catalog.json" then "audio/data"

Implementation status (2026-04-26 PM)

Landed (listener path complete end-to-end through :nestsClient):

Phase Commit Surface
5a fb47a4c MoqLitePath (mandatory wire-boundary normalisation), MoqLitePathTest
5b fb47a4c MoqLiteCodec + every Lite-03 message type + MoqLiteCodecTest (round-trip + negative paths)
5c 4e136ca MoqLiteSession.client(...) (no SETUP), announce, subscribe, group uni-stream demux, framing helpers, MoqLiteSessionTest
5d 41f4dcd connectNestsListener swap — MoqLiteNestsListener adapts MoqLiteFrameMoqObject for downstream AudioRoomPlayer / AudioRoomViewModel. WT URL path = /<namespace>?jwt=<token>.

Speaker path landed (phase 5c-speaker):

Layer Surface
WebTransportSession incomingBidiStreams() (relay → us) + openUniStream() (group push) added.
:quic WtPeerStreamDemux StrippedWtStream carries optional send + finish closures (driver-aware) so peer-initiated bidis can write back; demux now takes the QuicConnectionDriver so wakeups fire after each app-level write.
FakeWebTransport now exposes incomingBidiStreams + openUniStream directly, so production callers and tests use the same API.
MoqLiteSession New publish(suffix) returns a MoqLitePublisherHandle. The session lazily launches a relay→us bidi pump that dispatches by ControlType: Announce → reply Active(suffix=ours); Subscribe → reply SubscribeOk and register an inbound subscription. MoqLitePublisherHandle.send opens a uni stream per group with DataType=0 + group header + varint(size)+payload frames; endGroup FINs the current uni; close emits Announce(Ended) on every active announce bidi.
MoqLiteNestsSpeaker NestsSpeaker adapter that wraps a MoqLiteSession + AudioRoomMoqLiteBroadcaster (Opus encode + push loop, mirror of the IETF AudioRoomBroadcaster but driving the moq-lite publisher).
connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and returns MoqLiteNestsSpeaker.

The existing integration tests (round-trip, multi-peer, fan-out, subscribe-before-announce) drive the end-to-end public API — they should now pass against the real Docker'd nostrnests stack when the host runs them with -DnestsInterop=true.

Implementation plan (original spec — kept for reference)

Phase 5a — codec primitives (1 day)

  • MoqLiteVarint — RFC 9000 varint reader/writer (we already have Varint in :quic; reuse).
  • MoqLitePath — string with normalize() (strip leading/trailing/ duplicate /) + join(prefix, suffix).
  • MoqLiteWriter / MoqLiteReader — varint, length-prefixed string, size-prefixed message envelope.

Phase 5b — message codec (12 days)

  • MoqLiteAnnouncePlease(prefix: String)
  • MoqLiteAnnounce(status: AnnounceStatus, suffix: String, hops: Long)
  • MoqLiteSubscribe(id, broadcast, track, priority, ordered, maxLatency, startGroup, endGroup)
  • MoqLiteSubscribeOk(priority, ordered, maxLatency, startGroup, endGroup)
  • MoqLiteSubscribeDrop(...) (decode-only)
  • MoqLiteGroupHeader(subscribeId, sequence)
  • MoqLiteFrame(payload)
  • Round-trip tests against hand-rolled byte sequences — exactly the pattern MoqCodecTest uses today.

Phase 5c — session layer (35 days)

  • MoqLiteSession parallel to MoqSession:
    • client(transport, scope) — no SETUP step; just spawn pumps.
    • announce(suffix) — subscriber-side: opens bidi with ControlType=Announce + AnnouncePlease(prefix=""), returns a flow of incoming Announce updates.
    • publish(suffix) — publisher-side: opens an uni-stream-per-group pump under our broadcast.
    • subscribe(broadcast, track) — opens bidi with ControlType=Subscribe + body, awaits SubscribeOk, returns a MoqLiteSubscribeHandle whose frames flow yields each frame grouped by sequence.
    • close() — close all streams.
  • Path normalisation applied automatically at every wire boundary.
  • ALPN wired through :quic's WT factory: "moq-lite-03".

Phase 5d — production wiring (1 day)

  • Replace MoqSession.client(...) calls in connectNestsListener / connectNestsSpeaker with MoqLiteSession.client(...).
  • subscribeSpeaker(pubkey) becomes:
    • subscribe (broadcast=pubkey, track="catalog.json") — discover metadata
    • subscribe (broadcast=pubkey, track="audio/data") — receive Opus frames
  • Speaker side: announce (suffix=ourPubkey), publish audio/data frames as one group per session (or rotate groups periodically).
  • Drop the TrackNamespace plumbing on the nests side — it doesn't apply to moq-lite.

Phase 5e — integration tests (existing)

  • The -DnestsInterop=true round-trip + multi-peer tests should now pass against the real Docker'd moq-rs. No test rewrites needed; the tests drive connectNestsSpeaker / connectNestsListener and listener.subscribeSpeaker(pubkey) — purely through the public API.

Decision points still open

  • Keep IETF MoQ-transport code (option A) vs delete (option B)? Recommend A — keeps MoqSession reachable for any future IETF target, and the unit-test suite is genuinely useful as a reference implementation. The IETF code is ~1.5k LOC of well-tested codec that costs ~nothing to keep around.
  • Where does MoqLiteSession live? Same package (com.vitorpamplona.nestsclient.moq) under a lite/ subpackage — matches the upstream rs/moq-lite/ layout.

When picking up

This doc captures the wire spec used during implementation. For the shipped surface, start from:

  • nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodec, MoqLiteSession, MoqLitePath, MoqLiteFraming.
  • nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ MoqLiteNestsListener.kt / MoqLiteNestsSpeaker.kt.
  • nestsClient/src/jvmTest/.../interop/ — Docker-driven interop tests (auth, round-trip, multi-peer, endpoint smoke).

For raw protocol reference:

  • ~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/ — JS reference once the harness has run once.
  • kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,path}.rs — canonical Rust implementation.
  • moq-auth/src/index.ts:160-166claims.put = [pubkey] rule.