Files
amethyst/nestsClient/plans/2026-04-26-moq-lite-gap.md
T
Claude 71cf99dc22 feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.

Transport layer:
  - WebTransportSession.incomingBidiStreams() — peer-initiated bidi
    flow. moq-lite publishers receive Announce + Subscribe bidis from
    the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
    Stream::accept(session)), so the abstraction grew the
    accept-bidi-from-peer surface.
  - WebTransportSession.openUniStream() — locally-opened uni stream
    for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
    session.open_uni()).
  - :quic WtPeerStreamDemux StrippedWtStream now carries optional
    send/finish closures. The demux takes the QuicConnectionDriver
    so wakeups fire after each app-level write on a peer-initiated
    bidi.
  - FakeWebTransport now exposes incomingBidiStreams + openUniStream
    directly; the openPeerUniStream test helper went away (production
    flow covers it).

Session layer:
  - MoqLiteSession.publish(suffix) — claims a broadcast suffix and
    lazily launches a relay→us bidi pump. ControlType=Announce reads
    AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
    body, replies SubscribeOk, registers the inbound subscription.
  - MoqLitePublisherHandle — startGroup / send / endGroup / close
    semantics. send opens a uni stream per group with DataType=0 +
    GroupHeader and pushes varint(size)+payload frames. close emits
    Announce(Ended) on every active announce bidi, FINs the uni.

Application layer:
  - AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
    drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
    its unit tests).
  - MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
    MoqLiteNestsListener on the publish side.
  - connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
    returns MoqLiteNestsSpeaker.

Tests:
  - 4 new MoqLiteSessionTest cases:
    publisher_replies_to_announcePlease_with_active_announce,
    publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
    publisher_send_returns_false_when_no_inbound_subscriber,
    publisher_close_emits_ended_announce.

Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.

Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
2026-04-26 18:01:00 +00:00

308 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: bridge the moq-lite protocol gap
**Status:** **listener AND speaker sides done** (phases 5a → 5d, commit
range `fb47a4c``5914e9e` + the speaker phase 5c-speaker landing in
this PR). Default `:nestsClient:jvmTest` suite passes; downstream
`:commons` and `:amethyst` compile clean.
**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
`0x20``ClientSetup` (Draft-14 IETF-format), reply `0x21`
`ServerSetup`, 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 `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 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
- Read `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/`
for the JS reference.
- Read `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,
path}.rs` for the canonical Rust implementation.
- The nests-side `claims.put = [pubkey]` rule is in
`moq-auth/src/index.ts:160-166`.