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 (fb47a4c → 71cf99d → 015b0d7); "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).
17 KiB
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 (fb47a4c →
71cf99d); 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 1–4).
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.jsondepends on@moq/lite,@moq/publish,@moq/watch. None implement IETF MoQ-transport. - Rust relay
kixelated/moq-rsis built onrs/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 byte0x20→ClientSetup(Draft-14 IETF-format), reply0x21→ServerSetup, then exchangeSessionInfo {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
ControlTypediscriminator and whose body is a size-prefixed message. The bidi stays open for the duration of the request/response/stream. ControlTypecodes (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 literal0byte).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:
-
Subscriber → publisher (or relay). Bidi opens with control byte
varint(1)=Announce, then size-prefixedAnnouncePlease: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. -
Publisher → subscriber. Server emits one size-prefixed
Announcemessage 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=trueis sent on first publish;active=falseon 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_SENDINGon the uni stream. - Mid-broadcast publisher disconnect: relay either FINs/resets the
announce bidi or emits
Announce::Endedif graceful. Consumers detect via the bidi/QUIC close — there is no "publisher gone" message. - Errors on any stream:
RESET_STREAMwithError::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
ControlTypebyte. There is no role announcement. Pathnormalisation 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/endGroupuse the off-by-one trick:0 = None, n = Some(n − 1). Easy to get wrong.maxLatencyis 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
sequenceto reorder. hopsis 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 MoqLiteFrame → MoqObject 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 haveVarintin:quic; reuse).MoqLitePath— string withnormalize()(strip leading/trailing/ duplicate/) +join(prefix, suffix).MoqLiteWriter/MoqLiteReader— varint, length-prefixed string, size-prefixed message envelope.
Phase 5b — message codec (1–2 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
MoqCodecTestuses today.
Phase 5c — session layer (3–5 days)
MoqLiteSessionparallel toMoqSession:client(transport, scope)— no SETUP step; just spawn pumps.announce(suffix)— subscriber-side: opens bidi with ControlType=Announce + AnnouncePlease(prefix=""), returns a flow of incomingAnnounceupdates.publish(suffix)— publisher-side: opens an uni-stream-per-group pump under our broadcast.subscribe(broadcast, track)— opens bidi with ControlType=Subscribe + body, awaitsSubscribeOk, returns aMoqLiteSubscribeHandlewhoseframesflow 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 inconnectNestsListener/connectNestsSpeakerwithMoqLiteSession.client(...). subscribeSpeaker(pubkey)becomes:- subscribe
(broadcast=pubkey, track="catalog.json")— discover metadata - subscribe
(broadcast=pubkey, track="audio/data")— receive Opus frames
- subscribe
- Speaker side: announce
(suffix=ourPubkey), publishaudio/dataframes as one group per session (or rotate groups periodically). - Drop the
TrackNamespaceplumbing on the nests side — it doesn't apply to moq-lite.
Phase 5e — integration tests (existing)
- The
-DnestsInterop=trueround-trip + multi-peer tests should now pass against the real Docker'd moq-rs. No test rewrites needed; the tests driveconnectNestsSpeaker/connectNestsListenerandlistener.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
MoqSessionreachable 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
MoqLiteSessionlive? Same package (com.vitorpamplona.nestsclient.moq) under alite/subpackage — matches the upstreamrs/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-166—claims.put = [pubkey]rule.