Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ player.on('catch_up_changed', ({ active, rate, latencyMs }) => { ... });

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `draftVersion` | 14 \| 16 \| 18 | 16 | Protocol version (14 for moq-rs / Red5 compat, 18 for draft-18 relays) |
| `draftVersion` | 14 \| 16 \| 18 \| 21 | 16 | Protocol version (14 for moq-rs / Red5 compat, 18 or 21 for relays speaking those drafts) |
| `maxRequestId` | number | 100 | Initial MOQT MAX_REQUEST_ID (auto-replenished) |
| `knownTracks` | object | — | Pre-known codec metadata for TTFF optimization |
| `catalog` | `{tracks}` | — | Inject catalog externally, skip catalog subscription |
Expand All @@ -241,6 +241,7 @@ player.on('catch_up_changed', ({ active, rate, latencyMs }) => { ... });

## Protocol Support

- **draft-ietf-moq-transport-21** - experimental on this development branch; opt-in (`draftVersion: 21` / `moqt-21`)
- **draft-ietf-moq-transport-18** — uni control-stream pair + per-request bidi streams (`draftVersion: 18` / `moqt-18`)
- **draft-ietf-moq-transport-16** — default supported transport draft
- **draft-ietf-moq-transport-14** — Red5/moq-rs interop (`draftVersion: 14`)
Expand All @@ -262,6 +263,7 @@ are not implemented. Callers with track-scoped defaults can pass `track` to

Browser WebTransport may expose `transport.protocol`, enabling automatic draft detection from the negotiated `WT-Available-Protocols`:

- `moqt-21` → draft 21
- `moqt-18` → draft 18
- `moqt-16` → draft 16
- `moq-00` → draft 14
Expand All @@ -276,7 +278,7 @@ For **draft-14 relays** (moq-rs, Red5, moqtail), you must explicitly specify the
const conn = new MoqtConnection(14); // required — CLIENT_SETUP is draft-specific
```

`MoqtConnection` auto-detects the draft from **any** `WebTransportLike` whose `protocol` exposes a supported token (`moqt-18`, `moqt-16`, or `moq-00`) — there's nothing factory-specific about detection. The browser transport factory is just the convenience that sets the WebTransport `protocols` offer for you. If you construct your own `WebTransport`, pass the appropriate `protocols` option yourself and make sure `transport.protocol` is readable; Playa reads it the same way. Some Node/polyfill transports may not support `protocols` yet.
`MoqtConnection` auto-detects the draft from **any** `WebTransportLike` whose `protocol` exposes a supported token (`moqt-21`, `moqt-18`, `moqt-16`, or `moq-00`) — there's nothing factory-specific about detection. The browser transport factory is just the convenience that sets the WebTransport `protocols` offer for you. If you construct your own `WebTransport`, pass the appropriate `protocols` option yourself and make sure `transport.protocol` is readable; Playa reads it the same way. Some Node/polyfill transports may not support `protocols` yet.

Node applications can use the experimental native QUIC binding for draft 18:

Expand All @@ -290,9 +292,32 @@ await connection.connect(transport);
```

`@openmoq/quic` requires a Node build configured and launched with
`--experimental-quic`. It offers only the `moqt-18` ALPN, requires QUIC
`--experimental-quic`. It offers `moqt-18` by default, or `moqt-21` with
`{ draft: 21 }`, requires QUIC
DATAGRAM negotiation, disables 0-RTT, and does not fall back to WebTransport.

#### draft 21 (experimental)

Draft 21 (`new MoqtConnection(21)`, `draftVersion: 21`, ALPN/WT protocol `moqt-21`) keeps the draft-18 stream model and adds:

- **Location filters.** SUBSCRIBE and FETCH carry their range in LOCATION_FILTER. `SubscriptionFilter` gains `RelativeStart` (`groups: N` starts at group Largest + 1 - N, so 1 is the current group) and an optional inclusive `endObject` on `AbsoluteRange`.
- **Fills instead of Joining FETCH.** Draft 21 has no Joining FETCH. `subscribe()` / `subscribeTrack()` take `fill: { filter?: SubscriptionFilter, groupOrder?: GroupOrder }`. Omitted fields inherit the subscription policy. The fill is bounded by the Largest Location in its response; Forward=0 or an empty range opens no fill stream. Its FETCH_HEADER carries the SUBSCRIBE's or REQUEST_UPDATE's Request ID. There is no FETCH_OK: FIN completes the fill and reset fails it. `cancelFill(requestId)` stops a fill without cancelling live delivery; passing the subscription ID stops all of its fills. The publisher uses `openFillStream(requestId)` to serve it.
- **Player.** The MSF-01 catalog bootstrap and warm start ask for the current group as a fill (`LOCATION_FILTER [1]`). A SUBSCRIBE_OK without a Largest Object means an empty track, so the player waits for the first live catalog object and does not expect a fill.
- **Other changes.** PUBLISH_STATE_NOTIFY on the subscription stream advances the Largest Location. GOAWAY has no Request ID. The End of Timed-Out Range fetch marker (`0x20C`) is accepted. PUBLISH_DONE `0x3` no longer exists.

- **REQUEST_UPDATE.** `requestUpdate()` also takes `fill`; that fill stream carries the update's Request ID. `SetupOptions.maxRequestUpdates` advertises MAX_REQUEST_UPDATES. Updates never exceed the peer's limit, and a peer that exceeds ours closes the session with TOO_MANY_REQUEST_UPDATES.
- **Several subscriptions to one track.** Draft 21 allows them, and a publisher may give them one Track Alias. Each subscription's filter controls delivery. Ending one leaves the others running. Publisher calls to `openSubgroup()` and `sendDatagram()` must supply `requestId` when the alias has several active owners, so cancellation and terminal stream counts remain attributable to the right request.
- **Native QUIC.** `connectQuic(uri, { draft: 21 })` offers `moqt-21`.

Range Filters beyond LOCATION_FILTER are not implemented. The endpoint advertises
the default MAX_FILTER_RANGES=0 and rejects requests that exceed it with
INVALID_FILTER rather than ignoring the filter.

Main remains on drafts 14/16/18. Draft 21 stays opt-in on this branch while
conformance and independent interoperability are evaluated. See
[draft development](docs/draft-development.md) for branch policy and the unresolved
FETCH End-of-Range framing question. Existing draft defaults and framing are unchanged.

#### draft-18 known gaps (non-blocking)

draft-18 support is functional for the subscriber and publisher paths. The deliberately deferred edges are documented as intentional gaps rather than silently dropped:
Expand Down
39 changes: 39 additions & 0 deletions docs/draft-development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Transport Draft Development

Main supports transport drafts 14, 16, and 18. This branch adds experimental
draft-21 support without changing the default draft. Later drafts can develop
here without making main an experimental release channel.

## Keeping Main and This Branch Aligned

- Land fixes for existing drafts on main first, then merge main into this branch.
- Keep draft-only codecs, parameter definitions, and conformance tests separate.
- Share lifecycle machinery where the ownership model is the same. Gate changed
wire rules by draft and test both sides of each boundary.
- Avoid broad renames, formatting changes, or duplicate session implementations.
- Merge main regularly, including its regression tests. Do not wait for a draft
release to bring in player, browser, or catalog fixes.
- Keep draft-specific fixes separate from maintenance commits where practical.
Updating the experimental draft does not authorize changes to older drafts.

The session and transport adapter still share stream ownership and cancellation
logic. Changes there need the old-draft suites as well as draft-21 loopback tests;
file separation alone does not prove compatibility.

## FETCH End-of-Range Framing

Draft 21 Figure 28 includes Object Payload Length, while section 11.4.1.2's
End-of-Range field omissions do not settle whether it is present. The same
ambiguity exists in draft 18. [Working-group issue 1861](https://github.com/moq-wg/moq-transport/issues/1861)
records the question but does not supply a technical resolution.

This branch uses the marker-only draft-21 form from the initial implementation,
without a payload-length field. That is an explicit interoperability choice, not
a claim that the ambiguity has been resolved. Drafts 14/16/18 retain their
existing zero-length field. Literal-byte tests cover both forms and a following
object, so one draft's change cannot silently shift another draft's parser.

Revisit the draft-21 choice when the text is clarified. Do not add a parser that
guesses between both forms: the optional zero would be ambiguous with subsequent
object bytes. Independent peer testing is still required before promoting the
experimental draft to main or a stable release.
4 changes: 2 additions & 2 deletions examples/broadcast/catalog-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* is included only when {@link BroadcastCatalogParams.audio} is present (a
* screen capture without audio must not advertise an audio track).
*/
import { varint, SubgroupIdMode, PublishDoneCode } from '@openmoq/transport';
import { varint, SubgroupIdMode, PublishDoneCode, isRequestStreamDraft } from '@openmoq/transport';
import type { DraftVersion } from '@openmoq/transport';
import { buildCatalog } from '@openmoq/msf';

Expand Down Expand Up @@ -152,7 +152,7 @@ export async function acceptCatalogSubscribe(
endOfGroup: true,
defaultPriority: true,
subgroupIdMode: SubgroupIdMode.ZERO,
...(wire.draft === 18 ? { firstObject: true } : {}),
...(isRequestStreamDraft(wire.draft) ? { firstObject: true } : {}),
},
);
await connection.sendObject(streamId, varint(0), catalogPayload);
Expand Down
17 changes: 14 additions & 3 deletions examples/broadcast/media-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,8 @@ describe('MediaPublisher — broadcast generations', () => {
});

describe('MediaPublisher — negotiated-draft wire binding', () => {
it('draft-18 subgroup opens set FIRST_OBJECT on video AND audio; 16/14 do not', async () => {
for (const draft of [14, 16, 18] as const) {
it('draft-18 and 21 subgroup opens set FIRST_OBJECT on video AND audio; 16/14 do not', async () => {
for (const draft of [14, 16, 18, 21] as const) {
const conn = recordingConnection();
const pub = makePublisher(conn, { draft });
pub.setVideoAlias(2n);
Expand All @@ -377,7 +377,7 @@ describe('MediaPublisher — negotiated-draft wire binding', () => {
await settle();
expect(conn.opened).toHaveLength(2);
for (const o of conn.opened) {
if (draft === 18) {
if (draft >= 18) {
expect(o.options['firstObject']).toBe(true); // §2.2 MUST for the original publisher
} else {
expect('firstObject' in o.options).toBe(false); // d14/16 bytes preserved
Expand All @@ -386,6 +386,17 @@ describe('MediaPublisher — negotiated-draft wire binding', () => {
}
});

it('draft 21 keeps the draft-18 LOC wire profile', async () => {
const timestampUs = Date.now() * 1000;
const conn = recordingConnection();
const pub = makePublisher(conn, { draft: 21, wallClockUs: () => timestampUs });
pub.setVideoAlias(2n);
pub.publishVideo(chunk(7), { isKeyframe: true, timestampUs });
await settle();
const parsed = parseLocHeaders(conn.sends[0]!.extensions, { wireProfile: locWireProfileForDraft(18) });
expect(parsed.captureTimestamp).toBe(BigInt(Math.round(timestampUs)));
});

it('draft-18 LOC extensions use the vi64 profile: they parse as d18 and are NOT d16 bytes', async () => {
const timestampUs = Date.now() * 1000; // a current capture timestamp
const conn18 = recordingConnection();
Expand Down
7 changes: 4 additions & 3 deletions examples/broadcast/media-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
*/
import { encodeLocHeaders, locWireProfileForDraft, type LocVersion } from '@openmoq/loc';
import type { DraftVersion } from '@openmoq/transport';
import { isRequestStreamDraft } from '@openmoq/transport';

/** The subset of MoqtConnection the media publication path uses. */
export interface MediaPublishConnection {
Expand Down Expand Up @@ -69,7 +70,7 @@ export interface AudioChunkMeta {
export interface MediaPublisherOptions {
/** Wraps a bigint as the wire integer type (the example passes `varint`). */
wrapInt: (n: bigint) => unknown;
/** NEGOTIATED MoQT draft — selects the LOC wire profile and, on draft-18,
/** NEGOTIATED MoQT draft — selects the LOC wire profile and, on draft 18+,
* the mandatory FIRST_OBJECT subgroup bit. Typed (not `number`) so an
* unsupported draft cannot silently inherit draft-16 LOC behavior. */
draft: DraftVersion;
Expand Down Expand Up @@ -99,7 +100,7 @@ export interface MediaPublisherOptions {
}

/** Drafts whose wire behavior this publisher implements explicitly. */
const SUPPORTED_DRAFTS: readonly DraftVersion[] = [14, 16, 18];
const SUPPORTED_DRAFTS: readonly DraftVersion[] = [14, 16, 18, 21];

/** Validate a queue bound: NaN/Infinity would disable backpressure entirely
* and a non-positive or fractional cap has no coherent meaning. */
Expand Down Expand Up @@ -296,7 +297,7 @@ export class MediaPublisher {
hasExtensions: true,
endOfGroup: true,
publisherPriority: priority,
...(this.draft === 18 ? { firstObject: true } : {}),
...(isRequestStreamDraft(this.draft) ? { firstObject: true } : {}),
};
}

Expand Down
1 change: 1 addition & 0 deletions examples/catalog/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="21">21</option>
</select>
</div>
<div class="field">
Expand Down
2 changes: 1 addition & 1 deletion examples/catalog/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ async function run(): Promise<void> {
const ns = (document.getElementById('namespace') as HTMLInputElement).value.trim();
const vRaw = (document.getElementById('draft-version') as HTMLSelectElement).value;
const hashHex = (document.getElementById('cert-hash') as HTMLInputElement).value.trim();
const v: 14 | 16 | 18 | undefined = vRaw === '14' ? 14 : vRaw === '16' ? 16 : vRaw === '18' ? 18 : undefined;
const v: 14 | 16 | 18 | 21 | undefined = vRaw === '14' ? 14 : vRaw === '16' ? 16 : vRaw === '18' ? 18 : vRaw === '21' ? 21 : undefined;

if (!ns) { setStatus('Namespace required.', 'error'); return; }
if (!url) {
Expand Down
Binary file added examples/node-publisher/fixtures/testsrc.mp4
Binary file not shown.
3 changes: 2 additions & 1 deletion examples/node-relay/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { MoqtConnection, IncomingPublish } from '@openmoq/webtransport';
import { MessageParam, RequestError18, SessionError, locationEncodingLength, varint, writeLocation, type Fetch, type Parameters, type StandaloneFetch } from '@openmoq/transport';
import { DEMO_NAMESPACE, DEMO_TRACK, MEDIA_TRACKS, td, nsStr, hex } from './demo.js';
import { SubgroupForwarder, type ForwardLimits } from './subgroup-forwarder.js';
import { isRequestStreamDraft } from '@openmoq/transport';

const log = (...a: unknown[]) => console.log('[relay]', ...a);

Expand Down Expand Up @@ -172,7 +173,7 @@ export class Relay {
const cachedLargest = latestCached(this.tracks.get(key));
let acceptParams: Parameters | undefined;
if (cachedLargest) {
const value = conn.draftVersion === 18
const value = isRequestStreamDraft(conn.draftVersion)
? { group: cachedLargest.groupId, object: cachedLargest.objectId }
: (() => {
const loc = { group: varint(cachedLargest.groupId), object: varint(cachedLargest.objectId) };
Expand Down
5 changes: 3 additions & 2 deletions examples/shared/cert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,13 @@ export const catalogBootstrap: 'auto' | 'joining-fetch' | 'strict' | 'subscribe'
return v === 'auto' || v === 'joining-fetch' || v === 'strict' || v === 'subscribe' ? v : undefined;
})();

/** Draft version override (e.g. ?v=14 for draft-14 relays, ?v=18 for draft-18). */
export const draftVersion: 14 | 16 | 18 | undefined = (() => {
/** Draft version override (e.g. ?v=14 for draft-14 relays, ?v=18 for draft-18, ?v=21 for draft-21). */
export const draftVersion: 14 | 16 | 18 | 21 | undefined = (() => {
const v = params.get('v');
if (v === '14') return 14;
if (v === '16') return 16;
if (v === '18') return 18;
if (v === '21') return 21;
return undefined;
})();

Expand Down
2 changes: 1 addition & 1 deletion examples/shared/probe-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface ProbeOptions {
/** SHA-256 certificate hash for self-signed relays. */
readonly certHash?: ArrayBuffer;
/** MOQT draft version, controlling the WT protocols offer. */
readonly draftVersion?: 14 | 16 | 18;
readonly draftVersion?: 14 | 16 | 18 | 21;
/** Abort the handshake: closes the connecting transport, rejects with `signal.reason`. */
readonly signal?: AbortSignal;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/webtransport-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface WebTransportFactoryOptions {
* sends the `WT-Available-Protocols` header to the server. The server
* selects a protocol and the result is available on `transport.protocol`.
*
* - 21 → `["moqt-21"]`
* - 18 → `["moqt-18"]`
* - 16 → `["moqt-16"]`
* - 14 → no protocols sent (h3 ALPN fallback, in-band CLIENT_SETUP negotiation)
Expand All @@ -46,7 +47,7 @@ export interface WebTransportFactoryOptions {
* @see draft-ietf-moq-transport-16 §3.1 (version negotiation)
* @see W3C WebTransport §3.3 (WT-Available-Protocols)
*/
readonly draftVersion?: 14 | 16 | 18;
readonly draftVersion?: 14 | 16 | 18 | 21;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/loc/src/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
encodeLocHeaders,
toVideoChunkInit,
toAudioChunkInit,
locWireProfileForDraft,
} from './headers.js';
import { LocExtensionId } from './types.js';
import type { LocHeaders } from './types.js';
Expand Down Expand Up @@ -69,6 +70,16 @@ function buildExtensionBytes(

// ─── parseLocHeaders ──────────────────────────────────────────────────

describe('locWireProfileForDraft', () => {
it('maps each wired draft to its property wire profile', () => {
expect(locWireProfileForDraft(14)).toBe('d14-absolute-varint');
expect(locWireProfileForDraft(16)).toBe('d16-delta-varint');
expect(locWireProfileForDraft(18)).toBe('d18-delta-vi64');
// draft 21 keeps draft 18's vi64 Object Properties encoding
expect(locWireProfileForDraft(21)).toBe('d18-delta-vi64');
});
});

describe('parseLocHeaders', () => {
it('returns empty headers for undefined extensions (§2.3)', () => {
const headers = parseLocHeaders(undefined);
Expand Down
1 change: 1 addition & 0 deletions packages/loc/src/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function locWireProfileForDraft(draft: number): PropertyWireProfile {
case 14:
return 'd14-absolute-varint';
case 18:
case 21:
return 'd18-delta-vi64';
default:
return 'd16-delta-varint';
Expand Down
2 changes: 1 addition & 1 deletion packages/playa/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface PlayerOptions {
/** SHA-256 cert hash for self-signed certs (local dev). */
readonly certHash?: ArrayBuffer;
/** MOQT draft version. Default: 16. */
readonly draftVersion?: 14 | 16 | 18;
readonly draftVersion?: 14 | 16 | 18 | 21;
/** Auth tokens for CLIENT_SETUP. */
readonly authTokens?: Uint8Array[];

Expand Down
Loading
Loading